Skip to main content

systemprompt_models/profile/
mod.rs

1//! Profile configuration models — the deserialized shape of a
2//! `.systemprompt/profiles/<name>/profile.yaml` document.
3//!
4//! Covers server, database, paths, secrets, security, rate limits,
5//! governance, and runtime sections, plus validation rules and
6//! environment-variable interpolation. The provider catalog and gateway
7//! routes are not profile sections: they live in the services tree
8//! (`crate::services::{ProviderRegistry, GatewayState}`), and a profile that
9//! still carries them fails to parse with [`ProfileError::MovedToServices`].
10//!
11//! Copyright (c) systemprompt.io — Business Source License 1.1.
12//! See <https://systemprompt.io> for licensing details.
13
14mod cloud;
15mod database;
16mod error;
17mod from_env;
18mod governance;
19mod info;
20mod paths;
21mod rate_limits;
22mod runtime;
23mod secrets;
24mod security;
25mod server;
26mod services;
27mod site;
28mod storage;
29mod style;
30mod validation;
31
32pub use cloud::{CloudConfig, CloudValidationMode};
33pub use database::{DatabaseConfig, PoolConfig};
34pub use error::{ProfileError, ProfileResult};
35pub use governance::{
36    AuthzConfig, AuthzHookConfig, AuthzMode, GovernanceConfig, UNRESTRICTED_ACKNOWLEDGEMENT,
37};
38pub use info::ProfileInfo;
39pub use paths::{PathsConfig, expand_home, resolve_path, resolve_with_home};
40pub use rate_limits::{
41    RateLimitsConfig, default_agent_registry, default_agents, default_artifacts, default_burst,
42    default_content, default_contexts, default_mcp, default_mcp_registry, default_oauth_auth,
43    default_oauth_public, default_stream, default_tasks,
44};
45pub use runtime::{Environment, LogLevel, OutputFormat, RuntimeConfig};
46pub use secrets::{SecretsConfig, SecretsSource, SecretsValidationMode};
47pub use security::{
48    DEFAULT_ID_JAG_TTL_SECS, GATEWAY_REQUIRED_RESOURCE_AUDIENCES, SecurityConfig, TrustedIssuer,
49    default_resource_audiences,
50};
51pub use server::{
52    ContentNegotiationConfig, FrameOptions, ReferrerPolicy, SecurityHeadersConfig, ServerConfig,
53};
54pub use services::ServicesProfileConfig;
55pub use site::SiteConfig;
56pub use storage::{StorageBackend, StorageConfig};
57pub use style::ProfileStyle;
58
59use serde::{Deserialize, Serialize};
60use std::path::Path;
61
62use crate::env::{interpolate, read_env_optional};
63
64#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
65#[serde(deny_unknown_fields)]
66pub struct ExtensionsConfig {
67    #[serde(default)]
68    pub disabled: Vec<String>,
69}
70
71impl ExtensionsConfig {
72    pub fn is_disabled(&self, extension_id: &str) -> bool {
73        self.disabled.iter().any(|id| id == extension_id)
74    }
75}
76
77#[derive(
78    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, schemars::JsonSchema,
79)]
80#[serde(rename_all = "lowercase")]
81pub enum ProfileType {
82    #[default]
83    Local,
84    Cloud,
85}
86
87impl ProfileType {
88    pub const fn is_cloud(&self) -> bool {
89        matches!(self, Self::Cloud)
90    }
91
92    pub const fn is_local(&self) -> bool {
93        matches!(self, Self::Local)
94    }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
98#[serde(deny_unknown_fields)]
99pub struct Profile {
100    pub name: String,
101
102    pub display_name: String,
103
104    #[serde(default)]
105    pub target: ProfileType,
106
107    pub site: SiteConfig,
108
109    pub database: DatabaseConfig,
110
111    pub server: ServerConfig,
112
113    pub paths: PathsConfig,
114
115    pub security: SecurityConfig,
116
117    pub rate_limits: RateLimitsConfig,
118
119    pub system_admin: crate::services::SystemAdminConfig,
120
121    #[serde(default)]
122    pub runtime: RuntimeConfig,
123
124    #[serde(default)]
125    pub cloud: Option<CloudConfig>,
126
127    #[serde(default)]
128    pub secrets: Option<SecretsConfig>,
129
130    #[serde(default)]
131    pub extensions: ExtensionsConfig,
132
133    #[serde(default)]
134    pub governance: Option<GovernanceConfig>,
135
136    #[serde(default)]
137    pub services: ServicesProfileConfig,
138
139    #[serde(default)]
140    pub storage: StorageConfig,
141}
142
143const MOVED_SECTIONS: &[(&str, &str)] = &[
144    ("providers", "services/ai/providers.yaml"),
145    ("gateway", "services/ai/gateway.yaml"),
146];
147
148// Why: `Profile` is `deny_unknown_fields`, so a profile written for a release
149// that still carried these sections would otherwise fail with a bare "unknown
150// field" — the one message that does not say where the section went.
151fn reject_moved_sections(content: &str, profile_path: &Path) -> ProfileResult<()> {
152    let Ok(serde_yaml::Value::Mapping(map)) = serde_yaml::from_str::<serde_yaml::Value>(content)
153    else {
154        return Ok(());
155    };
156    for (key, destination) in MOVED_SECTIONS {
157        if map.contains_key(serde_yaml::Value::String((*key).to_owned())) {
158            return Err(ProfileError::MovedToServices {
159                path: profile_path.to_path_buf(),
160                key: (*key).to_owned(),
161                destination: (*destination).to_owned(),
162            });
163        }
164    }
165    Ok(())
166}
167
168impl Profile {
169    #[must_use]
170    pub fn is_local_trial(&self) -> bool {
171        self.cloud.as_ref().is_none_or(CloudConfig::is_local_trial)
172    }
173
174    #[must_use]
175    pub const fn path_resolution(&self) -> crate::paths::PathResolution {
176        if self.target.is_cloud() {
177            crate::paths::PathResolution::Lexical
178        } else {
179            crate::paths::PathResolution::Canonicalize
180        }
181    }
182
183    pub fn from_yaml(content: &str, profile_path: &Path) -> ProfileResult<Self> {
184        let content = interpolate(content, &|name| read_env_optional(name));
185
186        reject_moved_sections(&content, profile_path)?;
187
188        let mut profile: Self =
189            serde_yaml::from_str(&content).map_err(|source| ProfileError::ParseYaml {
190                path: profile_path.to_path_buf(),
191                source,
192            })?;
193
194        let profile_dir =
195            profile_path
196                .parent()
197                .ok_or_else(|| ProfileError::InvalidProfilePath {
198                    path: profile_path.to_path_buf(),
199                })?;
200
201        profile.paths.resolve_relative_to(profile_dir);
202
203        Ok(profile)
204    }
205
206    pub fn to_yaml(&self) -> ProfileResult<String> {
207        serde_yaml::to_string(self).map_err(ProfileError::SerializeYaml)
208    }
209
210    pub fn profile_style(&self) -> ProfileStyle {
211        match self.name.to_lowercase().as_str() {
212            "dev" | "development" | "local" => ProfileStyle::Development,
213            "prod" | "production" => ProfileStyle::Production,
214            "staging" | "stage" => ProfileStyle::Staging,
215            "test" | "testing" => ProfileStyle::Test,
216            _ => ProfileStyle::Custom,
217        }
218    }
219
220    pub fn mask_secret(value: &str, visible_chars: usize) -> String {
221        if value.is_empty() {
222            return "(not set)".to_owned();
223        }
224        if value.len() <= visible_chars {
225            return "***".to_owned();
226        }
227        format!("{}...", &value[..visible_chars])
228    }
229
230    pub fn mask_database_url(url: &str) -> String {
231        if let Some(at_pos) = url.find('@')
232            && let Some(colon_pos) = url[..at_pos].rfind(':')
233        {
234            let prefix = &url[..=colon_pos];
235            let suffix = &url[at_pos..];
236            return format!("{}***{}", prefix, suffix);
237        }
238        url.to_owned()
239    }
240
241    pub fn is_masked_database_url(url: &str) -> bool {
242        url.contains(":***@") || url.contains(":********@")
243    }
244}