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