systemprompt_cli/commands/admin/config/
security.rs1use anyhow::{Result, bail};
11use clap::{Args, Subcommand};
12use systemprompt_config::{
13 ProfileBootstrap, SecurityChange, SecurityConfigService, SecurityUpdate,
14};
15use systemprompt_logging::CliService;
16use systemprompt_models::profile::TrustedIssuer;
17
18use super::profile_io::{load_profile, save_profile};
19use super::types::{SecurityConfigOutput, SecuritySetOutput};
20use crate::CliConfig;
21use crate::cli_settings::OutputFormat;
22use crate::shared::{CommandOutput, render_result};
23
24#[derive(Debug, Subcommand)]
25pub enum SecurityCommands {
26 #[command(about = "Show security configuration", alias = "list")]
27 Show,
28
29 #[command(about = "Set security configuration value")]
30 Set(SetArgs),
31
32 #[command(subcommand, about = "Manage federated trusted JWT issuers")]
33 TrustedIssuer(TrustedIssuerCommands),
34}
35
36#[derive(Debug, Clone, Args)]
37pub struct SetArgs {
38 #[arg(long, help = "JWT issuer")]
39 pub jwt_issuer: Option<String>,
40
41 #[arg(long, help = "Access token expiry in seconds")]
42 pub access_expiry: Option<i64>,
43
44 #[arg(long, help = "Refresh token expiry in seconds")]
45 pub refresh_expiry: Option<i64>,
46
47 #[arg(
48 long = "resource-audience",
49 help = "Resource audience to allow (repeatable). Gateway-required audiences are always kept."
50 )]
51 pub resource_audiences: Vec<String>,
52}
53
54#[derive(Debug, Subcommand)]
55pub enum TrustedIssuerCommands {
56 #[command(about = "Add or replace a trusted issuer")]
57 Add(TrustedIssuerAddArgs),
58
59 #[command(about = "Remove a trusted issuer by its issuer URL")]
60 Remove {
61 #[arg(long, help = "Issuer URL to remove")]
62 issuer: String,
63 },
64}
65
66#[derive(Debug, Clone, Args)]
67pub struct TrustedIssuerAddArgs {
68 #[arg(long, help = "Issuer URL (iss claim)")]
69 pub issuer: String,
70
71 #[arg(long, help = "JWKS URI for signature verification")]
72 pub jwks_uri: String,
73
74 #[arg(long, help = "Expected audience claim")]
75 pub audience: String,
76}
77
78pub fn execute(command: &SecurityCommands, config: &CliConfig) -> Result<()> {
79 match command {
80 SecurityCommands::Show => execute_show(config),
81 SecurityCommands::Set(args) => execute_set(args, config),
82 SecurityCommands::TrustedIssuer(cmd) => execute_trusted_issuer(cmd, config),
83 }
84}
85
86pub(super) fn execute_show(config: &CliConfig) -> Result<()> {
87 let profile = ProfileBootstrap::get()?;
88
89 let output = SecurityConfigOutput {
90 jwt_issuer: profile.security.issuer.clone(),
91 access_token_expiry_seconds: profile.security.access_token_expiration,
92 refresh_token_expiry_seconds: profile.security.refresh_token_expiration,
93 audiences: profile
94 .security
95 .audiences
96 .iter()
97 .map(ToString::to_string)
98 .collect(),
99 };
100
101 render_result(
102 &CommandOutput::card_value("Security Configuration", &output),
103 config,
104 );
105
106 Ok(())
107}
108
109pub(super) fn execute_set(args: &SetArgs, config: &CliConfig) -> Result<()> {
110 if args.jwt_issuer.is_none()
111 && args.access_expiry.is_none()
112 && args.refresh_expiry.is_none()
113 && args.resource_audiences.is_empty()
114 {
115 bail!(
116 "Must specify at least one option: --jwt-issuer, --access-expiry, --refresh-expiry, --resource-audience"
117 );
118 }
119
120 let profile_path = ProfileBootstrap::get_path()?;
121 let mut profile = load_profile(profile_path)?;
122
123 let update = SecurityUpdate {
124 jwt_issuer: args.jwt_issuer.clone(),
125 access_token_expiration: args.access_expiry,
126 refresh_token_expiration: args.refresh_expiry,
127 resource_audiences: args.resource_audiences.clone(),
128 };
129 let changes: Vec<SecuritySetOutput> =
130 SecurityConfigService::apply_update(&mut profile.security, &update)?
131 .into_iter()
132 .map(to_output)
133 .collect();
134
135 save_profile(&profile, profile_path)?;
136 render_changes(&changes, config);
137 Ok(())
138}
139
140fn execute_trusted_issuer(command: &TrustedIssuerCommands, config: &CliConfig) -> Result<()> {
141 let profile_path = ProfileBootstrap::get_path()?;
142 let mut profile = load_profile(profile_path)?;
143
144 let change = match command {
145 TrustedIssuerCommands::Add(args) => {
146 if args.issuer.is_empty() || args.jwks_uri.is_empty() || args.audience.is_empty() {
147 bail!("--issuer, --jwks-uri, and --audience are all required");
148 }
149 SecurityConfigService::upsert_trusted_issuer(
150 &mut profile.security,
151 TrustedIssuer {
152 issuer: args.issuer.clone(),
153 jwks_uri: args.jwks_uri.clone(),
154 audience: args.audience.clone(),
155 typ_allowlist: Vec::new(),
156 allowed_client_ids: Vec::new(),
157 can_issue_id_jag: false,
158 },
159 )
160 },
161 TrustedIssuerCommands::Remove { issuer } => {
162 SecurityConfigService::remove_trusted_issuer(&mut profile.security, issuer)?
163 },
164 };
165
166 save_profile(&profile, profile_path)?;
167 render_changes(&[to_output(change)], config);
168 Ok(())
169}
170
171fn to_output(change: SecurityChange) -> SecuritySetOutput {
172 SecuritySetOutput {
173 field: change.field,
174 old_value: change.old_value,
175 new_value: change.new_value,
176 message: change.message,
177 }
178}
179
180fn render_changes(changes: &[SecuritySetOutput], config: &CliConfig) {
181 for change in changes {
182 render_result(
183 &CommandOutput::card_value("Security Updated", change),
184 config,
185 );
186 }
187 if config.output_format() == OutputFormat::Table {
188 CliService::warning("Restart services for changes to take effect");
189 }
190}