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

@@ -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()
})?;
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 {
execution_id,
execution_id: ExecutionId::new(),
request_id: cx.request_id,
script_id: resolved.script_id,
script_name: resolved.name.clone(),
@@ -173,7 +192,7 @@ fn invoke_blocking(
path: "/invoke".into(),
method: String::new(),
headers: BTreeMap::new(),
body: args_json,
body: body_json,
params: BTreeMap::new(),
query: BTreeMap::new(),
rest: String::new(),
@@ -183,42 +202,24 @@ fn invoke_blocking(
// script's imports resolve from the group even when invoked by an
// app. `None` falls back to `App(cx.app_id)` in the engine.
script_owner: resolved.owner,
// Same-app invoke is a function call, not a re-auth boundary —
// inherit the caller's principal.
// Same-app re-entry is not a re-auth boundary — inherit the principal.
principal: cx.principal.clone(),
trigger_depth: cx.trigger_depth + 1,
root_execution_id: cx.root_execution_id,
is_dead_letter_handler: cx.is_dead_letter_handler,
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
.compile_for_identity(resolved.script_id, resolved.updated_at, &resolved.source)
.map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
format!("invoke({target_label}): {e}").into(),
rhai::Position::NONE,
)
.into()
EvalAltResult::ErrorRuntime(format!("{label}: {e}").into(), rhai::Position::NONE).into()
})?;
let resp = self_engine
.execute_ast(&ast, req)
.map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
format!("invoke({target_label}): {e}").into(),
rhai::Position::NONE,
)
.into()
EvalAltResult::ErrorRuntime(format!("{label}: {e}").into(), rhai::Position::NONE).into()
})?;
// 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))
Ok(resp.body)
}
/// Accept a string (route path OR script name) or a Rhai script-id

View File

@@ -30,18 +30,27 @@
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 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
/// plus an owned string).
/// Per-call handle captured by the Rhai SDK. Cheap to clone (a few Arcs plus an
/// 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)]
pub struct KvHandle {
collection: String,
service: Arc<dyn KvService>,
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
@@ -56,9 +65,17 @@ pub struct GroupKvHandle {
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 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
// 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 cx = cx.clone();
let interceptors = interceptors.clone();
let invoke = invoke.clone();
let self_engine = self_engine.clone();
module.set_native_fn(
"collection",
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(),
service: kv_service.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(
"set",
|handle: &mut KvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
let h = handle.clone();
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 {
h.service.set(&h.cx, &h.collection, key, json).await
})
@@ -197,6 +235,18 @@ fn register_delete(engine: &mut RhaiEngine) {
engine.register_fn(
"delete",
|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();
block_on("kv", async move {
h.service.delete(&h.cx, &h.collection, key).await

View File

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