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