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

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

View File

@@ -155,6 +155,15 @@ pub async fn run(
"+{} ~{} -{}",
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 {
block.field("warning", w.clone());
@@ -283,6 +292,15 @@ pub async fn run_tree(
"+{} ~{} -{}",
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 {
block.field("warning", w.clone());

View File

@@ -144,6 +144,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
vars: std::collections::BTreeMap::new(),
suppress: crate::manifest::ManifestSuppress::default(),
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),
("route", &n.routes),
("trigger", &n.triggers),
@@ -116,6 +116,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
("collection", &n.collections),
("suppression", &n.suppressions),
("workflow", &n.workflows),
("interceptor", &n.interceptors),
];
for (rk, changes) in groups {
for c in changes {
@@ -252,6 +253,17 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
.iter()
.map(workflow_to_wire)
.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),
("route", &plan.routes),
("trigger", &plan.triggers),
@@ -305,6 +317,7 @@ fn render(plan: &PlanDto, mode: OutputMode) {
("collection", &plan.collections),
("suppression", &plan.suppressions),
("workflow", &plan.workflows),
("interceptor", &plan.interceptors),
];
for (kind, changes) in groups {
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,
suppress: crate::manifest::ManifestSuppress::default(),
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()?)

View File

@@ -64,6 +64,29 @@ pub struct Manifest {
/// App-owned; a `[group]` carrying them is rejected server-side.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
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 {
@@ -783,6 +806,7 @@ mod tests {
]),
suppress: ManifestSuppress::default(),
workflows: Vec::new(),
interceptors: Vec::new(),
}
}
@@ -890,6 +914,7 @@ mod tests {
vars: BTreeMap::new(),
suppress: ManifestSuppress::default(),
workflows: Vec::new(),
interceptors: Vec::new(),
};
let text = m.to_toml().unwrap();
assert!(!text.contains("[[scripts]]"), "got:\n{text}");

View File

@@ -35,6 +35,7 @@ mod group_secrets;
mod group_triggers;
mod groups;
mod init;
mod interceptors;
mod invoke;
mod logs;
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"
);
}