From be0c618672e26799d7db9c90dba35737c6ec61e5 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 15 Jul 2026 22:54:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(interceptors):=20ordered=20before-chains?= =?UTF-8?q?=20+=20cycle=20guard=20+=20ctx=20plumbing=20(=C2=A79.4=20M1+M2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/executor-core/src/sdk/docs.rs | 19 +- crates/executor-core/src/sdk/files.rs | 19 +- crates/executor-core/src/sdk/http.rs | 12 +- crates/executor-core/src/sdk/interceptor.rs | 212 ++++++++++++------ crates/executor-core/src/sdk/kv.rs | 47 ++-- crates/executor-core/src/sdk/mod.rs | 20 +- crates/executor-core/src/sdk/pubsub.rs | 14 +- crates/executor-core/src/sdk/queue.rs | 14 +- .../migrations/0074_interceptor_phase.sql | 20 ++ crates/manager-core/src/interceptor_repo.rs | 101 ++++++--- .../manager-core/src/interceptor_service.rs | 68 ++++-- crates/manager-core/tests/expected_schema.txt | 7 +- crates/picloud-cli/tests/interceptors.rs | 202 ++++++++++++++++- crates/shared/src/interceptor.rs | 63 ++++-- crates/shared/src/lib.rs | 5 +- 15 files changed, 623 insertions(+), 200 deletions(-) create mode 100644 crates/manager-core/migrations/0074_interceptor_phase.sql diff --git a/crates/executor-core/src/sdk/docs.rs b/crates/executor-core/src/sdk/docs.rs index 05faad7..7e2c004 100644 --- a/crates/executor-core/src/sdk/docs.rs +++ b/crates/executor-core/src/sdk/docs.rs @@ -30,6 +30,7 @@ use uuid::Uuid; use super::bridge::{ block_on, dynamic_to_json_capped, json_to_dynamic, MAX_JSON_MATERIALIZE_BYTES, }; +use super::interceptor::InterceptorCtx; /// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs /// plus an owned string). @@ -38,6 +39,10 @@ pub struct DocsHandle { collection: String, service: Arc, cx: Arc, + // §9.4 M1 plumbing: carried so `create`/`update`/`delete` can run a before/ + // after hook in a later milestone (M8). Unused until then. + #[allow(dead_code)] + ictx: InterceptorCtx, } /// §11.6 shared-collection handle, returned by `docs::shared_collection(name)`. @@ -49,9 +54,17 @@ pub struct GroupDocsHandle { collection: String, service: Arc, cx: Arc, + // §9.4 M1 plumbing (see `DocsHandle`). Unused until M8. + #[allow(dead_code)] + ictx: InterceptorCtx, } -pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc) { +pub(super) fn register( + engine: &mut RhaiEngine, + services: &Services, + cx: Arc, + ictx: InterceptorCtx, +) { let docs_service = services.docs.clone(); let group_docs_service = services.group_docs.clone(); @@ -59,6 +72,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc Result> { @@ -69,6 +83,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc Result> { @@ -86,6 +102,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc, cx: Arc, + // §9.4 M1 plumbing: carried so `create`/`update`/`delete` can run a before/ + // after hook in a later milestone (M9). Unused until then. + #[allow(dead_code)] + ictx: InterceptorCtx, } /// §11.6 shared-collection handle, returned by `files::shared_collection(name)`. @@ -47,9 +52,17 @@ pub struct GroupFilesHandle { collection: String, service: Arc, cx: Arc, + // §9.4 M1 plumbing (see `FilesHandle`). Unused until M9. + #[allow(dead_code)] + ictx: InterceptorCtx, } -pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc) { +pub(super) fn register( + engine: &mut RhaiEngine, + services: &Services, + cx: Arc, + ictx: InterceptorCtx, +) { let files_service = services.files.clone(); let group_files_service = services.group_files.clone(); @@ -57,6 +70,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc Result> { @@ -67,6 +81,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc Result> { @@ -84,6 +100,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc) { +// `http` registers its verbs as module native fns (no handle struct), so there +// is nowhere to stash the interceptor ctx yet. The param is threaded uniformly +// from `register_all` (§9.4 M1) and consumed by M11, which adds the before/after +// hook around the outbound-request closures. Prefixed `_` until then. +pub(super) fn register( + engine: &mut RhaiEngine, + services: &Services, + cx: Arc, + _ictx: InterceptorCtx, +) { let svc = services.http.clone(); let mut module = Module::new(); diff --git a/crates/executor-core/src/sdk/interceptor.rs b/crates/executor-core/src/sdk/interceptor.rs index e6adad7..6663166 100644 --- a/crates/executor-core/src/sdk/interceptor.rs +++ b/crates/executor-core/src/sdk/interceptor.rs @@ -22,10 +22,10 @@ //! thread-local guard, set for the duration of the interceptor's synchronous //! execution, makes any nested op the interceptor performs bypass interception. -use std::cell::Cell; +use std::cell::{Cell, RefCell}; use std::sync::Arc; -use picloud_shared::{InterceptorResolution, InterceptorService, SdkCallCx}; +use picloud_shared::{InterceptorEntry, InterceptorService, ScriptId, SdkCallCx}; use rhai::EvalAltResult; use serde_json::{json, Value as Json}; use tokio::runtime::Handle as TokioHandle; @@ -35,12 +35,31 @@ use crate::sandbox::Limits; use crate::sdk::bridge::runtime_err; use crate::sdk::invoke::run_resolved_blocking; +/// Clone-cheap bundle of the §9.4 interceptor dependencies threaded into every +/// SDK service `register` fn (M1). Carries the resolver, the `invoke()` re-entry +/// engine back-reference, and the sandbox limits — everything `run_before` +/// (and, from M3, `run_after`) needs. A few `Arc`s plus a small `Limits` copy. +#[derive(Clone)] +pub(crate) struct InterceptorCtx { + pub interceptors: Arc, + pub self_engine: Option>, + pub limits: Limits, +} + thread_local! { /// Re-entrancy depth: `> 0` while an interceptor script (and anything it /// synchronously invokes) is executing on this thread. The whole re-entry /// runs synchronously in the caller's `spawn_blocking` thread (same model /// as `invoke()`), so a thread-local is sufficient and correct. static IN_INTERCEPTOR: Cell = const { Cell::new(0) }; + + /// Identity cycle guard (M2): the `script_id`s of the interceptors currently + /// on the synchronous run stack. A chain that would run an interceptor whose + /// script is already executing is a cycle — we DENY rather than recurse. + /// Precise (keyed by script id) and independent of the binary + /// `IN_INTERCEPTOR` counter, which suppresses interception of an + /// interceptor's OWN nested writes but does not track identity. + static VISITED: RefCell> = const { RefCell::new(Vec::new()) }; } fn currently_in_interceptor() -> bool { @@ -62,17 +81,37 @@ impl Drop for ReentryGuard { } } -/// Run the before-op interceptor for `(service, op)` if one is registered. -/// Returns `Ok(())` to allow the operation (un-hooked, or the interceptor -/// explicitly allowed it) or an `Err` runtime error to deny it (the caller must -/// NOT then perform the write). `value` is the payload being written (`None` -/// for delete). +/// RAII for the identity cycle guard: push a `script_id` on enter, pop on drop +/// (including the `?`/panic-unwind paths), so the visited set exactly tracks the +/// interceptors on the current synchronous run stack. +struct CycleGuard; +impl CycleGuard { + fn enter(id: ScriptId) -> Self { + VISITED.with(|v| v.borrow_mut().push(id)); + Self + } + fn contains(id: ScriptId) -> bool { + VISITED.with(|v| v.borrow().contains(&id)) + } +} +impl Drop for CycleGuard { + fn drop(&mut self) { + VISITED.with(|v| { + v.borrow_mut().pop(); + }); + } +} + +/// Run the before-op interceptor CHAIN for `(service, op)` if any are +/// registered. Returns `Ok(())` to allow the operation (un-hooked, or every +/// before-interceptor explicitly allowed it) or an `Err` runtime error to deny +/// it (the caller must NOT then perform the write). A single deny +/// short-circuits — the write is blocked. `value` is the payload being written +/// (`None` for delete). #[allow(clippy::too_many_arguments)] pub(super) fn run_before( - interceptors: &Arc, - self_engine: Option<&Arc>, + ictx: &InterceptorCtx, cx: &Arc, - limits: Limits, service: &'static str, op: &'static str, collection: &str, @@ -89,47 +128,21 @@ pub(super) fn run_before( let handle = TokioHandle::try_current() .map_err(|e| runtime_err(&format!("{service} interceptor: no tokio runtime: {e}")))?; - // (1) Resolve the nearest interceptor, sealed to its declaring owner. - let resolution = { - let interceptors = interceptors.clone(); + // (1) Resolve the whole before+after chain, each entry sealed to its + // declaring owner. `before` is ordered ancestor→app (an outer/group guard + // runs first). `after` is unused this milestone. + let chain = { + let interceptors = ictx.interceptors.clone(); let cx = cx.clone(); handle - .block_on(async move { interceptors.resolve_before(&cx, service, op).await }) + .block_on(async move { interceptors.resolve(&cx, service, op).await }) .map_err(|e| runtime_err(&format!("{service}::{op} interceptor resolve: {e}")))? }; - let resolved = match resolution { - // Un-hooked: the ONLY path that allows without running anything. - InterceptorResolution::None => return Ok(()), - // A guard is declared but its script is missing/disabled — fail closed. - InterceptorResolution::Dangling(name) => { - return Err(runtime_err(&format!( - "{service}::{op} denied: interceptor script `{name}` is missing or disabled" - ))); - } - InterceptorResolution::Run(r) => r, - }; - - // A guard is registered but the engine back-reference isn't installed (a - // bare test engine without `set_self_weak`): we cannot run it, so DENY — - // a resolvable guard we can't execute must not fail open. Production always - // installs the back-ref. - let Some(self_engine) = self_engine else { - return Err(runtime_err(&format!( - "{service}::{op} denied: interceptor `{}` cannot run (engine back-reference not installed)", - resolved.name - ))); - }; - - // Depth bound (shared with invoke / trigger fan-out). - if cx.trigger_depth + 1 > limits.trigger_depth_max { - return Err(runtime_err(&format!( - "{service}::{op} interceptor `{}`: depth limit exceeded (max {})", - resolved.name, limits.trigger_depth_max - ))); + // Un-hooked: the ONLY path that allows without running anything. + if chain.before.is_empty() { + return Ok(()); } - // (2) Run the sealed script with the operation context as its request body, - // under the re-entrancy guard so its own writes bypass interception. let payload = json!({ "service": service, "action": op, @@ -139,35 +152,88 @@ pub(super) fn run_before( "caller_script_id": cx.script_id.to_string(), "caller_execution_id": cx.execution_id.to_string(), }); - let ret = { - let _guard = ReentryGuard::enter(); - run_resolved_blocking( - self_engine, - cx, - &resolved, - payload, - &format!("{service}::{op} interceptor `{}`", resolved.name), - )? - }; - // Fail-closed verdict: allow ONLY on an explicit `#{ allowed: true }`. A - // bare bool, a typo'd key, a non-bool `allowed`, a bare unit, or any other - // shape DENIES — a control whose job is to deny must not allow by omission. - if let Json::Object(m) = &ret { - if m.get("allowed") == Some(&Json::Bool(true)) { - return Ok(()); + // (2) Run each before-interceptor in order; the FIRST deny short-circuits. + for entry in &chain.before { + let resolved = match entry { + // A guard is declared but its script is missing/disabled — fail closed. + InterceptorEntry::Dangling(name) => { + return Err(runtime_err(&format!( + "{service}::{op} denied: interceptor script `{name}` is missing or disabled" + ))); + } + InterceptorEntry::Run(r) => &r.script, + }; + + // Identity cycle guard: if this interceptor's script is already on the + // run stack, running it again would recurse forever — DENY. + if CycleGuard::contains(resolved.script_id) { + return Err(runtime_err(&format!( + "{service}::{op} denied: interceptor cycle detected: `{}`", + resolved.name + ))); + } + + // A guard is registered but the engine back-reference isn't installed (a + // bare test engine without `set_self_weak`): we cannot run it, so DENY — + // a resolvable guard we can't execute must not fail open. Production + // always installs the back-ref. + let Some(self_engine) = ictx.self_engine.as_ref() else { + return Err(runtime_err(&format!( + "{service}::{op} denied: interceptor `{}` cannot run (engine back-reference not installed)", + resolved.name + ))); + }; + + // Depth bound (shared with invoke / trigger fan-out). + if cx.trigger_depth + 1 > ictx.limits.trigger_depth_max { + return Err(runtime_err(&format!( + "{service}::{op} interceptor `{}`: depth limit exceeded (max {})", + resolved.name, ictx.limits.trigger_depth_max + ))); + } + + // Run the sealed script with the operation context as its request body, + // under the re-entrancy guard (its own writes bypass interception) and + // the identity cycle guard (its own script id is on the stack). + let ret = { + let _reentry = ReentryGuard::enter(); + let _cycle = CycleGuard::enter(resolved.script_id); + run_resolved_blocking( + self_engine, + cx, + resolved, + payload.clone(), + &format!("{service}::{op} interceptor `{}`", resolved.name), + )? + }; + + // Fail-closed verdict: allow ONLY on an explicit `#{ allowed: true }`. A + // bare bool, a typo'd key, a non-bool `allowed`, a bare unit, or any + // other shape DENIES — a control whose job is to deny must not allow by + // omission. + match &ret { + // Explicit allow → this entry passes; continue to the next. + Json::Object(m) if m.get("allowed") == Some(&Json::Bool(true)) => {} + Json::Object(m) => { + let reason = m + .get("reason") + .and_then(Json::as_str) + .unwrap_or("denied by interceptor"); + return Err(runtime_err(&format!( + "{service}::{op} denied by interceptor `{}`: {reason}", + resolved.name + ))); + } + _ => { + return Err(runtime_err(&format!( + "{service}::{op} denied by interceptor `{}`: no allow verdict returned", + resolved.name + ))); + } } - let reason = m - .get("reason") - .and_then(Json::as_str) - .unwrap_or("denied by interceptor"); - return Err(runtime_err(&format!( - "{service}::{op} denied by interceptor `{}`: {reason}", - resolved.name - ))); } - Err(runtime_err(&format!( - "{service}::{op} denied by interceptor `{}`: no allow verdict returned", - resolved.name - ))) + + // Every before-interceptor allowed. + Ok(()) } diff --git a/crates/executor-core/src/sdk/kv.rs b/crates/executor-core/src/sdk/kv.rs index 911813a..8a0f35d 100644 --- a/crates/executor-core/src/sdk/kv.rs +++ b/crates/executor-core/src/sdk/kv.rs @@ -30,26 +30,23 @@ use std::sync::Arc; -use picloud_shared::{GroupKvService, InterceptorService, KvService, SdkCallCx, Services}; +use picloud_shared::{GroupKvService, KvService, SdkCallCx, Services}; use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module}; use super::bridge::{ block_on, dynamic_to_json_capped, json_to_dynamic, JsonSizeError, MAX_JSON_MATERIALIZE_BYTES, }; -use crate::engine::Engine; -use crate::sandbox::Limits; +use super::interceptor::InterceptorCtx; /// Per-call handle captured by the Rhai SDK. Cheap to clone (a few Arcs plus an -/// owned string). Carries the §9.4 interceptor deps so `set`/`delete` can run a +/// owned string). Carries the §9.4 interceptor ctx so `set`/`delete` can run a /// before-op allow/deny hook (the resolver + the `invoke()` re-entry engine). #[derive(Clone)] pub struct KvHandle { collection: String, service: Arc, cx: Arc, - interceptors: Arc, - self_engine: Option>, - limits: Limits, + ictx: InterceptorCtx, } /// §11.6 shared-collection handle, returned by `kv::shared_collection(name)`. A distinct @@ -57,28 +54,24 @@ pub struct KvHandle { /// collections never grow triggers) and a script's choice of private-vs-shared /// scope is explicit. Routes through the `GroupKvService`, which resolves the /// owning group from `cx.app_id` (the script never names a group). Carries the -/// §9.4 interceptor deps too so a `(kv, set/delete)` guard covers shared- +/// §9.4 interceptor ctx too so a `(kv, set/delete)` guard covers shared- /// collection writes — otherwise the shared handle would be a silent bypass. #[derive(Clone)] pub struct GroupKvHandle { collection: String, service: Arc, cx: Arc, - interceptors: Arc, - self_engine: Option>, - limits: Limits, + ictx: InterceptorCtx, } pub(super) fn register( engine: &mut RhaiEngine, services: &Services, cx: Arc, - limits: Limits, - self_engine: Option>, + ictx: InterceptorCtx, ) { let kv_service = services.kv.clone(); let group_kv_service = services.group_kv.clone(); - let interceptors = services.interceptors.clone(); // `kv::collection(name)` / `kv::shared_collection(name)` — both constructors live in // the `kv` static module so the script-visible calls are `kv::collection` @@ -87,8 +80,7 @@ pub(super) fn register( { let kv_service = kv_service.clone(); let cx = cx.clone(); - let interceptors = interceptors.clone(); - let self_engine = self_engine.clone(); + let ictx = ictx.clone(); module.set_native_fn( "collection", move |name: &str| -> Result> { @@ -99,9 +91,7 @@ pub(super) fn register( collection: name.to_string(), service: kv_service.clone(), cx: cx.clone(), - interceptors: interceptors.clone(), - self_engine: self_engine.clone(), - limits, + ictx: ictx.clone(), }) }, ); @@ -109,8 +99,7 @@ pub(super) fn register( { let group_kv_service = group_kv_service.clone(); let cx = cx.clone(); - let interceptors = interceptors.clone(); - let self_engine = self_engine.clone(); + let ictx = ictx.clone(); module.set_native_fn( "shared_collection", move |name: &str| -> Result> { @@ -121,9 +110,7 @@ pub(super) fn register( collection: name.to_string(), service: group_kv_service.clone(), cx: cx.clone(), - interceptors: interceptors.clone(), - self_engine: self_engine.clone(), - limits, + ictx: ictx.clone(), }) }, ); @@ -188,10 +175,8 @@ fn register_set(engine: &mut RhaiEngine) { // §9.4 before-op interceptor (allow/deny). A denial errors here and // the write below never runs. super::interceptor::run_before( - &handle.interceptors, - handle.self_engine.as_ref(), + &handle.ictx, &handle.cx, - handle.limits, "kv", "set", &handle.collection, @@ -244,10 +229,8 @@ fn register_delete(engine: &mut RhaiEngine) { "delete", |handle: &mut KvHandle, key: &str| -> Result> { super::interceptor::run_before( - &handle.interceptors, - handle.self_engine.as_ref(), + &handle.ictx, &handle.cx, - handle.limits, "kv", "delete", &handle.collection, @@ -271,10 +254,8 @@ fn group_run_before( value: Option<&serde_json::Value>, ) -> Result<(), Box> { super::interceptor::run_before( - &handle.interceptors, - handle.self_engine.as_ref(), + &handle.ictx, &handle.cx, - handle.limits, "kv", op, &handle.collection, diff --git a/crates/executor-core/src/sdk/mod.rs b/crates/executor-core/src/sdk/mod.rs index 6d0ce51..f3f4d99 100644 --- a/crates/executor-core/src/sdk/mod.rs +++ b/crates/executor-core/src/sdk/mod.rs @@ -60,13 +60,21 @@ pub fn register_all( limits: Limits, self_engine: Option>, ) { - kv::register(engine, services, cx.clone(), limits, self_engine.clone()); - docs::register(engine, services, cx.clone()); + // §9.4 M1: build the interceptor ctx once and thread it into every SDK + // service that has (or will gain) a before/after hook. Only `kv` runs a + // hook today; docs/files/queue/pubsub/http carry it for M7–M11. + let ictx = interceptor::InterceptorCtx { + interceptors: services.interceptors.clone(), + self_engine: self_engine.clone(), + limits, + }; + kv::register(engine, services, cx.clone(), ictx.clone()); + docs::register(engine, services, cx.clone(), ictx.clone()); dead_letters::register(engine, services, cx.clone()); - http::register(engine, services, cx.clone()); - files::register(engine, services, cx.clone()); - pubsub::register(engine, services, cx.clone()); - queue::register(engine, services, cx.clone()); + http::register(engine, services, cx.clone(), ictx.clone()); + files::register(engine, services, cx.clone(), ictx.clone()); + pubsub::register(engine, services, cx.clone(), ictx.clone()); + queue::register(engine, services, cx.clone(), ictx); retry::register(engine, services, cx.clone()); secrets::register(engine, services, cx.clone()); vars::register(engine, services, cx.clone()); diff --git a/crates/executor-core/src/sdk/pubsub.rs b/crates/executor-core/src/sdk/pubsub.rs index 69abd47..8a61c42 100644 --- a/crates/executor-core/src/sdk/pubsub.rs +++ b/crates/executor-core/src/sdk/pubsub.rs @@ -25,8 +25,14 @@ use serde_json::Value as Json; use tokio::runtime::Handle as TokioHandle; use super::bridge::{block_on, MAX_JSON_MATERIALIZE_BYTES}; +use super::interceptor::InterceptorCtx; -pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc) { +pub(super) fn register( + engine: &mut RhaiEngine, + services: &Services, + cx: Arc, + ictx: InterceptorCtx, +) { let svc = services.pubsub.clone(); let mut module = Module::new(); { @@ -77,6 +83,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc Result> { @@ -87,6 +94,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc, cx: Arc, + // §9.4 M1 plumbing: carried so `publish` can run a before/after hook in a + // later milestone (M11). Unused until then. + #[allow(dead_code)] + ictx: InterceptorCtx, } fn shared_publish( diff --git a/crates/executor-core/src/sdk/queue.rs b/crates/executor-core/src/sdk/queue.rs index 4b9f1a0..b0ac3b9 100644 --- a/crates/executor-core/src/sdk/queue.rs +++ b/crates/executor-core/src/sdk/queue.rs @@ -28,8 +28,14 @@ use serde_json::Value as Json; use tokio::runtime::Handle as TokioHandle; use super::bridge::MAX_JSON_MATERIALIZE_BYTES; +use super::interceptor::InterceptorCtx; -pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc) { +pub(super) fn register( + engine: &mut RhaiEngine, + services: &Services, + cx: Arc, + ictx: InterceptorCtx, +) { let svc = services.queue.clone(); let mut module = Module::new(); @@ -100,6 +106,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc Result> { @@ -110,6 +117,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc, cx: Arc, + // §9.4 M1 plumbing: carried so `enqueue` can run a before/after hook in a + // later milestone (M10). Unused until then. + #[allow(dead_code)] + ictx: InterceptorCtx, } fn group_enqueue( diff --git a/crates/manager-core/migrations/0074_interceptor_phase.sql b/crates/manager-core/migrations/0074_interceptor_phase.sql new file mode 100644 index 0000000..02476ad --- /dev/null +++ b/crates/manager-core/migrations/0074_interceptor_phase.sql @@ -0,0 +1,20 @@ +-- §9.4 Service Interceptors M2 — before + after phases. +-- +-- The 0073 marker guarded only the BEFORE-op allow/deny hook. M2 introduces an +-- explicit `phase` so an owner can declare a `before` AND an `after` interceptor +-- for one `(service, op)`. Existing markers default to `phase='before'` — the +-- 0073 behaviour is unchanged. The partial-unique indexes gain `phase` so the +-- two phases are distinct rows per owner (one before + one after each). + +ALTER TABLE interceptors + ADD COLUMN phase TEXT NOT NULL DEFAULT 'before' + CHECK (phase IN ('before', 'after')); + +-- Recreate the per-owner uniqueness to be per (owner, service, op, PHASE). +DROP INDEX interceptors_group_uidx; +DROP INDEX interceptors_app_uidx; + +CREATE UNIQUE INDEX interceptors_group_uidx + ON interceptors (group_id, service, op, phase) WHERE group_id IS NOT NULL; +CREATE UNIQUE INDEX interceptors_app_uidx + ON interceptors (app_id, service, op, phase) WHERE app_id IS NOT NULL; diff --git a/crates/manager-core/src/interceptor_repo.rs b/crates/manager-core/src/interceptor_repo.rs index 407c92a..385a59d 100644 --- a/crates/manager-core/src/interceptor_repo.rs +++ b/crates/manager-core/src/interceptor_repo.rs @@ -5,7 +5,7 @@ //! interceptor's behaviour is a normal script resolved + run through `invoke()` //! re-entry. This module holds the read + transactional-write helpers (free //! functions over `&PgPool` / `&mut Transaction`, keyed by [`ScriptOwner`]), -//! plus the runtime [`resolve_before`] chain walk (nearest-owner-wins). +//! plus the runtime [`resolve_chain`] walk (all markers on the app's chain). use chrono::{DateTime, Utc}; use picloud_shared::{AppId, ScriptOwner}; @@ -117,55 +117,77 @@ impl SealedInterceptor { } } -/// Resolve the interceptor guarding `(service, op)` for a calling app — the -/// nearest MARKER on the app's chain (app, then nearest ancestor group), with -/// its script **sealed to the owner that declared the marker** (a descendant -/// app cannot shadow it with a same-named local script). `None` when un-hooked. +/// The ordered before + after interceptors guarding a `(service, op)` for a +/// calling app, each sealed to its declaring owner (see [`SealedInterceptor`]). +#[derive(Debug, Clone, Default)] +pub struct SealedChain { + /// Ordered ancestor→app (outermost/group guard first). + pub before: Vec, + /// Ordered app→ancestor. + pub after: Vec, +} + +/// Resolve ALL interceptor markers guarding `(service, op)` for a calling app — +/// every marker visible on the app's chain (app + each ancestor group), each +/// with its script **sealed to the owner that declared the marker** (a +/// descendant app cannot shadow it with a same-named local script). Split by +/// `phase` and ordered: `before` ancestor→app (an outer/group guard runs first, +/// so a group denial cannot be bypassed by a descendant), `after` app→ancestor. /// **This chain walk is the resolution boundary** — a sibling-subtree app never /// sees another subtree's interceptor. The script join is `LEFT` so a marker -/// whose script is missing/disabled still returns a row (with `script: None`) -/// rather than silently falling through to a farther, weaker marker. -pub async fn resolve_before( +/// whose script is missing/disabled still returns an entry (with `script: +/// None`) the caller fails closed on, rather than being silently dropped. +/// +/// Per the `(owner, service, op, phase)` partial-unique index there is at most +/// one marker per owner per phase, so no per-owner dedup is needed. +pub async fn resolve_chain( pool: &PgPool, app_id: AppId, service: &str, op: &str, -) -> Result, sqlx::Error> { +) -> Result { #[allow(clippy::type_complexity)] - let row: Option<( - Option, - Option, - String, - Option, - Option, - Option>, + let rows: Vec<( + Option, // marker_app + Option, // marker_group + String, // script_name + String, // phase + i32, // depth (0 = app, larger = ancestor) + Option, // script id + Option, // script source + Option>, // script updated_at )> = sqlx::query_as(&format!( "{CHAIN_LEVELS_CTE}, \ - nearest AS ( \ + markers AS ( \ SELECT i.interceptor_script AS script_name, \ - i.app_id AS marker_app, i.group_id AS marker_group, c.depth AS depth \ + i.app_id AS marker_app, i.group_id AS marker_group, \ + i.phase AS phase, c.depth AS depth \ FROM chain c \ JOIN interceptors i ON (i.app_id = c.app_owner OR i.group_id = c.group_owner) \ WHERE i.service = $2 AND i.op = $3 \ - ORDER BY c.depth ASC LIMIT 1 \ ) \ - SELECT n.marker_app, n.marker_group, n.script_name, \ + SELECT m.marker_app, m.marker_group, m.script_name, m.phase, m.depth, \ s.id, s.source, s.updated_at \ - FROM nearest n \ + FROM markers m \ LEFT JOIN scripts s ON ( \ - (n.marker_app IS NOT NULL AND s.app_id = n.marker_app \ - AND s.name = n.script_name AND s.enabled) \ - OR (n.marker_group IS NOT NULL AND s.group_id = n.marker_group \ - AND s.name = n.script_name AND s.enabled) \ + (m.marker_app IS NOT NULL AND s.app_id = m.marker_app \ + AND s.name = m.script_name AND s.enabled) \ + OR (m.marker_group IS NOT NULL AND s.group_id = m.marker_group \ + AND s.name = m.script_name AND s.enabled) \ )", )) .bind(app_id.into_inner()) .bind(service) .bind(op) - .fetch_optional(pool) + .fetch_all(pool) .await?; - Ok(row.map( - |(marker_app, marker_group, script_name, sid, src, upd)| SealedInterceptor { + + // Split by phase and order: `before` ancestor→app = depth DESC (larger + // depth = ancestor runs first); `after` app→ancestor = depth ASC. + let mut before: Vec<(i32, SealedInterceptor)> = Vec::new(); + let mut after: Vec<(i32, SealedInterceptor)> = Vec::new(); + for (marker_app, marker_group, script_name, phase, depth, sid, src, upd) in rows { + let sealed = SealedInterceptor { marker_app, marker_group, script_name, @@ -173,8 +195,19 @@ pub async fn resolve_before( (Some(id), Some(source), Some(updated_at)) => Some((id, source, updated_at)), _ => None, }, - }, - )) + }; + if phase == "after" { + after.push((depth, sealed)); + } else { + before.push((depth, sealed)); + } + } + before.sort_by(|a, b| b.0.cmp(&a.0)); // depth DESC → ancestor first + after.sort_by(|a, b| a.0.cmp(&b.0)); // depth ASC → app first + Ok(SealedChain { + before: before.into_iter().map(|(_, s)| s).collect(), + after: after.into_iter().map(|(_, s)| s).collect(), + }) } /// Upsert a marker at `owner` in the apply transaction. A re-apply that changes @@ -190,9 +223,11 @@ pub async fn insert_interceptor_tx( match owner { ScriptOwner::App(a) => { sqlx::query( + // `phase` defaults to 'before' (0074); the conflict arbiter must + // include it to match the per-(owner, service, op, phase) index. "INSERT INTO interceptors (app_id, service, op, interceptor_script) \ VALUES ($1, $2, $3, $4) \ - ON CONFLICT (app_id, service, op) WHERE app_id IS NOT NULL \ + ON CONFLICT (app_id, service, op, phase) WHERE app_id IS NOT NULL \ DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, updated_at = NOW()", ) .bind(a.into_inner()) @@ -204,9 +239,11 @@ pub async fn insert_interceptor_tx( } ScriptOwner::Group(g) => { sqlx::query( + // `phase` defaults to 'before' (0074); the conflict arbiter must + // include it to match the per-(owner, service, op, phase) index. "INSERT INTO interceptors (group_id, service, op, interceptor_script) \ VALUES ($1, $2, $3, $4) \ - ON CONFLICT (group_id, service, op) WHERE group_id IS NOT NULL \ + ON CONFLICT (group_id, service, op, phase) WHERE group_id IS NOT NULL \ DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, updated_at = NOW()", ) .bind(g.into_inner()) diff --git a/crates/manager-core/src/interceptor_service.rs b/crates/manager-core/src/interceptor_service.rs index ac0394c..f0ca5f6 100644 --- a/crates/manager-core/src/interceptor_service.rs +++ b/crates/manager-core/src/interceptor_service.rs @@ -7,10 +7,13 @@ use async_trait::async_trait; use picloud_shared::{ - InterceptorResolution, InterceptorService, ResolvedScript, ScriptId, SdkCallCx, + InterceptorChain, InterceptorEntry, InterceptorService, ResolvedInterceptor, ResolvedScript, + ScriptId, SdkCallCx, }; use sqlx::PgPool; +use crate::interceptor_repo::SealedInterceptor; + pub struct InterceptorServiceImpl { pool: PgPool, } @@ -22,38 +25,55 @@ impl InterceptorServiceImpl { } } +impl InterceptorServiceImpl { + /// Map one sealed marker to a chain entry: a runnable [`ResolvedScript`] + /// (sealed to the declaring owner — the lexical `import` origin, §5.5) or a + /// [`Dangling`](InterceptorEntry::Dangling) entry when the marker's script + /// is missing/disabled. The execution boundary is always the CALLER's app + /// (`cx.app_id`); only the `owner` is the sealing owner. + fn map_entry(app_id: picloud_shared::AppId, sealed: SealedInterceptor) -> InterceptorEntry { + let owner = sealed.sealing_owner(); + match sealed.script { + Some((sid, source, updated_at)) => InterceptorEntry::Run(ResolvedInterceptor { + script: Box::new(ResolvedScript { + script_id: ScriptId::from(sid), + app_id, + owner: Some(owner), + source, + updated_at, + name: sealed.script_name, + }), + timeout_ms: None, + }), + None => InterceptorEntry::Dangling(sealed.script_name), + } + } +} + #[async_trait] impl InterceptorService for InterceptorServiceImpl { - async fn resolve_before( + async fn resolve( &self, cx: &SdkCallCx, service: &str, op: &str, - ) -> Result { + ) -> Result { // `app_id` derives from `cx` (never a script arg) — the isolation // boundary; the chain walk then scopes resolution to this app's subtree. - let sealed = crate::interceptor_repo::resolve_before(&self.pool, cx.app_id, service, op) + let chain = crate::interceptor_repo::resolve_chain(&self.pool, cx.app_id, service, op) .await .map_err(|e| e.to_string())?; - let Some(sealed) = sealed else { - return Ok(InterceptorResolution::None); - }; - // A marker exists but its sealed script is missing/disabled → dangling, - // the executor fails closed (denies) rather than silently allowing. - let Some((sid, source, updated_at)) = sealed.script.clone() else { - return Ok(InterceptorResolution::Dangling(sealed.script_name)); - }; - // The execution boundary is always the CALLER's app; the `owner` is the - // sealing (declaring) owner, the lexical origin for the interceptor's - // own `import`s (§5.5) — a group marker's script runs its imports from - // the group. - Ok(InterceptorResolution::Run(Box::new(ResolvedScript { - script_id: ScriptId::from(sid), - app_id: cx.app_id, - owner: Some(sealed.sealing_owner()), - source, - updated_at, - name: sealed.script_name, - }))) + Ok(InterceptorChain { + before: chain + .before + .into_iter() + .map(|s| Self::map_entry(cx.app_id, s)) + .collect(), + after: chain + .after + .into_iter() + .map(|s| Self::map_entry(cx.app_id, s)) + .collect(), + }) } } diff --git a/crates/manager-core/tests/expected_schema.txt b/crates/manager-core/tests/expected_schema.txt index ec9a544..c086717 100644 --- a/crates/manager-core/tests/expected_schema.txt +++ b/crates/manager-core/tests/expected_schema.txt @@ -317,6 +317,7 @@ table: interceptors interceptor_script: text NOT NULL created_at: timestamp with time zone NOT NULL default=now() updated_at: timestamp with time zone NOT NULL default=now() + phase: text NOT NULL default='before'::text table: kv_entries app_id: uuid NOT NULL @@ -675,9 +676,9 @@ indexes on groups: indexes on interceptors: interceptors_app_id_idx: public.interceptors USING btree (app_id) WHERE (app_id IS NOT NULL) - interceptors_app_uidx: public.interceptors USING btree (app_id, service, op) WHERE (app_id IS NOT NULL) + interceptors_app_uidx: public.interceptors USING btree (app_id, service, op, phase) WHERE (app_id IS NOT NULL) interceptors_group_id_idx: public.interceptors USING btree (group_id) WHERE (group_id IS NOT NULL) - interceptors_group_uidx: public.interceptors USING btree (group_id, service, op) WHERE (group_id IS NOT NULL) + interceptors_group_uidx: public.interceptors USING btree (group_id, service, op, phase) WHERE (group_id IS NOT NULL) interceptors_pkey: public.interceptors USING btree (id) indexes on kv_entries: @@ -952,6 +953,7 @@ constraints on groups: constraints on interceptors: [CHECK] interceptors_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL))) + [CHECK] interceptors_phase_check: CHECK ((phase = ANY (ARRAY['before'::text, 'after'::text]))) [FOREIGN KEY] interceptors_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE [FOREIGN KEY] interceptors_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE [PRIMARY KEY] interceptors_pkey: PRIMARY KEY (id) @@ -1145,3 +1147,4 @@ constraints on workflows: 0071: workflows 0072: execution source workflow 0073: interceptors + 0074: interceptor phase diff --git a/crates/picloud-cli/tests/interceptors.rs b/crates/picloud-cli/tests/interceptors.rs index ea4ed18..76a1ade 100644 --- a/crates/picloud-cli/tests/interceptors.rs +++ b/crates/picloud-cli/tests/interceptors.rs @@ -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}"); +} diff --git a/crates/shared/src/interceptor.rs b/crates/shared/src/interceptor.rs index d4811c0..d49cc3c 100644 --- a/crates/shared/src/interceptor.rs +++ b/crates/shared/src/interceptor.rs @@ -20,35 +20,54 @@ use async_trait::async_trait; use crate::{ResolvedScript, SdkCallCx}; -/// Outcome of resolving the before-op interceptor for a `(service, op)`. -/// -/// The three arms encode a **fail-closed** contract: a registered-but-broken -/// hook DENIES rather than silently allowing (only [`None`](Self::None) — no -/// marker at all — allows without running anything). +/// One resolved, runnable interceptor: a script **sealed to the owner that +/// declared the marker** (its lexical `import` origin), plus its per-hook +/// timeout. `timeout_ms` is threaded through the chain now but unused until +/// M5 — always `None` for the M2 slice. #[derive(Debug, Clone)] -pub enum InterceptorResolution { - /// No marker registered for `(service, op)` on the chain — allow, no work. - None, - /// A marker IS registered but its sealed script is missing or disabled. - /// The caller must DENY (a mis-declared guard must never fail open). - /// Carries the script name for the error message. +pub struct ResolvedInterceptor { + pub script: Box, + pub timeout_ms: Option, +} + +/// One entry in an interceptor chain. A **fail-closed** contract: a +/// registered-but-broken hook is a [`Dangling`](Self::Dangling) entry the +/// caller must DENY on — it is never dropped in favour of a farther, weaker +/// marker. +#[derive(Debug, Clone)] +pub enum InterceptorEntry { + /// Run this script as a before/after hook. + Run(ResolvedInterceptor), + /// A marker exists but its sealed script is missing/disabled → the caller + /// DENIES (a mis-declared guard must never fail open). Carries the script + /// name for the error message. Dangling(String), - /// Run this script (sealed to the declaring owner) as the before-op hook. - Run(Box), +} + +/// The ordered before + after interceptors guarding a `(service, op)` for the +/// calling app. `before` runs ancestor→app (an outer/group guard runs first, +/// so a group denial cannot be bypassed by a descendant); `after` runs +/// app→ancestor. An empty chain means un-hooked (allow, no work). `after` is +/// populated but UNUSED until §9.4 M3. +#[derive(Debug, Clone, Default)] +pub struct InterceptorChain { + pub before: Vec, + pub after: Vec, } #[async_trait] pub trait InterceptorService: Send + Sync { - /// Resolve the interceptor guarding `(service, op)` for the calling app — - /// the nearest marker on `cx.app_id`'s chain, its script sealed to the - /// declaring owner. `Err` is a backend failure (the caller fails closed: - /// turns it into an operation error rather than silently allowing). - async fn resolve_before( + /// Resolve the before + after interceptors guarding `(service, op)` for the + /// calling app — every marker visible on `cx.app_id`'s chain, each script + /// sealed to the owner that declared it, ordered per phase. `Err` is a + /// backend failure (the caller fails closed: turns it into an operation + /// error rather than silently allowing). + async fn resolve( &self, cx: &SdkCallCx, service: &str, op: &str, - ) -> Result; + ) -> Result; } /// Default: nothing is ever intercepted. The shape every non-picloud `Services` @@ -59,12 +78,12 @@ pub struct NoopInterceptorService; #[async_trait] impl InterceptorService for NoopInterceptorService { - async fn resolve_before( + async fn resolve( &self, _cx: &SdkCallCx, _service: &str, _op: &str, - ) -> Result { - Ok(InterceptorResolution::None) + ) -> Result { + Ok(InterceptorChain::default()) } } diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 1b600e5..c86e123 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -87,7 +87,10 @@ pub use ids::{ pub use inbox::{ InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver, }; -pub use interceptor::{InterceptorResolution, InterceptorService, NoopInterceptorService}; +pub use interceptor::{ + InterceptorChain, InterceptorEntry, InterceptorService, NoopInterceptorService, + ResolvedInterceptor, +}; pub use invoke::{InvokeError, InvokeService, InvokeTarget, NoopInvokeService, ResolvedScript}; pub use kv::{KvError, KvListPage, KvService, NoopKvService}; pub use log_sink::{ExecutionLogSink, LogSinkError};