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