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::{CommandOutput, 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(&CommandOutput::card_value(
91 "Security Configuration",
92 &output,
93 ));
94
95 Ok(())
96}
97
98pub(super) fn execute_set(args: &SetArgs, config: &CliConfig) -> Result<()> {
99 if args.jwt_issuer.is_none()
100 && args.access_expiry.is_none()
101 && args.refresh_expiry.is_none()
102 && args.resource_audiences.is_empty()
103 {
104 bail!(
105 "Must specify at least one option: --jwt-issuer, --access-expiry, --refresh-expiry, --resource-audience"
106 );
107 }
108
109 let profile_path = ProfileBootstrap::get_path()?;
110 let mut profile = load_profile(profile_path)?;
111 let mut changes: Vec<SecuritySetOutput> = Vec::new();
112
113 if let Some(ref issuer) = args.jwt_issuer {
114 let old = profile.security.issuer.clone();
115 profile.security.issuer.clone_from(issuer);
116 changes.push(SecuritySetOutput {
117 field: "jwt_issuer".to_owned(),
118 old_value: old,
119 new_value: issuer.clone(),
120 message: format!("Updated JWT issuer to {}", issuer),
121 });
122 }
123
124 if let Some(expiry) = args.access_expiry {
125 if expiry <= 0 {
126 bail!("Access token expiry must be positive");
127 }
128 let old = profile.security.access_token_expiration;
129 profile.security.access_token_expiration = expiry;
130 changes.push(SecuritySetOutput {
131 field: "access_token_expiration".to_owned(),
132 old_value: old.to_string(),
133 new_value: expiry.to_string(),
134 message: format!("Updated access token expiry to {} seconds", expiry),
135 });
136 }
137
138 if let Some(expiry) = args.refresh_expiry {
139 if expiry <= 0 {
140 bail!("Refresh token expiry must be positive");
141 }
142 let old = profile.security.refresh_token_expiration;
143 profile.security.refresh_token_expiration = expiry;
144 changes.push(SecuritySetOutput {
145 field: "refresh_token_expiration".to_owned(),
146 old_value: old.to_string(),
147 new_value: expiry.to_string(),
148 message: format!("Updated refresh token expiry to {} seconds", expiry),
149 });
150 }
151
152 if !args.resource_audiences.is_empty() {
153 let old = profile.security.allowed_resource_audiences.join(",");
154 let mut merged = default_resource_audiences();
155 for aud in &args.resource_audiences {
156 if !merged.contains(aud) {
157 merged.push(aud.clone());
158 }
159 }
160 profile
161 .security
162 .allowed_resource_audiences
163 .clone_from(&merged);
164 changes.push(SecuritySetOutput {
165 field: "allowed_resource_audiences".to_owned(),
166 old_value: old,
167 new_value: merged.join(","),
168 message: "Updated allowed resource audiences".to_owned(),
169 });
170 }
171
172 save_profile(&profile, profile_path)?;
173 render_changes(&changes, config);
174 Ok(())
175}
176
177fn execute_trusted_issuer(command: &TrustedIssuerCommands, config: &CliConfig) -> Result<()> {
178 let profile_path = ProfileBootstrap::get_path()?;
179 let mut profile = load_profile(profile_path)?;
180
181 let change = match command {
182 TrustedIssuerCommands::Add(args) => {
183 if args.issuer.is_empty() || args.jwks_uri.is_empty() || args.audience.is_empty() {
184 bail!("--issuer, --jwks-uri, and --audience are all required");
185 }
186 profile
187 .security
188 .trusted_issuers
189 .retain(|t| t.issuer != args.issuer);
190 profile.security.trusted_issuers.push(TrustedIssuer {
191 issuer: args.issuer.clone(),
192 jwks_uri: args.jwks_uri.clone(),
193 audience: args.audience.clone(),
194 });
195 SecuritySetOutput {
196 field: "trusted_issuers".to_owned(),
197 old_value: String::new(),
198 new_value: args.issuer.clone(),
199 message: format!("Added trusted issuer {}", args.issuer),
200 }
201 },
202 TrustedIssuerCommands::Remove { issuer } => {
203 let before = profile.security.trusted_issuers.len();
204 profile
205 .security
206 .trusted_issuers
207 .retain(|t| &t.issuer != issuer);
208 if profile.security.trusted_issuers.len() == before {
209 bail!("No trusted issuer found with issuer {}", issuer);
210 }
211 SecuritySetOutput {
212 field: "trusted_issuers".to_owned(),
213 old_value: issuer.clone(),
214 new_value: String::new(),
215 message: format!("Removed trusted issuer {}", issuer),
216 }
217 },
218 };
219
220 save_profile(&profile, profile_path)?;
221 render_changes(std::slice::from_ref(&change), config);
222 Ok(())
223}
224
225fn render_changes(changes: &[SecuritySetOutput], config: &CliConfig) {
226 for change in changes {
227 render_result(&CommandOutput::card_value("Security Updated", change));
228 }
229 if config.output_format() == OutputFormat::Table {
230 CliService::warning("Restart services for changes to take effect");
231 }
232}