diff --git a/crates/manager-core/src/route_repo.rs b/crates/manager-core/src/route_repo.rs index 4865607..405cd8e 100644 --- a/crates/manager-core/src/route_repo.rs +++ b/crates/manager-core/src/route_repo.rs @@ -167,8 +167,27 @@ impl RouteRepository for PostgresRouteRepository { } async fn list_route_suppressions(&self) -> Result, ScriptRepositoryError> { + // §11 tail M1: a route suppression is owned by an app (declines for + // itself) OR a group (declines for its whole subtree). Expand each + // group-owned suppression across every descendant app via the all-apps + // `app_chain` CTE (the same one `list_effective` uses): the result is + // `(effective_app_id, path)` pairs the rebuild consumes unchanged — a + // group suppression appears once per descendant app. let rows: Vec<(Uuid, String)> = sqlx::query_as( - "SELECT app_id, reference FROM template_suppressions WHERE target_kind = 'route'", + "WITH RECURSIVE app_chain AS ( \ + SELECT a.id AS effective_app_id, a.id AS owner_app, \ + NULL::uuid AS owner_group, a.group_id AS next_group, 0 AS depth \ + FROM apps a \ + UNION ALL \ + SELECT ac.effective_app_id, NULL::uuid, g.id, g.parent_id, ac.depth + 1 \ + FROM groups g JOIN app_chain ac ON g.id = ac.next_group \ + WHERE ac.depth < 64 \ + ) \ + SELECT DISTINCT ac.effective_app_id, ts.reference \ + FROM app_chain ac \ + JOIN template_suppressions ts \ + ON (ts.app_id = ac.owner_app OR ts.group_id = ac.owner_group) \ + WHERE ts.target_kind = 'route'", ) .fetch_all(&self.pool) .await?; diff --git a/crates/manager-core/src/trigger_repo.rs b/crates/manager-core/src/trigger_repo.rs index b753693..d3782bb 100644 --- a/crates/manager-core/src/trigger_repo.rs +++ b/crates/manager-core/src/trigger_repo.rs @@ -17,16 +17,21 @@ use uuid::Uuid; use crate::config_resolver::CHAIN_LEVELS_CTE; use crate::trigger_config::BackoffShape; -/// §11 tail per-app opt-out: exclude an INHERITED (group-owned) trigger whose -/// handler script name the firing app suppresses. Appended to each dispatch -/// match query's WHERE clause; `$1` is the firing app_id (already bound by the -/// `CHAIN_LEVELS_CTE`). The `t.group_id IS NOT NULL` guard keeps an app's OWN -/// trigger unsuppressable (an app can only decline what it inherits). +/// §11 tail opt-out: exclude an INHERITED (group-owned) trigger whose handler +/// script name a suppression on the firing app's chain declines. Appended to +/// each dispatch match query's WHERE clause; `$1` is the firing app_id (already +/// bound by the `CHAIN_LEVELS_CTE`). §11 tail M1: the anti-join joins the +/// `chain` CTE, so a suppression owned by the firing app OR any ANCESTOR GROUP +/// on its chain applies — a group declines a template for its whole subtree. +/// The `t.group_id IS NOT NULL` guard keeps an app's OWN trigger unsuppressable +/// (an owner can only decline what it inherits); `t.sealed = FALSE` keeps a +/// mandatory template non-declinable. pub(crate) const TRIGGER_SUPPRESSION_ANTIJOIN: &str = " \ AND NOT EXISTS ( \ SELECT 1 FROM template_suppressions ts \ JOIN scripts s ON s.id = t.script_id \ - WHERE ts.app_id = $1 AND ts.target_kind = 'trigger' \ + JOIN chain sc ON (ts.app_id = sc.app_owner OR ts.group_id = sc.group_owner) \ + WHERE ts.target_kind = 'trigger' \ AND LOWER(ts.reference) = LOWER(s.name) \ AND t.group_id IS NOT NULL \ AND t.sealed = FALSE)"; diff --git a/crates/manager-core/tests/group_suppression.rs b/crates/manager-core/tests/group_suppression.rs new file mode 100644 index 0000000..ce916a2 --- /dev/null +++ b/crates/manager-core/tests/group_suppression.rs @@ -0,0 +1,261 @@ +//! §11 tail M1 integration test: GROUP-level template suppression. +//! A child group declares a suppression for a template it inherits from its +//! parent group → EVERY app in the child's subtree declines it (trigger +//! anti-join joins the chain; route rebuild expands the group suppression +//! across descendants), while an app under the parent but NOT under the child +//! still inherits. A `sealed` parent template ignores the child's suppression. +//! +//! Deterministic: drives `list_matching_kv` + `list_effective` / +//! `compile_effective_routes` directly. Skips when `DATABASE_URL` is unset. + +#![allow( + clippy::needless_pass_by_value, + clippy::many_single_char_names, + clippy::too_many_lines +)] + +use std::collections::HashSet; + +use picloud_manager_core::route_admin::compile_effective_routes; +use picloud_manager_core::route_repo::{PostgresRouteRepository, RouteRepository}; +use picloud_manager_core::trigger_repo::{PostgresTriggerRepo, TriggerRepo}; +use picloud_shared::{AppId, KvEventOp}; +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; +use uuid::Uuid; + +async fn pool_or_skip() -> Option { + let Ok(url) = std::env::var("DATABASE_URL") else { + eprintln!("group_suppression: DATABASE_URL unset — skipping"); + return None; + }; + let pool = PgPoolOptions::new() + .max_connections(2) + .connect(&url) + .await + .expect("connect to DATABASE_URL"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("apply migrations"); + Some(pool) +} + +async fn id1(pool: &PgPool, sql: &str, bind: &str) -> Uuid { + let row: (Uuid,) = sqlx::query_as(sql) + .bind(bind) + .fetch_one(pool) + .await + .expect("insert returning id"); + row.0 +} + +/// A group with an explicit parent. +async fn group_under(pool: &PgPool, slug: &str, parent: Option) -> Uuid { + let row: (Uuid,) = sqlx::query_as( + "INSERT INTO groups (slug, name, parent_id) VALUES ($1, $1, $2) RETURNING id", + ) + .bind(slug) + .bind(parent) + .fetch_one(pool) + .await + .expect("group insert"); + row.0 +} + +async fn app_under(pool: &PgPool, slug: &str, group: Uuid) -> Uuid { + let row: (Uuid,) = + sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id") + .bind(slug) + .bind(group) + .fetch_one(pool) + .await + .expect("app insert"); + row.0 +} + +/// Whether `app`'s compiled route slice serves `/hello`. +async fn serves_hello(repo: &PostgresRouteRepository, app: Uuid) -> bool { + let effective = repo.list_effective().await.expect("list_effective"); + let suppressed: HashSet<(AppId, String)> = repo + .list_route_suppressions() + .await + .expect("list_route_suppressions") + .into_iter() + .collect(); + compile_effective_routes(&effective, &suppressed) + .into_iter() + .any(|c| c.app_id == AppId::from(app) && format!("{:?}", c.path).contains("/hello")) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn group_suppression_declines_for_whole_subtree_only() { + let Some(pool) = pool_or_skip().await else { + return; + }; + let sfx = Uuid::new_v4().simple().to_string(); + let admin = id1( + &pool, + "INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id", + &format!("gs-{sfx}"), + ) + .await; + + // Parent group P owns a handler `audit` + a kv trigger template + a /hello + // route template. Child group C is under P. A sealed route template too. + let p = group_under(&pool, &format!("gs-p-{sfx}"), None).await; + let c = group_under(&pool, &format!("gs-c-{sfx}"), Some(p)).await; + let handler_name = format!("audit-{sfx}"); + let handler: (Uuid,) = sqlx::query_as( + "INSERT INTO scripts (name, source, group_id) VALUES ($1, 'x', $2) RETURNING id", + ) + .bind(&handler_name) + .bind(p) + .fetch_one(&pool) + .await + .expect("group handler"); + + let tmpl: (Uuid,) = sqlx::query_as( + "INSERT INTO triggers \ + (group_id, script_id, kind, enabled, dispatch_mode, \ + retry_max_attempts, retry_backoff, retry_base_ms, \ + registered_by_principal, name) \ + VALUES ($1, $2, 'kv', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id", + ) + .bind(p) + .bind(handler.0) + .bind(admin) + .bind(format!("tmpl-{sfx}")) + .fetch_one(&pool) + .await + .expect("kv template"); + sqlx::query( + "INSERT INTO kv_trigger_details (trigger_id, collection_glob, ops) \ + VALUES ($1, '*', ARRAY['insert'])", + ) + .bind(tmpl.0) + .execute(&pool) + .await + .expect("kv details"); + sqlx::query( + "INSERT INTO routes (group_id, script_id, host_kind, host, path_kind, path, method) \ + VALUES ($1, $2, 'any', '', 'exact', '/hello', NULL)", + ) + .bind(p) + .bind(handler.0) + .execute(&pool) + .await + .expect("route template"); + + // App A under child C (will inherit C's suppression); app D under parent P + // but NOT under C (control — must still inherit). + let a = app_under(&pool, &format!("gs-a-{sfx}"), c).await; + let d = app_under(&pool, &format!("gs-d-{sfx}"), p).await; + + // Child group C suppresses BOTH the trigger (by handler name) and the route. + sqlx::query( + "INSERT INTO template_suppressions (group_id, target_kind, reference) \ + VALUES ($1, 'trigger', $2), ($1, 'route', '/hello')", + ) + .bind(c) + .bind(&handler_name) + .execute(&pool) + .await + .expect("group suppressions for C"); + + let trig = PostgresTriggerRepo::new(pool.clone()); + let routes = PostgresRouteRepository::new(pool.clone()); + + // App A (under C) → both declined by C's group-level suppression. + let a_trig = trig + .list_matching_kv(AppId::from(a), "users", KvEventOp::Insert) + .await + .expect("match A"); + assert!( + !a_trig.iter().any(|m| m.trigger_id == tmpl.0.into()), + "an app under the suppressing group must NOT match the inherited trigger" + ); + assert!( + !serves_hello(&routes, a).await, + "an app under the suppressing group must NOT serve the inherited route" + ); + + // App D (under P only) → still inherits both (isolation: C's suppression + // does not reach a sibling subtree). + let d_trig = trig + .list_matching_kv(AppId::from(d), "users", KvEventOp::Insert) + .await + .expect("match D"); + assert!( + d_trig.iter().any(|m| m.trigger_id == tmpl.0.into()), + "an app outside the suppressing group's subtree still inherits the trigger" + ); + assert!( + serves_hello(&routes, d).await, + "an app outside the suppressing group's subtree still serves the route" + ); + + // A SEALED parent route template ignores the child group's suppression. + sqlx::query( + "INSERT INTO routes \ + (group_id, script_id, host_kind, host, path_kind, path, method, sealed) \ + VALUES ($1, $2, 'any', '', 'exact', '/sealed', NULL, TRUE)", + ) + .bind(p) + .bind(handler.0) + .execute(&pool) + .await + .expect("sealed route template"); + sqlx::query( + "INSERT INTO template_suppressions (group_id, target_kind, reference) \ + VALUES ($1, 'route', '/sealed')", + ) + .bind(c) + .execute(&pool) + .await + .expect("suppress sealed"); + let effective = routes.list_effective().await.expect("list_effective"); + let suppressed: HashSet<(AppId, String)> = routes + .list_route_suppressions() + .await + .expect("suppressions") + .into_iter() + .collect(); + let a_serves_sealed = compile_effective_routes(&effective, &suppressed) + .into_iter() + .any(|cr| cr.app_id == AppId::from(a) && format!("{:?}", cr.path).contains("/sealed")); + assert!( + a_serves_sealed, + "a sealed parent template is non-suppressible even by a group" + ); + + // Cleanup (FK order). + let _ = sqlx::query("DELETE FROM triggers WHERE id = $1") + .bind(tmpl.0) + .execute(&pool) + .await; + let _ = sqlx::query("DELETE FROM routes WHERE group_id = $1") + .bind(p) + .execute(&pool) + .await; + let _ = sqlx::query("DELETE FROM template_suppressions WHERE group_id = $1") + .bind(c) + .execute(&pool) + .await; + let _ = sqlx::query("DELETE FROM apps WHERE id = ANY($1)") + .bind(vec![a, d]) + .execute(&pool) + .await; + let _ = sqlx::query("DELETE FROM scripts WHERE id = $1") + .bind(handler.0) + .execute(&pool) + .await; + let _ = sqlx::query("DELETE FROM groups WHERE id = ANY($1)") + .bind(vec![c, p]) + .execute(&pool) + .await; + let _ = sqlx::query("DELETE FROM admin_users WHERE id = $1") + .bind(admin) + .execute(&pool) + .await; +}