systemprompt_cli/commands/admin/config/
security.rs1use anyhow::{Result, bail};
2use clap::{Args, Subcommand};
3use systemprompt_config::ProfileBootstrap;
4use systemprompt_logging::CliService;
5use systemprompt_models::profile::{TrustedIssuer, default_resource_audiences};
6
7use super::profile_io::{load_profile, save_profile};
8use super::types::{SecurityConfigOutput, SecuritySetOutput};
9use crate::CliConfig;
10use crate::cli_settings::OutputFormat;
11use crate::shared::{CommandResult, render_result};
12
13#[derive(Debug, Subcommand)]
14pub enum SecurityCommands {
15 #[command(about = "Show security configuration", alias = "list")]
16 Show,
17
18 #[command(about = "Set security configuration value")]
19 Set(SetArgs),
20
21 #[command(subcommand, about = "Manage federated trusted JWT issuers")]
22 TrustedIssuer(TrustedIssuerCommands),
23}
24
25#[derive(Debug, Clone, Args)]
26pub struct SetArgs {
27 #[arg(long, help = "JWT issuer")]
28 pub jwt_issuer: Option<String>,
29
30 #[arg(long, help = "Access token expiry in seconds")]
31 pub access_expiry: Option<i64>,
32
33 #[arg(long, help = "Refresh token expiry in seconds")]
34 pub refresh_expiry: Option<i64>,
35
36 #[arg(
37 long = "resource-audience",
38 help = "Resource audience to allow (repeatable). Gateway-required audiences are always kept."
39 )]
40 pub resource_audiences: Vec<String>,
41}
42
43#[derive(Debug, Subcommand)]
44pub enum TrustedIssuerCommands {
45 #[command(about = "Add or replace a trusted issuer")]
46 Add(TrustedIssuerAddArgs),
47
48 #[command(about = "Remove a trusted issuer by its issuer URL")]
49 Remove {
50 #[arg(long, help = "Issuer URL to remove")]
51 issuer: String,
52 },
53}
54
55#[derive(Debug, Clone, Args)]
56pub struct TrustedIssuerAddArgs {
57 #[arg(long, help = "Issuer URL (iss claim)")]
58 pub issuer: String,
59
60 #[arg(long, help = "JWKS URI for signature verification")]
61 pub jwks_uri: String,
62
63 #[arg(long, help = "Expected audience claim")]
64 pub audience: String,
65}
66
67pub fn execute(command: &SecurityCommands, config: &CliConfig) -> Result<()> {
68 match command {
69 SecurityCommands::Show => execute_show(),
70 SecurityCommands::Set(args) => execute_set(args, config),
71 SecurityCommands::TrustedIssuer(cmd) => execute_trusted_issuer(cmd, config),
72 }
73}
74
75pub(super) fn execute_show() -> Result<()> {
76 let profile = ProfileBootstrap::get()?;
77
78 let output = SecurityConfigOutput {
79 jwt_issuer: profile.security.issuer.clone(),
80 access_token_expiry_seconds: profile.security.access_token_expiration,
81 refresh_token_expiry_seconds: profile.security.refresh_token_expiration,
82 audiences: profile
83 .security
84 .audiences
85 .iter()
86 .map(ToString::to_string)
87 .collect(),
88 };
89
90 render_result(&CommandResult::card(output).with_title("Security Configuration"));
91
92 Ok(())
93}
94
95pub(super) fn execute_set(args: &SetArgs, config: &CliConfig) -> Result<()> {
96 if args.jwt_issuer.is_none()
97 && args.access_expiry.is_none()
98 && args.refresh_expiry.is_none()
99 && args.resource_audiences.is_empty()
100 {
101 bail!(
102 "Must specify at least one option: --jwt-issuer, --access-expiry, --refresh-expiry, --resource-audience"
103 );
104 }
105
106 let profile_path = ProfileBootstrap::get_path()?;
107 let mut profile = load_profile(profile_path)?;
108 let mut changes: Vec<SecuritySetOutput> = Vec::new();
109
110 if let Some(ref issuer) = args.jwt_issuer {
111 let old = profile.security.issuer.clone();
112 profile.security.issuer.clone_from(issuer);
113 changes.push(SecuritySetOutput {
114 field: "jwt_issuer".to_owned(),
115 old_value: old,
116 new_value: issuer.clone(),
117 message: format!("Updated JWT issuer to {}", issuer),
118 });
119 }
120
121 if let Some(expiry) = args.access_expiry {
122 if expiry <= 0 {
123 bail!("Access token expiry must be positive");
124 }
125 let old = profile.security.access_token_expiration;
126 profile.security.access_token_expiration = expiry;
127 changes.push(SecuritySetOutput {
128 field: "access_token_expiration".to_owned(),
129 old_value: old.to_string(),
130 new_value: expiry.to_string(),
131 message: format!("Updated access token expiry to {} seconds", expiry),
132 });
133 }
134
135 if let Some(expiry) = args.refresh_expiry {
136 if expiry <= 0 {
137 bail!("Refresh token expiry must be positive");
138 }
139 let old = profile.security.refresh_token_expiration;
140 profile.security.refresh_token_expiration = expiry;
141 changes.push(SecuritySetOutput {
142 field: "refresh_token_expiration".to_owned(),
143 old_value: old.to_string(),
144 new_value: expiry.to_string(),
145 message: format!("Updated refresh token expiry to {} seconds", expiry),
146 });
147 }
148
149 if !args.resource_audiences.is_empty() {
150 let old = profile.security.allowed_resource_audiences.join(",");
151 let mut merged = default_resource_audiences();
152 for aud in &args.resource_audiences {
153 if !merged.contains(aud) {
154 merged.push(aud.clone());
155 }
156 }
157 profile
158 .security
159 .allowed_resource_audiences
160 .clone_from(&merged);
161 changes.push(SecuritySetOutput {
162 field: "allowed_resource_audiences".to_owned(),
163 old_value: old,
164 new_value: merged.join(","),
165 message: "Updated allowed resource audiences".to_owned(),
166 });
167 }
168
169 save_profile(&profile, profile_path)?;
170 render_changes(&changes, config);
171 Ok(())
172}
173
174fn execute_trusted_issuer(command: &TrustedIssuerCommands, config: &CliConfig) -> Result<()> {
175 let profile_path = ProfileBootstrap::get_path()?;
176 let mut profile = load_profile(profile_path)?;
177
178 let change = match command {
179 TrustedIssuerCommands::Add(args) => {
180 if args.issuer.is_empty() || args.jwks_uri.is_empty() || args.audience.is_empty() {
181 bail!("--issuer, --jwks-uri, and --audience are all required");
182 }
183 profile
184 .security
185 .trusted_issuers
186 .retain(|t| t.issuer != args.issuer);
187 profile.security.trusted_issuers.push(TrustedIssuer {
188 issuer: args.issuer.clone(),
189 jwks_uri: args.jwks_uri.clone(),
190 audience: args.audience.clone(),
191 });
192 SecuritySetOutput {
193 field: "trusted_issuers".to_owned(),
194 old_value: String::new(),
195 new_value: args.issuer.clone(),
196 message: format!("Added trusted issuer {}", args.issuer),
197 }
198 },
199 TrustedIssuerCommands::Remove { issuer } => {
200 let before = profile.security.trusted_issuers.len();
201 profile
202 .security
203 .trusted_issuers
204 .retain(|t| &t.issuer != issuer);
205 if profile.security.trusted_issuers.len() == before {
206 bail!("No trusted issuer found with issuer {}", issuer);
207 }
208 SecuritySetOutput {
209 field: "trusted_issuers".to_owned(),
210 old_value: issuer.clone(),
211 new_value: String::new(),
212 message: format!("Removed trusted issuer {}", issuer),
213 }
214 },
215 };
216
217 save_profile(&profile, profile_path)?;
218 render_changes(std::slice::from_ref(&change), config);
219 Ok(())
220}
221
222fn render_changes(changes: &[SecuritySetOutput], config: &CliConfig) {
223 for change in changes {
224 render_result(&CommandResult::text(change.clone()).with_title("Security Updated"));
225 }
226 if config.output_format() == OutputFormat::Table {
227 CliService::warning("Restart services for changes to take effect");
228 }
229}