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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,111 @@
//! §9.4 Service Interceptors — the executor-side before-op hook.
//!
//! MVP: `kv::set` / `kv::delete` run an allow/deny interceptor first. The hook
//! (1) resolves the nearest interceptor script name for `(service, op)` on the
//! calling app's chain via the injected `InterceptorService` (a cheap indexed
//! query; `None` = un-hooked → allow, no further work), then (2) resolves that
//! name to a script and runs it through the SAME `invoke()` re-entry path
//! (`run_resolved_blocking`) — no second dispatch mechanism. The interceptor
//! receives the operation context as its request body and returns a map; the
//! op is DENIED iff that map has `allowed == false` (fail-open on any other
//! shape, documented — allow/deny only, no data transform).
use std::sync::Arc;
use picloud_shared::{InterceptorService, InvokeService, InvokeTarget, SdkCallCx};
use rhai::EvalAltResult;
use serde_json::{json, Value as Json};
use tokio::runtime::Handle as TokioHandle;
use crate::engine::Engine;
use crate::sandbox::Limits;
use crate::sdk::bridge::runtime_err;
use crate::sdk::invoke::run_resolved_blocking;
/// Run the before-op interceptor for `(service, op)` if one is registered.
/// Returns `Ok(())` to allow the operation (un-hooked, or the interceptor
/// allowed it) or an `Err` runtime error to deny it (the caller must NOT then
/// perform the write). `value` is the payload being written (`None` for delete).
#[allow(clippy::too_many_arguments)]
pub(super) fn run_before(
interceptors: &Arc<dyn InterceptorService>,
invoke: &Arc<dyn InvokeService>,
self_engine: Option<&Arc<Engine>>,
cx: &Arc<SdkCallCx>,
limits: Limits,
service: &'static str,
op: &'static str,
collection: &str,
key: &str,
value: Option<&Json>,
) -> Result<(), Box<EvalAltResult>> {
let handle = TokioHandle::try_current()
.map_err(|e| runtime_err(&format!("{service} interceptor: no tokio runtime: {e}")))?;
// (1) Resolve the nearest interceptor script name. Un-hooked → allow.
let name = {
let interceptors = interceptors.clone();
let cx = cx.clone();
handle
.block_on(async move { interceptors.resolve_before(&cx, service, op).await })
.map_err(|e| runtime_err(&format!("{service}::{op} interceptor resolve: {e}")))?
};
let Some(name) = name else { return Ok(()) };
// An interceptor is registered but the engine back-reference isn't installed
// (a bare test engine without `set_self_weak`): there is no way to run it, so
// skip rather than block a write. Production always installs the back-ref.
let Some(self_engine) = self_engine else {
return Ok(());
};
// Depth bound (shared with invoke / trigger fan-out): an interceptor that
// itself writes and re-enters can't recurse past the ceiling.
if cx.trigger_depth + 1 > limits.trigger_depth_max {
return Err(runtime_err(&format!(
"{service}::{op} interceptor `{name}`: depth limit exceeded (max {})",
limits.trigger_depth_max
)));
}
// (2) Resolve the interceptor script by name on the caller's chain and run
// it with the operation context as its request body.
let resolved = {
let invoke = invoke.clone();
let cx = cx.clone();
let target = InvokeTarget::Name(name.clone());
handle
.block_on(async move { invoke.resolve(&cx, target).await })
.map_err(|e| runtime_err(&format!("{service}::{op} interceptor `{name}`: {e}")))?
};
let payload = json!({
"service": service,
"action": op,
"collection": collection,
"key": key,
"value": value,
"caller_script_id": cx.script_id.to_string(),
"caller_execution_id": cx.execution_id.to_string(),
});
let ret = run_resolved_blocking(
self_engine,
cx,
&resolved,
payload,
&format!("{service}::{op} interceptor `{name}`"),
)?;
// Deny iff the interceptor returned a map with `allowed == false`.
if let Json::Object(m) = &ret {
if m.get("allowed") == Some(&Json::Bool(false)) {
let reason = m
.get("reason")
.and_then(Json::as_str)
.unwrap_or("denied by interceptor");
return Err(runtime_err(&format!(
"{service}::{op} denied by interceptor `{name}`: {reason}"
)));
}
}
Ok(())
}

View File

@@ -163,9 +163,28 @@ fn invoke_blocking(
.into() .into()
})?; })?;
let execution_id = ExecutionId::new(); // The callee's return is the response `body` JSON. Convert back to
// Dynamic for the caller. Status code + headers are dropped; the
// function-call mental model is "return value", not HTTP response.
let body = run_resolved_blocking(self_engine, cx, &resolved, args_json, &target_label)?;
Ok(json_to_dynamic(body))
}
/// Synchronous same-engine re-entry: build the callee `ExecRequest` (inheriting
/// the caller's app/principal/root/depth+1 and the callee's lexical owner),
/// compile through the per-Engine AST cache (F-P-004), execute, and return the
/// response `body` JSON. Shared by `invoke()` and the §9.4 interceptor hook —
/// the caller is responsible for the depth check (both do it before resolving).
/// `label` prefixes any compile/execute error.
pub(super) fn run_resolved_blocking(
self_engine: &Arc<Engine>,
cx: &Arc<SdkCallCx>,
resolved: &picloud_shared::ResolvedScript,
body_json: Json,
label: &str,
) -> Result<Json, Box<EvalAltResult>> {
let req = ExecRequest { let req = ExecRequest {
execution_id, execution_id: ExecutionId::new(),
request_id: cx.request_id, request_id: cx.request_id,
script_id: resolved.script_id, script_id: resolved.script_id,
script_name: resolved.name.clone(), script_name: resolved.name.clone(),
@@ -173,7 +192,7 @@ fn invoke_blocking(
path: "/invoke".into(), path: "/invoke".into(),
method: String::new(), method: String::new(),
headers: BTreeMap::new(), headers: BTreeMap::new(),
body: args_json, body: body_json,
params: BTreeMap::new(), params: BTreeMap::new(),
query: BTreeMap::new(), query: BTreeMap::new(),
rest: String::new(), rest: String::new(),
@@ -183,42 +202,24 @@ fn invoke_blocking(
// script's imports resolve from the group even when invoked by an // script's imports resolve from the group even when invoked by an
// app. `None` falls back to `App(cx.app_id)` in the engine. // app. `None` falls back to `App(cx.app_id)` in the engine.
script_owner: resolved.owner, script_owner: resolved.owner,
// Same-app invoke is a function call, not a re-auth boundary — // Same-app re-entry is not a re-auth boundary — inherit the principal.
// inherit the caller's principal.
principal: cx.principal.clone(), principal: cx.principal.clone(),
trigger_depth: cx.trigger_depth + 1, trigger_depth: cx.trigger_depth + 1,
root_execution_id: cx.root_execution_id, root_execution_id: cx.root_execution_id,
is_dead_letter_handler: cx.is_dead_letter_handler, is_dead_letter_handler: cx.is_dead_letter_handler,
event: None, event: None,
}; };
// F-P-004: synchronous re-entry — route through the per-Engine
// AST cache so each callee parses once per (script_id, updated_at),
// not once per invoke. Composed workflows multiply parse cost by
// depth; the cache cuts that to constant compile + N executions.
let ast = self_engine let ast = self_engine
.compile_for_identity(resolved.script_id, resolved.updated_at, &resolved.source) .compile_for_identity(resolved.script_id, resolved.updated_at, &resolved.source)
.map_err(|e| -> Box<EvalAltResult> { .map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime( EvalAltResult::ErrorRuntime(format!("{label}: {e}").into(), rhai::Position::NONE).into()
format!("invoke({target_label}): {e}").into(),
rhai::Position::NONE,
)
.into()
})?; })?;
let resp = self_engine let resp = self_engine
.execute_ast(&ast, req) .execute_ast(&ast, req)
.map_err(|e| -> Box<EvalAltResult> { .map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime( EvalAltResult::ErrorRuntime(format!("{label}: {e}").into(), rhai::Position::NONE).into()
format!("invoke({target_label}): {e}").into(),
rhai::Position::NONE,
)
.into()
})?; })?;
Ok(resp.body)
// The callee's return is the response `body` JSON. Convert back to
// Dynamic for the caller. Status code + headers are dropped; the
// function-call mental model is "return value", not HTTP response.
Ok(json_to_dynamic(resp.body))
} }
/// Accept a string (route path OR script name) or a Rhai script-id /// Accept a string (route path OR script name) or a Rhai script-id

View File

@@ -30,18 +30,27 @@
use std::sync::Arc; use std::sync::Arc;
use picloud_shared::{GroupKvService, KvService, SdkCallCx, Services}; use picloud_shared::{
GroupKvService, InterceptorService, InvokeService, KvService, SdkCallCx, Services,
};
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module}; use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
use super::bridge::{block_on, dynamic_to_json, json_to_dynamic}; use super::bridge::{block_on, dynamic_to_json, json_to_dynamic};
use crate::engine::Engine;
use crate::sandbox::Limits;
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs /// Per-call handle captured by the Rhai SDK. Cheap to clone (a few Arcs plus an
/// plus an owned string). /// owned string). Carries the §9.4 interceptor deps so `set`/`delete` can run a
/// before-op allow/deny hook (the resolver + the `invoke()` re-entry engine).
#[derive(Clone)] #[derive(Clone)]
pub struct KvHandle { pub struct KvHandle {
collection: String, collection: String,
service: Arc<dyn KvService>, service: Arc<dyn KvService>,
cx: Arc<SdkCallCx>, cx: Arc<SdkCallCx>,
interceptors: Arc<dyn InterceptorService>,
invoke: Arc<dyn InvokeService>,
self_engine: Option<Arc<Engine>>,
limits: Limits,
} }
/// §11.6 shared-collection handle, returned by `kv::shared_collection(name)`. A distinct /// §11.6 shared-collection handle, returned by `kv::shared_collection(name)`. A distinct
@@ -56,9 +65,17 @@ pub struct GroupKvHandle {
cx: Arc<SdkCallCx>, cx: Arc<SdkCallCx>,
} }
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) { pub(super) fn register(
engine: &mut RhaiEngine,
services: &Services,
cx: Arc<SdkCallCx>,
limits: Limits,
self_engine: Option<Arc<Engine>>,
) {
let kv_service = services.kv.clone(); let kv_service = services.kv.clone();
let group_kv_service = services.group_kv.clone(); let group_kv_service = services.group_kv.clone();
let interceptors = services.interceptors.clone();
let invoke = services.invoke.clone();
// `kv::collection(name)` / `kv::shared_collection(name)` — both constructors live in // `kv::collection(name)` / `kv::shared_collection(name)` — both constructors live in
// the `kv` static module so the script-visible calls are `kv::collection` // the `kv` static module so the script-visible calls are `kv::collection`
@@ -67,6 +84,9 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
{ {
let kv_service = kv_service.clone(); let kv_service = kv_service.clone();
let cx = cx.clone(); let cx = cx.clone();
let interceptors = interceptors.clone();
let invoke = invoke.clone();
let self_engine = self_engine.clone();
module.set_native_fn( module.set_native_fn(
"collection", "collection",
move |name: &str| -> Result<KvHandle, Box<EvalAltResult>> { move |name: &str| -> Result<KvHandle, Box<EvalAltResult>> {
@@ -77,6 +97,10 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
collection: name.to_string(), collection: name.to_string(),
service: kv_service.clone(), service: kv_service.clone(),
cx: cx.clone(), cx: cx.clone(),
interceptors: interceptors.clone(),
invoke: invoke.clone(),
self_engine: self_engine.clone(),
limits,
}) })
}, },
); );
@@ -151,8 +175,22 @@ fn register_set(engine: &mut RhaiEngine) {
engine.register_fn( engine.register_fn(
"set", "set",
|handle: &mut KvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> { |handle: &mut KvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
let h = handle.clone();
let json = dynamic_to_json(&value); let json = dynamic_to_json(&value);
// §9.4 before-op interceptor (allow/deny). A denial errors here and
// the write below never runs.
super::interceptor::run_before(
&handle.interceptors,
&handle.invoke,
handle.self_engine.as_ref(),
&handle.cx,
handle.limits,
"kv",
"set",
&handle.collection,
key,
Some(&json),
)?;
let h = handle.clone();
block_on("kv", async move { block_on("kv", async move {
h.service.set(&h.cx, &h.collection, key, json).await h.service.set(&h.cx, &h.collection, key, json).await
}) })
@@ -197,6 +235,18 @@ fn register_delete(engine: &mut RhaiEngine) {
engine.register_fn( engine.register_fn(
"delete", "delete",
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> { |handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
super::interceptor::run_before(
&handle.interceptors,
&handle.invoke,
handle.self_engine.as_ref(),
&handle.cx,
handle.limits,
"kv",
"delete",
&handle.collection,
key,
None,
)?;
let h = handle.clone(); let h = handle.clone();
block_on("kv", async move { block_on("kv", async move {
h.service.delete(&h.cx, &h.collection, key).await h.service.delete(&h.cx, &h.collection, key).await

View File

@@ -18,6 +18,7 @@ pub mod docs;
pub mod email; pub mod email;
pub mod files; pub mod files;
pub mod http; pub mod http;
pub mod interceptor;
pub mod invoke; pub mod invoke;
pub mod kv; pub mod kv;
pub mod pubsub; pub mod pubsub;
@@ -55,7 +56,7 @@ pub fn register_all(
limits: Limits, limits: Limits,
self_engine: Option<Arc<Engine>>, self_engine: Option<Arc<Engine>>,
) { ) {
kv::register(engine, services, cx.clone()); kv::register(engine, services, cx.clone(), limits, self_engine.clone());
docs::register(engine, services, cx.clone()); docs::register(engine, services, cx.clone());
dead_letters::register(engine, services, cx.clone()); dead_letters::register(engine, services, cx.clone());
http::register(engine, services, cx.clone()); http::register(engine, services, cx.clone());

View File

@@ -0,0 +1,42 @@
-- §9.4 Service Interceptors (v1.2) — before-op allow/deny hooks.
--
-- A marker `(owner, service, op) -> interceptor_script` declares that a script
-- runs BEFORE a data-plane operation and may deny it. MVP scope: `service='kv'`,
-- `op IN ('set','delete')`, allow/deny only (no data transform, no chaining,
-- no after-hooks). The interceptor is itself a script owned by the same node
-- (or an ancestor group); it is resolved + run through the existing `invoke()`
-- re-entry path, so this table holds only the MARKER — pure declaration,
-- structurally like an `extension_points` row (0051): config, not code, so
-- ON DELETE CASCADE.
--
-- Ownership is polymorphic (mirrors extension_points/vars/secrets/scripts):
-- exactly one of (app_id, group_id) is set. Resolution walks the calling app's
-- chain (app, then nearest ancestor group) and picks the nearest declaration
-- for a (service, op) — nearest-owner-wins, so an app overrides a group's
-- interceptor, the deliberate inverse of a sealed import.
CREATE TABLE interceptors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
CONSTRAINT interceptors_owner_exactly_one
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
-- The intercepted operation. MVP: service='kv', op IN ('set','delete').
service TEXT NOT NULL,
op TEXT NOT NULL,
-- Name of the interceptor script (resolved on the owner's chain at run time).
interceptor_script TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- One marker per (owner, service, op). Partial because the owner is split
-- across two nullable columns.
CREATE UNIQUE INDEX interceptors_group_uidx
ON interceptors (group_id, service, op) WHERE group_id IS NOT NULL;
CREATE UNIQUE INDEX interceptors_app_uidx
ON interceptors (app_id, service, op) WHERE app_id IS NOT NULL;
-- Lookup indexes for the resolver's chain join + list-by-owner.
CREATE INDEX interceptors_group_id_idx ON interceptors (group_id) WHERE group_id IS NOT NULL;
CREATE INDEX interceptors_app_id_idx ON interceptors (app_id) WHERE app_id IS NOT NULL;

View File

@@ -107,6 +107,21 @@ pub struct Bundle {
/// `[group]` carrying workflows is rejected in `validate_bundle_for`. /// `[group]` carrying workflows is rejected in `validate_bundle_for`.
#[serde(default)] #[serde(default)]
pub workflows: Vec<BundleWorkflow>, 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. /// 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. /// v1.2 Workflows: workflow definitions, keyed by name.
#[serde(default)] #[serde(default)]
pub workflows: Vec<ResourceChange>, 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 { impl Plan {
@@ -483,6 +502,7 @@ impl Plan {
.chain(&self.collections) .chain(&self.collections)
.chain(&self.suppressions) .chain(&self.suppressions)
.chain(&self.workflows) .chain(&self.workflows)
.chain(&self.interceptors)
.all(|c| c.op == Op::NoOp) .all(|c| c.op == Op::NoOp)
} }
} }
@@ -765,6 +785,9 @@ pub struct CurrentState {
pub suppressions: Vec<(String, String)>, pub suppressions: Vec<(String, String)>,
/// v1.2 Workflows owned directly by this node (app-owned; empty for a group). /// v1.2 Workflows owned directly by this node (app-owned; empty for a group).
pub workflows: Vec<crate::workflow_repo::Workflow>, 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). /// 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 + /// `validate_bundle`, plus a group-node guard: a group owns only scripts +
/// vars (+ declared secret names), so a `[group]` manifest carrying routes /// vars (+ declared secret names), so a `[group]` manifest carrying routes
/// or triggers is a 422 — those are app concerns. /// or triggers is a 422 — those are app concerns.
#[allow(clippy::too_many_lines)]
fn validate_bundle_for( fn validate_bundle_for(
&self, &self,
is_group: bool, is_group: bool,
@@ -1173,6 +1197,35 @@ impl ApplyService {
for w in &bundle.workflows { for w in &bundle.workflows {
validate_workflow_definition(&w.name, &w.definition).map_err(ApplyError::Invalid)?; 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) 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 // 3d. Shared group-collection markers (§11.6) — insert each Create
// (idempotent). Name-only identity; deletes happen in prune. Pruning a // (idempotent). Name-only identity; deletes happen in prune. Pruning a
// marker hides the store but does NOT drop its `group_kv_entries` data // 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). // Shared group-collection markers are prunable config too (§11.6).
// Only the marker is removed here — the data survives until the // Only the marker is removed here — the data survives until the
// owning group is deleted. // owning group is deleted.
@@ -4048,6 +4148,14 @@ impl ApplyService {
} }
ApplyOwner::Group(_) => Vec::new(), 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 { Ok(CurrentState {
scripts, scripts,
routes, routes,
@@ -4058,6 +4166,7 @@ impl ApplyService {
collections, collections,
suppressions, suppressions,
workflows, workflows,
interceptors,
}) })
} }
@@ -4124,9 +4233,58 @@ fn compute_diff_with_names(
collections: diff_collections(current, bundle), collections: diff_collections(current, bundle),
suppressions: diff_suppressions(current, bundle), suppressions: diff_suppressions(current, bundle),
workflows: diff_workflows(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 /// 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 /// 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 /// `Update`, not a delete+create. Live workflows absent from the manifest are
@@ -5472,6 +5630,12 @@ pub struct ApplyReport {
pub workflows_updated: u32, pub workflows_updated: u32,
#[serde(default)] #[serde(default)]
pub workflows_deleted: u32, 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")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>, pub warnings: Vec<String>,
} }
@@ -5816,6 +5980,7 @@ mod tests {
suppress_triggers: Vec::new(), suppress_triggers: Vec::new(),
suppress_routes: Vec::new(), suppress_routes: Vec::new(),
workflows: Vec::new(), workflows: Vec::new(),
interceptors: Vec::new(),
}; };
let plan = compute_diff(&current, &bundle); let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts.len(), 1); assert_eq!(plan.scripts.len(), 1);
@@ -5843,6 +6008,7 @@ mod tests {
suppress_triggers: Vec::new(), suppress_triggers: Vec::new(),
suppress_routes: Vec::new(), suppress_routes: Vec::new(),
workflows: Vec::new(), workflows: Vec::new(),
interceptors: Vec::new(),
}; };
let plan = compute_diff(&current, &bundle); let plan = compute_diff(&current, &bundle);
assert!(plan.is_noop(), "expected all no-op, got {plan:?}"); assert!(plan.is_noop(), "expected all no-op, got {plan:?}");
@@ -5865,6 +6031,7 @@ mod tests {
suppress_triggers: Vec::new(), suppress_triggers: Vec::new(),
suppress_routes: Vec::new(), suppress_routes: Vec::new(),
workflows: Vec::new(), workflows: Vec::new(),
interceptors: Vec::new(),
}; };
let plan = compute_diff(&current, &bundle); let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts[0].op, Op::Update); assert_eq!(plan.scripts[0].op, Op::Update);
@@ -5888,6 +6055,7 @@ mod tests {
suppress_triggers: Vec::new(), suppress_triggers: Vec::new(),
suppress_routes: Vec::new(), suppress_routes: Vec::new(),
workflows: Vec::new(), workflows: Vec::new(),
interceptors: Vec::new(),
}; };
let plan = compute_diff(&current, &bundle); let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts[0].op, Op::Delete); assert_eq!(plan.scripts[0].op, Op::Delete);
@@ -5942,6 +6110,7 @@ mod tests {
suppress_triggers: Vec::new(), suppress_triggers: Vec::new(),
suppress_routes: Vec::new(), suppress_routes: Vec::new(),
workflows: Vec::new(), workflows: Vec::new(),
interceptors: Vec::new(),
}; };
let plan = compute_diff(&current, &bundle); let plan = compute_diff(&current, &bundle);
assert_eq!(plan.routes[0].op, Op::Update); assert_eq!(plan.routes[0].op, Op::Update);
@@ -5959,6 +6128,7 @@ mod tests {
suppress_triggers: Vec::new(), suppress_triggers: Vec::new(),
suppress_routes: Vec::new(), suppress_routes: Vec::new(),
workflows: 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 group_scripts_api;
pub mod groups_api; pub mod groups_api;
pub mod http_service; pub mod http_service;
pub mod interceptor_repo;
pub mod interceptor_service;
pub mod invoke_service; pub mod invoke_service;
pub mod kv_api; pub mod kv_api;
pub mod kv_repo; pub mod kv_repo;

View File

@@ -308,6 +308,16 @@ table: groups
created_at: timestamp with time zone NOT NULL default=now() created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now() updated_at: timestamp with time zone NOT NULL default=now()
table: interceptors
id: uuid NOT NULL default=gen_random_uuid()
group_id: uuid NULL
app_id: uuid NULL
service: text NOT NULL
op: text NOT NULL
interceptor_script: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: kv_entries table: kv_entries
app_id: uuid NOT NULL app_id: uuid NOT NULL
collection: text NOT NULL collection: text NOT NULL
@@ -663,6 +673,13 @@ indexes on groups:
groups_pkey: public.groups USING btree (id) groups_pkey: public.groups USING btree (id)
groups_slug_key: public.groups USING btree (slug) groups_slug_key: public.groups USING btree (slug)
indexes on interceptors:
interceptors_app_id_idx: public.interceptors USING btree (app_id) WHERE (app_id IS NOT NULL)
interceptors_app_uidx: public.interceptors USING btree (app_id, service, op) WHERE (app_id IS NOT NULL)
interceptors_group_id_idx: public.interceptors USING btree (group_id) WHERE (group_id IS NOT NULL)
interceptors_group_uidx: public.interceptors USING btree (group_id, service, op) WHERE (group_id IS NOT NULL)
interceptors_pkey: public.interceptors USING btree (id)
indexes on kv_entries: indexes on kv_entries:
idx_kv_entries_app_collection: public.kv_entries USING btree (app_id, collection) idx_kv_entries_app_collection: public.kv_entries USING btree (app_id, collection)
kv_entries_pkey: public.kv_entries USING btree (app_id, collection, key) kv_entries_pkey: public.kv_entries USING btree (app_id, collection, key)
@@ -933,6 +950,12 @@ constraints on groups:
[PRIMARY KEY] groups_pkey: PRIMARY KEY (id) [PRIMARY KEY] groups_pkey: PRIMARY KEY (id)
[UNIQUE] groups_slug_key: UNIQUE (slug) [UNIQUE] groups_slug_key: UNIQUE (slug)
constraints on interceptors:
[CHECK] interceptors_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
[FOREIGN KEY] interceptors_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[FOREIGN KEY] interceptors_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] interceptors_pkey: PRIMARY KEY (id)
constraints on kv_entries: constraints on kv_entries:
[FOREIGN KEY] kv_entries_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE [FOREIGN KEY] kv_entries_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] kv_entries_pkey: PRIMARY KEY (app_id, collection, key) [PRIMARY KEY] kv_entries_pkey: PRIMARY KEY (app_id, collection, key)
@@ -1121,3 +1144,4 @@ constraints on workflows:
0070: admin session absolute expiry 0070: admin session absolute expiry
0071: workflows 0071: workflows
0072: execution source workflow 0072: execution source workflow
0073: interceptors

View File

@@ -1738,6 +1738,8 @@ pub struct PlanDto {
pub suppressions: Vec<ChangeDto>, pub suppressions: Vec<ChangeDto>,
#[serde(default)] #[serde(default)]
pub workflows: Vec<ChangeDto>, pub workflows: Vec<ChangeDto>,
#[serde(default)]
pub interceptors: Vec<ChangeDto>,
/// Fingerprint of the live state this plan was computed against; carried /// Fingerprint of the live state this plan was computed against; carried
/// in `.picloud/` and replayed to `apply` for the bound-plan check. /// in `.picloud/` and replayed to `apply` for the bound-plan check.
#[serde(default)] #[serde(default)]
@@ -1815,6 +1817,8 @@ pub struct NodePlanDto {
pub suppressions: Vec<ChangeDto>, pub suppressions: Vec<ChangeDto>,
#[serde(default)] #[serde(default)]
pub workflows: Vec<ChangeDto>, pub workflows: Vec<ChangeDto>,
#[serde(default)]
pub interceptors: Vec<ChangeDto>,
/// §7 M3: this node's ownership outcome under the tree's `[project]`. /// §7 M3: this node's ownership outcome under the tree's `[project]`.
#[serde(default)] #[serde(default)]
pub ownership: Option<OwnershipPreviewDto>, pub ownership: Option<OwnershipPreviewDto>,
@@ -1880,6 +1884,12 @@ pub struct ApplyReportDto {
#[serde(default)] #[serde(default)]
pub workflows_deleted: u32, pub workflows_deleted: u32,
#[serde(default)] #[serde(default)]
pub interceptors_created: u32,
#[serde(default)]
pub interceptors_updated: u32,
#[serde(default)]
pub interceptors_deleted: u32,
#[serde(default)]
pub warnings: Vec<String>, pub warnings: Vec<String>,
} }

View File

@@ -155,6 +155,15 @@ pub async fn run(
"+{} ~{} -{}", "+{} ~{} -{}",
report.workflows_created, report.workflows_updated, report.workflows_deleted report.workflows_created, report.workflows_updated, report.workflows_deleted
), ),
)
.field(
"interceptors",
format!(
"+{} ~{} -{}",
report.interceptors_created,
report.interceptors_updated,
report.interceptors_deleted
),
); );
for w in &report.warnings { for w in &report.warnings {
block.field("warning", w.clone()); block.field("warning", w.clone());
@@ -283,6 +292,15 @@ pub async fn run_tree(
"+{} ~{} -{}", "+{} ~{} -{}",
report.workflows_created, report.workflows_updated, report.workflows_deleted report.workflows_created, report.workflows_updated, report.workflows_deleted
), ),
)
.field(
"interceptors",
format!(
"+{} ~{} -{}",
report.interceptors_created,
report.interceptors_updated,
report.interceptors_deleted
),
); );
for w in &report.warnings { for w in &report.warnings {
block.field("warning", w.clone()); block.field("warning", w.clone());

View File

@@ -144,6 +144,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
vars: std::collections::BTreeMap::new(), vars: std::collections::BTreeMap::new(),
suppress: crate::manifest::ManifestSuppress::default(), suppress: crate::manifest::ManifestSuppress::default(),
workflows: Vec::new(), workflows: Vec::new(),
interceptors: Vec::new(),
} }
} }

View File

@@ -106,7 +106,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
]); ]);
} }
} }
let groups: [(&str, &Vec<ChangeDto>); 9] = [ let groups: [(&str, &Vec<ChangeDto>); 10] = [
("script", &n.scripts), ("script", &n.scripts),
("route", &n.routes), ("route", &n.routes),
("trigger", &n.triggers), ("trigger", &n.triggers),
@@ -116,6 +116,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
("collection", &n.collections), ("collection", &n.collections),
("suppression", &n.suppressions), ("suppression", &n.suppressions),
("workflow", &n.workflows), ("workflow", &n.workflows),
("interceptor", &n.interceptors),
]; ];
for (rk, changes) in groups { for (rk, changes) in groups {
for c in changes { for c in changes {
@@ -252,6 +253,17 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
.iter() .iter()
.map(workflow_to_wire) .map(workflow_to_wire)
.collect::<Result<Vec<_>>>()?, .collect::<Result<Vec<_>>>()?,
// §9.4: expand each `[[interceptors]]` entry's `ops` into one wire
// marker per (service, op).
"interceptors": manifest
.interceptors
.iter()
.flat_map(|i| {
i.ops.iter().map(move |op| {
json!({ "service": i.service, "op": op, "script": i.script })
})
})
.collect::<Vec<_>>(),
})) }))
} }
@@ -295,7 +307,7 @@ fn render(plan: &PlanDto, mode: OutputMode) {
]); ]);
} }
} }
let groups: [(&str, &Vec<ChangeDto>); 9] = [ let groups: [(&str, &Vec<ChangeDto>); 10] = [
("script", &plan.scripts), ("script", &plan.scripts),
("route", &plan.routes), ("route", &plan.routes),
("trigger", &plan.triggers), ("trigger", &plan.triggers),
@@ -305,6 +317,7 @@ fn render(plan: &PlanDto, mode: OutputMode) {
("collection", &plan.collections), ("collection", &plan.collections),
("suppression", &plan.suppressions), ("suppression", &plan.suppressions),
("workflow", &plan.workflows), ("workflow", &plan.workflows),
("interceptor", &plan.interceptors),
]; ];
for (kind, changes) in groups { for (kind, changes) in groups {
for c in changes { for c in changes {

View File

@@ -268,6 +268,9 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
vars: manifest_vars, vars: manifest_vars,
suppress: crate::manifest::ManifestSuppress::default(), suppress: crate::manifest::ManifestSuppress::default(),
workflows: workflows.iter().map(wire_workflow_to_manifest).collect(), workflows: workflows.iter().map(wire_workflow_to_manifest).collect(),
// `pull` does not round-trip interceptor markers yet (read surface TBD);
// an interceptor authored in the manifest survives re-apply regardless.
interceptors: Vec::new(),
}; };
std::fs::write(&manifest_path, manifest.to_toml()?) std::fs::write(&manifest_path, manifest.to_toml()?)

View File

@@ -64,6 +64,29 @@ pub struct Manifest {
/// App-owned; a `[group]` carrying them is rejected server-side. /// App-owned; a `[group]` carrying them is rejected server-side.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workflows: Vec<ManifestWorkflow>, pub workflows: Vec<ManifestWorkflow>,
/// `[[interceptors]]` (§9.4) — before-op allow/deny hooks. Each names a
/// `script` and the `ops` it guards on a `service` (MVP: `service = "kv"`,
/// `ops ⊆ [set, delete]`). Authored on an app OR group node.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub interceptors: Vec<ManifestInterceptor>,
}
/// One `[[interceptors]]` entry — an interceptor script and the operations it
/// guards. `ops` expands to one wire marker per `(service, op)` at apply.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestInterceptor {
/// Name of the interceptor script (resolved on the node's chain at run time).
pub script: String,
/// The guarded service. MVP: only `"kv"`.
#[serde(default = "default_interceptor_service")]
pub service: String,
/// The guarded operations, e.g. `["set", "delete"]`.
pub ops: Vec<String>,
}
fn default_interceptor_service() -> String {
"kv".to_string()
} }
impl Manifest { impl Manifest {
@@ -783,6 +806,7 @@ mod tests {
]), ]),
suppress: ManifestSuppress::default(), suppress: ManifestSuppress::default(),
workflows: Vec::new(), workflows: Vec::new(),
interceptors: Vec::new(),
} }
} }
@@ -890,6 +914,7 @@ mod tests {
vars: BTreeMap::new(), vars: BTreeMap::new(),
suppress: ManifestSuppress::default(), suppress: ManifestSuppress::default(),
workflows: Vec::new(), workflows: Vec::new(),
interceptors: Vec::new(),
}; };
let text = m.to_toml().unwrap(); let text = m.to_toml().unwrap();
assert!(!text.contains("[[scripts]]"), "got:\n{text}"); assert!(!text.contains("[[scripts]]"), "got:\n{text}");

View File

@@ -35,6 +35,7 @@ mod group_secrets;
mod group_triggers; mod group_triggers;
mod groups; mod groups;
mod init; mod init;
mod interceptors;
mod invoke; mod invoke;
mod logs; mod logs;
mod output; mod output;

View File

@@ -0,0 +1,193 @@
//! §9.4 Service Interceptors, end to end via `pic` — the KV allow/deny slice.
//!
//! A `[[interceptors]]` marker binds a script to run BEFORE `kv::set`/`delete`.
//! The interceptor reads the operation context (`ctx.request.body` — service,
//! action, collection, key, value) and returns `#{ allowed: bool, reason }`; a
//! `false` denies the op (the write never happens, the caller gets an error).
//! Registration is nearest-owner-wins on the calling app's chain, so a group's
//! interceptor is inherited by a descendant app (and an app can override it).
use std::fs;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard};
fn manifest_dir() -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
dir
}
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
let ls = common::pic_as(env)
.args(["scripts", "ls", "--app", app])
.output()
.expect("scripts ls");
let table = String::from_utf8(ls.stdout).unwrap();
table
.lines()
.map(common::cells)
.find(|c| c.get(2) == Some(&name))
.and_then(|c| c.first().map(|s| (*s).to_string()))
.unwrap_or_else(|| panic!("script `{name}` not found:\n{table}"))
}
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
let out = common::pic_as(env)
.args(["scripts", "invoke", id])
.output()
.expect("scripts invoke");
assert!(
out.status.success(),
"invoke failed: {}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
}
/// A guard that denies a `kv::set` of the key `"secret"` and allows everything
/// else, reading the op context from `ctx.request.body`.
const GUARD: &str = r#"
let op = ctx.request.body;
if op.action == "set" && op.key == "secret" {
#{ allowed: false, reason: "the `secret` key is protected" }
} else {
#{ allowed: true }
}
"#;
/// A writer that tries a denied set (caught), then a permitted set.
const WRITER: &str = r#"
let denied = false;
try { kv::collection("c").set("secret", 1); } catch(e) { denied = true; }
kv::collection("c").set("ok", 2);
#{ denied: denied }
"#;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn app_interceptor_denies_a_guarded_kv_write_and_allows_others() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let app = common::unique_slug("ic-app");
let _a = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app])
.assert()
.success();
// One apply deploys the guard + writer scripts AND registers the marker.
let dir = manifest_dir();
fs::write(dir.path().join("scripts/guard.rhai"), GUARD).unwrap();
fs::write(dir.path().join("scripts/writer.rhai"), WRITER).unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[app]\nslug = \"{app}\"\nname = \"IC\"\n\n\
[[scripts]]\nname = \"guard\"\nfile = \"scripts/guard.rhai\"\n\n\
[[scripts]]\nname = \"writer\"\nfile = \"scripts/writer.rhai\"\n\n\
[[interceptors]]\nscript = \"guard\"\nops = [\"set\"]\n"
),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir.path().join("picloud.toml"))
.assert()
.success();
// The writer's guarded set is denied (caught), the permitted set goes through.
let body = invoke_body(&env, &app_script_id(&env, &app, "writer"));
assert_eq!(
body,
serde_json::json!({ "denied": true }),
"the `secret` set must be denied by the interceptor"
);
// `ok` was written; `secret` was not (the write never ran).
let ok = String::from_utf8(
common::pic_as(&env)
.args(["kv", "get", "--app", &app, "--collection", "c", "ok"])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(ok.contains('2'), "the allowed write must persist:\n{ok}");
let secret = common::pic_as(&env)
.args(["kv", "get", "--app", &app, "--collection", "c", "secret"])
.output()
.unwrap();
let secret_out = String::from_utf8_lossy(&secret.stdout);
assert!(
secret_out.trim() == "null" || secret_out.trim() == "()" || secret_out.trim().is_empty(),
"the denied write must NOT persist, got: {secret_out}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn a_group_interceptor_is_inherited_by_a_descendant_app() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("icg-grp");
let app = common::unique_slug("icg-app");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
// The GROUP owns the guard script + the interceptor marker.
let dir = manifest_dir();
fs::write(dir.path().join("scripts/guard.rhai"), GUARD).unwrap();
fs::write(
dir.path().join("group.toml"),
format!(
"[group]\nslug = \"{group}\"\nname = \"ICG\"\n\n\
[[scripts]]\nname = \"guard\"\nfile = \"scripts/guard.rhai\"\n\n\
[[interceptors]]\nscript = \"guard\"\nops = [\"set\"]\n"
),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir.path().join("group.toml"))
.assert()
.success();
// An app UNDER the group inherits the interceptor (nearest-owner-wins), even
// though the marker + guard live on the group.
let _a = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &group])
.assert()
.success();
fs::write(dir.path().join("scripts/writer.rhai"), WRITER).unwrap();
fs::write(
dir.path().join("app.toml"),
format!(
"[app]\nslug = \"{app}\"\nname = \"ICApp\"\n\n\
[[scripts]]\nname = \"writer\"\nfile = \"scripts/writer.rhai\"\n"
),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir.path().join("app.toml"))
.assert()
.success();
let body = invoke_body(&env, &app_script_id(&env, &app, "writer"));
assert_eq!(
body,
serde_json::json!({ "denied": true }),
"a descendant app must inherit the group's interceptor"
);
}

View File

@@ -416,6 +416,11 @@ pub async fn build_app(
let workflow: Arc<dyn picloud_shared::WorkflowService> = Arc::new( let workflow: Arc<dyn picloud_shared::WorkflowService> = Arc::new(
picloud_manager_core::WorkflowServiceImpl::new(pool.clone()).with_authz(authz.clone()), picloud_manager_core::WorkflowServiceImpl::new(pool.clone()).with_authz(authz.clone()),
); );
// §9.4 interceptors: resolve-only (which script guards a kv op); running it
// reuses the invoke() re-entry path.
let interceptors: Arc<dyn picloud_shared::InterceptorService> = Arc::new(
picloud_manager_core::interceptor_service::InterceptorServiceImpl::new(pool.clone()),
);
let services = Services::new( let services = Services::new(
kv, kv,
docs, docs,
@@ -437,7 +442,8 @@ pub async fn build_app(
.with_group_files(group_files) .with_group_files(group_files)
.with_group_pubsub(group_pubsub) .with_group_pubsub(group_pubsub)
.with_group_queue(group_queue) .with_group_queue(group_queue)
.with_workflow(workflow); .with_workflow(workflow)
.with_interceptors(interceptors);
// v1.1.9: keep the invoke depth bound aligned with the dispatcher's // v1.1.9: keep the invoke depth bound aligned with the dispatcher's
// trigger-depth bound (same counter under the hood). // trigger-depth bound (same counter under the hood).
let engine_limits = Limits { let engine_limits = Limits {

View File

@@ -0,0 +1,51 @@
//! §9.4 Service Interceptors — the injected resolver seam.
//!
//! An interceptor is a script registered (declaratively, per app/group) to run
//! **before** a data-plane operation and allow or deny it. This trait is the
//! narrow, executor-facing seam: it only RESOLVES which interceptor script (by
//! name) applies to a `(service, op)` on the calling app's chain — nearest-owner
//! wins, like extension points. Running the resolved script reuses the existing
//! `invoke()` re-entry path in `executor-core` (resolve the name → compile →
//! execute), so `executor-core` stays Postgres-free and there is no second
//! script-dispatch mechanism.
//!
//! MVP scope (v1.2): `service = "kv"`, `op ∈ {set, delete}`, allow/deny only —
//! no data transform, no chaining, no `after_*` hooks. See blueprint §9.4.
use async_trait::async_trait;
use crate::SdkCallCx;
#[async_trait]
pub trait InterceptorService: Send + Sync {
/// The NAME of the interceptor script registered for `(service, op)` at the
/// nearest owner on `cx.app_id`'s chain (app, else nearest ancestor group),
/// or `None` when the operation is un-hooked. The executor resolves that
/// name to a script and runs it. `Err` is a backend failure (fail-closed:
/// the caller turns it into an operation error rather than silently
/// allowing).
async fn resolve_before(
&self,
cx: &SdkCallCx,
service: &str,
op: &str,
) -> Result<Option<String>, String>;
}
/// Default: nothing is ever intercepted. The shape every non-picloud `Services`
/// (tests, cluster skeletons) gets for free — an un-hooked write pays exactly
/// one `Ok(None)` here, no I/O.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopInterceptorService;
#[async_trait]
impl InterceptorService for NoopInterceptorService {
async fn resolve_before(
&self,
_cx: &SdkCallCx,
_service: &str,
_op: &str,
) -> Result<Option<String>, String> {
Ok(None)
}
}

View File

@@ -24,6 +24,7 @@ pub mod group_queue;
pub mod http; pub mod http;
pub mod ids; pub mod ids;
pub mod inbox; pub mod inbox;
pub mod interceptor;
pub mod invoke; pub mod invoke;
pub mod kv; pub mod kv;
pub mod log_sink; pub mod log_sink;
@@ -86,6 +87,7 @@ pub use ids::{
pub use inbox::{ pub use inbox::{
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver, InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
}; };
pub use interceptor::{InterceptorService, NoopInterceptorService};
pub use invoke::{InvokeError, InvokeService, InvokeTarget, NoopInvokeService, ResolvedScript}; pub use invoke::{InvokeError, InvokeService, InvokeTarget, NoopInvokeService, ResolvedScript};
pub use kv::{KvError, KvListPage, KvService, NoopKvService}; pub use kv::{KvError, KvListPage, KvService, NoopKvService};
pub use log_sink::{ExecutionLogSink, LogSinkError}; pub use log_sink::{ExecutionLogSink, LogSinkError};

View File

@@ -22,13 +22,13 @@ use std::sync::Arc;
use crate::{ use crate::{
DeadLetterService, DocsService, EmailService, FilesService, GroupDocsService, DeadLetterService, DocsService, EmailService, FilesService, GroupDocsService,
GroupFilesService, GroupKvService, GroupPubsubService, GroupQueueService, HttpService, GroupFilesService, GroupKvService, GroupPubsubService, GroupQueueService, HttpService,
InvokeService, KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, InterceptorService, InvokeService, KvService, ModuleSource, NoopDeadLetterService,
NoopEmailService, NoopEventEmitter, NoopFilesService, NoopGroupDocsService, NoopDocsService, NoopEmailService, NoopEventEmitter, NoopFilesService, NoopGroupDocsService,
NoopGroupFilesService, NoopGroupKvService, NoopGroupPubsubService, NoopGroupQueueService, NoopGroupFilesService, NoopGroupKvService, NoopGroupPubsubService, NoopGroupQueueService,
NoopHttpService, NoopInvokeService, NoopKvService, NoopModuleSource, NoopPubsubService, NoopHttpService, NoopInterceptorService, NoopInvokeService, NoopKvService, NoopModuleSource,
NoopQueueService, NoopSecretsService, NoopUsersService, NoopVarsService, NoopWorkflowService, NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService, NoopVarsService,
PubsubService, QueueService, SecretsService, ServiceEventEmitter, UsersService, VarsService, NoopWorkflowService, PubsubService, QueueService, SecretsService, ServiceEventEmitter,
WorkflowService, UsersService, VarsService, WorkflowService,
}; };
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x /// SDK service bundle. See module docs for the lifecycle and the v1.1.x
@@ -152,6 +152,12 @@ pub struct Services {
/// run of a named workflow in the caller's app. Wired via /// run of a named workflow in the caller's app. Wired via
/// [`Services::with_workflow`]; defaults to `NoopWorkflowService`. /// [`Services::with_workflow`]; defaults to `NoopWorkflowService`.
pub workflow: Arc<dyn WorkflowService>, pub workflow: Arc<dyn WorkflowService>,
/// §9.4 Service Interceptors — resolves which (if any) interceptor script
/// guards a `(service, op)` before it runs. Wired via
/// [`Services::with_interceptors`]; defaults to `NoopInterceptorService`
/// (nothing intercepted).
pub interceptors: Arc<dyn InterceptorService>,
} }
impl Services { impl Services {
@@ -200,9 +206,18 @@ impl Services {
group_pubsub: Arc::new(NoopGroupPubsubService), group_pubsub: Arc::new(NoopGroupPubsubService),
group_queue: Arc::new(NoopGroupQueueService), group_queue: Arc::new(NoopGroupQueueService),
workflow: Arc::new(NoopWorkflowService), workflow: Arc::new(NoopWorkflowService),
interceptors: Arc::new(NoopInterceptorService),
} }
} }
/// Set the §9.4 interceptor resolver (picloud binary wires the
/// Postgres-backed impl; tests leave the noop default = nothing hooked).
#[must_use]
pub fn with_interceptors(mut self, interceptors: Arc<dyn InterceptorService>) -> Self {
self.interceptors = interceptors;
self
}
/// Set the v1.2 Workflows service (picloud binary wires the Postgres-backed /// Set the v1.2 Workflows service (picloud binary wires the Postgres-backed
/// impl; tests leave the noop default). /// impl; tests leave the noop default).
#[must_use] #[must_use]

View File

@@ -1,6 +1,6 @@
# Project Blueprint: Lightweight Event-Based Serverless Cloud # Project Blueprint: Lightweight Event-Based Serverless Cloud
**Status**: v1.1 shipped (SDK + services) · v1.2 *Hierarchies* track **complete** · v1.2 *Workflows* track **shipped** (M1M6: DAG execution, conditional branching, nested sub-workflows, `workflow::start` SDK, dashboard DAG + run-history); §9.4 service interceptors + v1.3 cluster mode are next **Status**: v1.1 shipped (SDK + services) · v1.2 *Hierarchies* track **complete** · v1.2 *Workflows* track **shipped** (M1M6: DAG execution, conditional branching, nested sub-workflows, `workflow::start` SDK, dashboard DAG + run-history); a §9.4 service-interceptor KV allow/deny slice has shipped (migration 0073); the rest of §9.4 + v1.3 cluster mode are next
**Last Updated**: 2026-07-12 (reconciled to shipped code — CLAUDE.md is the live source of truth) **Last Updated**: 2026-07-12 (reconciled to shipped code — CLAUDE.md is the live source of truth)
**Audience**: Solo developer (DIY self-hosted) **Audience**: Solo developer (DIY self-hosted)
@@ -171,8 +171,8 @@ rhai_executor --script $SCRIPT_PATH --request "$REQUEST_JSON"
### 3.4 PostgreSQL Database ### 3.4 PostgreSQL Database
**Schema (MVP sketch — NOT authoritative):** the block below is the original MVP shape. The **authoritative **Schema (MVP sketch — NOT authoritative):** the block below is the original MVP shape. The **authoritative
schema is the migration set** in `crates/manager-core/migrations/` (through `0072` as of v1.2, incl. the schema is the migration set** in `crates/manager-core/migrations/` (through `0073` as of v1.2, incl. the
Workflows tables), which has Workflows tables + the §9.4 interceptor markers), which has
since added apps/domains, RBAC (`admin_users`/`app_members`/`api_keys`), the v1.1 data-plane services since added apps/domains, RBAC (`admin_users`/`app_members`/`api_keys`), the v1.1 data-plane services
(KV/docs/files/queues/…), and the v1.2 groups/collections/templates/projects tables. Treat this as (KV/docs/files/queues/…), and the v1.2 groups/collections/templates/projects tables. Treat this as
illustration only. illustration only.
@@ -1697,6 +1697,16 @@ CREATE INDEX idx_execution_parent ON execution_logs(parent_execution_id);
### 9.4 Service Interceptors & Middleware (v1.2+) ### 9.4 Service Interceptors & Middleware (v1.2+)
> **Status — thin KV allow/deny slice SHIPPED** (migration `0073_interceptors.sql`). A `[[interceptors]]`
> manifest block (app OR group) binds a script to run BEFORE `kv::set` / `kv::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). Registration is a marker
> `(owner, service, op) → script`, resolved **nearest-owner-wins** on the calling app's chain (an app overrides
> a group's), mirroring extension points (§5.5); the interceptor script itself is resolved + run through the
> `invoke()` re-entry path (shared depth bound, AST cache). **Deferred (the rest of the spec below):** the
> `data` transform return, services other than `kv`, `after_*` hooks, interceptor chaining + circular-dependency
> guard, and the timeout policy. The remaining subsections describe that full design.
**Concept**: A script can act as middleware to intercept and validate/transform service operations before they execute. **Concept**: A script can act as middleware to intercept and validate/transform service operations before they execute.
**Use Cases:** **Use Cases:**