Skip to main content

systemprompt_config/services/
security_config.rs

1//! Security-section mutations behind the `admin config security` surface.
2//!
3//! [`SecurityConfigService`] edits the profile's [`SecurityConfig`]: the JWT
4//! issuer, token expiries, the allowed resource audiences, and the federated
5//! trusted-issuer list. Every mutation yields a [`SecurityChange`] record
6//! (field, old value, new value, message) for rendering or auditing. Audience
7//! updates always re-seed from
8//! [`default_resource_audiences`], so the gateway-required audiences can never
9//! be removed. Callers revalidate and persist the profile afterwards.
10//!
11//! Copyright (c) systemprompt.io — Business Source License 1.1.
12//! See <https://systemprompt.io> for licensing details.
13
14use systemprompt_models::profile::{SecurityConfig, TrustedIssuer, default_resource_audiences};
15
16use crate::error::{ConfigError, ConfigResult};
17
18#[derive(Debug, Clone)]
19pub struct SecurityChange {
20    pub field: String,
21    pub old_value: String,
22    pub new_value: String,
23    pub message: String,
24}
25
26#[derive(Debug, Clone, Default)]
27pub struct SecurityUpdate {
28    pub jwt_issuer: Option<String>,
29    pub access_token_expiration: Option<i64>,
30    pub refresh_token_expiration: Option<i64>,
31    pub resource_audiences: Vec<String>,
32}
33
34#[derive(Debug, Clone, Copy)]
35pub struct SecurityConfigService;
36
37impl SecurityConfigService {
38    pub fn apply_update(
39        security: &mut SecurityConfig,
40        update: &SecurityUpdate,
41    ) -> ConfigResult<Vec<SecurityChange>> {
42        let mut changes = Vec::new();
43
44        if let Some(ref issuer) = update.jwt_issuer {
45            let old = security.issuer.clone();
46            security.issuer.clone_from(issuer);
47            changes.push(SecurityChange {
48                field: "jwt_issuer".to_owned(),
49                old_value: old,
50                new_value: issuer.clone(),
51                message: format!("Updated JWT issuer to {}", issuer),
52            });
53        }
54
55        if let Some(expiry) = update.access_token_expiration {
56            if expiry <= 0 {
57                return Err(ConfigError::NonPositiveAccessTokenExpiry);
58            }
59            let old = security.access_token_expiration;
60            security.access_token_expiration = expiry;
61            changes.push(SecurityChange {
62                field: "access_token_expiration".to_owned(),
63                old_value: old.to_string(),
64                new_value: expiry.to_string(),
65                message: format!("Updated access token expiry to {} seconds", expiry),
66            });
67        }
68
69        if let Some(expiry) = update.refresh_token_expiration {
70            if expiry <= 0 {
71                return Err(ConfigError::NonPositiveRefreshTokenExpiry);
72            }
73            let old = security.refresh_token_expiration;
74            security.refresh_token_expiration = expiry;
75            changes.push(SecurityChange {
76                field: "refresh_token_expiration".to_owned(),
77                old_value: old.to_string(),
78                new_value: expiry.to_string(),
79                message: format!("Updated refresh token expiry to {} seconds", expiry),
80            });
81        }
82
83        if !update.resource_audiences.is_empty() {
84            changes.push(Self::merge_resource_audiences(
85                security,
86                &update.resource_audiences,
87            ));
88        }
89
90        Ok(changes)
91    }
92
93    fn merge_resource_audiences(
94        security: &mut SecurityConfig,
95        requested: &[String],
96    ) -> SecurityChange {
97        let old = security.allowed_resource_audiences.join(",");
98        let mut merged = default_resource_audiences();
99        for aud in requested {
100            if !merged.contains(aud) {
101                merged.push(aud.clone());
102            }
103        }
104        security.allowed_resource_audiences.clone_from(&merged);
105        SecurityChange {
106            field: "allowed_resource_audiences".to_owned(),
107            old_value: old,
108            new_value: merged.join(","),
109            message: "Updated allowed resource audiences".to_owned(),
110        }
111    }
112
113    pub fn upsert_trusted_issuer(
114        security: &mut SecurityConfig,
115        entry: TrustedIssuer,
116    ) -> SecurityChange {
117        security
118            .trusted_issuers
119            .retain(|t| t.issuer != entry.issuer);
120        let change = SecurityChange {
121            field: "trusted_issuers".to_owned(),
122            old_value: String::new(),
123            new_value: entry.issuer.clone(),
124            message: format!("Added trusted issuer {}", entry.issuer),
125        };
126        security.trusted_issuers.push(entry);
127        change
128    }
129
130    pub fn remove_trusted_issuer(
131        security: &mut SecurityConfig,
132        issuer: &str,
133    ) -> ConfigResult<SecurityChange> {
134        let before = security.trusted_issuers.len();
135        security.trusted_issuers.retain(|t| t.issuer != issuer);
136        if security.trusted_issuers.len() == before {
137            return Err(ConfigError::TrustedIssuerNotFound {
138                issuer: issuer.to_owned(),
139            });
140        }
141        Ok(SecurityChange {
142            field: "trusted_issuers".to_owned(),
143            old_value: issuer.to_owned(),
144            new_value: String::new(),
145            message: format!("Removed trusted issuer {}", issuer),
146        })
147    }
148}