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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user