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_urls(&mut errors);
21        self.validate_paths(&mut errors, is_cloud);
22        self.validate_security_settings(&mut errors);
23        self.validate_database_pool(&mut errors);
24        self.validate_cors_origins(&mut errors);
25        self.validate_rate_limits(&mut errors);
26        self.validate_governance(&mut errors, is_cloud);
27
28        if errors.is_empty() {
29            Ok(())
30        } else {
31            Err(ProfileError::Validation {
32                name: self.name.clone(),
33                errors,
34            })
35        }
36    }
37
38    pub(super) fn validate_paths(&self, errors: &mut Vec<String>, is_cloud: bool) {
39        if is_cloud {
40            self.validate_cloud_paths(errors);
41        } else {
42            self.validate_local_paths(errors);
43        }
44    }
45
46    pub(super) fn validate_cloud_paths(&self, errors: &mut Vec<String>) {
47        Self::require_non_empty(errors, &self.paths.system, "Paths system");
48        Self::require_non_empty(errors, &self.paths.services, "Paths services");
49        Self::require_non_empty(errors, &self.paths.bin, "Paths bin");
50
51        for (name, path) in [
52            ("system", self.paths.system.as_str()),
53            ("services", self.paths.services.as_str()),
54            ("bin", self.paths.bin.as_str()),
55        ] {
56            if !path.is_empty() && !path.starts_with("/app") {
57                errors.push(format!(
58                    "Cloud profile {} path should start with /app, got: {}",
59                    name, path
60                ));
61            }
62        }
63
64        if let Some(web_path) = &self.paths.web_path
65            && !web_path.is_empty()
66        {
67            if !web_path.starts_with("/app/web") {
68                errors.push(format!(
69                    "Cloud profile web_path should start with /app/web, got: {}. Note: \
70                         web_path points to the parent of dist/, e.g., /app/web for /app/web/dist",
71                    web_path
72                ));
73            }
74            if web_path.contains("/services/web") {
75                errors.push(format!(
76                    "Cloud profile web_path should be /app/web (for dist output), not \
77                         /app/services/web (which is for templates/config). Got: {}",
78                    web_path
79                ));
80            }
81        }
82    }
83
84    pub(super) fn validate_local_paths(&self, errors: &mut Vec<String>) {
85        Self::require_non_empty(errors, &self.paths.system, "Paths system");
86        Self::require_non_empty(errors, &self.paths.services, "Paths services");
87        Self::require_non_empty(errors, &self.paths.bin, "Paths bin");
88    }
89
90    pub(super) fn validate_required_fields(&self, errors: &mut Vec<String>) {
91        Self::require_non_empty(errors, &self.name, "Profile name");
92        Self::require_non_empty(errors, &self.display_name, "Profile display_name");
93        Self::require_non_empty(errors, &self.site.name, "Site name");
94        Self::require_non_empty(errors, &self.server.host, "Server host");
95        Self::require_non_empty(errors, &self.server.api_server_url, "Server api_server_url");
96        Self::require_non_empty(
97            errors,
98            &self.server.api_internal_url,
99            "Server api_internal_url",
100        );
101        Self::require_non_empty(
102            errors,
103            &self.server.api_external_url,
104            "Server api_external_url",
105        );
106
107        if self.server.port == 0 {
108            errors.push("Server port must be greater than 0".to_owned());
109        }
110    }
111
112    pub(super) fn require_non_empty(errors: &mut Vec<String>, value: &str, field_name: &str) {
113        if value.is_empty() {
114            errors.push(format!("{field_name} is required"));
115        }
116    }
117
118    pub(super) fn validate_urls(&self, errors: &mut Vec<String>) {
119        for (name, value) in [
120            ("server.api_server_url", self.server.api_server_url.as_str()),
121            (
122                "server.api_internal_url",
123                self.server.api_internal_url.as_str(),
124            ),
125            (
126                "server.api_external_url",
127                self.server.api_external_url.as_str(),
128            ),
129            ("security.issuer", self.security.issuer.as_str()),
130        ] {
131            Self::require_absolute_url(errors, name, value, false);
132        }
133
134        if !self.server.host.is_empty() && self.server.host.contains("://") {
135            errors.push(format!(
136                "server.host must be a bare hostname or IP, not a URL (got: {})",
137                self.server.host
138            ));
139        }
140
141        for (idx, issuer) in self.security.trusted_issuers.iter().enumerate() {
142            Self::require_absolute_url(
143                errors,
144                &format!("security.trusted_issuers[{idx}].issuer"),
145                &issuer.issuer,
146                false,
147            );
148            Self::require_absolute_url(
149                errors,
150                &format!("security.trusted_issuers[{idx}].jwks_uri"),
151                &issuer.jwks_uri,
152                true,
153            );
154        }
155
156        if let Some(hook) = self.governance.as_ref().and_then(|g| g.authz.as_ref())
157            && let Some(url) = hook.hook.url.as_deref()
158        {
159            Self::require_absolute_url(errors, "governance.authz.hook.url", url, false);
160        }
161    }
162
163    fn require_absolute_url(errors: &mut Vec<String>, field: &str, value: &str, https_only: bool) {
164        if value.is_empty() {
165            return;
166        }
167        let allowed: &[&str] = if https_only {
168            &["https"]
169        } else {
170            &["http", "https"]
171        };
172        match url::Url::parse(value) {
173            Ok(url) if !allowed.contains(&url.scheme()) => {
174                errors.push(format!(
175                    "{field} must be {} (got scheme '{}': {value})",
176                    if https_only {
177                        "an https URL"
178                    } else {
179                        "an http(s) URL"
180                    },
181                    url.scheme()
182                ));
183            },
184            Ok(url) if url.host_str().is_none_or(str::is_empty) => {
185                errors.push(format!("{field} must include a host (got: {value})"));
186            },
187            Ok(_) => {},
188            Err(e) => errors.push(format!("{field} is not a valid URL ({e}): {value}")),
189        }
190    }
191
192    pub(super) fn validate_security_settings(&self, errors: &mut Vec<String>) {
193        if self.security.access_token_expiration <= 0 {
194            errors.push("Security access_token_expiration must be positive".to_owned());
195        }
196
197        if self.security.refresh_token_expiration <= 0 {
198            errors.push("Security refresh_token_expiration must be positive".to_owned());
199        }
200
201        if !self
202            .security
203            .audiences
204            .iter()
205            .any(|aud| JwtAudience::FIRST_PARTY.contains(aud))
206        {
207            errors.push(
208                "security.jwt_audiences must include at least one first-party surface \
209                 (web, api, a2a, mcp) — session-context token validation pins the `aud` \
210                 claim to that set, so tokens minted without one would be rejected on \
211                 every request. Add the standard audiences to the profile YAML and restart."
212                    .to_owned(),
213            );
214        }
215
216        for required in GATEWAY_REQUIRED_RESOURCE_AUDIENCES {
217            if !self
218                .security
219                .allowed_resource_audiences
220                .iter()
221                .any(|allowed| allowed == required)
222            {
223                errors.push(format!(
224                    "security.allowed_resource_audiences must include \"{required}\" — the \
225                     gateway issues tokens bound to audience=\"{required}\" for internal protocol \
226                     scopes (hook:govern, hook:track). Add it to the profile YAML and restart."
227                ));
228            }
229        }
230    }
231
232    pub(super) fn validate_database_pool(&self, errors: &mut Vec<String>) {
233        let Some(pool) = self.database.pool.as_ref() else {
234            return;
235        };
236        if let Some(max) = pool.max_connections
237            && !(1..=500).contains(&max)
238        {
239            errors.push(format!(
240                "database.pool.max_connections must be between 1 and 500 (got {max})"
241            ));
242        }
243        if pool.acquire_timeout_secs == Some(0) {
244            errors.push("database.pool.acquire_timeout_secs must be greater than 0".to_owned());
245        }
246    }
247
248    pub(super) fn validate_governance(&self, errors: &mut Vec<String>, is_cloud: bool) {
249        if !is_cloud {
250            return;
251        }
252
253        let Some(authz) = self.governance.as_ref().and_then(|g| g.authz.as_ref()) else {
254            errors.push(
255                "governance.authz is required for cloud profiles — without it the gateway boots \
256                 with DenyAllHook and denies every request. Add a governance.authz.hook block \
257                 (mode: webhook for production) to the profile YAML."
258                    .to_owned(),
259            );
260            return;
261        };
262
263        match authz.hook.mode {
264            AuthzMode::Webhook if authz.hook.url.as_deref().unwrap_or_default().is_empty() => {
265                errors.push(
266                    "governance.authz.hook.url is required when mode is webhook — the gateway \
267                     POSTs every request to it."
268                        .to_owned(),
269                );
270            },
271            AuthzMode::Unrestricted
272                if authz.hook.acknowledgement.as_deref() != Some(UNRESTRICTED_ACKNOWLEDGEMENT) =>
273            {
274                errors.push(format!(
275                    "governance.authz.hook.mode=unrestricted requires acknowledgement to equal \
276                     \"{UNRESTRICTED_ACKNOWLEDGEMENT}\" — it disables all authorization."
277                ));
278            },
279            _ => {},
280        }
281    }
282
283    pub(super) fn validate_cors_origins(&self, errors: &mut Vec<String>) {
284        for origin in &self.server.cors_allowed_origins {
285            if origin.is_empty() {
286                errors.push("CORS origin cannot be empty".to_owned());
287                continue;
288            }
289
290            if origin == "*" {
291                errors.push("CORS origin '*' is not permitted; list explicit origins".to_owned());
292                continue;
293            }
294
295            let parsed = match url::Url::parse(origin) {
296                Ok(url) => url,
297                Err(e) => {
298                    errors.push(format!("Invalid CORS origin ({e}): {origin}"));
299                    continue;
300                },
301            };
302
303            let Some(host) = parsed.host_str() else {
304                errors.push(format!("CORS origin must include a host: {origin}"));
305                continue;
306            };
307
308            let bare_host = host.trim_start_matches('[').trim_end_matches(']');
309            let is_loopback_http =
310                parsed.scheme() == "http" && matches!(bare_host, "localhost" | "127.0.0.1" | "::1");
311            if parsed.scheme() != "https" && !is_loopback_http {
312                errors.push(format!(
313                    "Invalid CORS origin (must be https:// or http://localhost): {origin}"
314                ));
315                continue;
316            }
317
318            if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() {
319                errors.push(format!(
320                    "CORS origin must be scheme://host[:port] with no path/query/fragment: {origin}"
321                ));
322            }
323        }
324    }
325
326    pub(super) fn validate_rate_limits(&self, errors: &mut Vec<String>) {
327        if self.rate_limits.disabled {
328            return;
329        }
330
331        if self.rate_limits.burst_multiplier == 0 {
332            errors.push("rate_limits.burst_multiplier must be greater than 0".to_owned());
333        }
334
335        Self::validate_rate_limit(
336            errors,
337            "oauth_public",
338            self.rate_limits.oauth_public_per_second,
339        );
340        Self::validate_rate_limit(errors, "oauth_auth", self.rate_limits.oauth_auth_per_second);
341        Self::validate_rate_limit(errors, "contexts", self.rate_limits.contexts_per_second);
342        Self::validate_rate_limit(errors, "tasks", self.rate_limits.tasks_per_second);
343        Self::validate_rate_limit(errors, "artifacts", self.rate_limits.artifacts_per_second);
344        Self::validate_rate_limit(errors, "agents", self.rate_limits.agents_per_second);
345        Self::validate_rate_limit(errors, "mcp", self.rate_limits.mcp_per_second);
346        Self::validate_rate_limit(errors, "stream", self.rate_limits.stream_per_second);
347        Self::validate_rate_limit(errors, "content", self.rate_limits.content_per_second);
348    }
349
350    fn validate_rate_limit(errors: &mut Vec<String>, name: &str, value: u64) {
351        if value == 0 {
352            errors.push(format!(
353                "rate_limits.{}_per_second must be greater than 0",
354                name
355            ));
356        }
357    }
358}