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:
@@ -7,8 +7,10 @@
|
||||
//! functions over `&PgPool` / `&mut Transaction`, keyed by [`ScriptOwner`]),
|
||||
//! plus the runtime [`resolve_before`] chain walk (nearest-owner-wins).
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AppId, ScriptOwner};
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config_resolver::CHAIN_LEVELS_CTE;
|
||||
|
||||
@@ -84,29 +86,95 @@ pub async fn list_on_app_chain(
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Resolve the interceptor script guarding `(service, op)` for a calling app —
|
||||
/// the nearest declaration on the app's chain (app, then nearest ancestor
|
||||
/// group). `None` when un-hooked. **This chain walk is the resolution boundary**
|
||||
/// — a sibling-subtree app never sees another subtree's interceptor.
|
||||
/// The nearest interceptor marker on an app's chain, with its script resolved
|
||||
/// **at the declaring owner** (the seal). `script` is `None` when the marker
|
||||
/// names a script that is missing or disabled at that owner — a dangling hook
|
||||
/// the caller must fail closed on.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SealedInterceptor {
|
||||
pub marker_app: Option<Uuid>,
|
||||
pub marker_group: Option<Uuid>,
|
||||
pub script_name: String,
|
||||
/// `(script_id, source, updated_at)` of the sealed script, or `None` if it
|
||||
/// is missing/disabled at the declaring owner.
|
||||
pub script: Option<(Uuid, String, DateTime<Utc>)>,
|
||||
}
|
||||
|
||||
impl SealedInterceptor {
|
||||
/// The owner that DECLARED the marker (and therefore owns the resolved
|
||||
/// script) — the lexical origin for the interceptor's own `import`s (§5.5).
|
||||
#[must_use]
|
||||
pub fn sealing_owner(&self) -> ScriptOwner {
|
||||
if let Some(g) = self.marker_group {
|
||||
ScriptOwner::Group(g.into())
|
||||
} else {
|
||||
ScriptOwner::App(
|
||||
self.marker_app
|
||||
.expect("marker XOR: app when not group")
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// **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(
|
||||
pool: &PgPool,
|
||||
app_id: AppId,
|
||||
service: &str,
|
||||
op: &str,
|
||||
) -> Result<Option<String>, sqlx::Error> {
|
||||
let row: Option<(String,)> = sqlx::query_as(&format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT i.interceptor_script 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",
|
||||
) -> Result<Option<SealedInterceptor>, sqlx::Error> {
|
||||
#[allow(clippy::type_complexity)]
|
||||
let row: Option<(
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
String,
|
||||
Option<Uuid>,
|
||||
Option<String>,
|
||||
Option<DateTime<Utc>>,
|
||||
)> = sqlx::query_as(&format!(
|
||||
"{CHAIN_LEVELS_CTE}, \
|
||||
nearest AS ( \
|
||||
SELECT i.interceptor_script AS script_name, \
|
||||
i.app_id AS marker_app, i.group_id AS marker_group, 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, \
|
||||
s.id, s.source, s.updated_at \
|
||||
FROM nearest n \
|
||||
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) \
|
||||
)",
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.bind(service)
|
||||
.bind(op)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|(s,)| s))
|
||||
Ok(row.map(
|
||||
|(marker_app, marker_group, script_name, sid, src, upd)| SealedInterceptor {
|
||||
marker_app,
|
||||
marker_group,
|
||||
script_name,
|
||||
script: match (sid, src, upd) {
|
||||
(Some(id), Some(source), Some(updated_at)) => Some((id, source, updated_at)),
|
||||
_ => None,
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Upsert a marker at `owner` in the apply transaction. A re-apply that changes
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
//! `InterceptorServiceImpl` — the Postgres-backed §9.4 interceptor resolver
|
||||
//! injected into `Services`. Resolve-only: it maps `(cx.app_id, service, op)`
|
||||
//! to the nearest interceptor script name on the calling app's chain (via
|
||||
//! [`crate::interceptor_repo::resolve_before`]). Running that script is the
|
||||
//! executor's job (the `invoke()` re-entry path), which keeps `executor-core`
|
||||
//! Postgres-free.
|
||||
//! 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::{InterceptorService, SdkCallCx};
|
||||
use picloud_shared::{
|
||||
InterceptorResolution, InterceptorService, ResolvedScript, ScriptId, SdkCallCx,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct InterceptorServiceImpl {
|
||||
@@ -27,11 +29,31 @@ impl InterceptorService for InterceptorServiceImpl {
|
||||
cx: &SdkCallCx,
|
||||
service: &str,
|
||||
op: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
) -> 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.
|
||||
crate::interceptor_repo::resolve_before(&self.pool, cx.app_id, service, op)
|
||||
let sealed = crate::interceptor_repo::resolve_before(&self.pool, cx.app_id, service, op)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.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,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user