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