Skip to main content

systemprompt_cli/commands/admin/config/
security.rs

1//! `admin config security` — show and edit the profile's security section.
2//!
3//! Parses the operator's arguments and delegates the mutation to
4//! [`SecurityConfigService`], then revalidates the whole profile before
5//! writing it back.
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10use 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
137    if args.jwt_issuer.is_some() {
138        crate::session::clear_session()?;
139        CliService::info(
140            "Cleared the stored CLI session: its token was minted under the previous issuer.",
141        );
142    }
143
144    render_changes(&changes, config);
145    Ok(())
146}
147
148fn execute_trusted_issuer(command: &TrustedIssuerCommands, config: &CliConfig) -> Result<()> {
149    let profile_path = ProfileBootstrap::get_path()?;
150    let mut profile = load_profile(profile_path)?;
151
152    let change = match command {
153        TrustedIssuerCommands::Add(args) => {
154            if args.issuer.is_empty() || args.jwks_uri.is_empty() || args.audience.is_empty() {
155                bail!("--issuer, --jwks-uri, and --audience are all required");
156            }
157            SecurityConfigService::upsert_trusted_issuer(
158                &mut profile.security,
159                TrustedIssuer {
160                    issuer: args.issuer.clone(),
161                    jwks_uri: args.jwks_uri.clone(),
162                    audience: args.audience.clone(),
163                    typ_allowlist: Vec::new(),
164                    allowed_client_ids: Vec::new(),
165                    can_issue_id_jag: false,
166                },
167            )
168        },
169        TrustedIssuerCommands::Remove { issuer } => {
170            SecurityConfigService::remove_trusted_issuer(&mut profile.security, issuer)?
171        },
172    };
173
174    save_profile(&profile, profile_path)?;
175    render_changes(&[to_output(change)], config);
176    Ok(())
177}
178
179fn to_output(change: SecurityChange) -> SecuritySetOutput {
180    SecuritySetOutput {
181        field: change.field,
182        old_value: change.old_value,
183        new_value: change.new_value,
184        message: change.message,
185    }
186}
187
188fn render_changes(changes: &[SecuritySetOutput], config: &CliConfig) {
189    for change in changes {
190        render_result(
191            &CommandOutput::card_value("Security Updated", change),
192            config,
193        );
194    }
195    if config.output_format() == OutputFormat::Table {
196        CliService::warning("Restart services for changes to take effect");
197    }
198}