feat(interceptors): §9.4 service interceptors — thin KV allow/deny slice
Smallest honest vertical slice of §9.4: a `[[interceptors]]` block (app OR
group) binds a script to run BEFORE `kv::set`/`delete`; it reads the operation
context (`ctx.request.body`: service, action, collection, key, value, caller
ids) and returns `#{ allowed, reason }` — `allowed == false` denies the op (the
write never runs, the caller gets a runtime error).
Reuses two existing mechanisms rather than inventing new ones:
- Registration mirrors extension points (§5.5): a marker table
`0073_interceptors.sql` (owner-polymorphic app_id/group_id XOR, keyed
(service, op) → script), `interceptor_repo` (insert/delete/list + the
nearest-owner-wins `resolve_before` chain walk), reconciled through the
declarative apply exactly like `vars` (create/update/delete, prunable).
- Execution reuses the `invoke()` re-entry path: the new `InterceptorService`
(shared trait + Postgres-backed impl) only RESOLVES the script name (keeping
executor-core Postgres-free); the executor's `sdk::interceptor::run_before`
resolves that name and runs it via `run_resolved_blocking` (extracted from
`invoke_blocking` — shared depth bound + AST cache). An un-hooked write pays
one indexed `Ok(None)` resolve; no interceptor ⇒ zero overhead.
Nearest-owner-wins so an app overrides a group's interceptor, and a group
interceptor is inherited by every descendant app — the chain walk is the
isolation boundary (a sibling subtree never matches). `validate_bundle_for`
restricts the MVP to `service = "kv"`, `op ∈ {set, delete}`, one marker per
(service, op).
Deferred (documented in §9.4): the `data` transform return, services other
than kv, `after_*` hooks, chaining + circular-dependency guard, the timeout
policy, and a `pic interceptors ls` read surface (needs a server route).
Pinned by `tests/interceptors.rs` (deny blocks the write; allow passes;
group→app inheritance), schema snapshot re-blessed. 154/154 journeys pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,18 +30,27 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{GroupKvService, KvService, SdkCallCx, Services};
|
||||
use picloud_shared::{
|
||||
GroupKvService, InterceptorService, InvokeService, KvService, SdkCallCx, Services,
|
||||
};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
|
||||
use super::bridge::{block_on, dynamic_to_json, json_to_dynamic};
|
||||
use crate::engine::Engine;
|
||||
use crate::sandbox::Limits;
|
||||
|
||||
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
||||
/// plus an owned string).
|
||||
/// 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
|
||||
/// 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>,
|
||||
invoke: Arc<dyn InvokeService>,
|
||||
self_engine: Option<Arc<Engine>>,
|
||||
limits: Limits,
|
||||
}
|
||||
|
||||
/// §11.6 shared-collection handle, returned by `kv::shared_collection(name)`. A distinct
|
||||
@@ -56,9 +65,17 @@ pub struct GroupKvHandle {
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
pub(super) fn register(
|
||||
engine: &mut RhaiEngine,
|
||||
services: &Services,
|
||||
cx: Arc<SdkCallCx>,
|
||||
limits: Limits,
|
||||
self_engine: Option<Arc<Engine>>,
|
||||
) {
|
||||
let kv_service = services.kv.clone();
|
||||
let group_kv_service = services.group_kv.clone();
|
||||
let interceptors = services.interceptors.clone();
|
||||
let invoke = services.invoke.clone();
|
||||
|
||||
// `kv::collection(name)` / `kv::shared_collection(name)` — both constructors live in
|
||||
// the `kv` static module so the script-visible calls are `kv::collection`
|
||||
@@ -67,6 +84,9 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
{
|
||||
let kv_service = kv_service.clone();
|
||||
let cx = cx.clone();
|
||||
let interceptors = interceptors.clone();
|
||||
let invoke = invoke.clone();
|
||||
let self_engine = self_engine.clone();
|
||||
module.set_native_fn(
|
||||
"collection",
|
||||
move |name: &str| -> Result<KvHandle, Box<EvalAltResult>> {
|
||||
@@ -77,6 +97,10 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
collection: name.to_string(),
|
||||
service: kv_service.clone(),
|
||||
cx: cx.clone(),
|
||||
interceptors: interceptors.clone(),
|
||||
invoke: invoke.clone(),
|
||||
self_engine: self_engine.clone(),
|
||||
limits,
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -151,8 +175,22 @@ fn register_set(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"set",
|
||||
|handle: &mut KvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let json = dynamic_to_json(&value);
|
||||
// §9.4 before-op interceptor (allow/deny). A denial errors here and
|
||||
// the write below never runs.
|
||||
super::interceptor::run_before(
|
||||
&handle.interceptors,
|
||||
&handle.invoke,
|
||||
handle.self_engine.as_ref(),
|
||||
&handle.cx,
|
||||
handle.limits,
|
||||
"kv",
|
||||
"set",
|
||||
&handle.collection,
|
||||
key,
|
||||
Some(&json),
|
||||
)?;
|
||||
let h = handle.clone();
|
||||
block_on("kv", async move {
|
||||
h.service.set(&h.cx, &h.collection, key, json).await
|
||||
})
|
||||
@@ -197,6 +235,18 @@ fn register_delete(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"delete",
|
||||
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
super::interceptor::run_before(
|
||||
&handle.interceptors,
|
||||
&handle.invoke,
|
||||
handle.self_engine.as_ref(),
|
||||
&handle.cx,
|
||||
handle.limits,
|
||||
"kv",
|
||||
"delete",
|
||||
&handle.collection,
|
||||
key,
|
||||
None,
|
||||
)?;
|
||||
let h = handle.clone();
|
||||
block_on("kv", async move {
|
||||
h.service.delete(&h.cx, &h.collection, key).await
|
||||
|
||||
Reference in New Issue
Block a user