feat(interceptors): KV after-hooks + before-hook data transform (§9.4 M3+M4)
M4: a before-interceptor returning #{ allowed: true, data: ... } rewrites the
value actually written (threaded through the chain; each hook sees the prior
transform), size-capped at MAX_JSON_MATERIALIZE_BYTES. Delete never transforms.
M3: an 'after' phase interceptor runs once the write has committed, with the
write's result in its payload. After-hooks observe/deny but CANNOT roll back —
a deny surfaces as an operation error while the write persists (documented at
the call site).
Adds phase authoring end to end: a [[interceptors]] entry gains phase =
before|after (default before), threaded through the plan wire, BundleInterceptor,
validate (phase in {before,after}), the reconcile diff/insert/prune key
(service/op/phase), and the repo (insert/delete/list_for_owner/list_on_app_chain,
+ the marker's phase). run_before now returns the transformed value; run_after is
new; both share one fail-closed per-entry runner. Pinned by two journeys: a
before-hook transforms the stored value, and an after-delete hook sees
result==true, denies, yet the key stays deleted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,7 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{InterceptorEntry, InterceptorService, ScriptId, SdkCallCx};
|
||||
use picloud_shared::{InterceptorChain, InterceptorEntry, InterceptorService, ScriptId, SdkCallCx};
|
||||
use rhai::EvalAltResult;
|
||||
use serde_json::{json, Value as Json};
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
@@ -102,12 +102,20 @@ impl Drop for CycleGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// The parsed outcome of ONE allowing hook: the operation passes, optionally
|
||||
/// carrying a `data` transform (M4) the caller writes instead of the original.
|
||||
struct HookAllow {
|
||||
/// `Some` iff the hook returned a `data` key alongside `allowed: true`.
|
||||
data: Option<Json>,
|
||||
}
|
||||
|
||||
/// Run the before-op interceptor CHAIN for `(service, op)` if any are
|
||||
/// registered. Returns `Ok(())` to allow the operation (un-hooked, or every
|
||||
/// before-interceptor explicitly allowed it) or an `Err` runtime error to deny
|
||||
/// it (the caller must NOT then perform the write). A single deny
|
||||
/// short-circuits — the write is blocked. `value` is the payload being written
|
||||
/// (`None` for delete).
|
||||
/// registered. Returns `Ok(Some(value))` when a before-hook rewrote the payload
|
||||
/// via a `data` transform (M4) — the caller writes THAT instead of the original
|
||||
/// — or `Ok(None)` to write the original unchanged (un-hooked, or every hook
|
||||
/// allowed without transforming). An `Err` denies the operation (the caller
|
||||
/// must NOT perform the write); the FIRST deny short-circuits. `value` is the
|
||||
/// original payload (`None` for delete, which never transforms).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn run_before(
|
||||
ictx: &InterceptorCtx,
|
||||
@@ -117,33 +125,103 @@ pub(super) fn run_before(
|
||||
collection: &str,
|
||||
key: &str,
|
||||
value: Option<&Json>,
|
||||
) -> Result<(), Box<EvalAltResult>> {
|
||||
) -> Result<Option<Json>, Box<EvalAltResult>> {
|
||||
// Re-entrancy break: writes performed BY an interceptor (or anything it
|
||||
// invokes) are not themselves intercepted — otherwise an "allow + write"
|
||||
// hook would recurse to the depth cap and deny the original op.
|
||||
if currently_in_interceptor() {
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let chain = resolve_chain(ictx, cx, service, op)?;
|
||||
if chain.before.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Thread the (possibly rewritten) value through the chain: each before-hook
|
||||
// sees the prior hook's transform in `value`, and the write uses the final.
|
||||
let mut current: Option<Json> = value.cloned();
|
||||
let mut transformed = false;
|
||||
for entry in &chain.before {
|
||||
let payload = op_payload(service, op, collection, key, current.as_ref(), cx, None);
|
||||
let outcome = run_one_hook(ictx, cx, entry, service, op, &payload)?;
|
||||
// M4 data-transform: honored ONLY on an allowing hook (the verdict parse
|
||||
// already enforced fail-closed). Delete has no value to rewrite.
|
||||
if let Some(data) = outcome.data {
|
||||
if value.is_some() {
|
||||
enforce_transform_size(service, op, &data)?;
|
||||
current = Some(data);
|
||||
transformed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(transformed.then(|| current.unwrap_or(Json::Null)))
|
||||
}
|
||||
|
||||
/// Run the after-op interceptor CHAIN (M3) once the write has committed.
|
||||
/// `result` is the write's outcome (serialized), surfaced to the hook so it can
|
||||
/// observe/audit. After-hooks CANNOT roll back — the write already happened; a
|
||||
/// deny here surfaces as an operation error to the caller but the write
|
||||
/// persists. Returns `Ok(())` when every after-hook allowed (or none exist), an
|
||||
/// `Err` on the first deny.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn run_after(
|
||||
ictx: &InterceptorCtx,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
service: &'static str,
|
||||
op: &'static str,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
value: Option<&Json>,
|
||||
result: Json,
|
||||
) -> Result<(), Box<EvalAltResult>> {
|
||||
if currently_in_interceptor() {
|
||||
return Ok(());
|
||||
}
|
||||
let chain = resolve_chain(ictx, cx, service, op)?;
|
||||
if chain.after.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in &chain.after {
|
||||
let payload = op_payload(service, op, collection, key, value, cx, Some(&result));
|
||||
// After-hooks observe/deny; a returned `data` is ignored (the write is
|
||||
// already durable, so there is nothing to rewrite for the kv slice).
|
||||
run_one_hook(ictx, cx, entry, service, op, &payload)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the before+after chain for `(service, op)` — every marker visible on
|
||||
/// the calling app's chain, ordered per phase. An `Err` (no tokio runtime, or a
|
||||
/// backend failure) makes the caller fail closed rather than silently allow.
|
||||
fn resolve_chain(
|
||||
ictx: &InterceptorCtx,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
service: &'static str,
|
||||
op: &'static str,
|
||||
) -> Result<InterceptorChain, Box<EvalAltResult>> {
|
||||
let handle = TokioHandle::try_current()
|
||||
.map_err(|e| runtime_err(&format!("{service} interceptor: no tokio runtime: {e}")))?;
|
||||
let interceptors = ictx.interceptors.clone();
|
||||
let cx = cx.clone();
|
||||
handle
|
||||
.block_on(async move { interceptors.resolve(&cx, service, op).await })
|
||||
.map_err(|e| runtime_err(&format!("{service}::{op} interceptor resolve: {e}")))
|
||||
}
|
||||
|
||||
// (1) Resolve the whole before+after chain, each entry sealed to its
|
||||
// declaring owner. `before` is ordered ancestor→app (an outer/group guard
|
||||
// runs first). `after` is unused this milestone.
|
||||
let chain = {
|
||||
let interceptors = ictx.interceptors.clone();
|
||||
let cx = cx.clone();
|
||||
handle
|
||||
.block_on(async move { interceptors.resolve(&cx, service, op).await })
|
||||
.map_err(|e| runtime_err(&format!("{service}::{op} interceptor resolve: {e}")))?
|
||||
};
|
||||
// Un-hooked: the ONLY path that allows without running anything.
|
||||
if chain.before.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let payload = json!({
|
||||
/// Build the operation-context payload handed to a hook. `result` is `Some` only
|
||||
/// for an after-hook (the write's outcome).
|
||||
fn op_payload(
|
||||
service: &str,
|
||||
op: &str,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
value: Option<&Json>,
|
||||
cx: &SdkCallCx,
|
||||
result: Option<&Json>,
|
||||
) -> Json {
|
||||
let mut m = json!({
|
||||
"service": service,
|
||||
"action": op,
|
||||
"collection": collection,
|
||||
@@ -152,88 +230,114 @@ pub(super) fn run_before(
|
||||
"caller_script_id": cx.script_id.to_string(),
|
||||
"caller_execution_id": cx.execution_id.to_string(),
|
||||
});
|
||||
if let (Json::Object(obj), Some(r)) = (&mut m, result) {
|
||||
obj.insert("result".to_string(), r.clone());
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
// (2) Run each before-interceptor in order; the FIRST deny short-circuits.
|
||||
for entry in &chain.before {
|
||||
let resolved = match entry {
|
||||
// A guard is declared but its script is missing/disabled — fail closed.
|
||||
InterceptorEntry::Dangling(name) => {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} denied: interceptor script `{name}` is missing or disabled"
|
||||
)));
|
||||
}
|
||||
InterceptorEntry::Run(r) => &r.script,
|
||||
};
|
||||
|
||||
// Identity cycle guard: if this interceptor's script is already on the
|
||||
// run stack, running it again would recurse forever — DENY.
|
||||
if CycleGuard::contains(resolved.script_id) {
|
||||
/// Run ONE chain entry: fail-closed on a dangling marker, an identity cycle, a
|
||||
/// missing engine back-reference, or a depth-limit breach; otherwise run the
|
||||
/// sealed script and parse its verdict. Returns the allow outcome (with any
|
||||
/// `data` transform) or an `Err` denying the operation.
|
||||
fn run_one_hook(
|
||||
ictx: &InterceptorCtx,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
entry: &InterceptorEntry,
|
||||
service: &str,
|
||||
op: &str,
|
||||
payload: &Json,
|
||||
) -> Result<HookAllow, Box<EvalAltResult>> {
|
||||
let resolved = match entry {
|
||||
// A guard is declared but its script is missing/disabled — fail closed.
|
||||
InterceptorEntry::Dangling(name) => {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} denied: interceptor cycle detected: `{}`",
|
||||
resolved.name
|
||||
"{service}::{op} denied: interceptor script `{name}` is missing or disabled"
|
||||
)));
|
||||
}
|
||||
InterceptorEntry::Run(r) => r,
|
||||
};
|
||||
let script = &resolved.script;
|
||||
|
||||
// A guard is registered but the engine back-reference isn't installed (a
|
||||
// bare test engine without `set_self_weak`): we cannot run it, so DENY —
|
||||
// a resolvable guard we can't execute must not fail open. Production
|
||||
// always installs the back-ref.
|
||||
let Some(self_engine) = ictx.self_engine.as_ref() else {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} denied: interceptor `{}` cannot run (engine back-reference not installed)",
|
||||
resolved.name
|
||||
)));
|
||||
};
|
||||
|
||||
// Depth bound (shared with invoke / trigger fan-out).
|
||||
if cx.trigger_depth + 1 > ictx.limits.trigger_depth_max {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} interceptor `{}`: depth limit exceeded (max {})",
|
||||
resolved.name, ictx.limits.trigger_depth_max
|
||||
)));
|
||||
}
|
||||
|
||||
// Run the sealed script with the operation context as its request body,
|
||||
// under the re-entrancy guard (its own writes bypass interception) and
|
||||
// the identity cycle guard (its own script id is on the stack).
|
||||
let ret = {
|
||||
let _reentry = ReentryGuard::enter();
|
||||
let _cycle = CycleGuard::enter(resolved.script_id);
|
||||
run_resolved_blocking(
|
||||
self_engine,
|
||||
cx,
|
||||
resolved,
|
||||
payload.clone(),
|
||||
&format!("{service}::{op} interceptor `{}`", resolved.name),
|
||||
)?
|
||||
};
|
||||
|
||||
// Fail-closed verdict: allow ONLY on an explicit `#{ allowed: true }`. A
|
||||
// bare bool, a typo'd key, a non-bool `allowed`, a bare unit, or any
|
||||
// other shape DENIES — a control whose job is to deny must not allow by
|
||||
// omission.
|
||||
match &ret {
|
||||
// Explicit allow → this entry passes; continue to the next.
|
||||
Json::Object(m) if m.get("allowed") == Some(&Json::Bool(true)) => {}
|
||||
Json::Object(m) => {
|
||||
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 `{}`: {reason}",
|
||||
resolved.name
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} denied by interceptor `{}`: no allow verdict returned",
|
||||
resolved.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
// Identity cycle guard: if this interceptor's script is already on the run
|
||||
// stack, running it again would recurse forever — DENY.
|
||||
if CycleGuard::contains(script.script_id) {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} denied: interceptor cycle detected: `{}`",
|
||||
script.name
|
||||
)));
|
||||
}
|
||||
|
||||
// Every before-interceptor allowed.
|
||||
// A guard is registered but the engine back-reference isn't installed (a bare
|
||||
// test engine without `set_self_weak`): we cannot run it, so DENY — a
|
||||
// resolvable guard we can't execute must not fail open. Production always
|
||||
// installs the back-ref.
|
||||
let Some(self_engine) = ictx.self_engine.as_ref() else {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} denied: interceptor `{}` cannot run (engine back-reference not installed)",
|
||||
script.name
|
||||
)));
|
||||
};
|
||||
|
||||
// Depth bound (shared with invoke / trigger fan-out).
|
||||
if cx.trigger_depth + 1 > ictx.limits.trigger_depth_max {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} interceptor `{}`: depth limit exceeded (max {})",
|
||||
script.name, ictx.limits.trigger_depth_max
|
||||
)));
|
||||
}
|
||||
|
||||
// Run the sealed script with the operation context as its request body, under
|
||||
// the re-entrancy guard (its own writes bypass interception) and the identity
|
||||
// cycle guard (its own script id is on the stack).
|
||||
let ret = {
|
||||
let _reentry = ReentryGuard::enter();
|
||||
let _cycle = CycleGuard::enter(script.script_id);
|
||||
run_resolved_blocking(
|
||||
self_engine,
|
||||
cx,
|
||||
script,
|
||||
payload.clone(),
|
||||
&format!("{service}::{op} interceptor `{}`", script.name),
|
||||
)?
|
||||
};
|
||||
|
||||
// Fail-closed verdict: allow ONLY on an explicit `#{ allowed: true }`. A bare
|
||||
// bool, a typo'd key, a non-bool `allowed`, a bare unit, or any other shape
|
||||
// DENIES — a control whose job is to deny must not allow by omission.
|
||||
match ret {
|
||||
Json::Object(mut m) if m.get("allowed") == Some(&Json::Bool(true)) => Ok(HookAllow {
|
||||
data: m.remove("data"),
|
||||
}),
|
||||
Json::Object(m) => {
|
||||
let reason = m
|
||||
.get("reason")
|
||||
.and_then(Json::as_str)
|
||||
.unwrap_or("denied by interceptor");
|
||||
Err(runtime_err(&format!(
|
||||
"{service}::{op} denied by interceptor `{}`: {reason}",
|
||||
script.name
|
||||
)))
|
||||
}
|
||||
_ => Err(runtime_err(&format!(
|
||||
"{service}::{op} denied by interceptor `{}`: no allow verdict returned",
|
||||
script.name
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap a `data` transform at the same byte ceiling the SDK uses for a Rhai→JSON
|
||||
/// materialization, so an interceptor can't rewrite a small write into an
|
||||
/// unbounded blob before it reaches the service's own value cap.
|
||||
fn enforce_transform_size(service: &str, op: &str, data: &Json) -> Result<(), Box<EvalAltResult>> {
|
||||
let len = serde_json::to_vec(data)
|
||||
.map(|v| v.len())
|
||||
.unwrap_or(usize::MAX);
|
||||
if len > crate::sdk::bridge::MAX_JSON_MATERIALIZE_BYTES {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} interceptor transform too large ({len} bytes exceeds the {}-byte limit)",
|
||||
crate::sdk::bridge::MAX_JSON_MATERIALIZE_BYTES
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -172,9 +172,9 @@ fn register_set(engine: &mut RhaiEngine) {
|
||||
"set",
|
||||
|handle: &mut KvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||
let json = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)?;
|
||||
// §9.4 before-op interceptor (allow/deny). A denial errors here and
|
||||
// the write below never runs.
|
||||
super::interceptor::run_before(
|
||||
// §9.4 before-op interceptor (allow/deny + M4 data-transform). A
|
||||
// denial errors here; a returned `data` rewrites the written value.
|
||||
let write_val = super::interceptor::run_before(
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
"kv",
|
||||
@@ -182,11 +182,24 @@ fn register_set(engine: &mut RhaiEngine) {
|
||||
&handle.collection,
|
||||
key,
|
||||
Some(&json),
|
||||
)?;
|
||||
)?
|
||||
.unwrap_or(json);
|
||||
let written = write_val.clone();
|
||||
let h = handle.clone();
|
||||
block_on("kv", async move {
|
||||
h.service.set(&h.cx, &h.collection, key, json).await
|
||||
})
|
||||
h.service.set(&h.cx, &h.collection, key, write_val).await
|
||||
})?;
|
||||
// §9.4 M3 after-hook (observe/audit; the write already committed).
|
||||
super::interceptor::run_after(
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
"kv",
|
||||
"set",
|
||||
&handle.collection,
|
||||
key,
|
||||
Some(&written),
|
||||
serde_json::Value::Null,
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -228,6 +241,7 @@ fn register_delete(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"delete",
|
||||
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
// Delete carries no value, so the before-hook never transforms.
|
||||
super::interceptor::run_before(
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
@@ -238,9 +252,20 @@ fn register_delete(engine: &mut RhaiEngine) {
|
||||
None,
|
||||
)?;
|
||||
let h = handle.clone();
|
||||
block_on("kv", async move {
|
||||
let was_present = block_on("kv", async move {
|
||||
h.service.delete(&h.cx, &h.collection, key).await
|
||||
})
|
||||
})?;
|
||||
super::interceptor::run_after(
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
"kv",
|
||||
"delete",
|
||||
&handle.collection,
|
||||
key,
|
||||
None,
|
||||
serde_json::Value::Bool(was_present),
|
||||
)?;
|
||||
Ok(was_present)
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -252,7 +277,7 @@ fn group_run_before(
|
||||
op: &'static str,
|
||||
key: &str,
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> Result<(), Box<EvalAltResult>> {
|
||||
) -> Result<Option<serde_json::Value>, Box<EvalAltResult>> {
|
||||
super::interceptor::run_before(
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
@@ -264,6 +289,25 @@ fn group_run_before(
|
||||
)
|
||||
}
|
||||
|
||||
fn group_run_after(
|
||||
handle: &GroupKvHandle,
|
||||
op: &'static str,
|
||||
key: &str,
|
||||
value: Option<&serde_json::Value>,
|
||||
result: serde_json::Value,
|
||||
) -> Result<(), Box<EvalAltResult>> {
|
||||
super::interceptor::run_after(
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
"kv",
|
||||
op,
|
||||
&handle.collection,
|
||||
key,
|
||||
value,
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
fn register_list(engine: &mut RhaiEngine) {
|
||||
// Zero-arg form — full page, no cursor.
|
||||
engine.register_fn(
|
||||
@@ -330,12 +374,15 @@ fn register_group_set(engine: &mut RhaiEngine) {
|
||||
"set",
|
||||
|handle: &mut GroupKvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||
let json = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)?;
|
||||
// §9.4 before-op interceptor also guards shared-collection writes.
|
||||
group_run_before(handle, "set", key, Some(&json))?;
|
||||
// §9.4 before-op interceptor also guards shared-collection writes
|
||||
// (allow/deny + M4 data-transform).
|
||||
let write_val = group_run_before(handle, "set", key, Some(&json))?.unwrap_or(json);
|
||||
let written = write_val.clone();
|
||||
let h = handle.clone();
|
||||
block_on("kv", async move {
|
||||
h.service.set(&h.cx, &h.collection, key, json).await
|
||||
})
|
||||
h.service.set(&h.cx, &h.collection, key, write_val).await
|
||||
})?;
|
||||
group_run_after(handle, "set", key, Some(&written), serde_json::Value::Null)
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -376,9 +423,17 @@ fn register_group_delete(engine: &mut RhaiEngine) {
|
||||
|handle: &mut GroupKvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
group_run_before(handle, "delete", key, None)?;
|
||||
let h = handle.clone();
|
||||
block_on("kv", async move {
|
||||
let was_present = block_on("kv", async move {
|
||||
h.service.delete(&h.cx, &h.collection, key).await
|
||||
})
|
||||
})?;
|
||||
group_run_after(
|
||||
handle,
|
||||
"delete",
|
||||
key,
|
||||
None,
|
||||
serde_json::Value::Bool(was_present),
|
||||
)?;
|
||||
Ok(was_present)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user