Files
PiCloud/crates/manager-core/src/interceptor_repo.rs
MechaCat02 d08df88df5 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>
2026-07-13 21:20:20 +02:00

253 lines
8.8 KiB
Rust

//! §9.4 Service-interceptor markers — the `interceptors` table (0073).
//!
//! A marker `(owner, service, op) -> interceptor_script` declares a before-op
//! allow/deny hook. Pure declaration (like `extension_points`, 0051); the
//! 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).
use chrono::{DateTime, Utc};
use picloud_shared::{AppId, ScriptOwner};
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
use crate::config_resolver::CHAIN_LEVELS_CTE;
/// One interceptor marker at an owner: which `(service, op)` it guards and the
/// interceptor script name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterceptorMarker {
pub service: String,
pub op: String,
pub script: String,
}
/// List the markers declared **directly at** `owner` (not inherited), ordered
/// deterministically. Used by `load_current` (apply diff) and `interceptors ls`.
pub async fn list_for_owner(
pool: &PgPool,
owner: ScriptOwner,
) -> Result<Vec<InterceptorMarker>, sqlx::Error> {
let rows: Vec<(String, String, String)> = match owner {
ScriptOwner::App(a) => {
sqlx::query_as(
"SELECT service, op, interceptor_script FROM interceptors \
WHERE app_id = $1 ORDER BY service, op",
)
.bind(a.into_inner())
.fetch_all(pool)
.await?
}
ScriptOwner::Group(g) => {
sqlx::query_as(
"SELECT service, op, interceptor_script FROM interceptors \
WHERE group_id = $1 ORDER BY service, op",
)
.bind(g.into_inner())
.fetch_all(pool)
.await?
}
};
Ok(rows
.into_iter()
.map(|(service, op, script)| InterceptorMarker {
service,
op,
script,
})
.collect())
}
/// All markers **visible to an app** — declared at the app or any ancestor
/// group (each `(service, op)` resolved nearest-owner-wins). Used by
/// `interceptors ls --app` and by the resolver's chain view.
pub async fn list_on_app_chain(
pool: &PgPool,
app_id: AppId,
) -> Result<Vec<InterceptorMarker>, sqlx::Error> {
let rows: Vec<(String, String, String)> = sqlx::query_as(&format!(
"{CHAIN_LEVELS_CTE} \
SELECT DISTINCT ON (i.service, i.op) i.service, i.op, i.interceptor_script \
FROM chain c \
JOIN interceptors i ON (i.app_id = c.app_owner OR i.group_id = c.group_owner) \
ORDER BY i.service, i.op, c.depth ASC",
))
.bind(app_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(service, op, script)| InterceptorMarker {
service,
op,
script,
})
.collect())
}
/// 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<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(
|(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
/// only the interceptor script updates it in place (no version churn on the
/// `(service, op)` identity).
pub async fn insert_interceptor_tx(
tx: &mut Transaction<'_, Postgres>,
owner: ScriptOwner,
service: &str,
op: &str,
script: &str,
) -> Result<(), sqlx::Error> {
match owner {
ScriptOwner::App(a) => {
sqlx::query(
"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 \
DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, updated_at = NOW()",
)
.bind(a.into_inner())
.bind(service)
.bind(op)
.bind(script)
.execute(&mut **tx)
.await?;
}
ScriptOwner::Group(g) => {
sqlx::query(
"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 \
DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, updated_at = NOW()",
)
.bind(g.into_inner())
.bind(service)
.bind(op)
.bind(script)
.execute(&mut **tx)
.await?;
}
}
Ok(())
}
/// Delete a marker at `owner` (by `(service, op)`), in the apply transaction.
/// Used by `--prune` when the manifest stops declaring it.
pub async fn delete_interceptor_tx(
tx: &mut Transaction<'_, Postgres>,
owner: ScriptOwner,
service: &str,
op: &str,
) -> Result<(), sqlx::Error> {
match owner {
ScriptOwner::App(a) => {
sqlx::query("DELETE FROM interceptors WHERE app_id = $1 AND service = $2 AND op = $3")
.bind(a.into_inner())
.bind(service)
.bind(op)
.execute(&mut **tx)
.await?;
}
ScriptOwner::Group(g) => {
sqlx::query(
"DELETE FROM interceptors WHERE group_id = $1 AND service = $2 AND op = $3",
)
.bind(g.into_inner())
.bind(service)
.bind(op)
.execute(&mut **tx)
.await?;
}
}
Ok(())
}