feat(interceptors): ordered before-chains + cycle guard + ctx plumbing (§9.4 M1+M2)
M1: introduces a clone-cheap InterceptorCtx { interceptors, self_engine,
limits } threaded into every SDK register fn (kv/docs/files/queue/pubsub/http)
so the non-KV services carry the hook seam (unused until M7-M11); KvHandle
collapses its three fields into one ictx.
M2: replaces the nearest-only resolver with ordered before/after chains. The
trait becomes resolve(cx, service, op) -> InterceptorChain { before, after }
(migration 0074 adds a phase column + phase-aware unique indexes). The before
-chain runs ancestor->app (depth DESC) so a group compliance guard can't be
bypassed by a descendant; single-marker behavior is byte-identical to before.
An identity cycle guard (thread-local visited-set keyed by script_id) denies a
detected cycle, alongside the existing binary re-entrancy break. Fail-closed
verdict preserved (allow only on #{ allowed: true }; a Dangling entry or a
missing engine back-ref denies); app_id still derives from cx.app_id only.
after-chains resolve but stay unused until M3. Pinned by two new interceptor
journeys (ancestor->app chain ordering; self-referential no-recurse).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ use uuid::Uuid;
|
||||
use super::bridge::{
|
||||
block_on, dynamic_to_json_capped, json_to_dynamic, MAX_JSON_MATERIALIZE_BYTES,
|
||||
};
|
||||
use super::interceptor::InterceptorCtx;
|
||||
|
||||
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
||||
/// plus an owned string).
|
||||
@@ -38,6 +39,10 @@ pub struct DocsHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn DocsService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
// §9.4 M1 plumbing: carried so `create`/`update`/`delete` can run a before/
|
||||
// after hook in a later milestone (M8). Unused until then.
|
||||
#[allow(dead_code)]
|
||||
ictx: InterceptorCtx,
|
||||
}
|
||||
|
||||
/// §11.6 shared-collection handle, returned by `docs::shared_collection(name)`.
|
||||
@@ -49,9 +54,17 @@ pub struct GroupDocsHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn GroupDocsService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
// §9.4 M1 plumbing (see `DocsHandle`). Unused until M8.
|
||||
#[allow(dead_code)]
|
||||
ictx: InterceptorCtx,
|
||||
}
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
pub(super) fn register(
|
||||
engine: &mut RhaiEngine,
|
||||
services: &Services,
|
||||
cx: Arc<SdkCallCx>,
|
||||
ictx: InterceptorCtx,
|
||||
) {
|
||||
let docs_service = services.docs.clone();
|
||||
let group_docs_service = services.group_docs.clone();
|
||||
|
||||
@@ -59,6 +72,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
{
|
||||
let docs_service = docs_service.clone();
|
||||
let cx = cx.clone();
|
||||
let ictx = ictx.clone();
|
||||
module.set_native_fn(
|
||||
"collection",
|
||||
move |name: &str| -> Result<DocsHandle, Box<EvalAltResult>> {
|
||||
@@ -69,6 +83,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
collection: name.to_string(),
|
||||
service: docs_service.clone(),
|
||||
cx: cx.clone(),
|
||||
ictx: ictx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -76,6 +91,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
{
|
||||
let group_docs_service = group_docs_service.clone();
|
||||
let cx = cx.clone();
|
||||
let ictx = ictx.clone();
|
||||
module.set_native_fn(
|
||||
"shared_collection",
|
||||
move |name: &str| -> Result<GroupDocsHandle, Box<EvalAltResult>> {
|
||||
@@ -86,6 +102,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
collection: name.to_string(),
|
||||
service: group_docs_service.clone(),
|
||||
cx: cx.clone(),
|
||||
ictx: ictx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::bridge::block_on;
|
||||
use super::interceptor::InterceptorCtx;
|
||||
use picloud_shared::{
|
||||
FileMeta, FileUpdate, FilesService, GroupFilesService, NewFile, SdkCallCx, Services,
|
||||
};
|
||||
@@ -36,6 +37,10 @@ pub struct FilesHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn FilesService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
// §9.4 M1 plumbing: carried so `create`/`update`/`delete` can run a before/
|
||||
// after hook in a later milestone (M9). Unused until then.
|
||||
#[allow(dead_code)]
|
||||
ictx: InterceptorCtx,
|
||||
}
|
||||
|
||||
/// §11.6 shared-collection handle, returned by `files::shared_collection(name)`.
|
||||
@@ -47,9 +52,17 @@ pub struct GroupFilesHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn GroupFilesService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
// §9.4 M1 plumbing (see `FilesHandle`). Unused until M9.
|
||||
#[allow(dead_code)]
|
||||
ictx: InterceptorCtx,
|
||||
}
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
pub(super) fn register(
|
||||
engine: &mut RhaiEngine,
|
||||
services: &Services,
|
||||
cx: Arc<SdkCallCx>,
|
||||
ictx: InterceptorCtx,
|
||||
) {
|
||||
let files_service = services.files.clone();
|
||||
let group_files_service = services.group_files.clone();
|
||||
|
||||
@@ -57,6 +70,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
{
|
||||
let files_service = files_service.clone();
|
||||
let cx = cx.clone();
|
||||
let ictx = ictx.clone();
|
||||
module.set_native_fn(
|
||||
"collection",
|
||||
move |name: &str| -> Result<FilesHandle, Box<EvalAltResult>> {
|
||||
@@ -67,6 +81,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
collection: name.to_string(),
|
||||
service: files_service.clone(),
|
||||
cx: cx.clone(),
|
||||
ictx: ictx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -74,6 +89,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
{
|
||||
let group_files_service = group_files_service.clone();
|
||||
let cx = cx.clone();
|
||||
let ictx = ictx.clone();
|
||||
module.set_native_fn(
|
||||
"shared_collection",
|
||||
move |name: &str| -> Result<GroupFilesHandle, Box<EvalAltResult>> {
|
||||
@@ -84,6 +100,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
collection: name.to_string(),
|
||||
service: group_files_service.clone(),
|
||||
cx: cx.clone(),
|
||||
ictx: ictx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
@@ -38,6 +38,7 @@ use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
use super::bridge::{
|
||||
block_on, dynamic_to_json_capped, json_to_dynamic, runtime_err, MAX_JSON_MATERIALIZE_BYTES,
|
||||
};
|
||||
use super::interceptor::InterceptorCtx;
|
||||
|
||||
/// Bridge-side defaults (the service clamps server-side too). The
|
||||
/// `MAX_*` ceilings stay `i64` because they're compared against the
|
||||
@@ -50,7 +51,16 @@ const MAX_REDIRECTS: i64 = 10;
|
||||
|
||||
const ALLOWED_OPT_KEYS: [&str; 4] = ["headers", "timeout_ms", "follow_redirects", "max_redirects"];
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
// `http` registers its verbs as module native fns (no handle struct), so there
|
||||
// is nowhere to stash the interceptor ctx yet. The param is threaded uniformly
|
||||
// from `register_all` (§9.4 M1) and consumed by M11, which adds the before/after
|
||||
// hook around the outbound-request closures. Prefixed `_` until then.
|
||||
pub(super) fn register(
|
||||
engine: &mut RhaiEngine,
|
||||
services: &Services,
|
||||
cx: Arc<SdkCallCx>,
|
||||
_ictx: InterceptorCtx,
|
||||
) {
|
||||
let svc = services.http.clone();
|
||||
let mut module = Module::new();
|
||||
|
||||
|
||||
@@ -22,10 +22,10 @@
|
||||
//! thread-local guard, set for the duration of the interceptor's synchronous
|
||||
//! execution, makes any nested op the interceptor performs bypass interception.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{InterceptorResolution, InterceptorService, SdkCallCx};
|
||||
use picloud_shared::{InterceptorEntry, InterceptorService, ScriptId, SdkCallCx};
|
||||
use rhai::EvalAltResult;
|
||||
use serde_json::{json, Value as Json};
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
@@ -35,12 +35,31 @@ use crate::sandbox::Limits;
|
||||
use crate::sdk::bridge::runtime_err;
|
||||
use crate::sdk::invoke::run_resolved_blocking;
|
||||
|
||||
/// Clone-cheap bundle of the §9.4 interceptor dependencies threaded into every
|
||||
/// SDK service `register` fn (M1). Carries the resolver, the `invoke()` re-entry
|
||||
/// engine back-reference, and the sandbox limits — everything `run_before`
|
||||
/// (and, from M3, `run_after`) needs. A few `Arc`s plus a small `Limits` copy.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct InterceptorCtx {
|
||||
pub interceptors: Arc<dyn InterceptorService>,
|
||||
pub self_engine: Option<Arc<Engine>>,
|
||||
pub limits: Limits,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Re-entrancy depth: `> 0` while an interceptor script (and anything it
|
||||
/// synchronously invokes) is executing on this thread. The whole re-entry
|
||||
/// runs synchronously in the caller's `spawn_blocking` thread (same model
|
||||
/// as `invoke()`), so a thread-local is sufficient and correct.
|
||||
static IN_INTERCEPTOR: Cell<u32> = const { Cell::new(0) };
|
||||
|
||||
/// Identity cycle guard (M2): the `script_id`s of the interceptors currently
|
||||
/// on the synchronous run stack. A chain that would run an interceptor whose
|
||||
/// script is already executing is a cycle — we DENY rather than recurse.
|
||||
/// Precise (keyed by script id) and independent of the binary
|
||||
/// `IN_INTERCEPTOR` counter, which suppresses interception of an
|
||||
/// interceptor's OWN nested writes but does not track identity.
|
||||
static VISITED: RefCell<Vec<ScriptId>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
fn currently_in_interceptor() -> bool {
|
||||
@@ -62,17 +81,37 @@ impl Drop for ReentryGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the before-op interceptor for `(service, op)` if one is registered.
|
||||
/// Returns `Ok(())` to allow the operation (un-hooked, or the interceptor
|
||||
/// explicitly 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).
|
||||
/// RAII for the identity cycle guard: push a `script_id` on enter, pop on drop
|
||||
/// (including the `?`/panic-unwind paths), so the visited set exactly tracks the
|
||||
/// interceptors on the current synchronous run stack.
|
||||
struct CycleGuard;
|
||||
impl CycleGuard {
|
||||
fn enter(id: ScriptId) -> Self {
|
||||
VISITED.with(|v| v.borrow_mut().push(id));
|
||||
Self
|
||||
}
|
||||
fn contains(id: ScriptId) -> bool {
|
||||
VISITED.with(|v| v.borrow().contains(&id))
|
||||
}
|
||||
}
|
||||
impl Drop for CycleGuard {
|
||||
fn drop(&mut self) {
|
||||
VISITED.with(|v| {
|
||||
v.borrow_mut().pop();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn run_before(
|
||||
interceptors: &Arc<dyn InterceptorService>,
|
||||
self_engine: Option<&Arc<Engine>>,
|
||||
ictx: &InterceptorCtx,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
limits: Limits,
|
||||
service: &'static str,
|
||||
op: &'static str,
|
||||
collection: &str,
|
||||
@@ -89,47 +128,21 @@ pub(super) fn run_before(
|
||||
let handle = TokioHandle::try_current()
|
||||
.map_err(|e| runtime_err(&format!("{service} interceptor: no tokio runtime: {e}")))?;
|
||||
|
||||
// (1) Resolve the nearest interceptor, sealed to its declaring owner.
|
||||
let resolution = {
|
||||
let interceptors = interceptors.clone();
|
||||
// (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_before(&cx, service, op).await })
|
||||
.block_on(async move { interceptors.resolve(&cx, service, op).await })
|
||||
.map_err(|e| runtime_err(&format!("{service}::{op} interceptor resolve: {e}")))?
|
||||
};
|
||||
let resolved = match resolution {
|
||||
// Un-hooked: the ONLY path that allows without running anything.
|
||||
InterceptorResolution::None => return Ok(()),
|
||||
// A guard is declared but its script is missing/disabled — fail closed.
|
||||
InterceptorResolution::Dangling(name) => {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} denied: interceptor script `{name}` is missing or disabled"
|
||||
)));
|
||||
}
|
||||
InterceptorResolution::Run(r) => r,
|
||||
};
|
||||
|
||||
// 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) = self_engine 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 > limits.trigger_depth_max {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} interceptor `{}`: depth limit exceeded (max {})",
|
||||
resolved.name, limits.trigger_depth_max
|
||||
)));
|
||||
// Un-hooked: the ONLY path that allows without running anything.
|
||||
if chain.before.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// (2) Run the sealed script with the operation context as its request body,
|
||||
// under the re-entrancy guard so its own writes bypass interception.
|
||||
let payload = json!({
|
||||
"service": service,
|
||||
"action": op,
|
||||
@@ -139,35 +152,88 @@ pub(super) fn run_before(
|
||||
"caller_script_id": cx.script_id.to_string(),
|
||||
"caller_execution_id": cx.execution_id.to_string(),
|
||||
});
|
||||
let ret = {
|
||||
let _guard = ReentryGuard::enter();
|
||||
run_resolved_blocking(
|
||||
self_engine,
|
||||
cx,
|
||||
&resolved,
|
||||
payload,
|
||||
&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.
|
||||
if let Json::Object(m) = &ret {
|
||||
if m.get("allowed") == Some(&Json::Bool(true)) {
|
||||
return Ok(());
|
||||
// (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) {
|
||||
return Err(runtime_err(&format!(
|
||||
"{service}::{op} denied: interceptor cycle detected: `{}`",
|
||||
resolved.name
|
||||
)));
|
||||
}
|
||||
|
||||
// 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
|
||||
)));
|
||||
}
|
||||
}
|
||||
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
|
||||
)));
|
||||
}
|
||||
Err(runtime_err(&format!(
|
||||
"{service}::{op} denied by interceptor `{}`: no allow verdict returned",
|
||||
resolved.name
|
||||
)))
|
||||
|
||||
// Every before-interceptor allowed.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -30,26 +30,23 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{GroupKvService, InterceptorService, KvService, SdkCallCx, Services};
|
||||
use picloud_shared::{GroupKvService, KvService, SdkCallCx, Services};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
|
||||
use super::bridge::{
|
||||
block_on, dynamic_to_json_capped, json_to_dynamic, JsonSizeError, MAX_JSON_MATERIALIZE_BYTES,
|
||||
};
|
||||
use crate::engine::Engine;
|
||||
use crate::sandbox::Limits;
|
||||
use super::interceptor::InterceptorCtx;
|
||||
|
||||
/// 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
|
||||
/// owned string). Carries the §9.4 interceptor ctx 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>,
|
||||
self_engine: Option<Arc<Engine>>,
|
||||
limits: Limits,
|
||||
ictx: InterceptorCtx,
|
||||
}
|
||||
|
||||
/// §11.6 shared-collection handle, returned by `kv::shared_collection(name)`. A distinct
|
||||
@@ -57,28 +54,24 @@ pub struct KvHandle {
|
||||
/// collections never grow triggers) and a script's choice of private-vs-shared
|
||||
/// scope is explicit. Routes through the `GroupKvService`, which resolves the
|
||||
/// owning group from `cx.app_id` (the script never names a group). Carries the
|
||||
/// §9.4 interceptor deps too so a `(kv, set/delete)` guard covers shared-
|
||||
/// §9.4 interceptor ctx too so a `(kv, set/delete)` guard covers shared-
|
||||
/// collection writes — otherwise the shared handle would be a silent bypass.
|
||||
#[derive(Clone)]
|
||||
pub struct GroupKvHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn GroupKvService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
interceptors: Arc<dyn InterceptorService>,
|
||||
self_engine: Option<Arc<Engine>>,
|
||||
limits: Limits,
|
||||
ictx: InterceptorCtx,
|
||||
}
|
||||
|
||||
pub(super) fn register(
|
||||
engine: &mut RhaiEngine,
|
||||
services: &Services,
|
||||
cx: Arc<SdkCallCx>,
|
||||
limits: Limits,
|
||||
self_engine: Option<Arc<Engine>>,
|
||||
ictx: InterceptorCtx,
|
||||
) {
|
||||
let kv_service = services.kv.clone();
|
||||
let group_kv_service = services.group_kv.clone();
|
||||
let interceptors = services.interceptors.clone();
|
||||
|
||||
// `kv::collection(name)` / `kv::shared_collection(name)` — both constructors live in
|
||||
// the `kv` static module so the script-visible calls are `kv::collection`
|
||||
@@ -87,8 +80,7 @@ pub(super) fn register(
|
||||
{
|
||||
let kv_service = kv_service.clone();
|
||||
let cx = cx.clone();
|
||||
let interceptors = interceptors.clone();
|
||||
let self_engine = self_engine.clone();
|
||||
let ictx = ictx.clone();
|
||||
module.set_native_fn(
|
||||
"collection",
|
||||
move |name: &str| -> Result<KvHandle, Box<EvalAltResult>> {
|
||||
@@ -99,9 +91,7 @@ pub(super) fn register(
|
||||
collection: name.to_string(),
|
||||
service: kv_service.clone(),
|
||||
cx: cx.clone(),
|
||||
interceptors: interceptors.clone(),
|
||||
self_engine: self_engine.clone(),
|
||||
limits,
|
||||
ictx: ictx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -109,8 +99,7 @@ pub(super) fn register(
|
||||
{
|
||||
let group_kv_service = group_kv_service.clone();
|
||||
let cx = cx.clone();
|
||||
let interceptors = interceptors.clone();
|
||||
let self_engine = self_engine.clone();
|
||||
let ictx = ictx.clone();
|
||||
module.set_native_fn(
|
||||
"shared_collection",
|
||||
move |name: &str| -> Result<GroupKvHandle, Box<EvalAltResult>> {
|
||||
@@ -121,9 +110,7 @@ pub(super) fn register(
|
||||
collection: name.to_string(),
|
||||
service: group_kv_service.clone(),
|
||||
cx: cx.clone(),
|
||||
interceptors: interceptors.clone(),
|
||||
self_engine: self_engine.clone(),
|
||||
limits,
|
||||
ictx: ictx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -188,10 +175,8 @@ fn register_set(engine: &mut RhaiEngine) {
|
||||
// §9.4 before-op interceptor (allow/deny). A denial errors here and
|
||||
// the write below never runs.
|
||||
super::interceptor::run_before(
|
||||
&handle.interceptors,
|
||||
handle.self_engine.as_ref(),
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
handle.limits,
|
||||
"kv",
|
||||
"set",
|
||||
&handle.collection,
|
||||
@@ -244,10 +229,8 @@ fn register_delete(engine: &mut RhaiEngine) {
|
||||
"delete",
|
||||
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
super::interceptor::run_before(
|
||||
&handle.interceptors,
|
||||
handle.self_engine.as_ref(),
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
handle.limits,
|
||||
"kv",
|
||||
"delete",
|
||||
&handle.collection,
|
||||
@@ -271,10 +254,8 @@ fn group_run_before(
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> Result<(), Box<EvalAltResult>> {
|
||||
super::interceptor::run_before(
|
||||
&handle.interceptors,
|
||||
handle.self_engine.as_ref(),
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
handle.limits,
|
||||
"kv",
|
||||
op,
|
||||
&handle.collection,
|
||||
|
||||
@@ -60,13 +60,21 @@ pub fn register_all(
|
||||
limits: Limits,
|
||||
self_engine: Option<Arc<Engine>>,
|
||||
) {
|
||||
kv::register(engine, services, cx.clone(), limits, self_engine.clone());
|
||||
docs::register(engine, services, cx.clone());
|
||||
// §9.4 M1: build the interceptor ctx once and thread it into every SDK
|
||||
// service that has (or will gain) a before/after hook. Only `kv` runs a
|
||||
// hook today; docs/files/queue/pubsub/http carry it for M7–M11.
|
||||
let ictx = interceptor::InterceptorCtx {
|
||||
interceptors: services.interceptors.clone(),
|
||||
self_engine: self_engine.clone(),
|
||||
limits,
|
||||
};
|
||||
kv::register(engine, services, cx.clone(), ictx.clone());
|
||||
docs::register(engine, services, cx.clone(), ictx.clone());
|
||||
dead_letters::register(engine, services, cx.clone());
|
||||
http::register(engine, services, cx.clone());
|
||||
files::register(engine, services, cx.clone());
|
||||
pubsub::register(engine, services, cx.clone());
|
||||
queue::register(engine, services, cx.clone());
|
||||
http::register(engine, services, cx.clone(), ictx.clone());
|
||||
files::register(engine, services, cx.clone(), ictx.clone());
|
||||
pubsub::register(engine, services, cx.clone(), ictx.clone());
|
||||
queue::register(engine, services, cx.clone(), ictx);
|
||||
retry::register(engine, services, cx.clone());
|
||||
secrets::register(engine, services, cx.clone());
|
||||
vars::register(engine, services, cx.clone());
|
||||
|
||||
@@ -25,8 +25,14 @@ use serde_json::Value as Json;
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
|
||||
use super::bridge::{block_on, MAX_JSON_MATERIALIZE_BYTES};
|
||||
use super::interceptor::InterceptorCtx;
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
pub(super) fn register(
|
||||
engine: &mut RhaiEngine,
|
||||
services: &Services,
|
||||
cx: Arc<SdkCallCx>,
|
||||
ictx: InterceptorCtx,
|
||||
) {
|
||||
let svc = services.pubsub.clone();
|
||||
let mut module = Module::new();
|
||||
{
|
||||
@@ -77,6 +83,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
{
|
||||
let group_svc = services.group_pubsub.clone();
|
||||
let cx = cx.clone();
|
||||
let ictx = ictx.clone();
|
||||
module.set_native_fn(
|
||||
"shared_topic",
|
||||
move |name: &str| -> Result<GroupTopicHandle, Box<EvalAltResult>> {
|
||||
@@ -87,6 +94,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
namespace: name.to_string(),
|
||||
service: group_svc.clone(),
|
||||
cx: cx.clone(),
|
||||
ictx: ictx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -104,6 +112,10 @@ pub struct GroupTopicHandle {
|
||||
namespace: String,
|
||||
service: Arc<dyn picloud_shared::GroupPubsubService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
// §9.4 M1 plumbing: carried so `publish` can run a before/after hook in a
|
||||
// later milestone (M11). Unused until then.
|
||||
#[allow(dead_code)]
|
||||
ictx: InterceptorCtx,
|
||||
}
|
||||
|
||||
fn shared_publish(
|
||||
|
||||
@@ -28,8 +28,14 @@ use serde_json::Value as Json;
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
|
||||
use super::bridge::MAX_JSON_MATERIALIZE_BYTES;
|
||||
use super::interceptor::InterceptorCtx;
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
pub(super) fn register(
|
||||
engine: &mut RhaiEngine,
|
||||
services: &Services,
|
||||
cx: Arc<SdkCallCx>,
|
||||
ictx: InterceptorCtx,
|
||||
) {
|
||||
let svc = services.queue.clone();
|
||||
let mut module = Module::new();
|
||||
|
||||
@@ -100,6 +106,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
{
|
||||
let group_svc = services.group_queue.clone();
|
||||
let cx = cx.clone();
|
||||
let ictx = ictx.clone();
|
||||
module.set_native_fn(
|
||||
"shared_collection",
|
||||
move |name: &str| -> Result<GroupQueueHandle, Box<EvalAltResult>> {
|
||||
@@ -110,6 +117,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
collection: name.to_string(),
|
||||
service: group_svc.clone(),
|
||||
cx: cx.clone(),
|
||||
ictx: ictx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -126,6 +134,10 @@ pub struct GroupQueueHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn GroupQueueService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
// §9.4 M1 plumbing: carried so `enqueue` can run a before/after hook in a
|
||||
// later milestone (M10). Unused until then.
|
||||
#[allow(dead_code)]
|
||||
ictx: InterceptorCtx,
|
||||
}
|
||||
|
||||
fn group_enqueue(
|
||||
|
||||
20
crates/manager-core/migrations/0074_interceptor_phase.sql
Normal file
20
crates/manager-core/migrations/0074_interceptor_phase.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- §9.4 Service Interceptors M2 — before + after phases.
|
||||
--
|
||||
-- The 0073 marker guarded only the BEFORE-op allow/deny hook. M2 introduces an
|
||||
-- explicit `phase` so an owner can declare a `before` AND an `after` interceptor
|
||||
-- for one `(service, op)`. Existing markers default to `phase='before'` — the
|
||||
-- 0073 behaviour is unchanged. The partial-unique indexes gain `phase` so the
|
||||
-- two phases are distinct rows per owner (one before + one after each).
|
||||
|
||||
ALTER TABLE interceptors
|
||||
ADD COLUMN phase TEXT NOT NULL DEFAULT 'before'
|
||||
CHECK (phase IN ('before', 'after'));
|
||||
|
||||
-- Recreate the per-owner uniqueness to be per (owner, service, op, PHASE).
|
||||
DROP INDEX interceptors_group_uidx;
|
||||
DROP INDEX interceptors_app_uidx;
|
||||
|
||||
CREATE UNIQUE INDEX interceptors_group_uidx
|
||||
ON interceptors (group_id, service, op, phase) WHERE group_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX interceptors_app_uidx
|
||||
ON interceptors (app_id, service, op, phase) WHERE app_id IS NOT NULL;
|
||||
@@ -5,7 +5,7 @@
|
||||
//! interceptor's behaviour is a normal script resolved + run through `invoke()`
|
||||
//! re-entry. This module holds the read + transactional-write helpers (free
|
||||
//! functions over `&PgPool` / `&mut Transaction`, keyed by [`ScriptOwner`]),
|
||||
//! plus the runtime [`resolve_before`] chain walk (nearest-owner-wins).
|
||||
//! plus the runtime [`resolve_chain`] walk (all markers on the app's chain).
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AppId, ScriptOwner};
|
||||
@@ -117,55 +117,77 @@ impl SealedInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the interceptor guarding `(service, op)` for a calling app — the
|
||||
/// nearest MARKER on the app's chain (app, then nearest ancestor group), with
|
||||
/// its script **sealed to the owner that declared the marker** (a descendant
|
||||
/// app cannot shadow it with a same-named local script). `None` when un-hooked.
|
||||
/// The ordered before + after interceptors guarding a `(service, op)` for a
|
||||
/// calling app, each sealed to its declaring owner (see [`SealedInterceptor`]).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SealedChain {
|
||||
/// Ordered ancestor→app (outermost/group guard first).
|
||||
pub before: Vec<SealedInterceptor>,
|
||||
/// Ordered app→ancestor.
|
||||
pub after: Vec<SealedInterceptor>,
|
||||
}
|
||||
|
||||
/// Resolve ALL interceptor markers guarding `(service, op)` for a calling app —
|
||||
/// every marker visible on the app's chain (app + each ancestor group), each
|
||||
/// with its script **sealed to the owner that declared the marker** (a
|
||||
/// descendant app cannot shadow it with a same-named local script). Split by
|
||||
/// `phase` and ordered: `before` ancestor→app (an outer/group guard runs first,
|
||||
/// so a group denial cannot be bypassed by a descendant), `after` app→ancestor.
|
||||
/// **This chain walk is the resolution boundary** — a sibling-subtree app never
|
||||
/// sees another subtree's interceptor. The script join is `LEFT` so a marker
|
||||
/// whose script is missing/disabled still returns a row (with `script: None`)
|
||||
/// rather than silently falling through to a farther, weaker marker.
|
||||
pub async fn resolve_before(
|
||||
/// whose script is missing/disabled still returns an entry (with `script:
|
||||
/// None`) the caller fails closed on, rather than being silently dropped.
|
||||
///
|
||||
/// Per the `(owner, service, op, phase)` partial-unique index there is at most
|
||||
/// one marker per owner per phase, so no per-owner dedup is needed.
|
||||
pub async fn resolve_chain(
|
||||
pool: &PgPool,
|
||||
app_id: AppId,
|
||||
service: &str,
|
||||
op: &str,
|
||||
) -> Result<Option<SealedInterceptor>, sqlx::Error> {
|
||||
) -> Result<SealedChain, sqlx::Error> {
|
||||
#[allow(clippy::type_complexity)]
|
||||
let row: Option<(
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
String,
|
||||
Option<Uuid>,
|
||||
Option<String>,
|
||||
Option<DateTime<Utc>>,
|
||||
let rows: Vec<(
|
||||
Option<Uuid>, // marker_app
|
||||
Option<Uuid>, // marker_group
|
||||
String, // script_name
|
||||
String, // phase
|
||||
i32, // depth (0 = app, larger = ancestor)
|
||||
Option<Uuid>, // script id
|
||||
Option<String>, // script source
|
||||
Option<DateTime<Utc>>, // script updated_at
|
||||
)> = sqlx::query_as(&format!(
|
||||
"{CHAIN_LEVELS_CTE}, \
|
||||
nearest AS ( \
|
||||
markers AS ( \
|
||||
SELECT i.interceptor_script AS script_name, \
|
||||
i.app_id AS marker_app, i.group_id AS marker_group, c.depth AS depth \
|
||||
i.app_id AS marker_app, i.group_id AS marker_group, \
|
||||
i.phase AS phase, c.depth AS depth \
|
||||
FROM chain c \
|
||||
JOIN interceptors i ON (i.app_id = c.app_owner OR i.group_id = c.group_owner) \
|
||||
WHERE i.service = $2 AND i.op = $3 \
|
||||
ORDER BY c.depth ASC LIMIT 1 \
|
||||
) \
|
||||
SELECT n.marker_app, n.marker_group, n.script_name, \
|
||||
SELECT m.marker_app, m.marker_group, m.script_name, m.phase, m.depth, \
|
||||
s.id, s.source, s.updated_at \
|
||||
FROM nearest n \
|
||||
FROM markers m \
|
||||
LEFT JOIN scripts s ON ( \
|
||||
(n.marker_app IS NOT NULL AND s.app_id = n.marker_app \
|
||||
AND s.name = n.script_name AND s.enabled) \
|
||||
OR (n.marker_group IS NOT NULL AND s.group_id = n.marker_group \
|
||||
AND s.name = n.script_name AND s.enabled) \
|
||||
(m.marker_app IS NOT NULL AND s.app_id = m.marker_app \
|
||||
AND s.name = m.script_name AND s.enabled) \
|
||||
OR (m.marker_group IS NOT NULL AND s.group_id = m.marker_group \
|
||||
AND s.name = m.script_name AND s.enabled) \
|
||||
)",
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.bind(service)
|
||||
.bind(op)
|
||||
.fetch_optional(pool)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(row.map(
|
||||
|(marker_app, marker_group, script_name, sid, src, upd)| SealedInterceptor {
|
||||
|
||||
// Split by phase and order: `before` ancestor→app = depth DESC (larger
|
||||
// depth = ancestor runs first); `after` app→ancestor = depth ASC.
|
||||
let mut before: Vec<(i32, SealedInterceptor)> = Vec::new();
|
||||
let mut after: Vec<(i32, SealedInterceptor)> = Vec::new();
|
||||
for (marker_app, marker_group, script_name, phase, depth, sid, src, upd) in rows {
|
||||
let sealed = SealedInterceptor {
|
||||
marker_app,
|
||||
marker_group,
|
||||
script_name,
|
||||
@@ -173,8 +195,19 @@ pub async fn resolve_before(
|
||||
(Some(id), Some(source), Some(updated_at)) => Some((id, source, updated_at)),
|
||||
_ => None,
|
||||
},
|
||||
},
|
||||
))
|
||||
};
|
||||
if phase == "after" {
|
||||
after.push((depth, sealed));
|
||||
} else {
|
||||
before.push((depth, sealed));
|
||||
}
|
||||
}
|
||||
before.sort_by(|a, b| b.0.cmp(&a.0)); // depth DESC → ancestor first
|
||||
after.sort_by(|a, b| a.0.cmp(&b.0)); // depth ASC → app first
|
||||
Ok(SealedChain {
|
||||
before: before.into_iter().map(|(_, s)| s).collect(),
|
||||
after: after.into_iter().map(|(_, s)| s).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Upsert a marker at `owner` in the apply transaction. A re-apply that changes
|
||||
@@ -190,9 +223,11 @@ pub async fn insert_interceptor_tx(
|
||||
match owner {
|
||||
ScriptOwner::App(a) => {
|
||||
sqlx::query(
|
||||
// `phase` defaults to 'before' (0074); the conflict arbiter must
|
||||
// include it to match the per-(owner, service, op, phase) index.
|
||||
"INSERT INTO interceptors (app_id, service, op, interceptor_script) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
ON CONFLICT (app_id, service, op) 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()",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
@@ -204,9 +239,11 @@ pub async fn insert_interceptor_tx(
|
||||
}
|
||||
ScriptOwner::Group(g) => {
|
||||
sqlx::query(
|
||||
// `phase` defaults to 'before' (0074); the conflict arbiter must
|
||||
// include it to match the per-(owner, service, op, phase) index.
|
||||
"INSERT INTO interceptors (group_id, service, op, interceptor_script) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
ON CONFLICT (group_id, service, op) 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()",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
InterceptorResolution, InterceptorService, ResolvedScript, ScriptId, SdkCallCx,
|
||||
InterceptorChain, InterceptorEntry, InterceptorService, ResolvedInterceptor, ResolvedScript,
|
||||
ScriptId, SdkCallCx,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::interceptor_repo::SealedInterceptor;
|
||||
|
||||
pub struct InterceptorServiceImpl {
|
||||
pool: PgPool,
|
||||
}
|
||||
@@ -22,38 +25,55 @@ impl InterceptorServiceImpl {
|
||||
}
|
||||
}
|
||||
|
||||
impl InterceptorServiceImpl {
|
||||
/// Map one sealed marker to a chain entry: a runnable [`ResolvedScript`]
|
||||
/// (sealed to the declaring owner — the lexical `import` origin, §5.5) or a
|
||||
/// [`Dangling`](InterceptorEntry::Dangling) entry when the marker's script
|
||||
/// is missing/disabled. The execution boundary is always the CALLER's app
|
||||
/// (`cx.app_id`); only the `owner` is the sealing owner.
|
||||
fn map_entry(app_id: picloud_shared::AppId, sealed: SealedInterceptor) -> InterceptorEntry {
|
||||
let owner = sealed.sealing_owner();
|
||||
match sealed.script {
|
||||
Some((sid, source, updated_at)) => InterceptorEntry::Run(ResolvedInterceptor {
|
||||
script: Box::new(ResolvedScript {
|
||||
script_id: ScriptId::from(sid),
|
||||
app_id,
|
||||
owner: Some(owner),
|
||||
source,
|
||||
updated_at,
|
||||
name: sealed.script_name,
|
||||
}),
|
||||
timeout_ms: None,
|
||||
}),
|
||||
None => InterceptorEntry::Dangling(sealed.script_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl InterceptorService for InterceptorServiceImpl {
|
||||
async fn resolve_before(
|
||||
async fn resolve(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
service: &str,
|
||||
op: &str,
|
||||
) -> Result<InterceptorResolution, String> {
|
||||
) -> Result<InterceptorChain, String> {
|
||||
// `app_id` derives from `cx` (never a script arg) — the isolation
|
||||
// boundary; the chain walk then scopes resolution to this app's subtree.
|
||||
let sealed = crate::interceptor_repo::resolve_before(&self.pool, cx.app_id, service, op)
|
||||
let chain = crate::interceptor_repo::resolve_chain(&self.pool, cx.app_id, service, op)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let Some(sealed) = sealed else {
|
||||
return Ok(InterceptorResolution::None);
|
||||
};
|
||||
// A marker exists but its sealed script is missing/disabled → dangling,
|
||||
// the executor fails closed (denies) rather than silently allowing.
|
||||
let Some((sid, source, updated_at)) = sealed.script.clone() else {
|
||||
return Ok(InterceptorResolution::Dangling(sealed.script_name));
|
||||
};
|
||||
// The execution boundary is always the CALLER's app; the `owner` is the
|
||||
// sealing (declaring) owner, the lexical origin for the interceptor's
|
||||
// own `import`s (§5.5) — a group marker's script runs its imports from
|
||||
// the group.
|
||||
Ok(InterceptorResolution::Run(Box::new(ResolvedScript {
|
||||
script_id: ScriptId::from(sid),
|
||||
app_id: cx.app_id,
|
||||
owner: Some(sealed.sealing_owner()),
|
||||
source,
|
||||
updated_at,
|
||||
name: sealed.script_name,
|
||||
})))
|
||||
Ok(InterceptorChain {
|
||||
before: chain
|
||||
.before
|
||||
.into_iter()
|
||||
.map(|s| Self::map_entry(cx.app_id, s))
|
||||
.collect(),
|
||||
after: chain
|
||||
.after
|
||||
.into_iter()
|
||||
.map(|s| Self::map_entry(cx.app_id, s))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +317,7 @@ table: interceptors
|
||||
interceptor_script: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
phase: text NOT NULL default='before'::text
|
||||
|
||||
table: kv_entries
|
||||
app_id: uuid NOT NULL
|
||||
@@ -675,9 +676,9 @@ indexes on groups:
|
||||
|
||||
indexes on interceptors:
|
||||
interceptors_app_id_idx: public.interceptors USING btree (app_id) WHERE (app_id IS NOT NULL)
|
||||
interceptors_app_uidx: public.interceptors USING btree (app_id, service, op) WHERE (app_id IS NOT NULL)
|
||||
interceptors_app_uidx: public.interceptors USING btree (app_id, service, op, phase) WHERE (app_id IS NOT NULL)
|
||||
interceptors_group_id_idx: public.interceptors USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||
interceptors_group_uidx: public.interceptors USING btree (group_id, service, op) WHERE (group_id IS NOT NULL)
|
||||
interceptors_group_uidx: public.interceptors USING btree (group_id, service, op, phase) WHERE (group_id IS NOT NULL)
|
||||
interceptors_pkey: public.interceptors USING btree (id)
|
||||
|
||||
indexes on kv_entries:
|
||||
@@ -952,6 +953,7 @@ constraints on groups:
|
||||
|
||||
constraints on interceptors:
|
||||
[CHECK] interceptors_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[CHECK] interceptors_phase_check: CHECK ((phase = ANY (ARRAY['before'::text, 'after'::text])))
|
||||
[FOREIGN KEY] interceptors_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] interceptors_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] interceptors_pkey: PRIMARY KEY (id)
|
||||
@@ -1145,3 +1147,4 @@ constraints on workflows:
|
||||
0071: workflows
|
||||
0072: execution source workflow
|
||||
0073: interceptors
|
||||
0074: interceptor phase
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
//! 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).
|
||||
//! A group's interceptor is inherited by a descendant app; from §9.4 M2 the
|
||||
//! `before` markers on the chain run as an ORDERED CHAIN (ancestor→app), so a
|
||||
//! group guard and an app guard both run — a single deny short-circuits.
|
||||
|
||||
use std::fs;
|
||||
|
||||
@@ -396,3 +397,200 @@ fn a_malformed_verdict_fails_closed() {
|
||||
"a verdict map with no `allowed` key must fail closed (deny)"
|
||||
);
|
||||
}
|
||||
|
||||
// --- §9.4 M2: ordered before-chain + cycle guard --------------------------
|
||||
|
||||
/// A group before-interceptor that denies only `kv::set` of key `"group-key"`.
|
||||
const GROUP_CHAIN_GUARD: &str = r#"
|
||||
let op = ctx.request.body;
|
||||
if op.action == "set" && op.key == "group-key" {
|
||||
#{ allowed: false, reason: "group guard" }
|
||||
} else {
|
||||
#{ allowed: true }
|
||||
}
|
||||
"#;
|
||||
|
||||
/// An app before-interceptor that denies only `kv::set` of key `"app-key"`.
|
||||
const APP_CHAIN_GUARD: &str = r#"
|
||||
let op = ctx.request.body;
|
||||
if op.action == "set" && op.key == "app-key" {
|
||||
#{ allowed: false, reason: "app guard" }
|
||||
} else {
|
||||
#{ allowed: true }
|
||||
}
|
||||
"#;
|
||||
|
||||
/// A writer that probes all three keys: the group-guarded one, the app-guarded
|
||||
/// one, and a free key. Proves BOTH chain entries run (ancestor→app).
|
||||
const CHAIN_WRITER: &str = r#"
|
||||
let g = false;
|
||||
let a = false;
|
||||
let free = false;
|
||||
try { kv::collection("c").set("group-key", 1); } catch(e) { g = true; }
|
||||
try { kv::collection("c").set("app-key", 1); } catch(e) { a = true; }
|
||||
try { kv::collection("c").set("free", 1); free = true; } catch(e) {}
|
||||
#{ group_denied: g, app_denied: a, free_ok: free }
|
||||
"#;
|
||||
|
||||
/// M2: a group AND a descendant app each declare a `(kv, set)` before
|
||||
/// interceptor. Both run in one write path, ordered ancestor→app: the group's
|
||||
/// guard denies its key, the app's guard denies its key, and a key neither
|
||||
/// guards passes — so BOTH chain entries demonstrably execute (a group denial
|
||||
/// cannot be bypassed by a descendant, and the app adds its own on top).
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn a_before_chain_runs_group_then_app_interceptors_in_order() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("chain-grp");
|
||||
let app = common::unique_slug("chain-app");
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// The group owns its guard + marker.
|
||||
let dir = manifest_dir();
|
||||
fs::write(
|
||||
dir.path().join("scripts/group_guard.rhai"),
|
||||
GROUP_CHAIN_GUARD,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join("group.toml"),
|
||||
format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"Chain\"\n\n\
|
||||
[[scripts]]\nname = \"group_guard\"\nfile = \"scripts/group_guard.rhai\"\n\n\
|
||||
[[interceptors]]\nscript = \"group_guard\"\nops = [\"set\"]\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(dir.path().join("group.toml"))
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// The app under the group owns its OWN guard + marker + writer.
|
||||
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/app_guard.rhai"), APP_CHAIN_GUARD).unwrap();
|
||||
fs::write(dir.path().join("scripts/writer.rhai"), CHAIN_WRITER).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("app.toml"),
|
||||
format!(
|
||||
"[app]\nslug = \"{app}\"\nname = \"ChainApp\"\n\n\
|
||||
[[scripts]]\nname = \"app_guard\"\nfile = \"scripts/app_guard.rhai\"\n\n\
|
||||
[[scripts]]\nname = \"writer\"\nfile = \"scripts/writer.rhai\"\n\n\
|
||||
[[interceptors]]\nscript = \"app_guard\"\nops = [\"set\"]\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!({ "group_denied": true, "app_denied": true, "free_ok": true }),
|
||||
"both the group's and the app's before interceptors must run (ancestor→app)"
|
||||
);
|
||||
}
|
||||
|
||||
/// A self-referential interceptor: the `(kv, set)` guard itself performs a
|
||||
/// `kv::set` (the very op it guards) before allowing. Without a re-entry break
|
||||
/// that nested write would re-resolve to this same interceptor and recurse
|
||||
/// forever. The RE-ENTRANCY guard catches it — writes performed BY an
|
||||
/// interceptor bypass interception — so the audit write lands and the original
|
||||
/// write proceeds, with no recursion and no deny.
|
||||
const SELF_REF_GUARD: &str = r#"
|
||||
kv::collection("audit").set("seen", ctx.request.body.key);
|
||||
#{ allowed: true }
|
||||
"#;
|
||||
|
||||
/// A writer whose single guarded set triggers the self-referential guard.
|
||||
const SELF_REF_WRITER: &str = r#"
|
||||
kv::collection("c").set("x", 1);
|
||||
#{ done: true }
|
||||
"#;
|
||||
|
||||
/// M2 (cycle safety): a self-referential interceptor completes cleanly rather
|
||||
/// than recursing. Documented outcome — the RE-ENTRANCY guard is what catches
|
||||
/// it here (the interceptor's own guarded write bypasses interception, so
|
||||
/// `run_before` is never re-entered and the identity cycle guard is never
|
||||
/// reached). The observable result: the writer finishes, both the audit write
|
||||
/// and the original write persist.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn a_self_referential_interceptor_does_not_recurse() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let app = common::unique_slug("selfref-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"), SELF_REF_GUARD).unwrap();
|
||||
fs::write(dir.path().join("scripts/writer.rhai"), SELF_REF_WRITER).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[app]\nslug = \"{app}\"\nname = \"SelfRef\"\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();
|
||||
|
||||
// No recursion / depth error: the writer completes and returns its map.
|
||||
let body = invoke_body(&env, &app_script_id(&env, &app, "writer"));
|
||||
assert_eq!(
|
||||
body,
|
||||
serde_json::json!({ "done": true }),
|
||||
"the self-referential interceptor must complete without recursing"
|
||||
);
|
||||
|
||||
// Both writes landed: the interceptor's audit write (bypassed interception)
|
||||
// and the original guarded write.
|
||||
let seen = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["kv", "get", "--app", &app, "--collection", "audit", "seen"])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
seen.contains('x'),
|
||||
"the interceptor's own write must persist (re-entrancy bypass):\n{seen}"
|
||||
);
|
||||
let x = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["kv", "get", "--app", &app, "--collection", "c", "x"])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(x.contains('1'), "the original write must persist:\n{x}");
|
||||
}
|
||||
|
||||
@@ -20,35 +20,54 @@ use async_trait::async_trait;
|
||||
|
||||
use crate::{ResolvedScript, SdkCallCx};
|
||||
|
||||
/// Outcome of resolving the before-op interceptor for a `(service, op)`.
|
||||
///
|
||||
/// The three arms encode a **fail-closed** contract: a registered-but-broken
|
||||
/// hook DENIES rather than silently allowing (only [`None`](Self::None) — no
|
||||
/// marker at all — allows without running anything).
|
||||
/// One resolved, runnable interceptor: a script **sealed to the owner that
|
||||
/// declared the marker** (its lexical `import` origin), plus its per-hook
|
||||
/// timeout. `timeout_ms` is threaded through the chain now but unused until
|
||||
/// M5 — always `None` for the M2 slice.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InterceptorResolution {
|
||||
/// No marker registered for `(service, op)` on the chain — allow, no work.
|
||||
None,
|
||||
/// A marker IS registered but its sealed script is missing or disabled.
|
||||
/// The caller must DENY (a mis-declared guard must never fail open).
|
||||
/// Carries the script name for the error message.
|
||||
pub struct ResolvedInterceptor {
|
||||
pub script: Box<ResolvedScript>,
|
||||
pub timeout_ms: Option<u32>,
|
||||
}
|
||||
|
||||
/// One entry in an interceptor chain. A **fail-closed** contract: a
|
||||
/// registered-but-broken hook is a [`Dangling`](Self::Dangling) entry the
|
||||
/// caller must DENY on — it is never dropped in favour of a farther, weaker
|
||||
/// marker.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InterceptorEntry {
|
||||
/// Run this script as a before/after hook.
|
||||
Run(ResolvedInterceptor),
|
||||
/// A marker exists but its sealed script is missing/disabled → the caller
|
||||
/// DENIES (a mis-declared guard must never fail open). Carries the script
|
||||
/// name for the error message.
|
||||
Dangling(String),
|
||||
/// Run this script (sealed to the declaring owner) as the before-op hook.
|
||||
Run(Box<ResolvedScript>),
|
||||
}
|
||||
|
||||
/// The ordered before + after interceptors guarding a `(service, op)` for the
|
||||
/// calling app. `before` runs ancestor→app (an outer/group guard runs first,
|
||||
/// so a group denial cannot be bypassed by a descendant); `after` runs
|
||||
/// app→ancestor. An empty chain means un-hooked (allow, no work). `after` is
|
||||
/// populated but UNUSED until §9.4 M3.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct InterceptorChain {
|
||||
pub before: Vec<InterceptorEntry>,
|
||||
pub after: Vec<InterceptorEntry>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait InterceptorService: Send + Sync {
|
||||
/// Resolve the interceptor guarding `(service, op)` for the calling app —
|
||||
/// the nearest marker on `cx.app_id`'s chain, its script sealed to the
|
||||
/// declaring owner. `Err` is a backend failure (the caller fails closed:
|
||||
/// turns it into an operation error rather than silently allowing).
|
||||
async fn resolve_before(
|
||||
/// Resolve the before + after interceptors guarding `(service, op)` for the
|
||||
/// calling app — every marker visible on `cx.app_id`'s chain, each script
|
||||
/// sealed to the owner that declared it, ordered per phase. `Err` is a
|
||||
/// backend failure (the caller fails closed: turns it into an operation
|
||||
/// error rather than silently allowing).
|
||||
async fn resolve(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
service: &str,
|
||||
op: &str,
|
||||
) -> Result<InterceptorResolution, String>;
|
||||
) -> Result<InterceptorChain, String>;
|
||||
}
|
||||
|
||||
/// Default: nothing is ever intercepted. The shape every non-picloud `Services`
|
||||
@@ -59,12 +78,12 @@ pub struct NoopInterceptorService;
|
||||
|
||||
#[async_trait]
|
||||
impl InterceptorService for NoopInterceptorService {
|
||||
async fn resolve_before(
|
||||
async fn resolve(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_service: &str,
|
||||
_op: &str,
|
||||
) -> Result<InterceptorResolution, String> {
|
||||
Ok(InterceptorResolution::None)
|
||||
) -> Result<InterceptorChain, String> {
|
||||
Ok(InterceptorChain::default())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,10 @@ pub use ids::{
|
||||
pub use inbox::{
|
||||
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
|
||||
};
|
||||
pub use interceptor::{InterceptorResolution, InterceptorService, NoopInterceptorService};
|
||||
pub use interceptor::{
|
||||
InterceptorChain, InterceptorEntry, InterceptorService, NoopInterceptorService,
|
||||
ResolvedInterceptor,
|
||||
};
|
||||
pub use invoke::{InvokeError, InvokeService, InvokeTarget, NoopInvokeService, ResolvedScript};
|
||||
pub use kv::{KvError, KvListPage, KvService, NoopKvService};
|
||||
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
||||
|
||||
Reference in New Issue
Block a user