feat(interceptors): §9.4 service interceptors — thin KV allow/deny slice

Smallest honest vertical slice of §9.4: a `[[interceptors]]` block (app OR
group) binds a script to run BEFORE `kv::set`/`delete`; it reads the operation
context (`ctx.request.body`: service, action, collection, key, value, caller
ids) and returns `#{ allowed, reason }` — `allowed == false` denies the op (the
write never runs, the caller gets a runtime error).

Reuses two existing mechanisms rather than inventing new ones:
- Registration mirrors extension points (§5.5): a marker table
  `0073_interceptors.sql` (owner-polymorphic app_id/group_id XOR, keyed
  (service, op) → script), `interceptor_repo` (insert/delete/list + the
  nearest-owner-wins `resolve_before` chain walk), reconciled through the
  declarative apply exactly like `vars` (create/update/delete, prunable).
- Execution reuses the `invoke()` re-entry path: the new `InterceptorService`
  (shared trait + Postgres-backed impl) only RESOLVES the script name (keeping
  executor-core Postgres-free); the executor's `sdk::interceptor::run_before`
  resolves that name and runs it via `run_resolved_blocking` (extracted from
  `invoke_blocking` — shared depth bound + AST cache). An un-hooked write pays
  one indexed `Ok(None)` resolve; no interceptor ⇒ zero overhead.

Nearest-owner-wins so an app overrides a group's interceptor, and a group
interceptor is inherited by every descendant app — the chain walk is the
isolation boundary (a sibling subtree never matches). `validate_bundle_for`
restricts the MVP to `service = "kv"`, `op ∈ {set, delete}`, one marker per
(service, op).

Deferred (documented in §9.4): the `data` transform return, services other
than kv, `after_*` hooks, chaining + circular-dependency guard, the timeout
policy, and a `pic interceptors ls` read surface (needs a server route).

Pinned by `tests/interceptors.rs` (deny blocks the write; allow passes;
group→app inheritance), schema snapshot re-blessed. 154/154 journeys pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-13 20:44:50 +02:00
parent fcd9451ab1
commit 2fc9476f9e
24 changed files with 1015 additions and 45 deletions

View File

@@ -107,6 +107,21 @@ pub struct Bundle {
/// `[group]` carrying workflows is rejected in `validate_bundle_for`.
#[serde(default)]
pub workflows: Vec<BundleWorkflow>,
/// §9.4 service interceptors: before-op allow/deny hooks. One entry per
/// `(service, op)` guarded, naming the interceptor script. App- OR
/// group-owned; resolved nearest-owner-wins on a descendant app's chain.
#[serde(default)]
pub interceptors: Vec<BundleInterceptor>,
}
/// One §9.4 interceptor marker on the wire: which `(service, op)` it guards and
/// the interceptor script name. The CLI expands a manifest `[[interceptors]]`
/// entry's `ops = [...]` into one of these per op.
#[derive(Debug, Clone, Deserialize)]
pub struct BundleInterceptor {
pub service: String,
pub op: String,
pub script: String,
}
/// One declared workflow on the wire: a name + its (already-parsed) DAG.
@@ -467,6 +482,10 @@ pub struct Plan {
/// v1.2 Workflows: workflow definitions, keyed by name.
#[serde(default)]
pub workflows: Vec<ResourceChange>,
/// §9.4 interceptor markers, keyed `"{service}/{op}"` with the script as the
/// value (create/update/delete like `vars`).
#[serde(default)]
pub interceptors: Vec<ResourceChange>,
}
impl Plan {
@@ -483,6 +502,7 @@ impl Plan {
.chain(&self.collections)
.chain(&self.suppressions)
.chain(&self.workflows)
.chain(&self.interceptors)
.all(|c| c.op == Op::NoOp)
}
}
@@ -765,6 +785,9 @@ pub struct CurrentState {
pub suppressions: Vec<(String, String)>,
/// v1.2 Workflows owned directly by this node (app-owned; empty for a group).
pub workflows: Vec<crate::workflow_repo::Workflow>,
/// §9.4 interceptor markers declared directly at this node, as
/// `(service, op, script)` triples.
pub interceptors: Vec<(String, String, String)>,
}
/// One row of the read-only extension-point report (§5.5).
@@ -1040,6 +1063,7 @@ impl ApplyService {
/// `validate_bundle`, plus a group-node guard: a group owns only scripts +
/// vars (+ declared secret names), so a `[group]` manifest carrying routes
/// or triggers is a 422 — those are app concerns.
#[allow(clippy::too_many_lines)]
fn validate_bundle_for(
&self,
is_group: bool,
@@ -1173,6 +1197,35 @@ impl ApplyService {
for w in &bundle.workflows {
validate_workflow_definition(&w.name, &w.definition).map_err(ApplyError::Invalid)?;
}
// §9.4 interceptors (MVP): `service = "kv"`, `op ∈ {set, delete}`,
// authored on app OR group. One marker per `(service, op)` — a duplicate
// would collide on the reconcile key and the DB's partial-unique index.
let mut seen_hooks: HashSet<(String, String)> = HashSet::new();
for i in &bundle.interceptors {
if i.service != "kv" {
return Err(ApplyError::Invalid(format!(
"interceptor service `{}` is not supported — only `kv` (MVP)",
i.service
)));
}
if i.op != "set" && i.op != "delete" {
return Err(ApplyError::Invalid(format!(
"interceptor op `{}` is not supported for kv — only `set` / `delete`",
i.op
)));
}
if i.script.trim().is_empty() {
return Err(ApplyError::Invalid(
"an interceptor must name a `script`".into(),
));
}
if !seen_hooks.insert((i.service.clone(), i.op.clone())) {
return Err(ApplyError::Invalid(format!(
"duplicate interceptor for `{}/{}`",
i.service, i.op
)));
}
}
self.validate_bundle(bundle, inherited_endpoints)
}
@@ -1450,6 +1503,35 @@ impl ApplyService {
}
}
// 3c.5. §9.4 interceptor markers — upsert each Create/Update by
// `(service, op)`. A changed script is an in-place Update; deletes happen
// in prune.
for ch in &plan.interceptors {
if ch.op == Op::Create || ch.op == Op::Update {
let bi = bundle
.interceptors
.iter()
.find(|i| format!("{}/{}", i.service, i.op) == ch.key)
.ok_or_else(|| {
ApplyError::Backend("internal: interceptor plan/bundle mismatch".into())
})?;
crate::interceptor_repo::insert_interceptor_tx(
&mut *tx,
owner.as_script_owner(),
&bi.service,
&bi.op,
&bi.script,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
if ch.op == Op::Create {
report.interceptors_created += 1;
} else {
report.interceptors_updated += 1;
}
}
}
// 3d. Shared group-collection markers (§11.6) — insert each Create
// (idempotent). Name-only identity; deletes happen in prune. Pruning a
// marker hides the store but does NOT drop its `group_kv_entries` data
@@ -1580,6 +1662,24 @@ impl ApplyService {
}
}
// §9.4 interceptor markers are prunable config too. The delete keys
// by `(service, op)` (the marker's identity), so a script-change
// Update above is never clobbered here.
for ch in &plan.interceptors {
if ch.op == Op::Delete {
let (service, op) = ch.key.split_once('/').unwrap_or((ch.key.as_str(), ""));
crate::interceptor_repo::delete_interceptor_tx(
&mut *tx,
owner.as_script_owner(),
service,
op,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
report.interceptors_deleted += 1;
}
}
// Shared group-collection markers are prunable config too (§11.6).
// Only the marker is removed here — the data survives until the
// owning group is deleted.
@@ -4048,6 +4148,14 @@ impl ApplyService {
}
ApplyOwner::Group(_) => Vec::new(),
};
// §9.4 interceptor markers declared directly at this node.
let interceptors =
crate::interceptor_repo::list_for_owner(&self.pool, owner.as_script_owner())
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.into_iter()
.map(|m| (m.service, m.op, m.script))
.collect();
Ok(CurrentState {
scripts,
routes,
@@ -4058,6 +4166,7 @@ impl ApplyService {
collections,
suppressions,
workflows,
interceptors,
})
}
@@ -4124,9 +4233,58 @@ fn compute_diff_with_names(
collections: diff_collections(current, bundle),
suppressions: diff_suppressions(current, bundle),
workflows: diff_workflows(current, bundle),
interceptors: diff_interceptors(current, bundle),
}
}
/// Diff §9.4 interceptor markers by `"{service}/{op}"` key with the script name
/// as the value — create/update/noop/delete like `vars`. A changed script for
/// the same `(service, op)` is an `Update`; a live marker the manifest stops
/// declaring is a `Delete` (applied only under `--prune`).
fn diff_interceptors(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
let key = |service: &str, op: &str| format!("{service}/{op}");
let live: HashMap<String, &str> = current
.interceptors
.iter()
.map(|(s, o, script)| (key(s, o), script.as_str()))
.collect();
let mut out = Vec::new();
for bi in &bundle.interceptors {
let k = key(&bi.service, &bi.op);
match live.get(&k) {
Some(cur) if *cur == bi.script => out.push(ResourceChange {
op: Op::NoOp,
key: k,
detail: None,
}),
Some(_) => out.push(ResourceChange {
op: Op::Update,
key: k,
detail: Some("interceptor script changed".into()),
}),
None => out.push(ResourceChange {
op: Op::Create,
key: k,
detail: Some(bi.script.clone()),
}),
}
}
for (s, o, _) in &current.interceptors {
let present = bundle
.interceptors
.iter()
.any(|bi| bi.service == *s && bi.op == *o);
if !present {
out.push(ResourceChange {
op: Op::Delete,
key: key(s, o),
detail: Some("on server, not declared".into()),
});
}
}
out
}
/// Diff workflows by `lower(name)`. Like scripts, a workflow has a stable name
/// identity with a mutable body, so a changed definition (or `enabled`) is an
/// `Update`, not a delete+create. Live workflows absent from the manifest are
@@ -5472,6 +5630,12 @@ pub struct ApplyReport {
pub workflows_updated: u32,
#[serde(default)]
pub workflows_deleted: u32,
#[serde(default)]
pub interceptors_created: u32,
#[serde(default)]
pub interceptors_updated: u32,
#[serde(default)]
pub interceptors_deleted: u32,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
@@ -5816,6 +5980,7 @@ mod tests {
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
workflows: Vec::new(),
interceptors: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts.len(), 1);
@@ -5843,6 +6008,7 @@ mod tests {
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
workflows: Vec::new(),
interceptors: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert!(plan.is_noop(), "expected all no-op, got {plan:?}");
@@ -5865,6 +6031,7 @@ mod tests {
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
workflows: Vec::new(),
interceptors: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts[0].op, Op::Update);
@@ -5888,6 +6055,7 @@ mod tests {
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
workflows: Vec::new(),
interceptors: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts[0].op, Op::Delete);
@@ -5942,6 +6110,7 @@ mod tests {
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
workflows: Vec::new(),
interceptors: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert_eq!(plan.routes[0].op, Op::Update);
@@ -5959,6 +6128,7 @@ mod tests {
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
workflows: Vec::new(),
interceptors: Vec::new(),
}
}

View File

@@ -0,0 +1,184 @@
//! §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 picloud_shared::{AppId, ScriptOwner};
use sqlx::{PgPool, Postgres, Transaction};
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())
}
/// 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.
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",
))
.bind(app_id.into_inner())
.bind(service)
.bind(op)
.fetch_optional(pool)
.await?;
Ok(row.map(|(s,)| s))
}
/// 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(())
}

View File

@@ -0,0 +1,37 @@
//! `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.
use async_trait::async_trait;
use picloud_shared::{InterceptorService, 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<Option<String>, 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)
.await
.map_err(|e| e.to_string())
}
}

View File

@@ -68,6 +68,8 @@ pub mod group_repo;
pub mod group_scripts_api;
pub mod groups_api;
pub mod http_service;
pub mod interceptor_repo;
pub mod interceptor_service;
pub mod invoke_service;
pub mod kv_api;
pub mod kv_repo;