feat(interceptors): extend before/after hooks to docs/files/queue/pubsub/http (§9.4 M7-M11)
Every non-KV mutating op now runs the same before/after interceptor machinery: - docs create/update/delete (+ group) — create/update honor the M4 data transform; - files create/update/delete (+ group) — allow/deny only (the blob is never surfaced to the hook, value = None); - queue enqueue (+ shared) — transform-capable; after reports the message id; - pubsub publish_durable (+ shared topic) — transform-capable; - http request — all verbs funnel through two svc.request sites; collection = method, key = url, body not surfaced. ictx threaded into the free-fn/handle paths that lacked it (http was _ictx; queue/pubsub per-app publish). validate_bundle_for generalized to a per-service allowed-ops map (kv set/delete; docs/files create/update/delete; queue enqueue; pubsub publish; http request) — unknown service/op still rejected. INVARIANT enforced + verified: every allowed (service, op) has a matching runtime hook, so no validated-but-unhooked fail-open. Pinned by a new docs-create deny journey (11 interceptor journeys green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -78,7 +78,8 @@ pub struct Manifest {
|
||||
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"`.
|
||||
/// The guarded service: one of `kv` / `docs` / `files` / `queue` /
|
||||
/// `pubsub` / `http` (§9.4 M7–M11). Defaults to `kv`.
|
||||
#[serde(default = "default_interceptor_service")]
|
||||
pub service: String,
|
||||
/// The guarded operations, e.g. `["set", "delete"]`.
|
||||
|
||||
@@ -398,6 +398,78 @@ fn a_malformed_verdict_fails_closed() {
|
||||
);
|
||||
}
|
||||
|
||||
// --- §9.4 M8: docs (a representative non-KV service) -----------------------
|
||||
|
||||
/// A `(docs, create)` guard that denies creating a doc whose `data.kind` is
|
||||
/// `"secret"`, reading the written value from `ctx.request.body.value`. Proves a
|
||||
/// non-KV service funnels through the same before/after machinery and that the
|
||||
/// created document's data is surfaced to the hook.
|
||||
const DOCS_GUARD: &str = r#"
|
||||
let op = ctx.request.body;
|
||||
if op.action == "create" && op.value.kind == "secret" {
|
||||
#{ allowed: false, reason: "secret docs are protected" }
|
||||
} else {
|
||||
#{ allowed: true }
|
||||
}
|
||||
"#;
|
||||
|
||||
/// A writer that tries a denied `docs::create` (caught), then a permitted one,
|
||||
/// and reports how many docs actually landed in the collection.
|
||||
const DOCS_WRITER: &str = r#"
|
||||
let denied = false;
|
||||
try { docs::collection("c").create(#{ kind: "secret", n: 1 }); } catch(e) { denied = true; }
|
||||
docs::collection("c").create(#{ kind: "public", n: 2 });
|
||||
let all = docs::collection("c").find(#{});
|
||||
#{ denied: denied, count: all.len() }
|
||||
"#;
|
||||
|
||||
/// §9.4 M8: `[[interceptors]] service = "docs" ops = ["create"]` guards
|
||||
/// `docs::collection(...).create(...)` through the same before/after machinery
|
||||
/// as KV — a guarded shape is denied (the doc is never created), an unguarded
|
||||
/// one is allowed.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn an_interceptor_guards_docs_create() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let app = common::unique_slug("icd-app");
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = manifest_dir();
|
||||
fs::write(dir.path().join("scripts/guard.rhai"), DOCS_GUARD).unwrap();
|
||||
fs::write(dir.path().join("scripts/writer.rhai"), DOCS_WRITER).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[app]\nslug = \"{app}\"\nname = \"ICD\"\n\n\
|
||||
[[scripts]]\nname = \"guard\"\nfile = \"scripts/guard.rhai\"\n\n\
|
||||
[[scripts]]\nname = \"writer\"\nfile = \"scripts/writer.rhai\"\n\n\
|
||||
[[interceptors]]\nscript = \"guard\"\nservice = \"docs\"\nops = [\"create\"]\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(dir.path().join("picloud.toml"))
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// The guarded (secret) create is denied; the public one lands — so exactly
|
||||
// one doc exists in the collection.
|
||||
let body = invoke_body(&env, &app_script_id(&env, &app, "writer"));
|
||||
assert_eq!(
|
||||
body,
|
||||
serde_json::json!({ "denied": true, "count": 1 }),
|
||||
"the `secret` docs::create must be denied; only the public doc is created"
|
||||
);
|
||||
}
|
||||
|
||||
// --- §9.4 M2: ordered before-chain + cycle guard --------------------------
|
||||
|
||||
/// A group before-interceptor that denies only `kv::set` of key `"group-key"`.
|
||||
|
||||
Reference in New Issue
Block a user