@@ -653,6 +653,27 @@ enum OutputFormat {
653653 Json ,
654654}
655655
656+ #[ derive( Clone , Debug , ValueEnum ) ]
657+ enum CliProviderRefreshStrategy {
658+ Static ,
659+ External ,
660+ Oauth2RefreshToken ,
661+ Oauth2ClientCredentials ,
662+ GoogleServiceAccountJwt ,
663+ }
664+
665+ impl CliProviderRefreshStrategy {
666+ fn as_str ( & self ) -> & ' static str {
667+ match self {
668+ Self :: Static => "static" ,
669+ Self :: External => "external" ,
670+ Self :: Oauth2RefreshToken => "oauth2_refresh_token" ,
671+ Self :: Oauth2ClientCredentials => "oauth2_client_credentials" ,
672+ Self :: GoogleServiceAccountJwt => "google_service_account_jwt" ,
673+ }
674+ }
675+ }
676+
656677impl OutputFormat {
657678 fn as_str ( & self ) -> & ' static str {
658679 match self {
@@ -708,6 +729,10 @@ enum ProviderCommands {
708729 config : Vec < String > ,
709730 } ,
710731
732+ /// Manage provider credential refresh.
733+ #[ command( subcommand, help_template = SUBCOMMAND_HELP_TEMPLATE ) ]
734+ Refresh ( ProviderRefreshCommands ) ,
735+
711736 /// Fetch a provider by name.
712737 #[ command( help_template = LEAF_HELP_TEMPLATE , next_help_heading = "FLAGS" ) ]
713738 Get {
@@ -766,6 +791,10 @@ enum ProviderCommands {
766791 /// Provider config key/value pair.
767792 #[ arg( long = "config" , value_name = "KEY=VALUE" ) ]
768793 config : Vec < String > ,
794+
795+ /// Credential expiry (`KEY=TIMESTAMP_MS`). A zero timestamp clears expiry.
796+ #[ arg( long = "credential-expires-at" , value_name = "KEY=TIMESTAMP_MS" ) ]
797+ credential_expires_at : Vec < String > ,
769798 } ,
770799
771800 /// Delete providers by name.
@@ -777,6 +806,73 @@ enum ProviderCommands {
777806 } ,
778807}
779808
809+ #[ derive( Subcommand , Debug ) ]
810+ enum ProviderRefreshCommands {
811+ /// Show provider credential refresh status.
812+ #[ command( help_template = LEAF_HELP_TEMPLATE , next_help_heading = "FLAGS" ) ]
813+ Status {
814+ /// Provider name.
815+ #[ arg( add = ArgValueCompleter :: new( completers:: complete_provider_names) ) ]
816+ name : String ,
817+
818+ /// Optional credential key to filter by.
819+ #[ arg( long = "credential-key" ) ]
820+ credential_key : Option < String > ,
821+ } ,
822+
823+ /// Configure refresh metadata for a provider credential.
824+ #[ command( help_template = LEAF_HELP_TEMPLATE , next_help_heading = "FLAGS" ) ]
825+ Configure {
826+ /// Provider name.
827+ #[ arg( add = ArgValueCompleter :: new( completers:: complete_provider_names) ) ]
828+ name : String ,
829+
830+ /// Injectable credential key, for example `MS_GRAPH_ACCESS_TOKEN`.
831+ #[ arg( long = "credential-key" ) ]
832+ credential_key : String ,
833+
834+ /// Refresh strategy.
835+ #[ arg( long, value_enum) ]
836+ strategy : CliProviderRefreshStrategy ,
837+
838+ /// Non-injectable refresh material (`KEY=VALUE`).
839+ #[ arg( long = "material" , value_name = "KEY=VALUE" ) ]
840+ material : Vec < String > ,
841+
842+ /// Material keys that are secret and must not be exposed.
843+ #[ arg( long = "secret-material-key" , value_name = "KEY" ) ]
844+ secret_material_keys : Vec < String > ,
845+
846+ /// Expiry for the current credential (`TIMESTAMP_MS`).
847+ #[ arg( long = "credential-expires-at" , value_name = "TIMESTAMP_MS" ) ]
848+ credential_expires_at : Option < i64 > ,
849+ } ,
850+
851+ /// Record a gateway-owned credential rotation request.
852+ #[ command( help_template = LEAF_HELP_TEMPLATE , next_help_heading = "FLAGS" ) ]
853+ Rotate {
854+ /// Provider name.
855+ #[ arg( add = ArgValueCompleter :: new( completers:: complete_provider_names) ) ]
856+ name : String ,
857+
858+ /// Injectable credential key, for example `MS_GRAPH_ACCESS_TOKEN`.
859+ #[ arg( long = "credential-key" ) ]
860+ credential_key : String ,
861+ } ,
862+
863+ /// Delete refresh metadata for a provider credential.
864+ #[ command( help_template = LEAF_HELP_TEMPLATE , next_help_heading = "FLAGS" ) ]
865+ Delete {
866+ /// Provider name.
867+ #[ arg( add = ArgValueCompleter :: new( completers:: complete_provider_names) ) ]
868+ name : String ,
869+
870+ /// Injectable credential key, for example `MS_GRAPH_ACCESS_TOKEN`.
871+ #[ arg( long = "credential-key" ) ]
872+ credential_key : String ,
873+ } ,
874+ }
875+
780876#[ derive( Subcommand , Debug ) ]
781877enum ProviderProfileCommands {
782878 /// Export a provider profile.
@@ -2641,6 +2737,55 @@ async fn main() -> Result<()> {
26412737 )
26422738 . await ?;
26432739 }
2740+ ProviderCommands :: Refresh ( command) => match command {
2741+ ProviderRefreshCommands :: Status {
2742+ name,
2743+ credential_key,
2744+ } => {
2745+ run:: provider_refresh_status (
2746+ endpoint,
2747+ & name,
2748+ credential_key. as_deref ( ) ,
2749+ & tls,
2750+ )
2751+ . await ?;
2752+ }
2753+ ProviderRefreshCommands :: Configure {
2754+ name,
2755+ credential_key,
2756+ strategy,
2757+ material,
2758+ secret_material_keys,
2759+ credential_expires_at,
2760+ } => {
2761+ run:: provider_refresh_config (
2762+ endpoint,
2763+ run:: ProviderRefreshConfigInput {
2764+ name : & name,
2765+ credential_key : & credential_key,
2766+ strategy : strategy. as_str ( ) ,
2767+ material : & material,
2768+ secret_material_keys : & secret_material_keys,
2769+ credential_expires_at_ms : credential_expires_at,
2770+ } ,
2771+ & tls,
2772+ )
2773+ . await ?;
2774+ }
2775+ ProviderRefreshCommands :: Rotate {
2776+ name,
2777+ credential_key,
2778+ } => {
2779+ run:: provider_rotate ( endpoint, & name, & credential_key, & tls) . await ?;
2780+ }
2781+ ProviderRefreshCommands :: Delete {
2782+ name,
2783+ credential_key,
2784+ } => {
2785+ run:: provider_refresh_delete ( endpoint, & name, & credential_key, & tls)
2786+ . await ?;
2787+ }
2788+ } ,
26442789 ProviderCommands :: Get { name } => {
26452790 run:: provider_get ( endpoint, & name, & tls) . await ?;
26462791 }
@@ -2685,13 +2830,15 @@ async fn main() -> Result<()> {
26852830 from_existing,
26862831 credentials,
26872832 config,
2833+ credential_expires_at,
26882834 } => {
26892835 run:: provider_update (
26902836 endpoint,
26912837 & name,
26922838 from_existing,
26932839 & credentials,
26942840 & config,
2841+ & credential_expires_at,
26952842 & tls,
26962843 )
26972844 . await ?;
@@ -3572,6 +3719,125 @@ mod tests {
35723719 }
35733720 }
35743721
3722+ #[ test]
3723+ fn provider_refresh_commands_parse ( ) {
3724+ let status = Cli :: try_parse_from ( [
3725+ "openshell" ,
3726+ "provider" ,
3727+ "refresh" ,
3728+ "status" ,
3729+ "my-graph" ,
3730+ "--credential-key" ,
3731+ "MS_GRAPH_ACCESS_TOKEN" ,
3732+ ] )
3733+ . expect ( "provider refresh status should parse" ) ;
3734+ assert ! ( matches!(
3735+ status. command,
3736+ Some ( Commands :: Provider {
3737+ command: Some ( ProviderCommands :: Refresh ( ProviderRefreshCommands :: Status {
3738+ name,
3739+ credential_key: Some ( key)
3740+ } ) )
3741+ } ) if name == "my-graph" && key == "MS_GRAPH_ACCESS_TOKEN"
3742+ ) ) ;
3743+
3744+ let config = Cli :: try_parse_from ( [
3745+ "openshell" ,
3746+ "provider" ,
3747+ "refresh" ,
3748+ "configure" ,
3749+ "my-graph" ,
3750+ "--credential-key" ,
3751+ "MS_GRAPH_ACCESS_TOKEN" ,
3752+ "--strategy" ,
3753+ "oauth2-client-credentials" ,
3754+ "--material" ,
3755+ "tenant_id=abc" ,
3756+ "--secret-material-key" ,
3757+ "client_secret" ,
3758+ "--credential-expires-at" ,
3759+ "1767225600000" ,
3760+ ] )
3761+ . expect ( "provider refresh configure should parse" ) ;
3762+ assert ! ( matches!(
3763+ config. command,
3764+ Some ( Commands :: Provider {
3765+ command: Some ( ProviderCommands :: Refresh (
3766+ ProviderRefreshCommands :: Configure {
3767+ strategy: CliProviderRefreshStrategy :: Oauth2ClientCredentials ,
3768+ credential_expires_at: Some ( 1_767_225_600_000 ) ,
3769+ ..
3770+ }
3771+ ) )
3772+ } )
3773+ ) ) ;
3774+
3775+ let rotate = Cli :: try_parse_from ( [
3776+ "openshell" ,
3777+ "provider" ,
3778+ "refresh" ,
3779+ "rotate" ,
3780+ "my-graph" ,
3781+ "--credential-key" ,
3782+ "MS_GRAPH_ACCESS_TOKEN" ,
3783+ ] )
3784+ . expect ( "provider refresh rotate should parse" ) ;
3785+ assert ! ( matches!(
3786+ rotate. command,
3787+ Some ( Commands :: Provider {
3788+ command: Some ( ProviderCommands :: Refresh ( ProviderRefreshCommands :: Rotate {
3789+ name,
3790+ credential_key
3791+ } ) )
3792+ } ) if name == "my-graph" && credential_key == "MS_GRAPH_ACCESS_TOKEN"
3793+ ) ) ;
3794+
3795+ let delete = Cli :: try_parse_from ( [
3796+ "openshell" ,
3797+ "provider" ,
3798+ "refresh" ,
3799+ "delete" ,
3800+ "my-graph" ,
3801+ "--credential-key" ,
3802+ "MS_GRAPH_ACCESS_TOKEN" ,
3803+ ] )
3804+ . expect ( "provider refresh delete should parse" ) ;
3805+ assert ! ( matches!(
3806+ delete. command,
3807+ Some ( Commands :: Provider {
3808+ command: Some ( ProviderCommands :: Refresh ( ProviderRefreshCommands :: Delete {
3809+ name,
3810+ credential_key
3811+ } ) )
3812+ } ) if name == "my-graph" && credential_key == "MS_GRAPH_ACCESS_TOKEN"
3813+ ) ) ;
3814+ }
3815+
3816+ #[ test]
3817+ fn provider_update_accepts_credential_expiry ( ) {
3818+ let cli = Cli :: try_parse_from ( [
3819+ "openshell" ,
3820+ "provider" ,
3821+ "update" ,
3822+ "my-graph" ,
3823+ "--credential" ,
3824+ "MS_GRAPH_ACCESS_TOKEN=abc" ,
3825+ "--credential-expires-at" ,
3826+ "MS_GRAPH_ACCESS_TOKEN=1767225600000" ,
3827+ ] )
3828+ . expect ( "provider update should parse credential expiry" ) ;
3829+
3830+ assert ! ( matches!(
3831+ cli. command,
3832+ Some ( Commands :: Provider {
3833+ command: Some ( ProviderCommands :: Update {
3834+ credential_expires_at,
3835+ ..
3836+ } )
3837+ } ) if credential_expires_at == vec![ "MS_GRAPH_ACCESS_TOKEN=1767225600000" ]
3838+ ) ) ;
3839+ }
3840+
35753841 #[ test]
35763842 fn settings_set_global_parses_yes_flag ( ) {
35773843 let cli = Cli :: try_parse_from ( [
0 commit comments