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