//! The §3 configuration-resolution engine: env-filtered, proximity-first //! inheritance down the group tree. //! //! To resolve a key for app A in environment E (docs/design §3): //! 1. **Env-filter first, per level** — a value scoped `@E` is eligible; //! `*` (env-agnostic) is the fallback. Within one level `@E` beats `*`. //! Env is *eligibility*, not a precedence tier. //! 2. **Nearest level wins** — walk A → parent group → … → root; the //! closest level that defines the (filtered) key wins. Proximity beats //! farther-level env-specificity (a leaf's `*` beats an ancestor's `@E`). //! 3. **Maps deep-merge per key; scalars/arrays replace; deletion is an //! explicit tombstone** that suppresses the inherited key. //! //! The recursive CTE that walks `apps.group_id → groups.parent_id → root` //! mirrors `app_members_repo::effective_app_role`. The env-eligibility //! filter happens in SQL; the §3 merge/replace/tombstone semantics — which //! a window-function pick can't express — happen in the pure `resolve` //! function below, so they're unit-tested without Postgres. use std::collections::BTreeMap; use serde_json::{Map, Value}; use sqlx::PgPool; use uuid::Uuid; use picloud_shared::AppId; /// Shared chain-walk CTE: emits one row per owner-level for an app — /// depth 0 = the app itself (`app_owner` set), then ancestor groups /// nearest-first (`group_owner` set), each carrying the app's environment. /// Depth-bounded `< 64` (the group cycle guard already forbids cycles; this /// is a runaway guard). Bind `$1 = app_id`. Reused by the vars and secret /// resolvers, which each append their own owner-keyed JOIN. pub(crate) const CHAIN_LEVELS_CTE: &str = "\ WITH RECURSIVE chain AS ( \ SELECT a.id AS app_owner, NULL::uuid AS group_owner, \ a.group_id AS next_group, 0 AS depth, a.environment AS app_env \ FROM apps a WHERE a.id = $1 \ UNION ALL \ SELECT NULL::uuid, g.id, g.parent_id, c.depth + 1, c.app_env \ FROM groups g JOIN chain c ON g.id = c.next_group \ WHERE c.depth < 64 \ )"; /// Group-rooted variant of [`CHAIN_LEVELS_CTE`]: the chain starts at a /// **group** (depth 0 = the group itself, `group_owner` set), then walks /// its ancestor groups nearest-first. There is no `app_owner` level — a /// group origin never sees app-owned rows below it (the §5.5 trust /// boundary). Bind `$1 = group_id`. Used for the lexical module resolver /// when the importing script's defining node is a group. pub(crate) const GROUP_CHAIN_LEVELS_CTE: &str = "\ WITH RECURSIVE chain AS ( \ SELECT g.id AS group_owner, g.parent_id AS next_group, 0 AS depth \ FROM groups g WHERE g.id = $1 \ UNION ALL \ SELECT g.id, g.parent_id, c.depth + 1 \ FROM groups g JOIN chain c ON g.id = c.next_group \ WHERE c.depth < 64 \ )"; /// Owner kind of a resolved value, for `--explain` provenance. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OwnerKind { App, Group, } impl OwnerKind { #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::App => "app", Self::Group => "group", } } } /// One eligible (env-filtered) candidate row pulled by the resolver, before /// §3 proximity/merge resolution. #[derive(Debug, Clone)] pub struct Candidate { /// 0 = the app itself; 1 = its parent group; … (nearest-first). pub depth: i32, pub owner_kind: OwnerKind, pub owner_id: Uuid, /// `*` (env-agnostic) or a concrete environment name. pub scope: String, pub key: String, pub value: Value, pub is_tombstone: bool, } /// Where a resolved key came from (for `config --effective --explain`). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Provenance { pub owner_kind: OwnerKind, pub owner_id: Uuid, pub depth: i32, pub scope: String, /// For a deep-merged map, the `(depth, scope)` of every layer that /// contributed, nearest-first. Empty for a plain scalar/array winner. pub merged_from: Vec<(i32, String)>, } /// `@E`-scoped values outrank `*` *within the same level* (§3 step 1). fn env_priority(scope: &str) -> u8 { u8::from(scope != "*") } /// Deep-merge `src` into `dst` per key: nested objects merge recursively, /// everything else is replaced by `src`. Caller merges farthest→nearest so /// nearer layers overwrite. fn deep_merge(dst: &mut Map, src: &Map) { for (k, sv) in src { match (dst.get_mut(k), sv) { (Some(Value::Object(dm)), Value::Object(sm)) => deep_merge(dm, sm), _ => { dst.insert(k.clone(), sv.clone()); } } } } /// Resolve one key's candidate rows (any order in) to its effective value, /// plus the provenance. Returns `None` when a tombstone suppresses the key. fn resolve_one(mut rows: Vec) -> Option<(Value, Provenance)> { // Nearest-first; within a level, `@E` before `*` (§3 steps 1-2). rows.sort_by(|a, b| { a.depth .cmp(&b.depth) .then(env_priority(&b.scope).cmp(&env_priority(&a.scope))) }); // §3 step 1: env is eligibility, not a merge tier — within a single level // `@E` *suppresses* `*` (it does not layer on top of it). Each level is one // owner (single-parent tree) with at most one `@E` and one `*` row per key // after env-filtering; keep only the level's winner (the `@E` row sorts // first). Without this, a level holding both an `@E` map and a `*` map would // deep-merge them instead of the `@E` shadowing the `*`. rows.dedup_by_key(|r| r.depth); // Collect the contiguous run of map-valued rows from the nearest. A // tombstone or scalar/array boundary stops the run (and, if it's the // nearest row, decides the result outright). let mut maps: Vec<&Candidate> = Vec::new(); let mut boundary: Option<&Candidate> = None; for r in &rows { if r.is_tombstone { boundary = Some(r); break; } if r.value.is_object() { maps.push(r); } else { boundary = Some(r); break; } } if let Some(first) = maps.first() { // Map run wins; deep-merge farthest→nearest so nearest keys win. let mut acc = Map::new(); for m in maps.iter().rev() { if let Value::Object(obj) = &m.value { deep_merge(&mut acc, obj); } } let prov = Provenance { owner_kind: first.owner_kind, owner_id: first.owner_id, depth: first.depth, scope: first.scope.clone(), merged_from: maps.iter().map(|m| (m.depth, m.scope.clone())).collect(), }; return Some((Value::Object(acc), prov)); } // No leading map run: the nearest row is the boundary. match boundary { // Nearest is a tombstone → key deleted. Some(b) if b.is_tombstone => None, // Nearest is a scalar/array → take it verbatim. Some(b) => Some(( b.value.clone(), Provenance { owner_kind: b.owner_kind, owner_id: b.owner_id, depth: b.depth, scope: b.scope.clone(), merged_from: Vec::new(), }, )), // No rows at all (caller only passes non-empty groups). None => None, } } /// Resolve a full candidate set (mixed keys, any order) into the effective /// config map + per-key provenance. Pure — unit-tested without Postgres. #[must_use] pub fn resolve( candidates: Vec, ) -> (BTreeMap, BTreeMap) { let mut by_key: BTreeMap> = BTreeMap::new(); for c in candidates { by_key.entry(c.key.clone()).or_default().push(c); } let mut values = BTreeMap::new(); let mut provenance = BTreeMap::new(); for (key, rows) in by_key { if let Some((v, p)) = resolve_one(rows) { values.insert(key.clone(), v); provenance.insert(key, p); } } (values, provenance) } /// Pull every env-eligible `vars` candidate for `app_id`: the app's own /// rows + every ancestor group's rows, filtered to `*` or the app's /// environment, ordered nearest-first. /// /// # Errors /// Propagates sqlx errors. pub async fn fetch_var_candidates( pool: &PgPool, app_id: AppId, ) -> Result, sqlx::Error> { let sql = format!( "{CHAIN_LEVELS_CTE} \ SELECT c.depth, \ CASE WHEN v.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \ COALESCE(v.app_id, v.group_id) AS owner_id, \ v.environment_scope, v.key, v.value, v.is_tombstone \ FROM chain c \ JOIN vars v ON (v.app_id = c.app_owner OR v.group_id = c.group_owner) \ WHERE v.environment_scope = '*' OR v.environment_scope = c.app_env \ ORDER BY c.depth ASC" ); let rows = sqlx::query_as::<_, VarCandidateRow>(&sql) .bind(app_id.into_inner()) .fetch_all(pool) .await?; Ok(rows.into_iter().map(Into::into).collect()) } /// The masked, resolved view of one inherited secret for `config/effective`: /// which owner/level/scope supplies it — **never** the value. #[derive(Debug, Clone)] pub struct EffectiveSecretMeta { pub name: String, pub owner_kind: OwnerKind, pub owner_id: Uuid, pub scope: String, pub depth: i32, } /// Resolve the *names* of every secret an app effectively sees — its own /// plus every ancestor group's, env-filtered, nearest-wins per name. Returns /// masked metadata only (owner/level/scope), so it's safe for an app-level /// principal to read. `DISTINCT ON (name)` with the same ordering as the /// per-name secret resolver guarantees the same winner. /// /// # Errors /// Propagates sqlx errors. pub async fn fetch_effective_secret_meta( pool: &PgPool, app_id: AppId, ) -> Result, sqlx::Error> { let sql = format!( "{CHAIN_LEVELS_CTE} \ SELECT DISTINCT ON (s.name) s.name, \ CASE WHEN s.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \ COALESCE(s.app_id, s.group_id) AS owner_id, \ s.environment_scope, c.depth \ FROM chain c \ JOIN secrets s ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \ WHERE s.environment_scope = '*' OR s.environment_scope = c.app_env \ ORDER BY s.name ASC, c.depth ASC, (s.environment_scope <> '*') DESC" ); let rows: Vec<(String, String, Uuid, String, i32)> = sqlx::query_as(&sql) .bind(app_id.into_inner()) .fetch_all(pool) .await?; Ok(rows .into_iter() .map( |(name, owner_kind, owner_id, scope, depth)| EffectiveSecretMeta { name, owner_kind: if owner_kind == "app" { OwnerKind::App } else { OwnerKind::Group }, owner_id, scope, depth, }, ) .collect()) } #[derive(sqlx::FromRow)] struct VarCandidateRow { depth: i32, owner_kind: String, owner_id: Uuid, environment_scope: String, key: String, value: Value, is_tombstone: bool, } impl From for Candidate { fn from(r: VarCandidateRow) -> Self { Self { depth: r.depth, owner_kind: if r.owner_kind == "app" { OwnerKind::App } else { OwnerKind::Group }, owner_id: r.owner_id, scope: r.environment_scope, key: r.key, value: r.value, is_tombstone: r.is_tombstone, } } } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn cand(depth: i32, scope: &str, key: &str, value: Value) -> Candidate { Candidate { depth, owner_kind: if depth == 0 { OwnerKind::App } else { OwnerKind::Group }, owner_id: Uuid::nil(), scope: scope.into(), key: key.into(), value, is_tombstone: false, } } fn tomb(depth: i32, scope: &str, key: &str) -> Candidate { Candidate { is_tombstone: true, value: Value::Null, ..cand(depth, scope, key, Value::Null) } } #[test] fn nearest_level_wins() { // app (depth 0) overrides group (depth 2). let (v, p) = resolve(vec![ cand(2, "*", "region", json!("eu")), cand(0, "*", "region", json!("us")), ]); assert_eq!(v["region"], json!("us")); assert_eq!(p["region"].depth, 0); } #[test] fn env_scoped_beats_agnostic_within_a_level() { let (v, p) = resolve(vec![ cand(1, "*", "db_url", json!("default")), cand(1, "staging", "db_url", json!("staging-db")), ]); assert_eq!(v["db_url"], json!("staging-db")); assert_eq!(p["db_url"].scope, "staging"); } #[test] fn proximity_beats_env_specificity_across_levels() { // §3.2 deliberately-novel call: a leaf's `*` beats an ancestor's `@E`. let (v, _) = resolve(vec![ cand(2, "production", "db_url", json!("anc-prod")), cand(0, "*", "db_url", json!("leaf-default")), ]); assert_eq!(v["db_url"], json!("leaf-default")); } #[test] fn maps_deep_merge_nearest_wins() { // ancestor sets {title, region}; leaf overrides title, adds locale. let (v, p) = resolve(vec![ cand(2, "*", "ui", json!({"title": "Base", "region": "eu"})), cand(0, "*", "ui", json!({"title": "Leaf", "locale": "en"})), ]); assert_eq!( v["ui"], json!({"title": "Leaf", "region": "eu", "locale": "en"}) ); assert_eq!(p["ui"].merged_from.len(), 2); } #[test] fn same_level_env_map_suppresses_agnostic_map_no_merge() { // §3 step 1: within ONE level, `@E` is not a merge layer over `*` — it // shadows it. A level holding both an `@staging` map and a `*` map must // resolve to the `@staging` map alone, never a deep-merge of the two. let (v, p) = resolve(vec![ cand(1, "*", "cfg", json!({"a": 1, "shared": "default"})), cand(1, "staging", "cfg", json!({"shared": "staging"})), ]); assert_eq!(v["cfg"], json!({"shared": "staging"})); assert_eq!(p["cfg"].scope, "staging"); // Provenance must not list the suppressed `*` layer. assert_eq!(p["cfg"].merged_from, vec![(1, "staging".to_string())]); } #[test] fn same_level_env_map_then_ancestor_map_merges_without_agnostic_sibling() { // The suppressed same-level `*` must also be invisible to cross-level // merge: leaf `@staging` map merges onto the ancestor map, but the // leaf's own `*` map (shadowed at its level) never contributes. let (v, _) = resolve(vec![ cand(2, "*", "cfg", json!({"region": "eu", "tier": "base"})), cand(0, "*", "cfg", json!({"leak": "should-not-appear"})), cand(0, "staging", "cfg", json!({"tier": "leaf"})), ]); assert_eq!(v["cfg"], json!({"region": "eu", "tier": "leaf"})); assert!(!v["cfg"].as_object().unwrap().contains_key("leak")); } #[test] fn nearer_scalar_replaces_whole_inherited_map() { let (v, _) = resolve(vec![ cand(2, "*", "x", json!({"a": 1})), cand(0, "*", "x", json!("scalar")), ]); assert_eq!(v["x"], json!("scalar")); } #[test] fn tombstone_suppresses_inherited_key() { let (v, _) = resolve(vec![ cand(2, "*", "secret_flag", json!(true)), tomb(0, "*", "secret_flag"), ]); assert!(!v.contains_key("secret_flag")); } #[test] fn json_null_is_a_value_not_a_deletion() { let (v, _) = resolve(vec![cand(0, "*", "k", Value::Null)]); assert!(v.contains_key("k")); assert_eq!(v["k"], Value::Null); } #[test] fn map_merge_stops_at_a_nearer_scalar_boundary() { // nearest map, then a scalar, then a farther map: only the // contiguous top map run merges; the scalar bounds it. let (v, _) = resolve(vec![ cand(3, "*", "m", json!({"deep": 1})), cand(2, "*", "m", json!("scalar-boundary")), cand(0, "*", "m", json!({"near": 2})), ]); // depth 0 map is the only one above the depth-2 scalar boundary. assert_eq!(v["m"], json!({"near": 2})); } }