11use std:: str:: FromStr ;
2+ use std:: sync:: Arc ;
23
4+ use anyhow:: Context ;
35use async_trait:: async_trait;
46use chrono:: SecondsFormat ;
57use p256:: ecdsa:: { Signature , VerifyingKey , signature:: Verifier } ;
68use p256:: pkcs8:: DecodePublicKey ;
79use rustls_pki_types:: CertificateDer ;
10+ use sqlx:: postgres:: { PgConnectOptions , PgPool , PgPoolOptions } ;
811use x509_parser:: oid_registry:: Oid ;
912use x509_parser:: prelude:: * ;
1013
14+ use crate :: config:: PolicyConfig ;
1115use crate :: registry:: { CandidatePermission , RegistryStore } ;
1216
1317pub struct RequestContext {
@@ -30,13 +34,13 @@ pub trait PolicyEngine: Send + Sync + 'static {
3034 async fn evaluate ( & self , ctx : & RequestContext ) -> PolicyDecision ;
3135}
3236
33- pub struct PostgresPolicyEngine {
37+ struct PostgresPolicyEngine {
3438 client_ext_oid : Oid < ' static > ,
3539 registry : RegistryStore ,
3640}
3741
3842impl PostgresPolicyEngine {
39- pub fn new ( client_ext_oid : & str , registry : RegistryStore ) -> anyhow:: Result < Self > {
43+ fn new ( client_ext_oid : & str , registry : RegistryStore ) -> anyhow:: Result < Self > {
4044 let client_ext_oid = parse_client_ext_oid ( client_ext_oid) ?;
4145
4246 Ok ( Self {
@@ -46,7 +50,33 @@ impl PostgresPolicyEngine {
4650 }
4751}
4852
49- pub fn parse_client_ext_oid ( value : & str ) -> anyhow:: Result < Oid < ' static > > {
53+ pub async fn build_engine ( config : & PolicyConfig ) -> anyhow:: Result < Arc < dyn PolicyEngine > > {
54+ let db_pool = build_pg_pool ( config) . await ?;
55+ RegistryStore :: verify_schema_version ( & db_pool) . await ?;
56+ let registry = RegistryStore :: new ( db_pool, config. query_timeout ( ) ) ;
57+ Ok ( Arc :: new ( PostgresPolicyEngine :: new (
58+ & config. client_ext_oid ,
59+ registry,
60+ ) ?) )
61+ }
62+
63+ async fn build_pg_pool ( policy : & PolicyConfig ) -> anyhow:: Result < PgPool > {
64+ let database_url = policy. database_url ( ) ?;
65+ let connect_options = PgConnectOptions :: from_str ( & database_url)
66+ . context ( "parsing authorization registry database URL" ) ?;
67+
68+ let connect = PgPoolOptions :: new ( )
69+ . max_connections ( policy. max_connections ( ) )
70+ . acquire_timeout ( policy. pool_acquire_timeout ( ) )
71+ . connect_with ( connect_options) ;
72+
73+ tokio:: time:: timeout ( policy. connect_timeout ( ) , connect)
74+ . await
75+ . context ( "authorization registry database connect timed out" ) ?
76+ . context ( "connecting to authorization registry database" )
77+ }
78+
79+ pub ( crate ) fn parse_client_ext_oid ( value : & str ) -> anyhow:: Result < Oid < ' static > > {
5080 Oid :: from_str ( value) . map_err ( |e| anyhow:: anyhow!( "invalid policy.client_ext_oid: {e:?}" ) )
5181}
5282
@@ -59,7 +89,7 @@ pub fn parse_client_ext_oid(value: &str) -> anyhow::Result<Oid<'static>> {
5989/// - `[ipv6]:port` → `[ipv6]:port`
6090/// - `[ipv6]` → `[ipv6]:443`
6191/// - bare `ipv6` (colons, no brackets) → `[ipv6]:443`
62- pub fn normalize_destination ( dest : & str ) -> anyhow:: Result < String > {
92+ pub ( crate ) fn normalize_destination ( dest : & str ) -> anyhow:: Result < String > {
6393 let dest = dest. trim ( ) ;
6494 anyhow:: ensure!( !dest. is_empty( ) , "destination must not be empty" ) ;
6595
@@ -308,6 +338,113 @@ mod tests {
308338 use super :: * ;
309339 use chrono:: { TimeZone , Utc } ;
310340
341+ #[ test]
342+ fn normalize_plain_hostname_defaults_to_443 ( ) {
343+ assert_eq ! (
344+ normalize_destination( "api.example.com" ) . unwrap( ) ,
345+ "api.example.com:443"
346+ ) ;
347+ }
348+
349+ #[ test]
350+ fn normalize_hostname_with_explicit_port ( ) {
351+ assert_eq ! (
352+ normalize_destination( "api.example.com:8080" ) . unwrap( ) ,
353+ "api.example.com:8080"
354+ ) ;
355+ }
356+
357+ #[ test]
358+ fn normalize_hostname_with_443 ( ) {
359+ assert_eq ! (
360+ normalize_destination( "api.example.com:443" ) . unwrap( ) ,
361+ "api.example.com:443"
362+ ) ;
363+ }
364+
365+ #[ test]
366+ fn normalize_lowercases_hostname ( ) {
367+ assert_eq ! (
368+ normalize_destination( "API.EXAMPLE.COM:443" ) . unwrap( ) ,
369+ "api.example.com:443"
370+ ) ;
371+ assert_eq ! (
372+ normalize_destination( "API.EXAMPLE.COM" ) . unwrap( ) ,
373+ "api.example.com:443"
374+ ) ;
375+ }
376+
377+ #[ test]
378+ fn normalize_bracketed_ipv6_with_port ( ) {
379+ assert_eq ! ( normalize_destination( "[::1]:8443" ) . unwrap( ) , "[::1]:8443" ) ;
380+ }
381+
382+ #[ test]
383+ fn normalize_bracketed_ipv6_without_port_defaults_to_443 ( ) {
384+ assert_eq ! ( normalize_destination( "[::1]" ) . unwrap( ) , "[::1]:443" ) ;
385+ }
386+
387+ #[ test]
388+ fn normalize_bare_ipv6_defaults_to_443 ( ) {
389+ assert_eq ! ( normalize_destination( "::1" ) . unwrap( ) , "[::1]:443" ) ;
390+ assert_eq ! (
391+ normalize_destination( "2001:db8::1" ) . unwrap( ) ,
392+ "[2001:db8::1]:443"
393+ ) ;
394+ }
395+
396+ #[ test]
397+ fn normalize_bare_ipv6_lowercases ( ) {
398+ assert_eq ! ( normalize_destination( "FE80::1" ) . unwrap( ) , "[fe80::1]:443" ) ;
399+ }
400+
401+ #[ test]
402+ fn normalize_rejects_empty ( ) {
403+ assert ! ( normalize_destination( "" ) . is_err( ) ) ;
404+ assert ! ( normalize_destination( " " ) . is_err( ) ) ;
405+ }
406+
407+ #[ test]
408+ fn normalize_rejects_empty_bracketed_host ( ) {
409+ assert ! ( normalize_destination( "[]" ) . is_err( ) ) ;
410+ assert ! ( normalize_destination( "[]:443" ) . is_err( ) ) ;
411+ }
412+
413+ #[ test]
414+ fn normalize_rejects_missing_close_bracket ( ) {
415+ assert ! ( normalize_destination( "[::1" ) . is_err( ) ) ;
416+ }
417+
418+ #[ test]
419+ fn normalize_rejects_non_numeric_port ( ) {
420+ assert ! ( normalize_destination( "host:abc" ) . is_err( ) ) ;
421+ }
422+
423+ #[ test]
424+ fn normalize_rejects_port_zero ( ) {
425+ assert ! ( normalize_destination( "host:0" ) . is_err( ) ) ;
426+ assert ! ( normalize_destination( "[::1]:0" ) . is_err( ) ) ;
427+ }
428+
429+ #[ test]
430+ fn normalize_rejects_invalid_multi_colon ( ) {
431+ assert ! ( normalize_destination( "foo:bar:baz" ) . is_err( ) ) ;
432+ assert ! ( normalize_destination( "api.example.com:443:extra" ) . is_err( ) ) ;
433+ }
434+
435+ #[ test]
436+ fn normalize_rejects_invalid_bracketed_host ( ) {
437+ assert ! ( normalize_destination( "[not-ipv6]:443" ) . is_err( ) ) ;
438+ }
439+
440+ #[ test]
441+ fn normalize_trims_whitespace ( ) {
442+ assert_eq ! (
443+ normalize_destination( " api.example.com:443 " ) . unwrap( ) ,
444+ "api.example.com:443"
445+ ) ;
446+ }
447+
311448 #[ test]
312449 fn canonical_permission_bytes_are_stable ( ) {
313450 let candidate = CandidatePermission {
0 commit comments