Make the module-loading contract origin-aware so group-owned modules become resolvable and imports can resolve lexically (§5.5). - `ModuleScript` gains a polymorphic owner (`app_id`/`group_id` + `owner()`), mirroring `Script`. - `ModuleSource::lookup(&cx, name)` -> `resolve(origin: ScriptOwner, name)`: resolution walks the group chain rooted at the importing script's defining node (nearest-owner-wins), not the inheriting app's view. - `PostgresModuleSource::resolve` branches on origin: app-rooted (`CHAIN_LEVELS_CTE`) or a new group-rooted `GROUP_CHAIN_LEVELS_CTE`, joining `kind='module'` rows. The executor resolver passes a temporary `App(cx.app_id)` origin (behaviour- preserving for app scripts; true lexical origin lands in C3). Group scripts still can't carry imports until C4, so no trust-inversion window opens here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
473 lines
17 KiB
Rust
473 lines
17 KiB
Rust
//! 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<String, Value>, src: &Map<String, Value>) {
|
|
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<Candidate>) -> 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<Candidate>,
|
|
) -> (BTreeMap<String, Value>, BTreeMap<String, Provenance>) {
|
|
let mut by_key: BTreeMap<String, Vec<Candidate>> = 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<Vec<Candidate>, 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<Vec<EffectiveSecretMeta>, 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<VarCandidateRow> 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}));
|
|
}
|
|
}
|