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 judge;
21mod observability;
22mod oci_reference;
23mod paths;
24mod rate_limits;
25mod retention;
26mod runtime;
27mod secrets;
28mod security;
29mod server;
30mod services;
31mod site;
32mod storage;
33mod style;
34mod validation;
35mod vault;
36
37pub use cloud::{CloudConfig, CloudValidationMode};
38pub use database::{DatabaseConfig, PoolConfig};
39pub use error::{ProfileError, ProfileResult};
40pub use governance::{
41    AuditConfig, AuthzConfig, AuthzHookConfig, AuthzMode, GovernanceConfig,
42    UNRESTRICTED_ACKNOWLEDGEMENT,
43};
44pub use info::ProfileInfo;
45pub use judge::JudgeProfile;
46pub use observability::{ObservabilityConfig, OtlpExportConfig, OtlpProtocol, OtlpSignal};
47pub use oci_reference::{OciReference, OciReferenceError};
48pub use paths::{PathsConfig, expand_home, resolve_path, resolve_with_home};
49pub use rate_limits::{
50    RateLimitsConfig, default_agent_registry, default_agents, default_artifacts,
51    default_bridge_auth, default_burst, default_content, default_contexts, default_gateway,
52    default_mcp, default_mcp_registry, default_oauth_auth, default_oauth_public, default_stream,
53    default_tasks,
54};
55pub use retention::RetentionConfig;
56pub use runtime::{Environment, LogLevel, OutputFormat, RuntimeConfig};
57pub use secrets::{SecretsConfig, SecretsSource, SecretsValidationMode};
58pub use security::{
59    DEFAULT_ID_JAG_TTL_SECS, GATEWAY_REQUIRED_RESOURCE_AUDIENCES, SecurityConfig, TrustedIssuer,
60    default_resource_audiences,
61};
62pub use server::{
63    ContentNegotiationConfig, FrameOptions, ReferrerPolicy, SecurityHeadersConfig, ServerConfig,
64};
65pub use services::{
66    BundleVerification, FetchFailurePolicy, HttpsServicesSource, OciServicesSource,
67    ServicesProfileConfig, ServicesSource,
68};
69pub use site::SiteConfig;
70pub use storage::{StorageBackend, StorageConfig};
71pub use style::ProfileStyle;
72pub use vault::{
73    DEFAULT_VAULT_RETRIES, DEFAULT_VAULT_TIMEOUT_SECS, MAX_VAULT_RETRIES, MAX_VAULT_TIMEOUT_SECS,
74    VaultAuth, VaultKeyRef, VaultSecretsConfig,
75};
76
77use serde::{Deserialize, Serialize};
78use std::path::Path;
79
80use crate::env::{interpolate, read_env_optional};
81
82#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
83#[serde(deny_unknown_fields)]
84pub struct ExtensionsConfig {
85    #[serde(default)]
86    pub disabled: Vec<String>,
87}
88
89impl ExtensionsConfig {
90    pub fn is_disabled(&self, extension_id: &str) -> bool {
91        self.disabled.iter().any(|id| id == extension_id)
92    }
93}
94
95#[derive(
96    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, schemars::JsonSchema,
97)]
98#[serde(rename_all = "lowercase")]
99pub enum ProfileType {
100    #[default]
101    Local,
102    Cloud,
103}
104
105impl ProfileType {
106    pub const fn is_cloud(&self) -> bool {
107        matches!(self, Self::Cloud)
108    }
109
110    pub const fn is_local(&self) -> bool {
111        matches!(self, Self::Local)
112    }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
116#[serde(deny_unknown_fields)]
117pub struct Profile {
118    pub name: String,
119
120    pub display_name: String,
121
122    #[serde(default)]
123    pub target: ProfileType,
124
125    pub site: SiteConfig,
126
127    pub database: DatabaseConfig,
128
129    pub server: ServerConfig,
130
131    pub paths: PathsConfig,
132
133    pub security: SecurityConfig,
134
135    pub rate_limits: RateLimitsConfig,
136
137    pub system_admin: crate::services::SystemAdminConfig,
138
139    #[serde(default)]
140    pub runtime: RuntimeConfig,
141
142    #[serde(default)]
143    pub cloud: Option<CloudConfig>,
144
145    #[serde(default)]
146    pub secrets: Option<SecretsConfig>,
147
148    #[serde(default)]
149    pub extensions: ExtensionsConfig,
150
151    #[serde(default)]
152    pub governance: Option<GovernanceConfig>,
153
154    // Why: renamed from `evaluation` in 5c8d3ae9e; the profile denies unknown
155    // fields, so a deployed profile.yaml still on the old key would refuse to
156    // boot. See the same alias on PluginHooksRef.
157    #[serde(default, alias = "evaluation")]
158    pub judge: JudgeProfile,
159
160    #[serde(default)]
161    pub retention: RetentionConfig,
162
163    #[serde(default)]
164    pub services: ServicesProfileConfig,
165
166    #[serde(default)]
167    pub storage: StorageConfig,
168
169    #[serde(default)]
170    pub observability: ObservabilityConfig,
171}
172
173const MOVED_SECTIONS: &[(&str, &str)] = &[
174    ("providers", "services/ai/providers.yaml"),
175    ("gateway", "services/ai/gateway.yaml"),
176];
177
178fn reject_moved_sections(content: &str, profile_path: &Path) -> ProfileResult<()> {
179    let Ok(serde_yaml::Value::Mapping(map)) = serde_yaml::from_str::<serde_yaml::Value>(content)
180    else {
181        return Ok(());
182    };
183    for (key, destination) in MOVED_SECTIONS {
184        if map.contains_key(serde_yaml::Value::String((*key).to_owned())) {
185            return Err(ProfileError::MovedToServices {
186                path: profile_path.to_path_buf(),
187                key: (*key).to_owned(),
188                destination: (*destination).to_owned(),
189            });
190        }
191    }
192    Ok(())
193}
194
195impl Profile {
196    #[must_use]
197    pub fn is_local_trial(&self) -> bool {
198        self.cloud
199            .as_ref()
200            .map_or_else(|| self.target.is_local(), CloudConfig::is_local_trial)
201    }
202
203    #[must_use]
204    pub const fn path_resolution(&self) -> crate::paths::PathResolution {
205        if self.target.is_cloud() {
206            crate::paths::PathResolution::Lexical
207        } else {
208            crate::paths::PathResolution::Canonicalize
209        }
210    }
211
212    pub fn from_yaml(content: &str, profile_path: &Path) -> ProfileResult<Self> {
213        let content = interpolate(content, &|name| read_env_optional(name));
214
215        reject_moved_sections(&content, profile_path)?;
216
217        let mut profile: Self =
218            serde_yaml::from_str(&content).map_err(|source| ProfileError::ParseYaml {
219                path: profile_path.to_path_buf(),
220                source,
221            })?;
222
223        let profile_dir =
224            profile_path
225                .parent()
226                .ok_or_else(|| ProfileError::InvalidProfilePath {
227                    path: profile_path.to_path_buf(),
228                })?;
229
230        profile.paths.resolve_relative_to(profile_dir);
231
232        if let Some(secrets) = profile.secrets.as_ref() {
233            secrets.validate()?;
234        }
235
236        Ok(profile)
237    }
238
239    pub fn to_yaml(&self) -> ProfileResult<String> {
240        serde_yaml::to_string(self).map_err(ProfileError::SerializeYaml)
241    }
242
243    pub fn payload_cap_bytes(&self) -> usize {
244        self.governance
245            .as_ref()
246            .map_or(AuditConfig::DEFAULT_PAYLOAD_CAP_BYTES, |governance| {
247                governance.audit.payload_cap_bytes
248            })
249    }
250
251    pub fn profile_style(&self) -> ProfileStyle {
252        match self.name.to_lowercase().as_str() {
253            "dev" | "development" | "local" => ProfileStyle::Development,
254            "prod" | "production" => ProfileStyle::Production,
255            "staging" | "stage" => ProfileStyle::Staging,
256            "test" | "testing" => ProfileStyle::Test,
257            _ => ProfileStyle::Custom,
258        }
259    }
260
261    pub fn mask_secret(value: &str, visible_chars: usize) -> String {
262        if value.is_empty() {
263            return "(not set)".to_owned();
264        }
265        if value.len() <= visible_chars {
266            return "***".to_owned();
267        }
268        format!("{}...", &value[..visible_chars])
269    }
270
271    pub fn mask_database_url(url: &str) -> String {
272        if let Some(at_pos) = url.find('@')
273            && let Some(colon_pos) = url[..at_pos].rfind(':')
274        {
275            let prefix = &url[..=colon_pos];
276            let suffix = &url[at_pos..];
277            return format!("{}***{}", prefix, suffix);
278        }
279        url.to_owned()
280    }
281
282    pub fn is_masked_database_url(url: &str) -> bool {
283        url.contains(":***@") || url.contains(":********@")
284    }
285}