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>
60 lines
2.2 KiB
Rust
60 lines
2.2 KiB
Rust
//! `InterceptorServiceImpl` — the Postgres-backed §9.4 interceptor resolver
|
|
//! injected into `Services`. It maps `(cx.app_id, service, op)` to the nearest
|
|
//! interceptor marker on the calling app's chain and materializes its script
|
|
//! **sealed to the declaring owner** (via [`crate::interceptor_repo`]). Running
|
|
//! that script is the executor's job (the `invoke()` re-entry core), which keeps
|
|
//! `executor-core` Postgres-free.
|
|
|
|
use async_trait::async_trait;
|
|
use picloud_shared::{
|
|
InterceptorResolution, InterceptorService, ResolvedScript, ScriptId, SdkCallCx,
|
|
};
|
|
use sqlx::PgPool;
|
|
|
|
pub struct InterceptorServiceImpl {
|
|
pool: PgPool,
|
|
}
|
|
|
|
impl InterceptorServiceImpl {
|
|
#[must_use]
|
|
pub fn new(pool: PgPool) -> Self {
|
|
Self { pool }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl InterceptorService for InterceptorServiceImpl {
|
|
async fn resolve_before(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
service: &str,
|
|
op: &str,
|
|
) -> Result<InterceptorResolution, String> {
|
|
// `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)
|
|
.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,
|
|
})))
|
|
}
|
|
}
|