Skip to main content

systemprompt_models/profile/
validation.rs

1//! Profile validation logic.
2//!
3//! This module contains all validation logic for Profile configurations,
4//! including path validation, security settings, CORS, and rate limits.
5//!
6//! Copyright (c) systemprompt.io — Business Source License 1.1.
7//! See <https://systemprompt.io> for licensing details.
8
9use super::governance::{AuthzMode, UNRESTRICTED_ACKNOWLEDGEMENT};
10use super::security::GATEWAY_REQUIRED_RESOURCE_AUDIENCES;
11use super::{Profile, ProfileError, ProfileResult};
12use crate::auth::JwtAudience;
13
14impl Profile {
15    pub fn validate(&self) -> ProfileResult<()> {
16        let mut errors: Vec<String> = Vec::new();
17        let is_cloud = self.target.is_cloud();
18
19        self.validate_required_fields(&mut errors);
20        self.validate_paths(&mut errors, is_cloud);
21        self.validate_security_settings(&mut errors);
22        self.validate_database_pool(&mut errors);
23        self.validate_cors_origins(&mut errors);
24        self.validate_rate_limits(&mut errors);
25        self.validate_governance(&mut errors, is_cloud);
26
27        if errors.is_empty() {
28            Ok(())
29        } else {
30            Err(ProfileError::Validation {
31                name: self.name.clone(),
32                errors,
33            })
34        }
35    }
36
37    pub(super) fn validate_paths(&self, errors: &mut Vec<String>, is_cloud: bool) {
38        if is_cloud {
39            self.validate_cloud_paths(errors);
40        } else {
41            self.validate_local_paths(errors);
42        }
43    }
44
45    pub(super) fn validate_cloud_paths(&self, errors: &mut Vec<String>) {
46        Self::require_non_empty(errors, &self.paths.system, "Paths system");
47        Self::require_non_empty(errors, &self.paths.services, "Paths services");
48        Self::require_non_empty(errors, &self.paths.bin, "Paths bin");
49
50        for (name, path) in [
51            ("system", self.paths.system.as_str()),
52            ("services", self.paths.services.as_str()),
53            ("bin", self.paths.bin.as_str()),
54        ] {
55            if !path.is_empty() && !path.starts_with("/app") {
56                errors.push(format!(
57                    "Cloud profile {} path should start with /app, got: {}",
58                    name, path
59                ));
60            }
61        }
62
63        if let Some(web_path) = &self.paths.web_path
64            && !web_path.is_empty()
65        {
66            if !web_path.starts_with("/app/web") {
67                errors.push(format!(
68                    "Cloud profile web_path should start with /app/web, got: {}. Note: \
69                         web_path points to the parent of dist/, e.g., /app/web for /app/web/dist",
70                    web_path
71                ));
72            }
73            if web_path.contains("/services/web") {
74                errors.push(format!(
75                    "Cloud profile web_path should be /app/web (for dist output), not \
76                         /app/services/web (which is for templates/config). Got: {}",
77                    web_path
78                ));
79            }
80        }
81    }
82
83    pub(super) fn validate_local_paths(&self, errors: &mut Vec<String>) {
84        Self::require_non_empty(errors, &self.paths.system, "Paths system");
85        Self::require_non_empty(errors, &self.paths.services, "Paths services");
86        Self::require_non_empty(errors, &self.paths.bin, "Paths bin");
87    }
88
89    pub(super) fn validate_required_fields(&self, errors: &mut Vec<String>) {
90        Self::require_non_empty(errors, &self.name, "Profile name");
91        Self::require_non_empty(errors, &self.display_name, "Profile display_name");
92        Self::require_non_empty(errors, &self.site.name, "Site name");
93        Self::require_non_empty(errors, &self.server.host, "Server host");
94        Self::require_non_empty(errors, &self.server.api_server_url, "Server api_server_url");
95        Self::require_non_empty(
96            errors,
97            &self.server.api_internal_url,
98            "Server api_internal_url",
99        );
100        Self::require_non_empty(
101            errors,
102            &self.server.api_external_url,
103            "Server api_external_url",
104        );
105
106        if self.server.port == 0 {
107            errors.push("Server port must be greater than 0".to_owned());
108        }
109    }
110
111    pub(super) fn require_non_empty(errors: &mut Vec<String>, value: &str, field_name: &str) {
112        if value.is_empty() {
113            errors.push(format!("{field_name} is required"));
114        }
115    }
116
117    pub(super) fn validate_security_settings(&self, errors: &mut Vec<String>) {
118        if self.security.access_token_expiration <= 0 {
119            errors.push("Security access_token_expiration must be positive".to_owned());
120        }
121
122        if self.security.refresh_token_expiration <= 0 {
123            errors.push("Security refresh_token_expiration must be positive".to_owned());
124        }
125
126        if !self
127            .security
128            .audiences
129            .iter()
130            .any(|aud| JwtAudience::FIRST_PARTY.contains(aud))
131        {
132            errors.push(
133                "security.jwt_audiences must include at least one first-party surface \
134                 (web, api, a2a, mcp) — session-context token validation pins the `aud` \
135                 claim to that set, so tokens minted without one would be rejected on \
136                 every request. Add the standard audiences to the profile YAML and restart."
137                    .to_owned(),
138            );
139        }
140
141        for required in GATEWAY_REQUIRED_RESOURCE_AUDIENCES {
142            if !self
143                .security
144                .allowed_resource_audiences
145                .iter()
146                .any(|allowed| allowed == required)
147            {
148                errors.push(format!(
149                    "security.allowed_resource_audiences must include \"{required}\" — the \
150                     gateway issues tokens bound to audience=\"{required}\" for internal protocol \
151                     scopes (hook:govern, hook:track). Add it to the profile YAML and restart."
152                ));
153            }
154        }
155    }
156
157    pub(super) fn validate_database_pool(&self, errors: &mut Vec<String>) {
158        let Some(pool) = self.database.pool.as_ref() else {
159            return;
160        };
161        if let Some(max) = pool.max_connections
162            && !(1..=500).contains(&max)
163        {
164            errors.push(format!(
165                "database.pool.max_connections must be between 1 and 500 (got {max})"
166            ));
167        }
168        if pool.acquire_timeout_secs == Some(0) {
169            errors.push("database.pool.acquire_timeout_secs must be greater than 0".to_owned());
170        }
171    }
172
173    pub(super) fn validate_governance(&self, errors: &mut Vec<String>, is_cloud: bool) {
174        if !is_cloud {
175            return;
176        }
177
178        let Some(authz) = self.governance.as_ref().and_then(|g| g.authz.as_ref()) else {
179            errors.push(
180                "governance.authz is required for cloud profiles — without it the gateway boots \
181                 with DenyAllHook and denies every request. Add a governance.authz.hook block \
182                 (mode: webhook for production) to the profile YAML."
183                    .to_owned(),
184            );
185            return;
186        };
187
188        match authz.hook.mode {
189            AuthzMode::Webhook if authz.hook.url.as_deref().unwrap_or_default().is_empty() => {
190                errors.push(
191                    "governance.authz.hook.url is required when mode is webhook — the gateway \
192                     POSTs every request to it."
193                        .to_owned(),
194                );
195            },
196            AuthzMode::Unrestricted
197                if authz.hook.acknowledgement.as_deref() != Some(UNRESTRICTED_ACKNOWLEDGEMENT) =>
198            {
199                errors.push(format!(
200                    "governance.authz.hook.mode=unrestricted requires acknowledgement to equal \
201                     \"{UNRESTRICTED_ACKNOWLEDGEMENT}\" — it disables all authorization."
202                ));
203            },
204            _ => {},
205        }
206    }
207
208    pub(super) fn validate_cors_origins(&self, errors: &mut Vec<String>) {
209        for origin in &self.server.cors_allowed_origins {
210            if origin.is_empty() {
211                errors.push("CORS origin cannot be empty".to_owned());
212                continue;
213            }
214
215            if origin == "*" {
216                errors.push("CORS origin '*' is not permitted; list explicit origins".to_owned());
217                continue;
218            }
219
220            let is_https = origin.starts_with("https://");
221            let is_loopback_http = origin.starts_with("http://localhost")
222                || origin.starts_with("http://127.0.0.1")
223                || origin.starts_with("http://[::1]");
224            if !is_https && !is_loopback_http {
225                errors.push(format!(
226                    "Invalid CORS origin (must be https:// or http://localhost): {origin}"
227                ));
228            }
229        }
230    }
231
232    pub(super) fn validate_rate_limits(&self, errors: &mut Vec<String>) {
233        if self.rate_limits.disabled {
234            return;
235        }
236
237        if self.rate_limits.burst_multiplier == 0 {
238            errors.push("rate_limits.burst_multiplier must be greater than 0".to_owned());
239        }
240
241        Self::validate_rate_limit(
242            errors,
243            "oauth_public",
244            self.rate_limits.oauth_public_per_second,
245        );
246        Self::validate_rate_limit(errors, "oauth_auth", self.rate_limits.oauth_auth_per_second);
247        Self::validate_rate_limit(errors, "contexts", self.rate_limits.contexts_per_second);
248        Self::validate_rate_limit(errors, "tasks", self.rate_limits.tasks_per_second);
249        Self::validate_rate_limit(errors, "artifacts", self.rate_limits.artifacts_per_second);
250        Self::validate_rate_limit(errors, "agents", self.rate_limits.agents_per_second);
251        Self::validate_rate_limit(errors, "mcp", self.rate_limits.mcp_per_second);
252        Self::validate_rate_limit(errors, "stream", self.rate_limits.stream_per_second);
253        Self::validate_rate_limit(errors, "content", self.rate_limits.content_per_second);
254    }
255
256    fn validate_rate_limit(errors: &mut Vec<String>, name: &str, value: u64) {
257        if value == 0 {
258            errors.push(format!(
259                "rate_limits.{}_per_second must be greater than 0",
260                name
261            ));
262        }
263    }
264}