feat(interceptors): ordered before-chains + cycle guard + ctx plumbing (§9.4 M1+M2)

M1: introduces a clone-cheap InterceptorCtx { interceptors, self_engine,
limits } threaded into every SDK register fn (kv/docs/files/queue/pubsub/http)
so the non-KV services carry the hook seam (unused until M7-M11); KvHandle
collapses its three fields into one ictx.

M2: replaces the nearest-only resolver with ordered before/after chains. The
trait becomes resolve(cx, service, op) -> InterceptorChain { before, after }
(migration 0074 adds a phase column + phase-aware unique indexes). The before
-chain runs ancestor->app (depth DESC) so a group compliance guard can't be
bypassed by a descendant; single-marker behavior is byte-identical to before.
An identity cycle guard (thread-local visited-set keyed by script_id) denies a
detected cycle, alongside the existing binary re-entrancy break. Fail-closed
verdict preserved (allow only on #{ allowed: true }; a Dangling entry or a
missing engine back-ref denies); app_id still derives from cx.app_id only.

after-chains resolve but stay unused until M3. Pinned by two new interceptor
journeys (ancestor->app chain ordering; self-referential no-recurse).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-15 22:54:51 +02:00
parent eae2ee08f1
commit be0c618672
15 changed files with 623 additions and 200 deletions

View File

@@ -4,8 +4,9 @@
//! The interceptor reads the operation context (`ctx.request.body` — service,
//! action, collection, key, value) and returns `#{ allowed: bool, reason }`; a
//! `false` denies the op (the write never happens, the caller gets an error).
//! Registration is nearest-owner-wins on the calling app's chain, so a group's
//! interceptor is inherited by a descendant app (and an app can override it).
//! A group's interceptor is inherited by a descendant app; from §9.4 M2 the
//! `before` markers on the chain run as an ORDERED CHAIN (ancestor→app), so a
//! group guard and an app guard both run — a single deny short-circuits.
use std::fs;
@@ -396,3 +397,200 @@ fn a_malformed_verdict_fails_closed() {
"a verdict map with no `allowed` key must fail closed (deny)"
);
}
// --- §9.4 M2: ordered before-chain + cycle guard --------------------------
/// A group before-interceptor that denies only `kv::set` of key `"group-key"`.
const GROUP_CHAIN_GUARD: &str = r#"
let op = ctx.request.body;
if op.action == "set" && op.key == "group-key" {
#{ allowed: false, reason: "group guard" }
} else {
#{ allowed: true }
}
"#;
/// An app before-interceptor that denies only `kv::set` of key `"app-key"`.
const APP_CHAIN_GUARD: &str = r#"
let op = ctx.request.body;
if op.action == "set" && op.key == "app-key" {
#{ allowed: false, reason: "app guard" }
} else {
#{ allowed: true }
}
"#;
/// A writer that probes all three keys: the group-guarded one, the app-guarded
/// one, and a free key. Proves BOTH chain entries run (ancestor→app).
const CHAIN_WRITER: &str = r#"
let g = false;
let a = false;
let free = false;
try { kv::collection("c").set("group-key", 1); } catch(e) { g = true; }
try { kv::collection("c").set("app-key", 1); } catch(e) { a = true; }
try { kv::collection("c").set("free", 1); free = true; } catch(e) {}
#{ group_denied: g, app_denied: a, free_ok: free }
"#;
/// M2: a group AND a descendant app each declare a `(kv, set)` before
/// interceptor. Both run in one write path, ordered ancestor→app: the group's
/// guard denies its key, the app's guard denies its key, and a key neither
/// guards passes — so BOTH chain entries demonstrably execute (a group denial
/// cannot be bypassed by a descendant, and the app adds its own on top).
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn a_before_chain_runs_group_then_app_interceptors_in_order() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("chain-grp");
let app = common::unique_slug("chain-app");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
// The group owns its guard + marker.
let dir = manifest_dir();
fs::write(
dir.path().join("scripts/group_guard.rhai"),
GROUP_CHAIN_GUARD,
)
.unwrap();
fs::write(
dir.path().join("group.toml"),
format!(
"[group]\nslug = \"{group}\"\nname = \"Chain\"\n\n\
[[scripts]]\nname = \"group_guard\"\nfile = \"scripts/group_guard.rhai\"\n\n\
[[interceptors]]\nscript = \"group_guard\"\nops = [\"set\"]\n"
),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir.path().join("group.toml"))
.assert()
.success();
// The app under the group owns its OWN guard + marker + writer.
let _a = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &group])
.assert()
.success();
fs::write(dir.path().join("scripts/app_guard.rhai"), APP_CHAIN_GUARD).unwrap();
fs::write(dir.path().join("scripts/writer.rhai"), CHAIN_WRITER).unwrap();
fs::write(
dir.path().join("app.toml"),
format!(
"[app]\nslug = \"{app}\"\nname = \"ChainApp\"\n\n\
[[scripts]]\nname = \"app_guard\"\nfile = \"scripts/app_guard.rhai\"\n\n\
[[scripts]]\nname = \"writer\"\nfile = \"scripts/writer.rhai\"\n\n\
[[interceptors]]\nscript = \"app_guard\"\nops = [\"set\"]\n"
),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir.path().join("app.toml"))
.assert()
.success();
let body = invoke_body(&env, &app_script_id(&env, &app, "writer"));
assert_eq!(
body,
serde_json::json!({ "group_denied": true, "app_denied": true, "free_ok": true }),
"both the group's and the app's before interceptors must run (ancestor→app)"
);
}
/// A self-referential interceptor: the `(kv, set)` guard itself performs a
/// `kv::set` (the very op it guards) before allowing. Without a re-entry break
/// that nested write would re-resolve to this same interceptor and recurse
/// forever. The RE-ENTRANCY guard catches it — writes performed BY an
/// interceptor bypass interception — so the audit write lands and the original
/// write proceeds, with no recursion and no deny.
const SELF_REF_GUARD: &str = r#"
kv::collection("audit").set("seen", ctx.request.body.key);
#{ allowed: true }
"#;
/// A writer whose single guarded set triggers the self-referential guard.
const SELF_REF_WRITER: &str = r#"
kv::collection("c").set("x", 1);
#{ done: true }
"#;
/// M2 (cycle safety): a self-referential interceptor completes cleanly rather
/// than recursing. Documented outcome — the RE-ENTRANCY guard is what catches
/// it here (the interceptor's own guarded write bypasses interception, so
/// `run_before` is never re-entered and the identity cycle guard is never
/// reached). The observable result: the writer finishes, both the audit write
/// and the original write persist.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn a_self_referential_interceptor_does_not_recurse() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let app = common::unique_slug("selfref-app");
let _a = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app])
.assert()
.success();
let dir = manifest_dir();
fs::write(dir.path().join("scripts/guard.rhai"), SELF_REF_GUARD).unwrap();
fs::write(dir.path().join("scripts/writer.rhai"), SELF_REF_WRITER).unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[app]\nslug = \"{app}\"\nname = \"SelfRef\"\n\n\
[[scripts]]\nname = \"guard\"\nfile = \"scripts/guard.rhai\"\n\n\
[[scripts]]\nname = \"writer\"\nfile = \"scripts/writer.rhai\"\n\n\
[[interceptors]]\nscript = \"guard\"\nops = [\"set\"]\n"
),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir.path().join("picloud.toml"))
.assert()
.success();
// No recursion / depth error: the writer completes and returns its map.
let body = invoke_body(&env, &app_script_id(&env, &app, "writer"));
assert_eq!(
body,
serde_json::json!({ "done": true }),
"the self-referential interceptor must complete without recursing"
);
// Both writes landed: the interceptor's audit write (bypassed interception)
// and the original guarded write.
let seen = String::from_utf8(
common::pic_as(&env)
.args(["kv", "get", "--app", &app, "--collection", "audit", "seen"])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
seen.contains('x'),
"the interceptor's own write must persist (re-entrancy bypass):\n{seen}"
);
let x = String::from_utf8(
common::pic_as(&env)
.args(["kv", "get", "--app", &app, "--collection", "c", "x"])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(x.contains('1'), "the original write must persist:\n{x}");
}