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::{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}