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)]
120 pub read_only: bool,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub fetch_timeout_ms: Option<u64>,
123 #[serde(default, skip_serializing_if = "ConsoleUiConfig::is_default")]
124 pub ui: ConsoleUiConfig,
125}
126
127impl Default for ConsolePolicy {
128 fn default() -> Self {
129 Self {
130 require_app_auth: true,
131 read_only: false,
132 fetch_timeout_ms: None,
133 ui: ConsoleUiConfig::default(),
134 }
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct ConsoleAccessRequest {
140 pub provider: AuthProvider,
141 pub email: String,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct MetricsPolicy {
146 pub enforce_slo_targets: bool,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct RuntimeOpsPolicy {
151 pub replica_count: u16,
152 pub metrics: MetricsPolicy,
153}
154
155impl Default for RuntimeOpsPolicy {
156 fn default() -> Self {
157 Self {
158 replica_count: 1,
159 metrics: MetricsPolicy {
160 enforce_slo_targets: false,
161 },
162 }
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167pub struct ReleaseMetadata {
168 pub targets: Vec<String>,
169 pub support_matrix: String,
170}
171
172pub fn validate_bigquery_naming(naming: &BigQueryNaming) -> Result<(), DecisionPolicyError> {
173 if naming.dataset.trim().is_empty() {
174 return Err(DecisionPolicyError::EmptyBigQueryDataset);
175 }
176 if naming.table.trim().is_empty() {
177 return Err(DecisionPolicyError::EmptyBigQueryTable);
178 }
179
180 for value in [&naming.dataset, &naming.table] {
181 if !value
182 .chars()
183 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
184 {
185 return Err(DecisionPolicyError::InvalidBigQueryName(value.clone()));
186 }
187 }
188
189 Ok(())
190}
191
192pub fn load_trusted_mobkit_modules_from_toml(
193 toml_text: &str,
194) -> Result<Vec<ModuleConfig>, DecisionPolicyError> {
195 let parsed: TrustedMobkitToml =
196 toml::from_str(toml_text).map_err(|err| DecisionPolicyError::TomlParse(err.to_string()))?;
197
198 parsed
199 .modules
200 .into_iter()
201 .map(|module| {
202 if module.id.trim().is_empty() {
203 return Err(DecisionPolicyError::MissingModuleId);
204 }
205 if module.command.trim().is_empty() {
206 return Err(DecisionPolicyError::MissingModuleCommand);
207 }
208 Ok(ModuleConfig {
209 id: module.id,
210 command: module.command,
211 args: module.args,
212 restart_policy: module.restart_policy.unwrap_or(RestartPolicy::OnFailure),
213 })
214 })
215 .collect()
216}
217
218pub fn enforce_console_route_access(
219 auth_policy: &AuthPolicy,
220 console_policy: &ConsolePolicy,
221 request: &ConsoleAccessRequest,
222) -> Result<(), DecisionPolicyError> {
223 if !console_policy.require_app_auth {
224 return Ok(());
225 }
226
227 if request.provider == AuthProvider::ServiceIdentity {
228 if !request.email.starts_with("svc:") || request.email.len() <= 4 {
229 return Err(DecisionPolicyError::InvalidServiceIdentity);
230 }
231 if !auth_policy
232 .email_allowlist
233 .iter()
234 .any(|principal| principal == &request.email)
235 {
236 return Err(DecisionPolicyError::ServiceIdentityNotAllowlisted);
237 }
238 return Ok(());
239 }
240
241 if request.provider != auth_policy.default_provider {
242 return Err(DecisionPolicyError::AuthProviderMismatch);
243 }
244
245 if matches!(request.provider, AuthProvider::TestProvider) {
246 return Err(DecisionPolicyError::AuthProviderNotSupported);
247 }
248
249 if !auth_policy
250 .email_allowlist
251 .iter()
252 .any(|email| email == &request.email)
253 {
254 return Err(DecisionPolicyError::EmailNotAllowlisted);
255 }
256
257 Ok(())
258}
259
260pub fn validate_runtime_ops_policy(policy: &RuntimeOpsPolicy) -> Result<(), DecisionPolicyError> {
261 if policy.replica_count != 1 {
262 return Err(DecisionPolicyError::ReplicaCountMustBeOne(
263 policy.replica_count,
264 ));
265 }
266 if policy.metrics.enforce_slo_targets {
267 return Err(DecisionPolicyError::SloTargetsNotSupportedV01);
268 }
269 Ok(())
270}
271
272pub fn parse_release_metadata_json(
273 json_text: &str,
274) -> Result<ReleaseMetadata, DecisionPolicyError> {
275 serde_json::from_str(json_text).map_err(|err| DecisionPolicyError::TomlParse(err.to_string()))
276}
277
278pub fn validate_release_metadata(metadata: &ReleaseMetadata) -> Result<(), DecisionPolicyError> {
279 let mut seen = BTreeSet::new();
280 for target in &metadata.targets {
281 if !seen.insert(target.clone()) {
282 return Err(DecisionPolicyError::DuplicateReleaseTarget(target.clone()));
283 }
284 }
285
286 for required in REQUIRED_RELEASE_TARGETS {
287 if !seen.contains(*required) {
288 return Err(DecisionPolicyError::MissingReleaseTarget(
289 (*required).to_string(),
290 ));
291 }
292 }
293
294 if metadata.support_matrix != "same-as-meerkat" {
295 return Err(DecisionPolicyError::InvalidSupportMatrix(
296 metadata.support_matrix.clone(),
297 ));
298 }
299
300 Ok(())
301}