fix(interceptors): harden the §9.4 slice — seal, fail-closed, re-entrancy, shared coverage

An end-to-end audit of the (unmerged) interceptor slice surfaced seven ways a
KV allow/deny guard could be silently defeated or misbehave. Close all of them:

1. Shared-collection bypass — `kv::shared_collection(...).set/delete` skipped
   the hook entirely, so any `(kv, set/delete)` guard was circumvented by
   choosing the shared handle. `GroupKvHandle` now runs the same before-op hook.

2. Seal to the declaring owner — the marker resolved nearest-owner-wins but its
   script name was then re-resolved on the CALLER's chain, so a descendant app
   could shadow a group's mandatory guard with a same-named local script.
   `resolve_before` now returns the script sealed to the owner that declared the
   marker (one LEFT JOIN in manager-core), so the executor never re-resolves by
   name. A descendant can only override with its OWN explicit marker.

3. Re-entrancy — an "allow + audit-log" guard that itself wrote KV re-triggered
   itself to the depth cap and then DENIED the original write (plus ~8x
   resolve/compile/execute amplification). A thread-local guard makes a write
   performed by an interceptor bypass interception.

4. Fail-closed verdict — only `#{ allowed: false }` denied; a bare bool, a
   typo'd key, a non-bool, or a bare unit all ALLOWED. Now allow ONLY on an
   explicit `#{ allowed: true }`; every other shape denies.

5/6. Fail-closed edges — a dangling (missing/disabled) interceptor script and a
   missing engine back-reference now DENY instead of allowing.

7. AppInvoke coupling — resolution no longer routes through `invoke.resolve`, so
   installing a guard no longer denies writes to principals who hold KV-write
   but not AppInvoke.

The seam (`InterceptorService::resolve_before`) now returns an
`InterceptorResolution { None | Dangling | Run(ResolvedScript) }`; the executor
runs the sealed script straight through the shared `run_resolved_blocking` core
and drops its `InvokeService` dependency. executor-core stays Postgres-free.
No migration change (0073 unchanged).

Pinned by three new journeys in tests/interceptors.rs: the seal (a same-named
app script does NOT shadow the group's guard), shared-collection coverage, and
the fail-closed verdict. Full journey suite 157/157.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-13 21:20:20 +02:00
parent 2fc9476f9e
commit d08df88df5
7 changed files with 504 additions and 102 deletions

View File

@@ -2,34 +2,53 @@
//!
//! An interceptor is a script registered (declaratively, per app/group) to run
//! **before** a data-plane operation and allow or deny it. This trait is the
//! narrow, executor-facing seam: it only RESOLVES which interceptor script (by
//! name) applies to a `(service, op)` on the calling app's chain — nearest-owner
//! wins, like extension points. Running the resolved script reuses the existing
//! `invoke()` re-entry path in `executor-core` (resolve the name → compile →
//! execute), so `executor-core` stays Postgres-free and there is no second
//! script-dispatch mechanism.
//! narrow, executor-facing seam: it RESOLVES the interceptor for a
//! `(service, op)` on the calling app's chain — the nearest MARKER wins
//! (nearest-owner, like extension points), and the marker's script is then
//! resolved **at the owner that declared it**. That seal is the compliance
//! guarantee: a descendant app cannot shadow a group's interceptor with a
//! same-named local script (contrast a bare `invoke()` name, which
//! nearest-owner-wins and *would* let the app win). Resolution returns a fully
//! materialized [`ResolvedScript`] so the executor runs it straight through the
//! existing `invoke()` re-entry core — no second dispatch mechanism, no re-
//! resolution by name, and `executor-core` stays Postgres-free.
//!
//! MVP scope (v1.2): `service = "kv"`, `op ∈ {set, delete}`, allow/deny only —
//! no data transform, no chaining, no `after_*` hooks. See blueprint §9.4.
use async_trait::async_trait;
use crate::SdkCallCx;
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).
#[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.
Dangling(String),
/// Run this script (sealed to the declaring owner) as the before-op hook.
Run(Box<ResolvedScript>),
}
#[async_trait]
pub trait InterceptorService: Send + Sync {
/// The NAME of the interceptor script registered for `(service, op)` at the
/// nearest owner on `cx.app_id`'s chain (app, else nearest ancestor group),
/// or `None` when the operation is un-hooked. The executor resolves that
/// name to a script and runs it. `Err` is a backend failure (fail-closed:
/// the caller turns it into an operation error rather than silently
/// allowing).
/// 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(
&self,
cx: &SdkCallCx,
service: &str,
op: &str,
) -> Result<Option<String>, String>;
) -> Result<InterceptorResolution, String>;
}
/// Default: nothing is ever intercepted. The shape every non-picloud `Services`
@@ -45,7 +64,7 @@ impl InterceptorService for NoopInterceptorService {
_cx: &SdkCallCx,
_service: &str,
_op: &str,
) -> Result<Option<String>, String> {
Ok(None)
) -> Result<InterceptorResolution, String> {
Ok(InterceptorResolution::None)
}
}

View File

@@ -87,7 +87,7 @@ pub use ids::{
pub use inbox::{
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
};
pub use interceptor::{InterceptorService, NoopInterceptorService};
pub use interceptor::{InterceptorResolution, InterceptorService, NoopInterceptorService};
pub use invoke::{InvokeError, InvokeService, InvokeTarget, NoopInvokeService, ResolvedScript};
pub use kv::{KvError, KvListPage, KvService, NoopKvService};
pub use log_sink::{ExecutionLogSink, LogSinkError};