Skip to content

Commit 0566106

Browse files
committed
feat(providers): add credential refresh foundation
1 parent 71209e6 commit 0566106

37 files changed

Lines changed: 3795 additions & 214 deletions

crates/openshell-cli/src/main.rs

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
656677
impl 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)]
781877
enum 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

Comments
 (0)