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)]
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}