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