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
7use anyhow::{Result, bail};
8use clap::{Args, Subcommand};
9use systemprompt_config::{
10    ProfileBootstrap, SecurityChange, SecurityConfigService, SecurityUpdate,
11};
12use systemprompt_logging::CliService;
13use systemprompt_models::profile::TrustedIssuer;
14
15use super::profile_io::{load_profile, save_profile};
16use super::types::{SecurityConfigOutput, SecuritySetOutput};
17use crate::CliConfig;
18use crate::cli_settings::OutputFormat;
19use crate::shared::{CommandOutput, render_result};
20
21#[derive(Debug, Subcommand)]
22pub enum SecurityCommands {
23    #[command(about = "Show security configuration", alias = "list")]
24    Show,
25
26    #[command(about = "Set security configuration value")]
27    Set(SetArgs),
28
29    #[command(subcommand, about = "Manage federated trusted JWT issuers")]
30    TrustedIssuer(TrustedIssuerCommands),
31}
32
33#[derive(Debug, Clone, Args)]
34pub struct SetArgs {
35    #[arg(long, help = "JWT issuer")]
36    pub jwt_issuer: Option<String>,
37
38    #[arg(long, help = "Access token expiry in seconds")]
39    pub access_expiry: Option<i64>,
40
41    #[arg(long, help = "Refresh token expiry in seconds")]
42    pub refresh_expiry: Option<i64>,
43
44    #[arg(
45        long = "resource-audience",
46        help = "Resource audience to allow (repeatable). Gateway-required audiences are always kept."
47    )]
48    pub resource_audiences: Vec<String>,
49}
50
51#[derive(Debug, Subcommand)]
52pub enum TrustedIssuerCommands {
53    #[command(about = "Add or replace a trusted issuer")]
54    Add(TrustedIssuerAddArgs),
55
56    #[command(about = "Remove a trusted issuer by its issuer URL")]
57    Remove {
58        #[arg(long, help = "Issuer URL to remove")]
59        issuer: String,
60    },
61}
62
63#[derive(Debug, Clone, Args)]
64pub struct TrustedIssuerAddArgs {
65    #[arg(long, help = "Issuer URL (iss claim)")]
66    pub issuer: String,
67
68    #[arg(long, help = "JWKS URI for signature verification")]
69    pub jwks_uri: String,
70
71    #[arg(long, help = "Expected audience claim")]
72    pub audience: String,
73}
74
75pub fn execute(command: &SecurityCommands, config: &CliConfig) -> Result<()> {
76    match command {
77        SecurityCommands::Show => execute_show(config),
78        SecurityCommands::Set(args) => execute_set(args, config),
79        SecurityCommands::TrustedIssuer(cmd) => execute_trusted_issuer(cmd, config),
80    }
81}
82
83pub(super) fn execute_show(config: &CliConfig) -> Result<()> {
84    let profile = ProfileBootstrap::get()?;
85
86    let output = SecurityConfigOutput {
87        jwt_issuer: profile.security.issuer.clone(),
88        access_token_expiry_seconds: profile.security.access_token_expiration,
89        refresh_token_expiry_seconds: profile.security.refresh_token_expiration,
90        audiences: profile
91            .security
92            .audiences
93            .iter()
94            .map(ToString::to_string)
95            .collect(),
96    };
97
98    render_result(
99        &CommandOutput::card_value("Security Configuration", &output),
100        config,
101    );
102
103    Ok(())
104}
105
106pub(super) fn execute_set(args: &SetArgs, config: &CliConfig) -> Result<()> {
107    if args.jwt_issuer.is_none()
108        && args.access_expiry.is_none()
109        && args.refresh_expiry.is_none()
110        && args.resource_audiences.is_empty()
111    {
112        bail!(
113            "Must specify at least one option: --jwt-issuer, --access-expiry, --refresh-expiry, --resource-audience"
114        );
115    }
116
117    let profile_path = ProfileBootstrap::get_path()?;
118    let mut profile = load_profile(profile_path)?;
119
120    let update = SecurityUpdate {
121        jwt_issuer: args.jwt_issuer.clone(),
122        access_token_expiration: args.access_expiry,
123        refresh_token_expiration: args.refresh_expiry,
124        resource_audiences: args.resource_audiences.clone(),
125    };
126    let changes: Vec<SecuritySetOutput> =
127        SecurityConfigService::apply_update(&mut profile.security, &update)?
128            .into_iter()
129            .map(to_output)
130            .collect();
131
132    save_profile(&profile, profile_path)?;
133    render_changes(&changes, config);
134    Ok(())
135}
136
137fn execute_trusted_issuer(command: &TrustedIssuerCommands, config: &CliConfig) -> Result<()> {
138    let profile_path = ProfileBootstrap::get_path()?;
139    let mut profile = load_profile(profile_path)?;
140
141    let change = match command {
142        TrustedIssuerCommands::Add(args) => {
143            if args.issuer.is_empty() || args.jwks_uri.is_empty() || args.audience.is_empty() {
144                bail!("--issuer, --jwks-uri, and --audience are all required");
145            }
146            SecurityConfigService::upsert_trusted_issuer(
147                &mut profile.security,
148                TrustedIssuer {
149                    issuer: args.issuer.clone(),
150                    jwks_uri: args.jwks_uri.clone(),
151                    audience: args.audience.clone(),
152                    typ_allowlist: Vec::new(),
153                    allowed_client_ids: Vec::new(),
154                    can_issue_id_jag: false,
155                },
156            )
157        },
158        TrustedIssuerCommands::Remove { issuer } => {
159            SecurityConfigService::remove_trusted_issuer(&mut profile.security, issuer)?
160        },
161    };
162
163    save_profile(&profile, profile_path)?;
164    render_changes(&[to_output(change)], config);
165    Ok(())
166}
167
168fn to_output(change: SecurityChange) -> SecuritySetOutput {
169    SecuritySetOutput {
170        field: change.field,
171        old_value: change.old_value,
172        new_value: change.new_value,
173        message: change.message,
174    }
175}
176
177fn render_changes(changes: &[SecuritySetOutput], config: &CliConfig) {
178    for change in changes {
179        render_result(
180            &CommandOutput::card_value("Security Updated", change),
181            config,
182        );
183    }
184    if config.output_format() == OutputFormat::Table {
185        CliService::warning("Restart services for changes to take effect");
186    }
187}