A [[interceptors]] marker can set timeout_ms (migration 0075, CHECK > 0); a
runaway guard (loop {}) is interrupted and the op DENIED (fail closed) within
budget rather than hanging the write path. The effective deadline is
min(caller-remaining, now + timeout) — a hook can only tighten, never extend,
its caller's deadline. NULL uses PICLOUD_INTERCEPTOR_TIMEOUT_MS (default 5s).
Wiring: run_resolved_blocking splits into a core + a _with_timeout variant that
computes the effective deadline from engine::ambient_deadline() and runs via
execute_ast_with_deadline; run_one_hook passes the marker's timeout_ms (or the
env default). timeout_ms threaded end to end — manifest, plan, BundleInterceptor,
the reconcile diff (part of the mutable body: a timeout change is an Update),
insert_interceptor_tx, resolve_chain/list_for_owner/list_on_app_chain +
SealedInterceptor/InterceptorMarker, and interceptor_service → ResolvedInterceptor.
Schema snapshot re-blessed. Pinned by a journey: a loop{} guard with
timeout_ms=100 is denied and its write does not persist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
321 lines
12 KiB
Rust
321 lines
12 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_chain`] walk (all markers on the app's chain).
|
|
|
|
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,
|
|
/// `before` (allow/deny + transform) or `after` (observe; §9.4 M3).
|
|
pub phase: String,
|
|
pub script: String,
|
|
/// §9.4 M5: per-hook timeout, or `None` for the instance default.
|
|
pub timeout_ms: Option<i32>,
|
|
}
|
|
|
|
/// 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, String, Option<i32>)> = match owner {
|
|
ScriptOwner::App(a) => {
|
|
sqlx::query_as(
|
|
"SELECT service, op, phase, interceptor_script, timeout_ms FROM interceptors \
|
|
WHERE app_id = $1 ORDER BY service, op, phase",
|
|
)
|
|
.bind(a.into_inner())
|
|
.fetch_all(pool)
|
|
.await?
|
|
}
|
|
ScriptOwner::Group(g) => {
|
|
sqlx::query_as(
|
|
"SELECT service, op, phase, interceptor_script, timeout_ms FROM interceptors \
|
|
WHERE group_id = $1 ORDER BY service, op, phase",
|
|
)
|
|
.bind(g.into_inner())
|
|
.fetch_all(pool)
|
|
.await?
|
|
}
|
|
};
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(
|
|
|(service, op, phase, script, timeout_ms)| InterceptorMarker {
|
|
service,
|
|
op,
|
|
phase,
|
|
script,
|
|
timeout_ms,
|
|
},
|
|
)
|
|
.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, String, Option<i32>)> = sqlx::query_as(&format!(
|
|
"{CHAIN_LEVELS_CTE} \
|
|
SELECT DISTINCT ON (i.service, i.op, i.phase) \
|
|
i.service, i.op, i.phase, i.interceptor_script, i.timeout_ms \
|
|
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, i.phase, c.depth ASC",
|
|
))
|
|
.bind(app_id.into_inner())
|
|
.fetch_all(pool)
|
|
.await?;
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(
|
|
|(service, op, phase, script, timeout_ms)| InterceptorMarker {
|
|
service,
|
|
op,
|
|
phase,
|
|
script,
|
|
timeout_ms,
|
|
},
|
|
)
|
|
.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,
|
|
/// §9.4 M5: per-hook wall-clock timeout, or `None` for the instance default.
|
|
pub timeout_ms: Option<i32>,
|
|
/// `(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(),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The ordered before + after interceptors guarding a `(service, op)` for a
|
|
/// calling app, each sealed to its declaring owner (see [`SealedInterceptor`]).
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct SealedChain {
|
|
/// Ordered ancestor→app (outermost/group guard first).
|
|
pub before: Vec<SealedInterceptor>,
|
|
/// Ordered app→ancestor.
|
|
pub after: Vec<SealedInterceptor>,
|
|
}
|
|
|
|
/// Resolve ALL interceptor markers guarding `(service, op)` for a calling app —
|
|
/// every marker visible on the app's chain (app + each ancestor group), each
|
|
/// with its script **sealed to the owner that declared the marker** (a
|
|
/// descendant app cannot shadow it with a same-named local script). Split by
|
|
/// `phase` and ordered: `before` ancestor→app (an outer/group guard runs first,
|
|
/// so a group denial cannot be bypassed by a descendant), `after` app→ancestor.
|
|
/// **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 an entry (with `script:
|
|
/// None`) the caller fails closed on, rather than being silently dropped.
|
|
///
|
|
/// Per the `(owner, service, op, phase)` partial-unique index there is at most
|
|
/// one marker per owner per phase, so no per-owner dedup is needed.
|
|
pub async fn resolve_chain(
|
|
pool: &PgPool,
|
|
app_id: AppId,
|
|
service: &str,
|
|
op: &str,
|
|
) -> Result<SealedChain, sqlx::Error> {
|
|
#[allow(clippy::type_complexity)]
|
|
let rows: Vec<(
|
|
Option<Uuid>, // marker_app
|
|
Option<Uuid>, // marker_group
|
|
String, // script_name
|
|
String, // phase
|
|
Option<i32>, // timeout_ms
|
|
i32, // depth (0 = app, larger = ancestor)
|
|
Option<Uuid>, // script id
|
|
Option<String>, // script source
|
|
Option<DateTime<Utc>>, // script updated_at
|
|
)> = sqlx::query_as(&format!(
|
|
"{CHAIN_LEVELS_CTE}, \
|
|
markers AS ( \
|
|
SELECT i.interceptor_script AS script_name, \
|
|
i.app_id AS marker_app, i.group_id AS marker_group, \
|
|
i.phase AS phase, i.timeout_ms AS timeout_ms, 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 \
|
|
) \
|
|
SELECT m.marker_app, m.marker_group, m.script_name, m.phase, m.timeout_ms, m.depth, \
|
|
s.id, s.source, s.updated_at \
|
|
FROM markers m \
|
|
LEFT JOIN scripts s ON ( \
|
|
(m.marker_app IS NOT NULL AND s.app_id = m.marker_app \
|
|
AND s.name = m.script_name AND s.enabled) \
|
|
OR (m.marker_group IS NOT NULL AND s.group_id = m.marker_group \
|
|
AND s.name = m.script_name AND s.enabled) \
|
|
)",
|
|
))
|
|
.bind(app_id.into_inner())
|
|
.bind(service)
|
|
.bind(op)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
|
|
// Split by phase and order: `before` ancestor→app = depth DESC (larger
|
|
// depth = ancestor runs first); `after` app→ancestor = depth ASC.
|
|
let mut before: Vec<(i32, SealedInterceptor)> = Vec::new();
|
|
let mut after: Vec<(i32, SealedInterceptor)> = Vec::new();
|
|
for (marker_app, marker_group, script_name, phase, timeout_ms, depth, sid, src, upd) in rows {
|
|
let sealed = SealedInterceptor {
|
|
marker_app,
|
|
marker_group,
|
|
script_name,
|
|
timeout_ms,
|
|
script: match (sid, src, upd) {
|
|
(Some(id), Some(source), Some(updated_at)) => Some((id, source, updated_at)),
|
|
_ => None,
|
|
},
|
|
};
|
|
if phase == "after" {
|
|
after.push((depth, sealed));
|
|
} else {
|
|
before.push((depth, sealed));
|
|
}
|
|
}
|
|
before.sort_by(|a, b| b.0.cmp(&a.0)); // depth DESC → ancestor first
|
|
after.sort_by(|a, b| a.0.cmp(&b.0)); // depth ASC → app first
|
|
Ok(SealedChain {
|
|
before: before.into_iter().map(|(_, s)| s).collect(),
|
|
after: after.into_iter().map(|(_, s)| s).collect(),
|
|
})
|
|
}
|
|
|
|
/// 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,
|
|
phase: &str,
|
|
script: &str,
|
|
timeout_ms: Option<i32>,
|
|
) -> Result<(), sqlx::Error> {
|
|
match owner {
|
|
ScriptOwner::App(a) => {
|
|
sqlx::query(
|
|
// The conflict arbiter includes `phase` to match the
|
|
// per-(owner, service, op, phase) index (§9.4 M3). A re-apply
|
|
// that changes only the script or timeout updates in place.
|
|
"INSERT INTO interceptors (app_id, service, op, phase, interceptor_script, timeout_ms) \
|
|
VALUES ($1, $2, $3, $4, $5, $6) \
|
|
ON CONFLICT (app_id, service, op, phase) WHERE app_id IS NOT NULL \
|
|
DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, \
|
|
timeout_ms = EXCLUDED.timeout_ms, updated_at = NOW()",
|
|
)
|
|
.bind(a.into_inner())
|
|
.bind(service)
|
|
.bind(op)
|
|
.bind(phase)
|
|
.bind(script)
|
|
.bind(timeout_ms)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
}
|
|
ScriptOwner::Group(g) => {
|
|
sqlx::query(
|
|
"INSERT INTO interceptors (group_id, service, op, phase, interceptor_script, timeout_ms) \
|
|
VALUES ($1, $2, $3, $4, $5, $6) \
|
|
ON CONFLICT (group_id, service, op, phase) WHERE group_id IS NOT NULL \
|
|
DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, \
|
|
timeout_ms = EXCLUDED.timeout_ms, updated_at = NOW()",
|
|
)
|
|
.bind(g.into_inner())
|
|
.bind(service)
|
|
.bind(op)
|
|
.bind(phase)
|
|
.bind(script)
|
|
.bind(timeout_ms)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete a marker at `owner` (by `(service, op, phase)`), 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,
|
|
phase: &str,
|
|
) -> Result<(), sqlx::Error> {
|
|
match owner {
|
|
ScriptOwner::App(a) => {
|
|
sqlx::query(
|
|
"DELETE FROM interceptors \
|
|
WHERE app_id = $1 AND service = $2 AND op = $3 AND phase = $4",
|
|
)
|
|
.bind(a.into_inner())
|
|
.bind(service)
|
|
.bind(op)
|
|
.bind(phase)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
}
|
|
ScriptOwner::Group(g) => {
|
|
sqlx::query(
|
|
"DELETE FROM interceptors \
|
|
WHERE group_id = $1 AND service = $2 AND op = $3 AND phase = $4",
|
|
)
|
|
.bind(g.into_inner())
|
|
.bind(service)
|
|
.bind(op)
|
|
.bind(phase)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|