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::cell::{Cell, RefCell};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use picloud_shared::{InterceptorEntry, InterceptorService, ScriptId, SdkCallCx};
|
use picloud_shared::{InterceptorChain, InterceptorEntry, InterceptorService, ScriptId, SdkCallCx};
|
||||||
use rhai::EvalAltResult;
|
use rhai::EvalAltResult;
|
||||||
use serde_json::{json, Value as Json};
|
use serde_json::{json, Value as Json};
|
||||||
use tokio::runtime::Handle as TokioHandle;
|
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
|
/// Run the before-op interceptor CHAIN for `(service, op)` if any are
|
||||||
/// registered. Returns `Ok(())` to allow the operation (un-hooked, or every
|
/// registered. Returns `Ok(Some(value))` when a before-hook rewrote the payload
|
||||||
/// before-interceptor explicitly allowed it) or an `Err` runtime error to deny
|
/// via a `data` transform (M4) — the caller writes THAT instead of the original
|
||||||
/// it (the caller must NOT then perform the write). A single deny
|
/// — or `Ok(None)` to write the original unchanged (un-hooked, or every hook
|
||||||
/// short-circuits — the write is blocked. `value` is the payload being written
|
/// allowed without transforming). An `Err` denies the operation (the caller
|
||||||
/// (`None` for delete).
|
/// 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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(super) fn run_before(
|
pub(super) fn run_before(
|
||||||
ictx: &InterceptorCtx,
|
ictx: &InterceptorCtx,
|
||||||
@@ -117,33 +125,103 @@ pub(super) fn run_before(
|
|||||||
collection: &str,
|
collection: &str,
|
||||||
key: &str,
|
key: &str,
|
||||||
value: Option<&Json>,
|
value: Option<&Json>,
|
||||||
) -> Result<(), Box<EvalAltResult>> {
|
) -> Result<Option<Json>, Box<EvalAltResult>> {
|
||||||
// Re-entrancy break: writes performed BY an interceptor (or anything it
|
// Re-entrancy break: writes performed BY an interceptor (or anything it
|
||||||
// invokes) are not themselves intercepted — otherwise an "allow + write"
|
// invokes) are not themselves intercepted — otherwise an "allow + write"
|
||||||
// hook would recurse to the depth cap and deny the original op.
|
// hook would recurse to the depth cap and deny the original op.
|
||||||
if currently_in_interceptor() {
|
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()
|
let handle = TokioHandle::try_current()
|
||||||
.map_err(|e| runtime_err(&format!("{service} interceptor: no tokio runtime: {e}")))?;
|
.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
|
/// Build the operation-context payload handed to a hook. `result` is `Some` only
|
||||||
// declaring owner. `before` is ordered ancestor→app (an outer/group guard
|
/// for an after-hook (the write's outcome).
|
||||||
// runs first). `after` is unused this milestone.
|
fn op_payload(
|
||||||
let chain = {
|
service: &str,
|
||||||
let interceptors = ictx.interceptors.clone();
|
op: &str,
|
||||||
let cx = cx.clone();
|
collection: &str,
|
||||||
handle
|
key: &str,
|
||||||
.block_on(async move { interceptors.resolve(&cx, service, op).await })
|
value: Option<&Json>,
|
||||||
.map_err(|e| runtime_err(&format!("{service}::{op} interceptor resolve: {e}")))?
|
cx: &SdkCallCx,
|
||||||
};
|
result: Option<&Json>,
|
||||||
// Un-hooked: the ONLY path that allows without running anything.
|
) -> Json {
|
||||||
if chain.before.is_empty() {
|
let mut m = json!({
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload = json!({
|
|
||||||
"service": service,
|
"service": service,
|
||||||
"action": op,
|
"action": op,
|
||||||
"collection": collection,
|
"collection": collection,
|
||||||
@@ -152,88 +230,114 @@ pub(super) fn run_before(
|
|||||||
"caller_script_id": cx.script_id.to_string(),
|
"caller_script_id": cx.script_id.to_string(),
|
||||||
"caller_execution_id": cx.execution_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.
|
/// Run ONE chain entry: fail-closed on a dangling marker, an identity cycle, a
|
||||||
for entry in &chain.before {
|
/// missing engine back-reference, or a depth-limit breach; otherwise run the
|
||||||
let resolved = match entry {
|
/// sealed script and parse its verdict. Returns the allow outcome (with any
|
||||||
// A guard is declared but its script is missing/disabled — fail closed.
|
/// `data` transform) or an `Err` denying the operation.
|
||||||
InterceptorEntry::Dangling(name) => {
|
fn run_one_hook(
|
||||||
return Err(runtime_err(&format!(
|
ictx: &InterceptorCtx,
|
||||||
"{service}::{op} denied: interceptor script `{name}` is missing or disabled"
|
cx: &Arc<SdkCallCx>,
|
||||||
)));
|
entry: &InterceptorEntry,
|
||||||
}
|
service: &str,
|
||||||
InterceptorEntry::Run(r) => &r.script,
|
op: &str,
|
||||||
};
|
payload: &Json,
|
||||||
|
) -> Result<HookAllow, Box<EvalAltResult>> {
|
||||||
// Identity cycle guard: if this interceptor's script is already on the
|
let resolved = match entry {
|
||||||
// run stack, running it again would recurse forever — DENY.
|
// A guard is declared but its script is missing/disabled — fail closed.
|
||||||
if CycleGuard::contains(resolved.script_id) {
|
InterceptorEntry::Dangling(name) => {
|
||||||
return Err(runtime_err(&format!(
|
return Err(runtime_err(&format!(
|
||||||
"{service}::{op} denied: interceptor cycle detected: `{}`",
|
"{service}::{op} denied: interceptor script `{name}` is missing or disabled"
|
||||||
resolved.name
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
InterceptorEntry::Run(r) => r,
|
||||||
|
};
|
||||||
|
let script = &resolved.script;
|
||||||
|
|
||||||
// A guard is registered but the engine back-reference isn't installed (a
|
// Identity cycle guard: if this interceptor's script is already on the run
|
||||||
// bare test engine without `set_self_weak`): we cannot run it, so DENY —
|
// stack, running it again would recurse forever — DENY.
|
||||||
// a resolvable guard we can't execute must not fail open. Production
|
if CycleGuard::contains(script.script_id) {
|
||||||
// always installs the back-ref.
|
return Err(runtime_err(&format!(
|
||||||
let Some(self_engine) = ictx.self_engine.as_ref() else {
|
"{service}::{op} denied: interceptor cycle detected: `{}`",
|
||||||
return Err(runtime_err(&format!(
|
script.name
|
||||||
"{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
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,9 +172,9 @@ fn register_set(engine: &mut RhaiEngine) {
|
|||||||
"set",
|
"set",
|
||||||
|handle: &mut KvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
|handle: &mut KvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||||
let json = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)?;
|
let json = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)?;
|
||||||
// §9.4 before-op interceptor (allow/deny). A denial errors here and
|
// §9.4 before-op interceptor (allow/deny + M4 data-transform). A
|
||||||
// the write below never runs.
|
// denial errors here; a returned `data` rewrites the written value.
|
||||||
super::interceptor::run_before(
|
let write_val = super::interceptor::run_before(
|
||||||
&handle.ictx,
|
&handle.ictx,
|
||||||
&handle.cx,
|
&handle.cx,
|
||||||
"kv",
|
"kv",
|
||||||
@@ -182,11 +182,24 @@ fn register_set(engine: &mut RhaiEngine) {
|
|||||||
&handle.collection,
|
&handle.collection,
|
||||||
key,
|
key,
|
||||||
Some(&json),
|
Some(&json),
|
||||||
)?;
|
)?
|
||||||
|
.unwrap_or(json);
|
||||||
|
let written = write_val.clone();
|
||||||
let h = handle.clone();
|
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, 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(
|
engine.register_fn(
|
||||||
"delete",
|
"delete",
|
||||||
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
|
// Delete carries no value, so the before-hook never transforms.
|
||||||
super::interceptor::run_before(
|
super::interceptor::run_before(
|
||||||
&handle.ictx,
|
&handle.ictx,
|
||||||
&handle.cx,
|
&handle.cx,
|
||||||
@@ -238,9 +252,20 @@ fn register_delete(engine: &mut RhaiEngine) {
|
|||||||
None,
|
None,
|
||||||
)?;
|
)?;
|
||||||
let h = handle.clone();
|
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
|
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,
|
op: &'static str,
|
||||||
key: &str,
|
key: &str,
|
||||||
value: Option<&serde_json::Value>,
|
value: Option<&serde_json::Value>,
|
||||||
) -> Result<(), Box<EvalAltResult>> {
|
) -> Result<Option<serde_json::Value>, Box<EvalAltResult>> {
|
||||||
super::interceptor::run_before(
|
super::interceptor::run_before(
|
||||||
&handle.ictx,
|
&handle.ictx,
|
||||||
&handle.cx,
|
&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) {
|
fn register_list(engine: &mut RhaiEngine) {
|
||||||
// Zero-arg form — full page, no cursor.
|
// Zero-arg form — full page, no cursor.
|
||||||
engine.register_fn(
|
engine.register_fn(
|
||||||
@@ -330,12 +374,15 @@ fn register_group_set(engine: &mut RhaiEngine) {
|
|||||||
"set",
|
"set",
|
||||||
|handle: &mut GroupKvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
|handle: &mut GroupKvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||||
let json = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)?;
|
let json = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)?;
|
||||||
// §9.4 before-op interceptor also guards shared-collection writes.
|
// §9.4 before-op interceptor also guards shared-collection writes
|
||||||
group_run_before(handle, "set", key, Some(&json))?;
|
// (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();
|
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, 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>> {
|
|handle: &mut GroupKvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||||
group_run_before(handle, "delete", key, None)?;
|
group_run_before(handle, "delete", key, None)?;
|
||||||
let h = handle.clone();
|
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
|
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)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,9 +121,17 @@ pub struct Bundle {
|
|||||||
pub struct BundleInterceptor {
|
pub struct BundleInterceptor {
|
||||||
pub service: String,
|
pub service: String,
|
||||||
pub op: String,
|
pub op: String,
|
||||||
|
/// `before` (allow/deny + transform) or `after` (observe; §9.4 M3).
|
||||||
|
/// Defaults to `before` so a pre-M3 CLI (which omits it) still applies.
|
||||||
|
#[serde(default = "default_before_phase")]
|
||||||
|
pub phase: String,
|
||||||
pub script: String,
|
pub script: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_before_phase() -> String {
|
||||||
|
"before".to_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.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct BundleWorkflow {
|
pub struct BundleWorkflow {
|
||||||
@@ -820,8 +828,8 @@ pub struct CurrentState {
|
|||||||
/// 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
|
/// §9.4 interceptor markers declared directly at this node, as
|
||||||
/// `(service, op, script)` triples.
|
/// `(service, op, phase, script)` tuples.
|
||||||
pub interceptors: Vec<(String, String, String)>,
|
pub interceptors: Vec<(String, 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).
|
||||||
@@ -1281,9 +1289,10 @@ impl ApplyService {
|
|||||||
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}`,
|
// §9.4 interceptors (MVP): `service = "kv"`, `op ∈ {set, delete}`,
|
||||||
// authored on app OR group. One marker per `(service, op)` — a duplicate
|
// `phase ∈ {before, after}` (§9.4 M3), authored on app OR group. One
|
||||||
// would collide on the reconcile key and the DB's partial-unique index.
|
// marker per `(service, op, phase)` — a duplicate would collide on the
|
||||||
let mut seen_hooks: HashSet<(String, String)> = HashSet::new();
|
// reconcile key and the DB's partial-unique index.
|
||||||
|
let mut seen_hooks: HashSet<(String, String, String)> = HashSet::new();
|
||||||
for i in &bundle.interceptors {
|
for i in &bundle.interceptors {
|
||||||
if i.service != "kv" {
|
if i.service != "kv" {
|
||||||
return Err(ApplyError::Invalid(format!(
|
return Err(ApplyError::Invalid(format!(
|
||||||
@@ -1297,15 +1306,21 @@ impl ApplyService {
|
|||||||
i.op
|
i.op
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
if i.phase != "before" && i.phase != "after" {
|
||||||
|
return Err(ApplyError::Invalid(format!(
|
||||||
|
"interceptor phase `{}` is not supported — only `before` / `after`",
|
||||||
|
i.phase
|
||||||
|
)));
|
||||||
|
}
|
||||||
if i.script.trim().is_empty() {
|
if i.script.trim().is_empty() {
|
||||||
return Err(ApplyError::Invalid(
|
return Err(ApplyError::Invalid(
|
||||||
"an interceptor must name a `script`".into(),
|
"an interceptor must name a `script`".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !seen_hooks.insert((i.service.clone(), i.op.clone())) {
|
if !seen_hooks.insert((i.service.clone(), i.op.clone(), i.phase.clone())) {
|
||||||
return Err(ApplyError::Invalid(format!(
|
return Err(ApplyError::Invalid(format!(
|
||||||
"duplicate interceptor for `{}/{}`",
|
"duplicate interceptor for `{}/{}/{}`",
|
||||||
i.service, i.op
|
i.service, i.op, i.phase
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1594,7 +1609,7 @@ impl ApplyService {
|
|||||||
let bi = bundle
|
let bi = bundle
|
||||||
.interceptors
|
.interceptors
|
||||||
.iter()
|
.iter()
|
||||||
.find(|i| format!("{}/{}", i.service, i.op) == ch.key)
|
.find(|i| format!("{}/{}/{}", i.service, i.op, i.phase) == ch.key)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
ApplyError::Backend("internal: interceptor plan/bundle mismatch".into())
|
ApplyError::Backend("internal: interceptor plan/bundle mismatch".into())
|
||||||
})?;
|
})?;
|
||||||
@@ -1603,6 +1618,7 @@ impl ApplyService {
|
|||||||
owner.as_script_owner(),
|
owner.as_script_owner(),
|
||||||
&bi.service,
|
&bi.service,
|
||||||
&bi.op,
|
&bi.op,
|
||||||
|
&bi.phase,
|
||||||
&bi.script,
|
&bi.script,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1746,16 +1762,20 @@ impl ApplyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// §9.4 interceptor markers are prunable config too. The delete keys
|
// §9.4 interceptor markers are prunable config too. The delete keys
|
||||||
// by `(service, op)` (the marker's identity), so a script-change
|
// by `(service, op, phase)` (the marker's identity), so a
|
||||||
// Update above is never clobbered here.
|
// script-change Update above is never clobbered here.
|
||||||
for ch in &plan.interceptors {
|
for ch in &plan.interceptors {
|
||||||
if ch.op == Op::Delete {
|
if ch.op == Op::Delete {
|
||||||
let (service, op) = ch.key.split_once('/').unwrap_or((ch.key.as_str(), ""));
|
let mut parts = ch.key.splitn(3, '/');
|
||||||
|
let service = parts.next().unwrap_or("");
|
||||||
|
let op = parts.next().unwrap_or("");
|
||||||
|
let phase = parts.next().unwrap_or("before");
|
||||||
crate::interceptor_repo::delete_interceptor_tx(
|
crate::interceptor_repo::delete_interceptor_tx(
|
||||||
&mut *tx,
|
&mut *tx,
|
||||||
owner.as_script_owner(),
|
owner.as_script_owner(),
|
||||||
service,
|
service,
|
||||||
op,
|
op,
|
||||||
|
phase,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
@@ -4243,7 +4263,7 @@ impl ApplyService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|m| (m.service, m.op, m.script))
|
.map(|m| (m.service, m.op, m.phase, m.script))
|
||||||
.collect();
|
.collect();
|
||||||
Ok(CurrentState {
|
Ok(CurrentState {
|
||||||
scripts,
|
scripts,
|
||||||
@@ -4331,15 +4351,15 @@ fn compute_diff_with_names(
|
|||||||
/// the same `(service, op)` is an `Update`; a live marker the manifest stops
|
/// the same `(service, op)` is an `Update`; a live marker the manifest stops
|
||||||
/// declaring is a `Delete` (applied only under `--prune`).
|
/// declaring is a `Delete` (applied only under `--prune`).
|
||||||
fn diff_interceptors(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
fn diff_interceptors(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
||||||
let key = |service: &str, op: &str| format!("{service}/{op}");
|
let key = |service: &str, op: &str, phase: &str| format!("{service}/{op}/{phase}");
|
||||||
let live: HashMap<String, &str> = current
|
let live: HashMap<String, &str> = current
|
||||||
.interceptors
|
.interceptors
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(s, o, script)| (key(s, o), script.as_str()))
|
.map(|(s, o, p, script)| (key(s, o, p), script.as_str()))
|
||||||
.collect();
|
.collect();
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for bi in &bundle.interceptors {
|
for bi in &bundle.interceptors {
|
||||||
let k = key(&bi.service, &bi.op);
|
let k = key(&bi.service, &bi.op, &bi.phase);
|
||||||
match live.get(&k) {
|
match live.get(&k) {
|
||||||
Some(cur) if *cur == bi.script => out.push(ResourceChange {
|
Some(cur) if *cur == bi.script => out.push(ResourceChange {
|
||||||
op: Op::NoOp,
|
op: Op::NoOp,
|
||||||
@@ -4358,15 +4378,15 @@ fn diff_interceptors(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceCha
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (s, o, _) in ¤t.interceptors {
|
for (s, o, p, _) in ¤t.interceptors {
|
||||||
let present = bundle
|
let present = bundle
|
||||||
.interceptors
|
.interceptors
|
||||||
.iter()
|
.iter()
|
||||||
.any(|bi| bi.service == *s && bi.op == *o);
|
.any(|bi| bi.service == *s && bi.op == *o && bi.phase == *p);
|
||||||
if !present {
|
if !present {
|
||||||
out.push(ResourceChange {
|
out.push(ResourceChange {
|
||||||
op: Op::Delete,
|
op: Op::Delete,
|
||||||
key: key(s, o),
|
key: key(s, o, p),
|
||||||
detail: Some("on server, not declared".into()),
|
detail: Some("on server, not declared".into()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ use crate::config_resolver::CHAIN_LEVELS_CTE;
|
|||||||
pub struct InterceptorMarker {
|
pub struct InterceptorMarker {
|
||||||
pub service: String,
|
pub service: String,
|
||||||
pub op: String,
|
pub op: String,
|
||||||
|
/// `before` (allow/deny + transform) or `after` (observe; §9.4 M3).
|
||||||
|
pub phase: String,
|
||||||
pub script: String,
|
pub script: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,11 +31,11 @@ pub async fn list_for_owner(
|
|||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
owner: ScriptOwner,
|
owner: ScriptOwner,
|
||||||
) -> Result<Vec<InterceptorMarker>, sqlx::Error> {
|
) -> Result<Vec<InterceptorMarker>, sqlx::Error> {
|
||||||
let rows: Vec<(String, String, String)> = match owner {
|
let rows: Vec<(String, String, String, String)> = match owner {
|
||||||
ScriptOwner::App(a) => {
|
ScriptOwner::App(a) => {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
"SELECT service, op, interceptor_script FROM interceptors \
|
"SELECT service, op, phase, interceptor_script FROM interceptors \
|
||||||
WHERE app_id = $1 ORDER BY service, op",
|
WHERE app_id = $1 ORDER BY service, op, phase",
|
||||||
)
|
)
|
||||||
.bind(a.into_inner())
|
.bind(a.into_inner())
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
@@ -41,8 +43,8 @@ pub async fn list_for_owner(
|
|||||||
}
|
}
|
||||||
ScriptOwner::Group(g) => {
|
ScriptOwner::Group(g) => {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
"SELECT service, op, interceptor_script FROM interceptors \
|
"SELECT service, op, phase, interceptor_script FROM interceptors \
|
||||||
WHERE group_id = $1 ORDER BY service, op",
|
WHERE group_id = $1 ORDER BY service, op, phase",
|
||||||
)
|
)
|
||||||
.bind(g.into_inner())
|
.bind(g.into_inner())
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
@@ -51,9 +53,10 @@ pub async fn list_for_owner(
|
|||||||
};
|
};
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(service, op, script)| InterceptorMarker {
|
.map(|(service, op, phase, script)| InterceptorMarker {
|
||||||
service,
|
service,
|
||||||
op,
|
op,
|
||||||
|
phase,
|
||||||
script,
|
script,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -66,21 +69,23 @@ pub async fn list_on_app_chain(
|
|||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
) -> Result<Vec<InterceptorMarker>, sqlx::Error> {
|
) -> Result<Vec<InterceptorMarker>, sqlx::Error> {
|
||||||
let rows: Vec<(String, String, String)> = sqlx::query_as(&format!(
|
let rows: Vec<(String, String, String, String)> = sqlx::query_as(&format!(
|
||||||
"{CHAIN_LEVELS_CTE} \
|
"{CHAIN_LEVELS_CTE} \
|
||||||
SELECT DISTINCT ON (i.service, i.op) i.service, i.op, i.interceptor_script \
|
SELECT DISTINCT ON (i.service, i.op, i.phase) \
|
||||||
|
i.service, i.op, i.phase, i.interceptor_script \
|
||||||
FROM chain c \
|
FROM chain c \
|
||||||
JOIN interceptors i ON (i.app_id = c.app_owner OR i.group_id = c.group_owner) \
|
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",
|
ORDER BY i.service, i.op, i.phase, c.depth ASC",
|
||||||
))
|
))
|
||||||
.bind(app_id.into_inner())
|
.bind(app_id.into_inner())
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(service, op, script)| InterceptorMarker {
|
.map(|(service, op, phase, script)| InterceptorMarker {
|
||||||
service,
|
service,
|
||||||
op,
|
op,
|
||||||
|
phase,
|
||||||
script,
|
script,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -218,37 +223,38 @@ pub async fn insert_interceptor_tx(
|
|||||||
owner: ScriptOwner,
|
owner: ScriptOwner,
|
||||||
service: &str,
|
service: &str,
|
||||||
op: &str,
|
op: &str,
|
||||||
|
phase: &str,
|
||||||
script: &str,
|
script: &str,
|
||||||
) -> Result<(), sqlx::Error> {
|
) -> Result<(), sqlx::Error> {
|
||||||
match owner {
|
match owner {
|
||||||
ScriptOwner::App(a) => {
|
ScriptOwner::App(a) => {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
// `phase` defaults to 'before' (0074); the conflict arbiter must
|
// The conflict arbiter includes `phase` to match the
|
||||||
// include it to match the per-(owner, service, op, phase) index.
|
// per-(owner, service, op, phase) index (§9.4 M3).
|
||||||
"INSERT INTO interceptors (app_id, service, op, interceptor_script) \
|
"INSERT INTO interceptors (app_id, service, op, phase, interceptor_script) \
|
||||||
VALUES ($1, $2, $3, $4) \
|
VALUES ($1, $2, $3, $4, $5) \
|
||||||
ON CONFLICT (app_id, service, op, phase) WHERE app_id IS NOT NULL \
|
ON CONFLICT (app_id, service, op, phase) WHERE app_id IS NOT NULL \
|
||||||
DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, updated_at = NOW()",
|
DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, updated_at = NOW()",
|
||||||
)
|
)
|
||||||
.bind(a.into_inner())
|
.bind(a.into_inner())
|
||||||
.bind(service)
|
.bind(service)
|
||||||
.bind(op)
|
.bind(op)
|
||||||
|
.bind(phase)
|
||||||
.bind(script)
|
.bind(script)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
ScriptOwner::Group(g) => {
|
ScriptOwner::Group(g) => {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
// `phase` defaults to 'before' (0074); the conflict arbiter must
|
"INSERT INTO interceptors (group_id, service, op, phase, interceptor_script) \
|
||||||
// include it to match the per-(owner, service, op, phase) index.
|
VALUES ($1, $2, $3, $4, $5) \
|
||||||
"INSERT INTO interceptors (group_id, service, op, interceptor_script) \
|
|
||||||
VALUES ($1, $2, $3, $4) \
|
|
||||||
ON CONFLICT (group_id, service, op, phase) WHERE group_id IS NOT NULL \
|
ON CONFLICT (group_id, service, op, phase) WHERE group_id IS NOT NULL \
|
||||||
DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, updated_at = NOW()",
|
DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, updated_at = NOW()",
|
||||||
)
|
)
|
||||||
.bind(g.into_inner())
|
.bind(g.into_inner())
|
||||||
.bind(service)
|
.bind(service)
|
||||||
.bind(op)
|
.bind(op)
|
||||||
|
.bind(phase)
|
||||||
.bind(script)
|
.bind(script)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -257,30 +263,37 @@ pub async fn insert_interceptor_tx(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a marker at `owner` (by `(service, op)`), in the apply transaction.
|
/// Delete a marker at `owner` (by `(service, op, phase)`), in the apply
|
||||||
/// Used by `--prune` when the manifest stops declaring it.
|
/// transaction. Used by `--prune` when the manifest stops declaring it.
|
||||||
pub async fn delete_interceptor_tx(
|
pub async fn delete_interceptor_tx(
|
||||||
tx: &mut Transaction<'_, Postgres>,
|
tx: &mut Transaction<'_, Postgres>,
|
||||||
owner: ScriptOwner,
|
owner: ScriptOwner,
|
||||||
service: &str,
|
service: &str,
|
||||||
op: &str,
|
op: &str,
|
||||||
|
phase: &str,
|
||||||
) -> Result<(), sqlx::Error> {
|
) -> Result<(), sqlx::Error> {
|
||||||
match owner {
|
match owner {
|
||||||
ScriptOwner::App(a) => {
|
ScriptOwner::App(a) => {
|
||||||
sqlx::query("DELETE FROM interceptors WHERE app_id = $1 AND service = $2 AND op = $3")
|
sqlx::query(
|
||||||
.bind(a.into_inner())
|
"DELETE FROM interceptors \
|
||||||
.bind(service)
|
WHERE app_id = $1 AND service = $2 AND op = $3 AND phase = $4",
|
||||||
.bind(op)
|
)
|
||||||
.execute(&mut **tx)
|
.bind(a.into_inner())
|
||||||
.await?;
|
.bind(service)
|
||||||
|
.bind(op)
|
||||||
|
.bind(phase)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
ScriptOwner::Group(g) => {
|
ScriptOwner::Group(g) => {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"DELETE FROM interceptors WHERE group_id = $1 AND service = $2 AND op = $3",
|
"DELETE FROM interceptors \
|
||||||
|
WHERE group_id = $1 AND service = $2 AND op = $3 AND phase = $4",
|
||||||
)
|
)
|
||||||
.bind(g.into_inner())
|
.bind(g.into_inner())
|
||||||
.bind(service)
|
.bind(service)
|
||||||
.bind(op)
|
.bind(op)
|
||||||
|
.bind(phase)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
|||||||
.iter()
|
.iter()
|
||||||
.flat_map(|i| {
|
.flat_map(|i| {
|
||||||
i.ops.iter().map(move |op| {
|
i.ops.iter().map(move |op| {
|
||||||
json!({ "service": i.service, "op": op, "script": i.script })
|
json!({ "service": i.service, "op": op, "phase": i.phase, "script": i.script })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
|
|||||||
@@ -83,12 +83,21 @@ pub struct ManifestInterceptor {
|
|||||||
pub service: String,
|
pub service: String,
|
||||||
/// The guarded operations, e.g. `["set", "delete"]`.
|
/// The guarded operations, e.g. `["set", "delete"]`.
|
||||||
pub ops: Vec<String>,
|
pub ops: Vec<String>,
|
||||||
|
/// When the hook runs relative to the op: `"before"` (default —
|
||||||
|
/// allow/deny + data transform) or `"after"` (§9.4 M3 — observe/audit;
|
||||||
|
/// cannot roll back the write).
|
||||||
|
#[serde(default = "default_interceptor_phase")]
|
||||||
|
pub phase: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_interceptor_service() -> String {
|
fn default_interceptor_service() -> String {
|
||||||
"kv".to_string()
|
"kv".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_interceptor_phase() -> String {
|
||||||
|
"before".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
impl Manifest {
|
impl Manifest {
|
||||||
/// Parse a manifest from TOML text. Enforces the app-XOR-group invariant.
|
/// Parse a manifest from TOML text. Enforces the app-XOR-group invariant.
|
||||||
pub fn parse(text: &str) -> Result<Self> {
|
pub fn parse(text: &str) -> Result<Self> {
|
||||||
|
|||||||
@@ -594,3 +594,136 @@ fn a_self_referential_interceptor_does_not_recurse() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(x.contains('1'), "the original write must persist:\n{x}");
|
assert!(x.contains('1'), "the original write must persist:\n{x}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- §9.4 M4: data transform (before) -------------------------------------
|
||||||
|
|
||||||
|
/// A before-interceptor that REWRITES the written value via `data` for the key
|
||||||
|
/// `"doc"` (allow + transform), passing everything else through unchanged.
|
||||||
|
const TRANSFORM_GUARD: &str = r#"
|
||||||
|
let op = ctx.request.body;
|
||||||
|
if op.action == "set" && op.key == "doc" {
|
||||||
|
#{ allowed: true, data: #{ redacted: true } }
|
||||||
|
} else {
|
||||||
|
#{ allowed: true }
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// A writer that sets `doc` to a raw value and reads back what was stored.
|
||||||
|
const TRANSFORM_WRITER: &str = r#"
|
||||||
|
kv::collection("c").set("doc", #{ secret: "raw" });
|
||||||
|
kv::collection("c").get("doc")
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// M4: a before-interceptor that returns `#{ allowed: true, data: ... }` rewrites
|
||||||
|
/// the value actually written — the store holds the transform, not the original.
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn a_before_interceptor_transforms_the_written_value() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let app = common::unique_slug("xf-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"), TRANSFORM_GUARD).unwrap();
|
||||||
|
fs::write(dir.path().join("scripts/writer.rhai"), TRANSFORM_WRITER).unwrap();
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("picloud.toml"),
|
||||||
|
format!(
|
||||||
|
"[app]\nslug = \"{app}\"\nname = \"XF\"\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 reads back the STORED value — it must be the transform.
|
||||||
|
let body = invoke_body(&env, &app_script_id(&env, &app, "writer"));
|
||||||
|
assert_eq!(
|
||||||
|
body,
|
||||||
|
serde_json::json!({ "redacted": true }),
|
||||||
|
"the stored value must be the interceptor's `data` transform, not the original"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- §9.4 M3: after-hooks --------------------------------------------------
|
||||||
|
|
||||||
|
/// An AFTER-interceptor on `delete` that reads the write's outcome
|
||||||
|
/// (`op.result`, the was-present bool) and DENIES when a key was actually
|
||||||
|
/// removed. It cannot roll the delete back (the write already committed) — the
|
||||||
|
/// deny only surfaces as an error to the caller.
|
||||||
|
const AFTER_DELETE_GUARD: &str = r#"
|
||||||
|
let op = ctx.request.body;
|
||||||
|
if op.action == "delete" && op.result == true {
|
||||||
|
#{ allowed: false, reason: "an existing key was deleted" }
|
||||||
|
} else {
|
||||||
|
#{ allowed: true }
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// A writer that seeds a key, deletes it (triggering the after-hook), then
|
||||||
|
/// checks whether the key survived.
|
||||||
|
const AFTER_DELETE_WRITER: &str = r#"
|
||||||
|
kv::collection("c").set("k", 1);
|
||||||
|
let after_denied = false;
|
||||||
|
try { kv::collection("c").delete("k"); } catch(e) { after_denied = true; }
|
||||||
|
let still = kv::collection("c").has("k");
|
||||||
|
#{ after_denied: after_denied, still: still }
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// M3: an after-hook runs with the write's `result` in its payload, can DENY
|
||||||
|
/// (surfacing an error to the caller), but CANNOT roll the write back. The
|
||||||
|
/// delete's `result` (was-present = true) drives the deny, and the key is gone
|
||||||
|
/// afterwards despite the error.
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn an_after_hook_sees_the_result_and_cannot_roll_back() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let app = common::unique_slug("after-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"), AFTER_DELETE_GUARD).unwrap();
|
||||||
|
fs::write(dir.path().join("scripts/writer.rhai"), AFTER_DELETE_WRITER).unwrap();
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("picloud.toml"),
|
||||||
|
format!(
|
||||||
|
"[app]\nslug = \"{app}\"\nname = \"After\"\n\n\
|
||||||
|
[[scripts]]\nname = \"guard\"\nfile = \"scripts/guard.rhai\"\n\n\
|
||||||
|
[[scripts]]\nname = \"writer\"\nfile = \"scripts/writer.rhai\"\n\n\
|
||||||
|
[[interceptors]]\nscript = \"guard\"\nops = [\"delete\"]\nphase = \"after\"\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apply", "--file"])
|
||||||
|
.arg(dir.path().join("picloud.toml"))
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let body = invoke_body(&env, &app_script_id(&env, &app, "writer"));
|
||||||
|
assert_eq!(
|
||||||
|
body,
|
||||||
|
serde_json::json!({ "after_denied": true, "still": false }),
|
||||||
|
"the after-hook must see result==true and deny, but the delete must have persisted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user