@@ -30,6 +30,13 @@ struct ProviderCredentialStateInner {
3030 suppressed_keys : HashSet < String > ,
3131 static_credential_bindings : HashMap < String , StaticCredentialBinding > ,
3232 known_static_credential_keys : HashSet < String > ,
33+ static_credential_identity_epochs : HashMap < String , StaticCredentialIdentityEpoch > ,
34+ }
35+
36+ #[ derive( Debug ) ]
37+ struct StaticCredentialIdentityEpoch {
38+ identity : String ,
39+ first_revision : u64 ,
3340}
3441
3542#[ derive( Debug , Clone ) ]
@@ -81,6 +88,7 @@ impl ProviderCredentialState {
8188 suppressed_keys : HashSet :: new ( ) ,
8289 static_credential_bindings : HashMap :: new ( ) ,
8390 known_static_credential_keys : HashSet :: new ( ) ,
91+ static_credential_identity_epochs : HashMap :: new ( ) ,
8492 } ) ) ,
8593 }
8694 }
@@ -108,6 +116,11 @@ impl ProviderCredentialState {
108116 inner
109117 . known_static_credential_keys
110118 . extend ( static_credential_bindings. keys ( ) . cloned ( ) ) ;
119+ update_static_credential_identity_epochs (
120+ & mut inner. static_credential_identity_epochs ,
121+ revision,
122+ & static_credential_bindings,
123+ ) ;
111124 inner. static_credential_bindings = static_credential_bindings;
112125 }
113126 Ok ( state)
@@ -137,6 +150,7 @@ impl ProviderCredentialState {
137150 suppressed_keys : HashSet :: new ( ) ,
138151 static_credential_bindings : HashMap :: new ( ) ,
139152 known_static_credential_keys : HashSet :: new ( ) ,
153+ static_credential_identity_epochs : HashMap :: new ( ) ,
140154 } ) ) ,
141155 }
142156 }
@@ -171,6 +185,7 @@ impl ProviderCredentialState {
171185 inner. combined_resolver = None ;
172186 inner. static_credential_bindings . clear ( ) ;
173187 inner. known_static_credential_keys . clear ( ) ;
188+ inner. static_credential_identity_epochs . clear ( ) ;
174189 inner. current . child_env . len ( )
175190 }
176191
@@ -205,7 +220,7 @@ impl ProviderCredentialState {
205220 . read ( )
206221 . expect ( "provider credential state poisoned" ) ;
207222 let request_path = path. split_once ( '?' ) . map_or ( path, |( path, _) | path) ;
208- let allowed = inner
223+ let allowed: HashSet < String > = inner
209224 . static_credential_bindings
210225 . iter ( )
211226 . filter ( |( _, binding) | {
@@ -216,7 +231,23 @@ impl ProviderCredentialState {
216231 . map ( |( key, _) | key. clone ( ) )
217232 . collect ( ) ;
218233 inner. combined_resolver . as_ref ( ) . map ( |resolver| {
219- Arc :: new ( resolver. scoped_to_env_keys ( & inner. known_static_credential_keys , & allowed) )
234+ let revision_fallback_min_revisions = inner
235+ . static_credential_identity_epochs
236+ . iter ( )
237+ . filter ( |( key, epoch) | {
238+ allowed. contains ( * key)
239+ && inner
240+ . static_credential_bindings
241+ . get ( * key)
242+ . is_some_and ( |binding| binding. credential_identity == epoch. identity )
243+ } )
244+ . map ( |( key, epoch) | ( key. clone ( ) , epoch. first_revision ) )
245+ . collect ( ) ;
246+ Arc :: new ( resolver. scoped_to_env_keys (
247+ & inner. known_static_credential_keys ,
248+ & allowed,
249+ revision_fallback_min_revisions,
250+ ) )
220251 } )
221252 }
222253
@@ -442,6 +473,11 @@ impl ProviderCredentialState {
442473 inner
443474 . known_static_credential_keys
444475 . extend ( static_credential_bindings. keys ( ) . cloned ( ) ) ;
476+ update_static_credential_identity_epochs (
477+ & mut inner. static_credential_identity_epochs ,
478+ revision,
479+ & static_credential_bindings,
480+ ) ;
445481 inner. static_credential_bindings = static_credential_bindings;
446482 Ok ( inner. current . child_env . len ( ) )
447483 }
@@ -474,6 +510,7 @@ impl ProviderCredentialState {
474510 inner. current_resolver = None ;
475511 inner. combined_resolver = None ;
476512 inner. static_credential_bindings . clear ( ) ;
513+ inner. static_credential_identity_epochs . clear ( ) ;
477514 }
478515}
479516
@@ -545,6 +582,32 @@ fn static_credential_identities(
545582 . collect ( )
546583}
547584
585+ fn update_static_credential_identity_epochs (
586+ epochs : & mut HashMap < String , StaticCredentialIdentityEpoch > ,
587+ revision : u64 ,
588+ bindings : & HashMap < String , StaticCredentialBinding > ,
589+ ) {
590+ epochs. retain ( |key, _| bindings. contains_key ( key) ) ;
591+ for ( key, binding) in bindings {
592+ match epochs. get_mut ( key) {
593+ Some ( epoch) if epoch. identity == binding. credential_identity => { }
594+ Some ( epoch) => {
595+ epoch. identity . clone_from ( & binding. credential_identity ) ;
596+ epoch. first_revision = revision;
597+ }
598+ None => {
599+ epochs. insert (
600+ key. clone ( ) ,
601+ StaticCredentialIdentityEpoch {
602+ identity : binding. credential_identity . clone ( ) ,
603+ first_revision : revision,
604+ } ,
605+ ) ;
606+ }
607+ }
608+ }
609+ }
610+
548611fn binding_error ( message : & str ) -> StaticCredentialBindingError {
549612 StaticCredentialBindingError {
550613 message : message. to_string ( ) ,
@@ -722,6 +785,47 @@ mod tests {
722785 ) ;
723786 }
724787
788+ #[ test]
789+ fn aged_generation_falls_back_after_many_rotations_of_same_provider_credential ( ) {
790+ let state = ProviderCredentialState :: from_bound_environment (
791+ 1 ,
792+ HashMap :: from ( [ ( "API_KEY" . to_string ( ) , "secret-1" . to_string ( ) ) ] ) ,
793+ HashMap :: new ( ) ,
794+ HashMap :: new ( ) ,
795+ HashMap :: from ( [ (
796+ "API_KEY" . to_string ( ) ,
797+ binding ( "api.example.com" , 443 , "/**" ) ,
798+ ) ] ) ,
799+ Vec :: new ( ) ,
800+ )
801+ . expect ( "initial bindings" ) ;
802+
803+ for revision in 2 ..=10 {
804+ state
805+ . install_bound_environment (
806+ revision,
807+ HashMap :: from ( [ ( "API_KEY" . to_string ( ) , format ! ( "secret-{revision}" ) ) ] ) ,
808+ HashMap :: new ( ) ,
809+ HashMap :: new ( ) ,
810+ HashMap :: from ( [ (
811+ "API_KEY" . to_string ( ) ,
812+ binding ( "api.example.com" , 443 , "/**" ) ,
813+ ) ] ) ,
814+ Vec :: new ( ) ,
815+ )
816+ . expect ( "rotated bindings" ) ;
817+ }
818+
819+ let resolver = state
820+ . resolver_for_endpoint ( "api.example.com" , 443 , "/v1" )
821+ . expect ( "resolver" ) ;
822+ assert_eq ! (
823+ resolver. resolve_placeholder( "openshell:resolve:env:v1_API_KEY" ) ,
824+ Some ( "secret-10" ) ,
825+ "an aged-out placeholder may use the current secret while its provider identity is unchanged"
826+ ) ;
827+ }
828+
725829 #[ test]
726830 fn replacing_provider_with_reused_key_purges_retained_generation ( ) {
727831 let state = ProviderCredentialState :: from_bound_environment (
0 commit comments