systemprompt_models/profile/
validation.rs1use 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}