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 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}