1use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6
7use crate::console_config::ConsoleUiConfig;
8use crate::types::{ModuleConfig, RestartPolicy};
9
10pub const REQUIRED_RELEASE_TARGETS: &[&str] = &["crates.io", "npm", "pypi", "github-releases"];
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum DecisionPolicyError {
14 EmptyBigQueryDataset,
15 EmptyBigQueryTable,
16 InvalidBigQueryName(String),
17 TomlParse(String),
18 MissingModuleId,
19 MissingModuleCommand,
20 AuthProviderMismatch,
21 AuthProviderNotSupported,
22 EmailNotAllowlisted,
23 InvalidServiceIdentity,
24 ServiceIdentityNotAllowlisted,
25 ReplicaCountMustBeOne(u16),
26 SloTargetsNotSupportedV01,
27 MissingReleaseTarget(String),
28 DuplicateReleaseTarget(String),
29 InvalidSupportMatrix(String),
30 InvalidTrustedAuthConfig(String),
31}
32
33impl std::fmt::Display for DecisionPolicyError {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 match self {
36 Self::EmptyBigQueryDataset => write!(f, "empty BigQuery dataset"),
37 Self::EmptyBigQueryTable => write!(f, "empty BigQuery table"),
38 Self::InvalidBigQueryName(name) => write!(f, "invalid BigQuery name: {name}"),
39 Self::TomlParse(msg) => write!(f, "TOML parse error: {msg}"),
40 Self::MissingModuleId => write!(f, "missing module id"),
41 Self::MissingModuleCommand => write!(f, "missing module command"),
42 Self::AuthProviderMismatch => write!(f, "auth provider mismatch"),
43 Self::AuthProviderNotSupported => write!(f, "auth provider not supported"),
44 Self::EmailNotAllowlisted => write!(f, "email not allowlisted"),
45 Self::InvalidServiceIdentity => write!(f, "invalid service identity"),
46 Self::ServiceIdentityNotAllowlisted => write!(f, "service identity not allowlisted"),
47 Self::ReplicaCountMustBeOne(count) => {
48 write!(f, "replica count must be 1, got {count}")
49 }
50 Self::SloTargetsNotSupportedV01 => {
51 write!(f, "SLO targets not supported in v0.1")
52 }
53 Self::MissingReleaseTarget(target) => {
54 write!(f, "missing release target: {target}")
55 }
56 Self::DuplicateReleaseTarget(target) => {
57 write!(f, "duplicate release target: {target}")
58 }
59 Self::InvalidSupportMatrix(matrix) => {
60 write!(f, "invalid support matrix: {matrix}")
61 }
62 Self::InvalidTrustedAuthConfig(msg) => {
63 write!(f, "invalid trusted auth config: {msg}")
64 }
65 }
66 }
67}
68
69impl std::error::Error for DecisionPolicyError {}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct BigQueryNaming {
73 pub dataset: String,
74 pub table: String,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78struct TrustedMobkitToml {
79 pub modules: Vec<TrustedModuleDecl>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83struct TrustedModuleDecl {
84 pub id: String,
85 pub command: String,
86 #[serde(default)]
87 pub args: Vec<String>,
88 pub restart_policy: Option<RestartPolicy>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum AuthProvider {
94 GoogleOAuth,
95 GitHubOAuth,
96 GenericOidc,
97 ServiceIdentity,
98 TestProvider,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct AuthPolicy {
103 pub default_provider: AuthProvider,
104 pub email_allowlist: Vec<String>,
105}
106
107impl Default for AuthPolicy {
108 fn default() -> Self {
109 Self {
110 default_provider: AuthProvider::GoogleOAuth,
111 email_allowlist: Vec::new(),
112 }
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct ConsolePolicy {
118 pub require_app_auth: bool,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub fetch_timeout_ms: Option<u64>,
121 #[serde(default, skip_serializing_if = "ConsoleUiConfig::is_default")]
122 pub ui: ConsoleUiConfig,
123}
124
125impl Default for ConsolePolicy {
126 fn default() -> Self {
127 Self {
128 require_app_auth: true,
129 fetch_timeout_ms: None,
130 ui: ConsoleUiConfig::default(),
131 }
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub struct ConsoleAccessRequest {
137 pub provider: AuthProvider,
138 pub email: String,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142pub struct MetricsPolicy {
143 pub enforce_slo_targets: bool,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct RuntimeOpsPolicy {
148 pub replica_count: u16,
149 pub metrics: MetricsPolicy,
150}
151
152impl Default for RuntimeOpsPolicy {
153 fn default() -> Self {
154 Self {
155 replica_count: 1,
156 metrics: MetricsPolicy {
157 enforce_slo_targets: false,
158 },
159 }
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct ReleaseMetadata {
165 pub targets: Vec<String>,
166 pub support_matrix: String,
167}
168
169pub fn validate_bigquery_naming(naming: &BigQueryNaming) -> Result<(), DecisionPolicyError> {
170 if naming.dataset.trim().is_empty() {
171 return Err(DecisionPolicyError::EmptyBigQueryDataset);
172 }
173 if naming.table.trim().is_empty() {
174 return Err(DecisionPolicyError::EmptyBigQueryTable);
175 }
176
177 for value in [&naming.dataset, &naming.table] {
178 if !value
179 .chars()
180 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
181 {
182 return Err(DecisionPolicyError::InvalidBigQueryName(value.clone()));
183 }
184 }
185
186 Ok(())
187}
188
189pub fn load_trusted_mobkit_modules_from_toml(
190 toml_text: &str,
191) -> Result<Vec<ModuleConfig>, DecisionPolicyError> {
192 let parsed: TrustedMobkitToml =
193 toml::from_str(toml_text).map_err(|err| DecisionPolicyError::TomlParse(err.to_string()))?;
194
195 parsed
196 .modules
197 .into_iter()
198 .map(|module| {
199 if module.id.trim().is_empty() {
200 return Err(DecisionPolicyError::MissingModuleId);
201 }
202 if module.command.trim().is_empty() {
203 return Err(DecisionPolicyError::MissingModuleCommand);
204 }
205 Ok(ModuleConfig {
206 id: module.id,
207 command: module.command,
208 args: module.args,
209 restart_policy: module.restart_policy.unwrap_or(RestartPolicy::OnFailure),
210 })
211 })
212 .collect()
213}
214
215pub fn enforce_console_route_access(
216 auth_policy: &AuthPolicy,
217 console_policy: &ConsolePolicy,
218 request: &ConsoleAccessRequest,
219) -> Result<(), DecisionPolicyError> {
220 if !console_policy.require_app_auth {
221 return Ok(());
222 }
223
224 if request.provider == AuthProvider::ServiceIdentity {
225 if !request.email.starts_with("svc:") || request.email.len() <= 4 {
226 return Err(DecisionPolicyError::InvalidServiceIdentity);
227 }
228 if !auth_policy
229 .email_allowlist
230 .iter()
231 .any(|principal| principal == &request.email)
232 {
233 return Err(DecisionPolicyError::ServiceIdentityNotAllowlisted);
234 }
235 return Ok(());
236 }
237
238 if request.provider != auth_policy.default_provider {
239 return Err(DecisionPolicyError::AuthProviderMismatch);
240 }
241
242 if matches!(request.provider, AuthProvider::TestProvider) {
243 return Err(DecisionPolicyError::AuthProviderNotSupported);
244 }
245
246 if !auth_policy
247 .email_allowlist
248 .iter()
249 .any(|email| email == &request.email)
250 {
251 return Err(DecisionPolicyError::EmailNotAllowlisted);
252 }
253
254 Ok(())
255}
256
257pub fn validate_runtime_ops_policy(policy: &RuntimeOpsPolicy) -> Result<(), DecisionPolicyError> {
258 if policy.replica_count != 1 {
259 return Err(DecisionPolicyError::ReplicaCountMustBeOne(
260 policy.replica_count,
261 ));
262 }
263 if policy.metrics.enforce_slo_targets {
264 return Err(DecisionPolicyError::SloTargetsNotSupportedV01);
265 }
266 Ok(())
267}
268
269pub fn parse_release_metadata_json(
270 json_text: &str,
271) -> Result<ReleaseMetadata, DecisionPolicyError> {
272 serde_json::from_str(json_text).map_err(|err| DecisionPolicyError::TomlParse(err.to_string()))
273}
274
275pub fn validate_release_metadata(metadata: &ReleaseMetadata) -> Result<(), DecisionPolicyError> {
276 let mut seen = BTreeSet::new();
277 for target in &metadata.targets {
278 if !seen.insert(target.clone()) {
279 return Err(DecisionPolicyError::DuplicateReleaseTarget(target.clone()));
280 }
281 }
282
283 for required in REQUIRED_RELEASE_TARGETS {
284 if !seen.contains(*required) {
285 return Err(DecisionPolicyError::MissingReleaseTarget(
286 (*required).to_string(),
287 ));
288 }
289 }
290
291 if metadata.support_matrix != "same-as-meerkat" {
292 return Err(DecisionPolicyError::InvalidSupportMatrix(
293 metadata.support_matrix.clone(),
294 ));
295 }
296
297 Ok(())
298}