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