Skip to main content

systemprompt_cli/commands/admin/config/
security.rs

1use 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}