Skip to main content

systemprompt_models/profile/
validation.rs

1//! Profile validation logic.
2//!
3//! This module contains all validation logic for Profile configurations,
4//! including path validation, security settings, CORS, and rate limits.
5
6use 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}