Skip to main content

meerkat_mobkit/
decisions.rs

1//! Policy decision framework — auth, console access, metrics, and runtime ops.
2
3use 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}