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