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:
111
crates/executor-core/src/sdk/interceptor.rs
Normal file
111
crates/executor-core/src/sdk/interceptor.rs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
//! §9.4 Service Interceptors — the executor-side before-op hook.
|
||||||
|
//!
|
||||||
|
//! MVP: `kv::set` / `kv::delete` run an allow/deny interceptor first. The hook
|
||||||
|
//! (1) resolves the nearest interceptor script name for `(service, op)` on the
|
||||||
|
//! calling app's chain via the injected `InterceptorService` (a cheap indexed
|
||||||
|
//! query; `None` = un-hooked → allow, no further work), then (2) resolves that
|
||||||
|
//! name to a script and runs it through the SAME `invoke()` re-entry path
|
||||||
|
//! (`run_resolved_blocking`) — no second dispatch mechanism. The interceptor
|
||||||
|
//! receives the operation context as its request body and returns a map; the
|
||||||
|
//! op is DENIED iff that map has `allowed == false` (fail-open on any other
|
||||||
|
//! shape, documented — allow/deny only, no data transform).
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use picloud_shared::{InterceptorService, InvokeService, InvokeTarget, SdkCallCx};
|
||||||
|
use rhai::EvalAltResult;
|
||||||
|
use serde_json::{json, Value as Json};
|
||||||
|
use tokio::runtime::Handle as TokioHandle;
|
||||||
|
|
||||||
|
use crate::engine::Engine;
|
||||||
|
use crate::sandbox::Limits;
|
||||||
|
use crate::sdk::bridge::runtime_err;
|
||||||
|
use crate::sdk::invoke::run_resolved_blocking;
|
||||||
|
|
||||||
|
/// Run the before-op interceptor for `(service, op)` if one is registered.
|
||||||
|
/// Returns `Ok(())` to allow the operation (un-hooked, or the interceptor
|
||||||
|
/// 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).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn run_before(
|
||||||
|
interceptors: &Arc<dyn InterceptorService>,
|
||||||
|
invoke: &Arc<dyn InvokeService>,
|
||||||
|
self_engine: Option<&Arc<Engine>>,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
limits: Limits,
|
||||||
|
service: &'static str,
|
||||||
|
op: &'static str,
|
||||||
|
collection: &str,
|
||||||
|
key: &str,
|
||||||
|
value: Option<&Json>,
|
||||||
|
) -> Result<(), Box<EvalAltResult>> {
|
||||||
|
let handle = TokioHandle::try_current()
|
||||||
|
.map_err(|e| runtime_err(&format!("{service} interceptor: no tokio runtime: {e}")))?;
|
||||||
|
|
||||||
|
// (1) Resolve the nearest interceptor script name. Un-hooked → allow.
|
||||||
|
let name = {
|
||||||
|
let interceptors = interceptors.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
handle
|
||||||
|
.block_on(async move { interceptors.resolve_before(&cx, service, op).await })
|
||||||
|
.map_err(|e| runtime_err(&format!("{service}::{op} interceptor resolve: {e}")))?
|
||||||
|
};
|
||||||
|
let Some(name) = name else { return Ok(()) };
|
||||||
|
|
||||||
|
// An interceptor is registered but the engine back-reference isn't installed
|
||||||
|
// (a bare test engine without `set_self_weak`): there is no way to run it, so
|
||||||
|
// skip rather than block a write. Production always installs the back-ref.
|
||||||
|
let Some(self_engine) = self_engine else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Depth bound (shared with invoke / trigger fan-out): an interceptor that
|
||||||
|
// itself writes and re-enters can't recurse past the ceiling.
|
||||||
|
if cx.trigger_depth + 1 > limits.trigger_depth_max {
|
||||||
|
return Err(runtime_err(&format!(
|
||||||
|
"{service}::{op} interceptor `{name}`: depth limit exceeded (max {})",
|
||||||
|
limits.trigger_depth_max
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// (2) Resolve the interceptor script by name on the caller's chain and run
|
||||||
|
// it with the operation context as its request body.
|
||||||
|
let resolved = {
|
||||||
|
let invoke = invoke.clone();
|
||||||
|
let cx = cx.clone();
|
||||||
|
let target = InvokeTarget::Name(name.clone());
|
||||||
|
handle
|
||||||
|
.block_on(async move { invoke.resolve(&cx, target).await })
|
||||||
|
.map_err(|e| runtime_err(&format!("{service}::{op} interceptor `{name}`: {e}")))?
|
||||||
|
};
|
||||||
|
let payload = json!({
|
||||||
|
"service": service,
|
||||||
|
"action": op,
|
||||||
|
"collection": collection,
|
||||||
|
"key": key,
|
||||||
|
"value": value,
|
||||||
|
"caller_script_id": cx.script_id.to_string(),
|
||||||
|
"caller_execution_id": cx.execution_id.to_string(),
|
||||||
|
});
|
||||||
|
let ret = run_resolved_blocking(
|
||||||
|
self_engine,
|
||||||
|
cx,
|
||||||
|
&resolved,
|
||||||
|
payload,
|
||||||
|
&format!("{service}::{op} interceptor `{name}`"),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Deny iff the interceptor returned a map with `allowed == false`.
|
||||||
|
if let Json::Object(m) = &ret {
|
||||||
|
if m.get("allowed") == Some(&Json::Bool(false)) {
|
||||||
|
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 `{name}`: {reason}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -163,9 +163,28 @@ fn invoke_blocking(
|
|||||||
.into()
|
.into()
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let execution_id = ExecutionId::new();
|
// The callee's return is the response `body` JSON. Convert back to
|
||||||
|
// Dynamic for the caller. Status code + headers are dropped; the
|
||||||
|
// function-call mental model is "return value", not HTTP response.
|
||||||
|
let body = run_resolved_blocking(self_engine, cx, &resolved, args_json, &target_label)?;
|
||||||
|
Ok(json_to_dynamic(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronous same-engine re-entry: build the callee `ExecRequest` (inheriting
|
||||||
|
/// the caller's app/principal/root/depth+1 and the callee's lexical owner),
|
||||||
|
/// compile through the per-Engine AST cache (F-P-004), execute, and return the
|
||||||
|
/// response `body` JSON. Shared by `invoke()` and the §9.4 interceptor hook —
|
||||||
|
/// the caller is responsible for the depth check (both do it before resolving).
|
||||||
|
/// `label` prefixes any compile/execute error.
|
||||||
|
pub(super) fn run_resolved_blocking(
|
||||||
|
self_engine: &Arc<Engine>,
|
||||||
|
cx: &Arc<SdkCallCx>,
|
||||||
|
resolved: &picloud_shared::ResolvedScript,
|
||||||
|
body_json: Json,
|
||||||
|
label: &str,
|
||||||
|
) -> Result<Json, Box<EvalAltResult>> {
|
||||||
let req = ExecRequest {
|
let req = ExecRequest {
|
||||||
execution_id,
|
execution_id: ExecutionId::new(),
|
||||||
request_id: cx.request_id,
|
request_id: cx.request_id,
|
||||||
script_id: resolved.script_id,
|
script_id: resolved.script_id,
|
||||||
script_name: resolved.name.clone(),
|
script_name: resolved.name.clone(),
|
||||||
@@ -173,7 +192,7 @@ fn invoke_blocking(
|
|||||||
path: "/invoke".into(),
|
path: "/invoke".into(),
|
||||||
method: String::new(),
|
method: String::new(),
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
body: args_json,
|
body: body_json,
|
||||||
params: BTreeMap::new(),
|
params: BTreeMap::new(),
|
||||||
query: BTreeMap::new(),
|
query: BTreeMap::new(),
|
||||||
rest: String::new(),
|
rest: String::new(),
|
||||||
@@ -183,42 +202,24 @@ fn invoke_blocking(
|
|||||||
// script's imports resolve from the group even when invoked by an
|
// script's imports resolve from the group even when invoked by an
|
||||||
// app. `None` falls back to `App(cx.app_id)` in the engine.
|
// app. `None` falls back to `App(cx.app_id)` in the engine.
|
||||||
script_owner: resolved.owner,
|
script_owner: resolved.owner,
|
||||||
// Same-app invoke is a function call, not a re-auth boundary —
|
// Same-app re-entry is not a re-auth boundary — inherit the principal.
|
||||||
// inherit the caller's principal.
|
|
||||||
principal: cx.principal.clone(),
|
principal: cx.principal.clone(),
|
||||||
trigger_depth: cx.trigger_depth + 1,
|
trigger_depth: cx.trigger_depth + 1,
|
||||||
root_execution_id: cx.root_execution_id,
|
root_execution_id: cx.root_execution_id,
|
||||||
is_dead_letter_handler: cx.is_dead_letter_handler,
|
is_dead_letter_handler: cx.is_dead_letter_handler,
|
||||||
event: None,
|
event: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// F-P-004: synchronous re-entry — route through the per-Engine
|
|
||||||
// AST cache so each callee parses once per (script_id, updated_at),
|
|
||||||
// not once per invoke. Composed workflows multiply parse cost by
|
|
||||||
// depth; the cache cuts that to constant compile + N executions.
|
|
||||||
let ast = self_engine
|
let ast = self_engine
|
||||||
.compile_for_identity(resolved.script_id, resolved.updated_at, &resolved.source)
|
.compile_for_identity(resolved.script_id, resolved.updated_at, &resolved.source)
|
||||||
.map_err(|e| -> Box<EvalAltResult> {
|
.map_err(|e| -> Box<EvalAltResult> {
|
||||||
EvalAltResult::ErrorRuntime(
|
EvalAltResult::ErrorRuntime(format!("{label}: {e}").into(), rhai::Position::NONE).into()
|
||||||
format!("invoke({target_label}): {e}").into(),
|
|
||||||
rhai::Position::NONE,
|
|
||||||
)
|
|
||||||
.into()
|
|
||||||
})?;
|
})?;
|
||||||
let resp = self_engine
|
let resp = self_engine
|
||||||
.execute_ast(&ast, req)
|
.execute_ast(&ast, req)
|
||||||
.map_err(|e| -> Box<EvalAltResult> {
|
.map_err(|e| -> Box<EvalAltResult> {
|
||||||
EvalAltResult::ErrorRuntime(
|
EvalAltResult::ErrorRuntime(format!("{label}: {e}").into(), rhai::Position::NONE).into()
|
||||||
format!("invoke({target_label}): {e}").into(),
|
|
||||||
rhai::Position::NONE,
|
|
||||||
)
|
|
||||||
.into()
|
|
||||||
})?;
|
})?;
|
||||||
|
Ok(resp.body)
|
||||||
// The callee's return is the response `body` JSON. Convert back to
|
|
||||||
// Dynamic for the caller. Status code + headers are dropped; the
|
|
||||||
// function-call mental model is "return value", not HTTP response.
|
|
||||||
Ok(json_to_dynamic(resp.body))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accept a string (route path OR script name) or a Rhai script-id
|
/// Accept a string (route path OR script name) or a Rhai script-id
|
||||||
|
|||||||
@@ -30,18 +30,27 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
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 rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||||
|
|
||||||
use super::bridge::{block_on, dynamic_to_json, json_to_dynamic};
|
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
|
/// Per-call handle captured by the Rhai SDK. Cheap to clone (a few Arcs plus an
|
||||||
/// plus an owned string).
|
/// 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)]
|
#[derive(Clone)]
|
||||||
pub struct KvHandle {
|
pub struct KvHandle {
|
||||||
collection: String,
|
collection: String,
|
||||||
service: Arc<dyn KvService>,
|
service: Arc<dyn KvService>,
|
||||||
cx: Arc<SdkCallCx>,
|
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
|
/// §11.6 shared-collection handle, returned by `kv::shared_collection(name)`. A distinct
|
||||||
@@ -56,9 +65,17 @@ pub struct GroupKvHandle {
|
|||||||
cx: Arc<SdkCallCx>,
|
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 kv_service = services.kv.clone();
|
||||||
let group_kv_service = services.group_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
|
// `kv::collection(name)` / `kv::shared_collection(name)` — both constructors live in
|
||||||
// the `kv` static module so the script-visible calls are `kv::collection`
|
// 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 kv_service = kv_service.clone();
|
||||||
let cx = cx.clone();
|
let cx = cx.clone();
|
||||||
|
let interceptors = interceptors.clone();
|
||||||
|
let invoke = invoke.clone();
|
||||||
|
let self_engine = self_engine.clone();
|
||||||
module.set_native_fn(
|
module.set_native_fn(
|
||||||
"collection",
|
"collection",
|
||||||
move |name: &str| -> Result<KvHandle, Box<EvalAltResult>> {
|
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(),
|
collection: name.to_string(),
|
||||||
service: kv_service.clone(),
|
service: kv_service.clone(),
|
||||||
cx: cx.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(
|
engine.register_fn(
|
||||||
"set",
|
"set",
|
||||||
|handle: &mut KvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
|handle: &mut KvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||||
let h = handle.clone();
|
|
||||||
let json = dynamic_to_json(&value);
|
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 {
|
block_on("kv", async move {
|
||||||
h.service.set(&h.cx, &h.collection, key, json).await
|
h.service.set(&h.cx, &h.collection, key, json).await
|
||||||
})
|
})
|
||||||
@@ -197,6 +235,18 @@ 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>> {
|
||||||
|
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();
|
let h = handle.clone();
|
||||||
block_on("kv", async move {
|
block_on("kv", async move {
|
||||||
h.service.delete(&h.cx, &h.collection, key).await
|
h.service.delete(&h.cx, &h.collection, key).await
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub mod docs;
|
|||||||
pub mod email;
|
pub mod email;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
|
pub mod interceptor;
|
||||||
pub mod invoke;
|
pub mod invoke;
|
||||||
pub mod kv;
|
pub mod kv;
|
||||||
pub mod pubsub;
|
pub mod pubsub;
|
||||||
@@ -55,7 +56,7 @@ pub fn register_all(
|
|||||||
limits: Limits,
|
limits: Limits,
|
||||||
self_engine: Option<Arc<Engine>>,
|
self_engine: Option<Arc<Engine>>,
|
||||||
) {
|
) {
|
||||||
kv::register(engine, services, cx.clone());
|
kv::register(engine, services, cx.clone(), limits, self_engine.clone());
|
||||||
docs::register(engine, services, cx.clone());
|
docs::register(engine, services, cx.clone());
|
||||||
dead_letters::register(engine, services, cx.clone());
|
dead_letters::register(engine, services, cx.clone());
|
||||||
http::register(engine, services, cx.clone());
|
http::register(engine, services, cx.clone());
|
||||||
|
|||||||
42
crates/manager-core/migrations/0073_interceptors.sql
Normal file
42
crates/manager-core/migrations/0073_interceptors.sql
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
-- §9.4 Service Interceptors (v1.2) — before-op allow/deny hooks.
|
||||||
|
--
|
||||||
|
-- A marker `(owner, service, op) -> interceptor_script` declares that a script
|
||||||
|
-- runs BEFORE a data-plane operation and may deny it. MVP scope: `service='kv'`,
|
||||||
|
-- `op IN ('set','delete')`, allow/deny only (no data transform, no chaining,
|
||||||
|
-- no after-hooks). The interceptor is itself a script owned by the same node
|
||||||
|
-- (or an ancestor group); it is resolved + run through the existing `invoke()`
|
||||||
|
-- re-entry path, so this table holds only the MARKER — pure declaration,
|
||||||
|
-- structurally like an `extension_points` row (0051): config, not code, so
|
||||||
|
-- ON DELETE CASCADE.
|
||||||
|
--
|
||||||
|
-- Ownership is polymorphic (mirrors extension_points/vars/secrets/scripts):
|
||||||
|
-- exactly one of (app_id, group_id) is set. Resolution walks the calling app's
|
||||||
|
-- chain (app, then nearest ancestor group) and picks the nearest declaration
|
||||||
|
-- for a (service, op) — nearest-owner-wins, so an app overrides a group's
|
||||||
|
-- interceptor, the deliberate inverse of a sealed import.
|
||||||
|
|
||||||
|
CREATE TABLE interceptors (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT interceptors_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
||||||
|
-- The intercepted operation. MVP: service='kv', op IN ('set','delete').
|
||||||
|
service TEXT NOT NULL,
|
||||||
|
op TEXT NOT NULL,
|
||||||
|
-- Name of the interceptor script (resolved on the owner's chain at run time).
|
||||||
|
interceptor_script TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One marker per (owner, service, op). Partial because the owner is split
|
||||||
|
-- across two nullable columns.
|
||||||
|
CREATE UNIQUE INDEX interceptors_group_uidx
|
||||||
|
ON interceptors (group_id, service, op) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX interceptors_app_uidx
|
||||||
|
ON interceptors (app_id, service, op) WHERE app_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Lookup indexes for the resolver's chain join + list-by-owner.
|
||||||
|
CREATE INDEX interceptors_group_id_idx ON interceptors (group_id) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE INDEX interceptors_app_id_idx ON interceptors (app_id) WHERE app_id IS NOT NULL;
|
||||||
@@ -107,6 +107,21 @@ pub struct Bundle {
|
|||||||
/// `[group]` carrying workflows is rejected in `validate_bundle_for`.
|
/// `[group]` carrying workflows is rejected in `validate_bundle_for`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub workflows: Vec<BundleWorkflow>,
|
pub workflows: Vec<BundleWorkflow>,
|
||||||
|
/// §9.4 service interceptors: before-op allow/deny hooks. One entry per
|
||||||
|
/// `(service, op)` guarded, naming the interceptor script. App- OR
|
||||||
|
/// group-owned; resolved nearest-owner-wins on a descendant app's chain.
|
||||||
|
#[serde(default)]
|
||||||
|
pub interceptors: Vec<BundleInterceptor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One §9.4 interceptor marker on the wire: which `(service, op)` it guards and
|
||||||
|
/// the interceptor script name. The CLI expands a manifest `[[interceptors]]`
|
||||||
|
/// entry's `ops = [...]` into one of these per op.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct BundleInterceptor {
|
||||||
|
pub service: String,
|
||||||
|
pub op: String,
|
||||||
|
pub script: 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.
|
||||||
@@ -467,6 +482,10 @@ pub struct Plan {
|
|||||||
/// v1.2 Workflows: workflow definitions, keyed by name.
|
/// v1.2 Workflows: workflow definitions, keyed by name.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub workflows: Vec<ResourceChange>,
|
pub workflows: Vec<ResourceChange>,
|
||||||
|
/// §9.4 interceptor markers, keyed `"{service}/{op}"` with the script as the
|
||||||
|
/// value (create/update/delete like `vars`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub interceptors: Vec<ResourceChange>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Plan {
|
impl Plan {
|
||||||
@@ -483,6 +502,7 @@ impl Plan {
|
|||||||
.chain(&self.collections)
|
.chain(&self.collections)
|
||||||
.chain(&self.suppressions)
|
.chain(&self.suppressions)
|
||||||
.chain(&self.workflows)
|
.chain(&self.workflows)
|
||||||
|
.chain(&self.interceptors)
|
||||||
.all(|c| c.op == Op::NoOp)
|
.all(|c| c.op == Op::NoOp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -765,6 +785,9 @@ pub struct CurrentState {
|
|||||||
pub suppressions: Vec<(String, String)>,
|
pub suppressions: Vec<(String, String)>,
|
||||||
/// 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
|
||||||
|
/// `(service, op, script)` triples.
|
||||||
|
pub interceptors: Vec<(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).
|
||||||
@@ -1040,6 +1063,7 @@ impl ApplyService {
|
|||||||
/// `validate_bundle`, plus a group-node guard: a group owns only scripts +
|
/// `validate_bundle`, plus a group-node guard: a group owns only scripts +
|
||||||
/// vars (+ declared secret names), so a `[group]` manifest carrying routes
|
/// vars (+ declared secret names), so a `[group]` manifest carrying routes
|
||||||
/// or triggers is a 422 — those are app concerns.
|
/// or triggers is a 422 — those are app concerns.
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
fn validate_bundle_for(
|
fn validate_bundle_for(
|
||||||
&self,
|
&self,
|
||||||
is_group: bool,
|
is_group: bool,
|
||||||
@@ -1173,6 +1197,35 @@ impl ApplyService {
|
|||||||
for w in &bundle.workflows {
|
for w in &bundle.workflows {
|
||||||
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}`,
|
||||||
|
// authored on app OR group. One marker per `(service, op)` — a duplicate
|
||||||
|
// would collide on the reconcile key and the DB's partial-unique index.
|
||||||
|
let mut seen_hooks: HashSet<(String, String)> = HashSet::new();
|
||||||
|
for i in &bundle.interceptors {
|
||||||
|
if i.service != "kv" {
|
||||||
|
return Err(ApplyError::Invalid(format!(
|
||||||
|
"interceptor service `{}` is not supported — only `kv` (MVP)",
|
||||||
|
i.service
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if i.op != "set" && i.op != "delete" {
|
||||||
|
return Err(ApplyError::Invalid(format!(
|
||||||
|
"interceptor op `{}` is not supported for kv — only `set` / `delete`",
|
||||||
|
i.op
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if i.script.trim().is_empty() {
|
||||||
|
return Err(ApplyError::Invalid(
|
||||||
|
"an interceptor must name a `script`".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !seen_hooks.insert((i.service.clone(), i.op.clone())) {
|
||||||
|
return Err(ApplyError::Invalid(format!(
|
||||||
|
"duplicate interceptor for `{}/{}`",
|
||||||
|
i.service, i.op
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
self.validate_bundle(bundle, inherited_endpoints)
|
self.validate_bundle(bundle, inherited_endpoints)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1450,6 +1503,35 @@ impl ApplyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3c.5. §9.4 interceptor markers — upsert each Create/Update by
|
||||||
|
// `(service, op)`. A changed script is an in-place Update; deletes happen
|
||||||
|
// in prune.
|
||||||
|
for ch in &plan.interceptors {
|
||||||
|
if ch.op == Op::Create || ch.op == Op::Update {
|
||||||
|
let bi = bundle
|
||||||
|
.interceptors
|
||||||
|
.iter()
|
||||||
|
.find(|i| format!("{}/{}", i.service, i.op) == ch.key)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ApplyError::Backend("internal: interceptor plan/bundle mismatch".into())
|
||||||
|
})?;
|
||||||
|
crate::interceptor_repo::insert_interceptor_tx(
|
||||||
|
&mut *tx,
|
||||||
|
owner.as_script_owner(),
|
||||||
|
&bi.service,
|
||||||
|
&bi.op,
|
||||||
|
&bi.script,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
|
if ch.op == Op::Create {
|
||||||
|
report.interceptors_created += 1;
|
||||||
|
} else {
|
||||||
|
report.interceptors_updated += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3d. Shared group-collection markers (§11.6) — insert each Create
|
// 3d. Shared group-collection markers (§11.6) — insert each Create
|
||||||
// (idempotent). Name-only identity; deletes happen in prune. Pruning a
|
// (idempotent). Name-only identity; deletes happen in prune. Pruning a
|
||||||
// marker hides the store but does NOT drop its `group_kv_entries` data
|
// marker hides the store but does NOT drop its `group_kv_entries` data
|
||||||
@@ -1580,6 +1662,24 @@ impl ApplyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §9.4 interceptor markers are prunable config too. The delete keys
|
||||||
|
// by `(service, op)` (the marker's identity), so a script-change
|
||||||
|
// Update above is never clobbered here.
|
||||||
|
for ch in &plan.interceptors {
|
||||||
|
if ch.op == Op::Delete {
|
||||||
|
let (service, op) = ch.key.split_once('/').unwrap_or((ch.key.as_str(), ""));
|
||||||
|
crate::interceptor_repo::delete_interceptor_tx(
|
||||||
|
&mut *tx,
|
||||||
|
owner.as_script_owner(),
|
||||||
|
service,
|
||||||
|
op,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
|
report.interceptors_deleted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Shared group-collection markers are prunable config too (§11.6).
|
// Shared group-collection markers are prunable config too (§11.6).
|
||||||
// Only the marker is removed here — the data survives until the
|
// Only the marker is removed here — the data survives until the
|
||||||
// owning group is deleted.
|
// owning group is deleted.
|
||||||
@@ -4048,6 +4148,14 @@ impl ApplyService {
|
|||||||
}
|
}
|
||||||
ApplyOwner::Group(_) => Vec::new(),
|
ApplyOwner::Group(_) => Vec::new(),
|
||||||
};
|
};
|
||||||
|
// §9.4 interceptor markers declared directly at this node.
|
||||||
|
let interceptors =
|
||||||
|
crate::interceptor_repo::list_for_owner(&self.pool, owner.as_script_owner())
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||||
|
.into_iter()
|
||||||
|
.map(|m| (m.service, m.op, m.script))
|
||||||
|
.collect();
|
||||||
Ok(CurrentState {
|
Ok(CurrentState {
|
||||||
scripts,
|
scripts,
|
||||||
routes,
|
routes,
|
||||||
@@ -4058,6 +4166,7 @@ impl ApplyService {
|
|||||||
collections,
|
collections,
|
||||||
suppressions,
|
suppressions,
|
||||||
workflows,
|
workflows,
|
||||||
|
interceptors,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4124,9 +4233,58 @@ fn compute_diff_with_names(
|
|||||||
collections: diff_collections(current, bundle),
|
collections: diff_collections(current, bundle),
|
||||||
suppressions: diff_suppressions(current, bundle),
|
suppressions: diff_suppressions(current, bundle),
|
||||||
workflows: diff_workflows(current, bundle),
|
workflows: diff_workflows(current, bundle),
|
||||||
|
interceptors: diff_interceptors(current, bundle),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Diff §9.4 interceptor markers by `"{service}/{op}"` key with the script name
|
||||||
|
/// as the value — create/update/noop/delete like `vars`. A changed script for
|
||||||
|
/// the same `(service, op)` is an `Update`; a live marker the manifest stops
|
||||||
|
/// declaring is a `Delete` (applied only under `--prune`).
|
||||||
|
fn diff_interceptors(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
||||||
|
let key = |service: &str, op: &str| format!("{service}/{op}");
|
||||||
|
let live: HashMap<String, &str> = current
|
||||||
|
.interceptors
|
||||||
|
.iter()
|
||||||
|
.map(|(s, o, script)| (key(s, o), script.as_str()))
|
||||||
|
.collect();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for bi in &bundle.interceptors {
|
||||||
|
let k = key(&bi.service, &bi.op);
|
||||||
|
match live.get(&k) {
|
||||||
|
Some(cur) if *cur == bi.script => out.push(ResourceChange {
|
||||||
|
op: Op::NoOp,
|
||||||
|
key: k,
|
||||||
|
detail: None,
|
||||||
|
}),
|
||||||
|
Some(_) => out.push(ResourceChange {
|
||||||
|
op: Op::Update,
|
||||||
|
key: k,
|
||||||
|
detail: Some("interceptor script changed".into()),
|
||||||
|
}),
|
||||||
|
None => out.push(ResourceChange {
|
||||||
|
op: Op::Create,
|
||||||
|
key: k,
|
||||||
|
detail: Some(bi.script.clone()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (s, o, _) in ¤t.interceptors {
|
||||||
|
let present = bundle
|
||||||
|
.interceptors
|
||||||
|
.iter()
|
||||||
|
.any(|bi| bi.service == *s && bi.op == *o);
|
||||||
|
if !present {
|
||||||
|
out.push(ResourceChange {
|
||||||
|
op: Op::Delete,
|
||||||
|
key: key(s, o),
|
||||||
|
detail: Some("on server, not declared".into()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Diff workflows by `lower(name)`. Like scripts, a workflow has a stable name
|
/// Diff workflows by `lower(name)`. Like scripts, a workflow has a stable name
|
||||||
/// identity with a mutable body, so a changed definition (or `enabled`) is an
|
/// identity with a mutable body, so a changed definition (or `enabled`) is an
|
||||||
/// `Update`, not a delete+create. Live workflows absent from the manifest are
|
/// `Update`, not a delete+create. Live workflows absent from the manifest are
|
||||||
@@ -5472,6 +5630,12 @@ pub struct ApplyReport {
|
|||||||
pub workflows_updated: u32,
|
pub workflows_updated: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub workflows_deleted: u32,
|
pub workflows_deleted: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub interceptors_created: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub interceptors_updated: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub interceptors_deleted: u32,
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
pub warnings: Vec<String>,
|
pub warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -5816,6 +5980,7 @@ mod tests {
|
|||||||
suppress_triggers: Vec::new(),
|
suppress_triggers: Vec::new(),
|
||||||
suppress_routes: Vec::new(),
|
suppress_routes: Vec::new(),
|
||||||
workflows: Vec::new(),
|
workflows: Vec::new(),
|
||||||
|
interceptors: Vec::new(),
|
||||||
};
|
};
|
||||||
let plan = compute_diff(¤t, &bundle);
|
let plan = compute_diff(¤t, &bundle);
|
||||||
assert_eq!(plan.scripts.len(), 1);
|
assert_eq!(plan.scripts.len(), 1);
|
||||||
@@ -5843,6 +6008,7 @@ mod tests {
|
|||||||
suppress_triggers: Vec::new(),
|
suppress_triggers: Vec::new(),
|
||||||
suppress_routes: Vec::new(),
|
suppress_routes: Vec::new(),
|
||||||
workflows: Vec::new(),
|
workflows: Vec::new(),
|
||||||
|
interceptors: Vec::new(),
|
||||||
};
|
};
|
||||||
let plan = compute_diff(¤t, &bundle);
|
let plan = compute_diff(¤t, &bundle);
|
||||||
assert!(plan.is_noop(), "expected all no-op, got {plan:?}");
|
assert!(plan.is_noop(), "expected all no-op, got {plan:?}");
|
||||||
@@ -5865,6 +6031,7 @@ mod tests {
|
|||||||
suppress_triggers: Vec::new(),
|
suppress_triggers: Vec::new(),
|
||||||
suppress_routes: Vec::new(),
|
suppress_routes: Vec::new(),
|
||||||
workflows: Vec::new(),
|
workflows: Vec::new(),
|
||||||
|
interceptors: Vec::new(),
|
||||||
};
|
};
|
||||||
let plan = compute_diff(¤t, &bundle);
|
let plan = compute_diff(¤t, &bundle);
|
||||||
assert_eq!(plan.scripts[0].op, Op::Update);
|
assert_eq!(plan.scripts[0].op, Op::Update);
|
||||||
@@ -5888,6 +6055,7 @@ mod tests {
|
|||||||
suppress_triggers: Vec::new(),
|
suppress_triggers: Vec::new(),
|
||||||
suppress_routes: Vec::new(),
|
suppress_routes: Vec::new(),
|
||||||
workflows: Vec::new(),
|
workflows: Vec::new(),
|
||||||
|
interceptors: Vec::new(),
|
||||||
};
|
};
|
||||||
let plan = compute_diff(¤t, &bundle);
|
let plan = compute_diff(¤t, &bundle);
|
||||||
assert_eq!(plan.scripts[0].op, Op::Delete);
|
assert_eq!(plan.scripts[0].op, Op::Delete);
|
||||||
@@ -5942,6 +6110,7 @@ mod tests {
|
|||||||
suppress_triggers: Vec::new(),
|
suppress_triggers: Vec::new(),
|
||||||
suppress_routes: Vec::new(),
|
suppress_routes: Vec::new(),
|
||||||
workflows: Vec::new(),
|
workflows: Vec::new(),
|
||||||
|
interceptors: Vec::new(),
|
||||||
};
|
};
|
||||||
let plan = compute_diff(¤t, &bundle);
|
let plan = compute_diff(¤t, &bundle);
|
||||||
assert_eq!(plan.routes[0].op, Op::Update);
|
assert_eq!(plan.routes[0].op, Op::Update);
|
||||||
@@ -5959,6 +6128,7 @@ mod tests {
|
|||||||
suppress_triggers: Vec::new(),
|
suppress_triggers: Vec::new(),
|
||||||
suppress_routes: Vec::new(),
|
suppress_routes: Vec::new(),
|
||||||
workflows: Vec::new(),
|
workflows: Vec::new(),
|
||||||
|
interceptors: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
184
crates/manager-core/src/interceptor_repo.rs
Normal file
184
crates/manager-core/src/interceptor_repo.rs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
//! §9.4 Service-interceptor markers — the `interceptors` table (0073).
|
||||||
|
//!
|
||||||
|
//! A marker `(owner, service, op) -> interceptor_script` declares a before-op
|
||||||
|
//! allow/deny hook. Pure declaration (like `extension_points`, 0051); the
|
||||||
|
//! 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).
|
||||||
|
|
||||||
|
use picloud_shared::{AppId, ScriptOwner};
|
||||||
|
use sqlx::{PgPool, Postgres, Transaction};
|
||||||
|
|
||||||
|
use crate::config_resolver::CHAIN_LEVELS_CTE;
|
||||||
|
|
||||||
|
/// One interceptor marker at an owner: which `(service, op)` it guards and the
|
||||||
|
/// interceptor script name.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct InterceptorMarker {
|
||||||
|
pub service: String,
|
||||||
|
pub op: String,
|
||||||
|
pub script: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List the markers declared **directly at** `owner` (not inherited), ordered
|
||||||
|
/// deterministically. Used by `load_current` (apply diff) and `interceptors ls`.
|
||||||
|
pub async fn list_for_owner(
|
||||||
|
pool: &PgPool,
|
||||||
|
owner: ScriptOwner,
|
||||||
|
) -> Result<Vec<InterceptorMarker>, sqlx::Error> {
|
||||||
|
let rows: Vec<(String, String, String)> = match owner {
|
||||||
|
ScriptOwner::App(a) => {
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT service, op, interceptor_script FROM interceptors \
|
||||||
|
WHERE app_id = $1 ORDER BY service, op",
|
||||||
|
)
|
||||||
|
.bind(a.into_inner())
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
ScriptOwner::Group(g) => {
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT service, op, interceptor_script FROM interceptors \
|
||||||
|
WHERE group_id = $1 ORDER BY service, op",
|
||||||
|
)
|
||||||
|
.bind(g.into_inner())
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(service, op, script)| InterceptorMarker {
|
||||||
|
service,
|
||||||
|
op,
|
||||||
|
script,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All markers **visible to an app** — declared at the app or any ancestor
|
||||||
|
/// group (each `(service, op)` resolved nearest-owner-wins). Used by
|
||||||
|
/// `interceptors ls --app` and by the resolver's chain view.
|
||||||
|
pub async fn list_on_app_chain(
|
||||||
|
pool: &PgPool,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Vec<InterceptorMarker>, sqlx::Error> {
|
||||||
|
let rows: Vec<(String, String, String)> = sqlx::query_as(&format!(
|
||||||
|
"{CHAIN_LEVELS_CTE} \
|
||||||
|
SELECT DISTINCT ON (i.service, i.op) i.service, i.op, i.interceptor_script \
|
||||||
|
FROM chain c \
|
||||||
|
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",
|
||||||
|
))
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(service, op, script)| InterceptorMarker {
|
||||||
|
service,
|
||||||
|
op,
|
||||||
|
script,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the interceptor script guarding `(service, op)` for a calling app —
|
||||||
|
/// the nearest declaration on the app's chain (app, then nearest ancestor
|
||||||
|
/// group). `None` when un-hooked. **This chain walk is the resolution boundary**
|
||||||
|
/// — a sibling-subtree app never sees another subtree's interceptor.
|
||||||
|
pub async fn resolve_before(
|
||||||
|
pool: &PgPool,
|
||||||
|
app_id: AppId,
|
||||||
|
service: &str,
|
||||||
|
op: &str,
|
||||||
|
) -> Result<Option<String>, sqlx::Error> {
|
||||||
|
let row: Option<(String,)> = sqlx::query_as(&format!(
|
||||||
|
"{CHAIN_LEVELS_CTE} \
|
||||||
|
SELECT i.interceptor_script 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",
|
||||||
|
))
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(service)
|
||||||
|
.bind(op)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|(s,)| s))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upsert a marker at `owner` in the apply transaction. A re-apply that changes
|
||||||
|
/// only the interceptor script updates it in place (no version churn on the
|
||||||
|
/// `(service, op)` identity).
|
||||||
|
pub async fn insert_interceptor_tx(
|
||||||
|
tx: &mut Transaction<'_, Postgres>,
|
||||||
|
owner: ScriptOwner,
|
||||||
|
service: &str,
|
||||||
|
op: &str,
|
||||||
|
script: &str,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
match owner {
|
||||||
|
ScriptOwner::App(a) => {
|
||||||
|
sqlx::query(
|
||||||
|
"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 \
|
||||||
|
DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, updated_at = NOW()",
|
||||||
|
)
|
||||||
|
.bind(a.into_inner())
|
||||||
|
.bind(service)
|
||||||
|
.bind(op)
|
||||||
|
.bind(script)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
ScriptOwner::Group(g) => {
|
||||||
|
sqlx::query(
|
||||||
|
"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 \
|
||||||
|
DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, updated_at = NOW()",
|
||||||
|
)
|
||||||
|
.bind(g.into_inner())
|
||||||
|
.bind(service)
|
||||||
|
.bind(op)
|
||||||
|
.bind(script)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a marker at `owner` (by `(service, op)`), in the apply transaction.
|
||||||
|
/// Used by `--prune` when the manifest stops declaring it.
|
||||||
|
pub async fn delete_interceptor_tx(
|
||||||
|
tx: &mut Transaction<'_, Postgres>,
|
||||||
|
owner: ScriptOwner,
|
||||||
|
service: &str,
|
||||||
|
op: &str,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
match owner {
|
||||||
|
ScriptOwner::App(a) => {
|
||||||
|
sqlx::query("DELETE FROM interceptors WHERE app_id = $1 AND service = $2 AND op = $3")
|
||||||
|
.bind(a.into_inner())
|
||||||
|
.bind(service)
|
||||||
|
.bind(op)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
ScriptOwner::Group(g) => {
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM interceptors WHERE group_id = $1 AND service = $2 AND op = $3",
|
||||||
|
)
|
||||||
|
.bind(g.into_inner())
|
||||||
|
.bind(service)
|
||||||
|
.bind(op)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
37
crates/manager-core/src/interceptor_service.rs
Normal file
37
crates/manager-core/src/interceptor_service.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
//! `InterceptorServiceImpl` — the Postgres-backed §9.4 interceptor resolver
|
||||||
|
//! injected into `Services`. Resolve-only: it maps `(cx.app_id, service, op)`
|
||||||
|
//! to the nearest interceptor script name on the calling app's chain (via
|
||||||
|
//! [`crate::interceptor_repo::resolve_before`]). Running that script is the
|
||||||
|
//! executor's job (the `invoke()` re-entry path), which keeps `executor-core`
|
||||||
|
//! Postgres-free.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use picloud_shared::{InterceptorService, SdkCallCx};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
pub struct InterceptorServiceImpl {
|
||||||
|
pool: PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InterceptorServiceImpl {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl InterceptorService for InterceptorServiceImpl {
|
||||||
|
async fn resolve_before(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
service: &str,
|
||||||
|
op: &str,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
// `app_id` derives from `cx` (never a script arg) — the isolation
|
||||||
|
// boundary; the chain walk then scopes resolution to this app's subtree.
|
||||||
|
crate::interceptor_repo::resolve_before(&self.pool, cx.app_id, service, op)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,8 @@ pub mod group_repo;
|
|||||||
pub mod group_scripts_api;
|
pub mod group_scripts_api;
|
||||||
pub mod groups_api;
|
pub mod groups_api;
|
||||||
pub mod http_service;
|
pub mod http_service;
|
||||||
|
pub mod interceptor_repo;
|
||||||
|
pub mod interceptor_service;
|
||||||
pub mod invoke_service;
|
pub mod invoke_service;
|
||||||
pub mod kv_api;
|
pub mod kv_api;
|
||||||
pub mod kv_repo;
|
pub mod kv_repo;
|
||||||
|
|||||||
@@ -308,6 +308,16 @@ table: groups
|
|||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
updated_at: timestamp with time zone NOT NULL default=now()
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: interceptors
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
group_id: uuid NULL
|
||||||
|
app_id: uuid NULL
|
||||||
|
service: text NOT NULL
|
||||||
|
op: text NOT NULL
|
||||||
|
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()
|
||||||
|
|
||||||
table: kv_entries
|
table: kv_entries
|
||||||
app_id: uuid NOT NULL
|
app_id: uuid NOT NULL
|
||||||
collection: text NOT NULL
|
collection: text NOT NULL
|
||||||
@@ -663,6 +673,13 @@ indexes on groups:
|
|||||||
groups_pkey: public.groups USING btree (id)
|
groups_pkey: public.groups USING btree (id)
|
||||||
groups_slug_key: public.groups USING btree (slug)
|
groups_slug_key: public.groups USING btree (slug)
|
||||||
|
|
||||||
|
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_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_pkey: public.interceptors USING btree (id)
|
||||||
|
|
||||||
indexes on kv_entries:
|
indexes on kv_entries:
|
||||||
idx_kv_entries_app_collection: public.kv_entries USING btree (app_id, collection)
|
idx_kv_entries_app_collection: public.kv_entries USING btree (app_id, collection)
|
||||||
kv_entries_pkey: public.kv_entries USING btree (app_id, collection, key)
|
kv_entries_pkey: public.kv_entries USING btree (app_id, collection, key)
|
||||||
@@ -933,6 +950,12 @@ constraints on groups:
|
|||||||
[PRIMARY KEY] groups_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] groups_pkey: PRIMARY KEY (id)
|
||||||
[UNIQUE] groups_slug_key: UNIQUE (slug)
|
[UNIQUE] groups_slug_key: UNIQUE (slug)
|
||||||
|
|
||||||
|
constraints on interceptors:
|
||||||
|
[CHECK] interceptors_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||||
|
[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)
|
||||||
|
|
||||||
constraints on kv_entries:
|
constraints on kv_entries:
|
||||||
[FOREIGN KEY] kv_entries_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
[FOREIGN KEY] kv_entries_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] kv_entries_pkey: PRIMARY KEY (app_id, collection, key)
|
[PRIMARY KEY] kv_entries_pkey: PRIMARY KEY (app_id, collection, key)
|
||||||
@@ -1121,3 +1144,4 @@ constraints on workflows:
|
|||||||
0070: admin session absolute expiry
|
0070: admin session absolute expiry
|
||||||
0071: workflows
|
0071: workflows
|
||||||
0072: execution source workflow
|
0072: execution source workflow
|
||||||
|
0073: interceptors
|
||||||
|
|||||||
@@ -1738,6 +1738,8 @@ pub struct PlanDto {
|
|||||||
pub suppressions: Vec<ChangeDto>,
|
pub suppressions: Vec<ChangeDto>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub workflows: Vec<ChangeDto>,
|
pub workflows: Vec<ChangeDto>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub interceptors: Vec<ChangeDto>,
|
||||||
/// Fingerprint of the live state this plan was computed against; carried
|
/// Fingerprint of the live state this plan was computed against; carried
|
||||||
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
|
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -1815,6 +1817,8 @@ pub struct NodePlanDto {
|
|||||||
pub suppressions: Vec<ChangeDto>,
|
pub suppressions: Vec<ChangeDto>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub workflows: Vec<ChangeDto>,
|
pub workflows: Vec<ChangeDto>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub interceptors: Vec<ChangeDto>,
|
||||||
/// §7 M3: this node's ownership outcome under the tree's `[project]`.
|
/// §7 M3: this node's ownership outcome under the tree's `[project]`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ownership: Option<OwnershipPreviewDto>,
|
pub ownership: Option<OwnershipPreviewDto>,
|
||||||
@@ -1880,6 +1884,12 @@ pub struct ApplyReportDto {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub workflows_deleted: u32,
|
pub workflows_deleted: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub interceptors_created: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub interceptors_updated: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub interceptors_deleted: u32,
|
||||||
|
#[serde(default)]
|
||||||
pub warnings: Vec<String>,
|
pub warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,15 @@ pub async fn run(
|
|||||||
"+{} ~{} -{}",
|
"+{} ~{} -{}",
|
||||||
report.workflows_created, report.workflows_updated, report.workflows_deleted
|
report.workflows_created, report.workflows_updated, report.workflows_deleted
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
.field(
|
||||||
|
"interceptors",
|
||||||
|
format!(
|
||||||
|
"+{} ~{} -{}",
|
||||||
|
report.interceptors_created,
|
||||||
|
report.interceptors_updated,
|
||||||
|
report.interceptors_deleted
|
||||||
|
),
|
||||||
);
|
);
|
||||||
for w in &report.warnings {
|
for w in &report.warnings {
|
||||||
block.field("warning", w.clone());
|
block.field("warning", w.clone());
|
||||||
@@ -283,6 +292,15 @@ pub async fn run_tree(
|
|||||||
"+{} ~{} -{}",
|
"+{} ~{} -{}",
|
||||||
report.workflows_created, report.workflows_updated, report.workflows_deleted
|
report.workflows_created, report.workflows_updated, report.workflows_deleted
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
.field(
|
||||||
|
"interceptors",
|
||||||
|
format!(
|
||||||
|
"+{} ~{} -{}",
|
||||||
|
report.interceptors_created,
|
||||||
|
report.interceptors_updated,
|
||||||
|
report.interceptors_deleted
|
||||||
|
),
|
||||||
);
|
);
|
||||||
for w in &report.warnings {
|
for w in &report.warnings {
|
||||||
block.field("warning", w.clone());
|
block.field("warning", w.clone());
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
|||||||
vars: std::collections::BTreeMap::new(),
|
vars: std::collections::BTreeMap::new(),
|
||||||
suppress: crate::manifest::ManifestSuppress::default(),
|
suppress: crate::manifest::ManifestSuppress::default(),
|
||||||
workflows: Vec::new(),
|
workflows: Vec::new(),
|
||||||
|
interceptors: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let groups: [(&str, &Vec<ChangeDto>); 9] = [
|
let groups: [(&str, &Vec<ChangeDto>); 10] = [
|
||||||
("script", &n.scripts),
|
("script", &n.scripts),
|
||||||
("route", &n.routes),
|
("route", &n.routes),
|
||||||
("trigger", &n.triggers),
|
("trigger", &n.triggers),
|
||||||
@@ -116,6 +116,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
|||||||
("collection", &n.collections),
|
("collection", &n.collections),
|
||||||
("suppression", &n.suppressions),
|
("suppression", &n.suppressions),
|
||||||
("workflow", &n.workflows),
|
("workflow", &n.workflows),
|
||||||
|
("interceptor", &n.interceptors),
|
||||||
];
|
];
|
||||||
for (rk, changes) in groups {
|
for (rk, changes) in groups {
|
||||||
for c in changes {
|
for c in changes {
|
||||||
@@ -252,6 +253,17 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(workflow_to_wire)
|
.map(workflow_to_wire)
|
||||||
.collect::<Result<Vec<_>>>()?,
|
.collect::<Result<Vec<_>>>()?,
|
||||||
|
// §9.4: expand each `[[interceptors]]` entry's `ops` into one wire
|
||||||
|
// marker per (service, op).
|
||||||
|
"interceptors": manifest
|
||||||
|
.interceptors
|
||||||
|
.iter()
|
||||||
|
.flat_map(|i| {
|
||||||
|
i.ops.iter().map(move |op| {
|
||||||
|
json!({ "service": i.service, "op": op, "script": i.script })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +307,7 @@ fn render(plan: &PlanDto, mode: OutputMode) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let groups: [(&str, &Vec<ChangeDto>); 9] = [
|
let groups: [(&str, &Vec<ChangeDto>); 10] = [
|
||||||
("script", &plan.scripts),
|
("script", &plan.scripts),
|
||||||
("route", &plan.routes),
|
("route", &plan.routes),
|
||||||
("trigger", &plan.triggers),
|
("trigger", &plan.triggers),
|
||||||
@@ -305,6 +317,7 @@ fn render(plan: &PlanDto, mode: OutputMode) {
|
|||||||
("collection", &plan.collections),
|
("collection", &plan.collections),
|
||||||
("suppression", &plan.suppressions),
|
("suppression", &plan.suppressions),
|
||||||
("workflow", &plan.workflows),
|
("workflow", &plan.workflows),
|
||||||
|
("interceptor", &plan.interceptors),
|
||||||
];
|
];
|
||||||
for (kind, changes) in groups {
|
for (kind, changes) in groups {
|
||||||
for c in changes {
|
for c in changes {
|
||||||
|
|||||||
@@ -268,6 +268,9 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
|||||||
vars: manifest_vars,
|
vars: manifest_vars,
|
||||||
suppress: crate::manifest::ManifestSuppress::default(),
|
suppress: crate::manifest::ManifestSuppress::default(),
|
||||||
workflows: workflows.iter().map(wire_workflow_to_manifest).collect(),
|
workflows: workflows.iter().map(wire_workflow_to_manifest).collect(),
|
||||||
|
// `pull` does not round-trip interceptor markers yet (read surface TBD);
|
||||||
|
// an interceptor authored in the manifest survives re-apply regardless.
|
||||||
|
interceptors: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||||
|
|||||||
@@ -64,6 +64,29 @@ pub struct Manifest {
|
|||||||
/// App-owned; a `[group]` carrying them is rejected server-side.
|
/// App-owned; a `[group]` carrying them is rejected server-side.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub workflows: Vec<ManifestWorkflow>,
|
pub workflows: Vec<ManifestWorkflow>,
|
||||||
|
/// `[[interceptors]]` (§9.4) — before-op allow/deny hooks. Each names a
|
||||||
|
/// `script` and the `ops` it guards on a `service` (MVP: `service = "kv"`,
|
||||||
|
/// `ops ⊆ [set, delete]`). Authored on an app OR group node.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub interceptors: Vec<ManifestInterceptor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `[[interceptors]]` entry — an interceptor script and the operations it
|
||||||
|
/// guards. `ops` expands to one wire marker per `(service, op)` at apply.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ManifestInterceptor {
|
||||||
|
/// Name of the interceptor script (resolved on the node's chain at run time).
|
||||||
|
pub script: String,
|
||||||
|
/// The guarded service. MVP: only `"kv"`.
|
||||||
|
#[serde(default = "default_interceptor_service")]
|
||||||
|
pub service: String,
|
||||||
|
/// The guarded operations, e.g. `["set", "delete"]`.
|
||||||
|
pub ops: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_interceptor_service() -> String {
|
||||||
|
"kv".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Manifest {
|
impl Manifest {
|
||||||
@@ -783,6 +806,7 @@ mod tests {
|
|||||||
]),
|
]),
|
||||||
suppress: ManifestSuppress::default(),
|
suppress: ManifestSuppress::default(),
|
||||||
workflows: Vec::new(),
|
workflows: Vec::new(),
|
||||||
|
interceptors: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,6 +914,7 @@ mod tests {
|
|||||||
vars: BTreeMap::new(),
|
vars: BTreeMap::new(),
|
||||||
suppress: ManifestSuppress::default(),
|
suppress: ManifestSuppress::default(),
|
||||||
workflows: Vec::new(),
|
workflows: Vec::new(),
|
||||||
|
interceptors: Vec::new(),
|
||||||
};
|
};
|
||||||
let text = m.to_toml().unwrap();
|
let text = m.to_toml().unwrap();
|
||||||
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ mod group_secrets;
|
|||||||
mod group_triggers;
|
mod group_triggers;
|
||||||
mod groups;
|
mod groups;
|
||||||
mod init;
|
mod init;
|
||||||
|
mod interceptors;
|
||||||
mod invoke;
|
mod invoke;
|
||||||
mod logs;
|
mod logs;
|
||||||
mod output;
|
mod output;
|
||||||
|
|||||||
193
crates/picloud-cli/tests/interceptors.rs
Normal file
193
crates/picloud-cli/tests/interceptors.rs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
//! §9.4 Service Interceptors, end to end via `pic` — the KV allow/deny slice.
|
||||||
|
//!
|
||||||
|
//! A `[[interceptors]]` marker binds a script to run BEFORE `kv::set`/`delete`.
|
||||||
|
//! 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).
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
use crate::common::cleanup::{AppGuard, GroupGuard};
|
||||||
|
|
||||||
|
fn manifest_dir() -> TempDir {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
|
||||||
|
let ls = common::pic_as(env)
|
||||||
|
.args(["scripts", "ls", "--app", app])
|
||||||
|
.output()
|
||||||
|
.expect("scripts ls");
|
||||||
|
let table = String::from_utf8(ls.stdout).unwrap();
|
||||||
|
table
|
||||||
|
.lines()
|
||||||
|
.map(common::cells)
|
||||||
|
.find(|c| c.get(2) == Some(&name))
|
||||||
|
.and_then(|c| c.first().map(|s| (*s).to_string()))
|
||||||
|
.unwrap_or_else(|| panic!("script `{name}` not found:\n{table}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
|
||||||
|
let out = common::pic_as(env)
|
||||||
|
.args(["scripts", "invoke", id])
|
||||||
|
.output()
|
||||||
|
.expect("scripts invoke");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"invoke failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A guard that denies a `kv::set` of the key `"secret"` and allows everything
|
||||||
|
/// else, reading the op context from `ctx.request.body`.
|
||||||
|
const GUARD: &str = r#"
|
||||||
|
let op = ctx.request.body;
|
||||||
|
if op.action == "set" && op.key == "secret" {
|
||||||
|
#{ allowed: false, reason: "the `secret` key is protected" }
|
||||||
|
} else {
|
||||||
|
#{ allowed: true }
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// A writer that tries a denied set (caught), then a permitted set.
|
||||||
|
const WRITER: &str = r#"
|
||||||
|
let denied = false;
|
||||||
|
try { kv::collection("c").set("secret", 1); } catch(e) { denied = true; }
|
||||||
|
kv::collection("c").set("ok", 2);
|
||||||
|
#{ denied: denied }
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn app_interceptor_denies_a_guarded_kv_write_and_allows_others() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let app = common::unique_slug("ic-app");
|
||||||
|
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &app])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// One apply deploys the guard + writer scripts AND registers the marker.
|
||||||
|
let dir = manifest_dir();
|
||||||
|
fs::write(dir.path().join("scripts/guard.rhai"), GUARD).unwrap();
|
||||||
|
fs::write(dir.path().join("scripts/writer.rhai"), WRITER).unwrap();
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("picloud.toml"),
|
||||||
|
format!(
|
||||||
|
"[app]\nslug = \"{app}\"\nname = \"IC\"\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's guarded set is denied (caught), the permitted set goes through.
|
||||||
|
let body = invoke_body(&env, &app_script_id(&env, &app, "writer"));
|
||||||
|
assert_eq!(
|
||||||
|
body,
|
||||||
|
serde_json::json!({ "denied": true }),
|
||||||
|
"the `secret` set must be denied by the interceptor"
|
||||||
|
);
|
||||||
|
|
||||||
|
// `ok` was written; `secret` was not (the write never ran).
|
||||||
|
let ok = String::from_utf8(
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["kv", "get", "--app", &app, "--collection", "c", "ok"])
|
||||||
|
.output()
|
||||||
|
.unwrap()
|
||||||
|
.stdout,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(ok.contains('2'), "the allowed write must persist:\n{ok}");
|
||||||
|
let secret = common::pic_as(&env)
|
||||||
|
.args(["kv", "get", "--app", &app, "--collection", "c", "secret"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let secret_out = String::from_utf8_lossy(&secret.stdout);
|
||||||
|
assert!(
|
||||||
|
secret_out.trim() == "null" || secret_out.trim() == "()" || secret_out.trim().is_empty(),
|
||||||
|
"the denied write must NOT persist, got: {secret_out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn a_group_interceptor_is_inherited_by_a_descendant_app() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let group = common::unique_slug("icg-grp");
|
||||||
|
let app = common::unique_slug("icg-app");
|
||||||
|
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["groups", "create", &group])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// The GROUP owns the guard script + the interceptor marker.
|
||||||
|
let dir = manifest_dir();
|
||||||
|
fs::write(dir.path().join("scripts/guard.rhai"), GUARD).unwrap();
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("group.toml"),
|
||||||
|
format!(
|
||||||
|
"[group]\nslug = \"{group}\"\nname = \"ICG\"\n\n\
|
||||||
|
[[scripts]]\nname = \"guard\"\nfile = \"scripts/guard.rhai\"\n\n\
|
||||||
|
[[interceptors]]\nscript = \"guard\"\nops = [\"set\"]\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apply", "--file"])
|
||||||
|
.arg(dir.path().join("group.toml"))
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// An app UNDER the group inherits the interceptor (nearest-owner-wins), even
|
||||||
|
// though the marker + guard live on the group.
|
||||||
|
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/writer.rhai"), WRITER).unwrap();
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("app.toml"),
|
||||||
|
format!(
|
||||||
|
"[app]\nslug = \"{app}\"\nname = \"ICApp\"\n\n\
|
||||||
|
[[scripts]]\nname = \"writer\"\nfile = \"scripts/writer.rhai\"\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!({ "denied": true }),
|
||||||
|
"a descendant app must inherit the group's interceptor"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -416,6 +416,11 @@ pub async fn build_app(
|
|||||||
let workflow: Arc<dyn picloud_shared::WorkflowService> = Arc::new(
|
let workflow: Arc<dyn picloud_shared::WorkflowService> = Arc::new(
|
||||||
picloud_manager_core::WorkflowServiceImpl::new(pool.clone()).with_authz(authz.clone()),
|
picloud_manager_core::WorkflowServiceImpl::new(pool.clone()).with_authz(authz.clone()),
|
||||||
);
|
);
|
||||||
|
// §9.4 interceptors: resolve-only (which script guards a kv op); running it
|
||||||
|
// reuses the invoke() re-entry path.
|
||||||
|
let interceptors: Arc<dyn picloud_shared::InterceptorService> = Arc::new(
|
||||||
|
picloud_manager_core::interceptor_service::InterceptorServiceImpl::new(pool.clone()),
|
||||||
|
);
|
||||||
let services = Services::new(
|
let services = Services::new(
|
||||||
kv,
|
kv,
|
||||||
docs,
|
docs,
|
||||||
@@ -437,7 +442,8 @@ pub async fn build_app(
|
|||||||
.with_group_files(group_files)
|
.with_group_files(group_files)
|
||||||
.with_group_pubsub(group_pubsub)
|
.with_group_pubsub(group_pubsub)
|
||||||
.with_group_queue(group_queue)
|
.with_group_queue(group_queue)
|
||||||
.with_workflow(workflow);
|
.with_workflow(workflow)
|
||||||
|
.with_interceptors(interceptors);
|
||||||
// v1.1.9: keep the invoke depth bound aligned with the dispatcher's
|
// v1.1.9: keep the invoke depth bound aligned with the dispatcher's
|
||||||
// trigger-depth bound (same counter under the hood).
|
// trigger-depth bound (same counter under the hood).
|
||||||
let engine_limits = Limits {
|
let engine_limits = Limits {
|
||||||
|
|||||||
51
crates/shared/src/interceptor.rs
Normal file
51
crates/shared/src/interceptor.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
//! §9.4 Service Interceptors — the injected resolver seam.
|
||||||
|
//!
|
||||||
|
//! An interceptor is a script registered (declaratively, per app/group) to run
|
||||||
|
//! **before** a data-plane operation and allow or deny it. This trait is the
|
||||||
|
//! narrow, executor-facing seam: it only RESOLVES which interceptor script (by
|
||||||
|
//! name) applies to a `(service, op)` on the calling app's chain — nearest-owner
|
||||||
|
//! wins, like extension points. Running the resolved script reuses the existing
|
||||||
|
//! `invoke()` re-entry path in `executor-core` (resolve the name → compile →
|
||||||
|
//! execute), so `executor-core` stays Postgres-free and there is no second
|
||||||
|
//! script-dispatch mechanism.
|
||||||
|
//!
|
||||||
|
//! MVP scope (v1.2): `service = "kv"`, `op ∈ {set, delete}`, allow/deny only —
|
||||||
|
//! no data transform, no chaining, no `after_*` hooks. See blueprint §9.4.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::SdkCallCx;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait InterceptorService: Send + Sync {
|
||||||
|
/// The NAME of the interceptor script registered for `(service, op)` at the
|
||||||
|
/// nearest owner on `cx.app_id`'s chain (app, else nearest ancestor group),
|
||||||
|
/// or `None` when the operation is un-hooked. The executor resolves that
|
||||||
|
/// name to a script and runs it. `Err` is a backend failure (fail-closed:
|
||||||
|
/// the caller turns it into an operation error rather than silently
|
||||||
|
/// allowing).
|
||||||
|
async fn resolve_before(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
service: &str,
|
||||||
|
op: &str,
|
||||||
|
) -> Result<Option<String>, String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default: nothing is ever intercepted. The shape every non-picloud `Services`
|
||||||
|
/// (tests, cluster skeletons) gets for free — an un-hooked write pays exactly
|
||||||
|
/// one `Ok(None)` here, no I/O.
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct NoopInterceptorService;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl InterceptorService for NoopInterceptorService {
|
||||||
|
async fn resolve_before(
|
||||||
|
&self,
|
||||||
|
_cx: &SdkCallCx,
|
||||||
|
_service: &str,
|
||||||
|
_op: &str,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ pub mod group_queue;
|
|||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
pub mod inbox;
|
pub mod inbox;
|
||||||
|
pub mod interceptor;
|
||||||
pub mod invoke;
|
pub mod invoke;
|
||||||
pub mod kv;
|
pub mod kv;
|
||||||
pub mod log_sink;
|
pub mod log_sink;
|
||||||
@@ -86,6 +87,7 @@ pub use ids::{
|
|||||||
pub use inbox::{
|
pub use inbox::{
|
||||||
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
|
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
|
||||||
};
|
};
|
||||||
|
pub use interceptor::{InterceptorService, NoopInterceptorService};
|
||||||
pub use invoke::{InvokeError, InvokeService, InvokeTarget, NoopInvokeService, ResolvedScript};
|
pub use invoke::{InvokeError, InvokeService, InvokeTarget, NoopInvokeService, ResolvedScript};
|
||||||
pub use kv::{KvError, KvListPage, KvService, NoopKvService};
|
pub use kv::{KvError, KvListPage, KvService, NoopKvService};
|
||||||
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ use std::sync::Arc;
|
|||||||
use crate::{
|
use crate::{
|
||||||
DeadLetterService, DocsService, EmailService, FilesService, GroupDocsService,
|
DeadLetterService, DocsService, EmailService, FilesService, GroupDocsService,
|
||||||
GroupFilesService, GroupKvService, GroupPubsubService, GroupQueueService, HttpService,
|
GroupFilesService, GroupKvService, GroupPubsubService, GroupQueueService, HttpService,
|
||||||
InvokeService, KvService, ModuleSource, NoopDeadLetterService, NoopDocsService,
|
InterceptorService, InvokeService, KvService, ModuleSource, NoopDeadLetterService,
|
||||||
NoopEmailService, NoopEventEmitter, NoopFilesService, NoopGroupDocsService,
|
NoopDocsService, NoopEmailService, NoopEventEmitter, NoopFilesService, NoopGroupDocsService,
|
||||||
NoopGroupFilesService, NoopGroupKvService, NoopGroupPubsubService, NoopGroupQueueService,
|
NoopGroupFilesService, NoopGroupKvService, NoopGroupPubsubService, NoopGroupQueueService,
|
||||||
NoopHttpService, NoopInvokeService, NoopKvService, NoopModuleSource, NoopPubsubService,
|
NoopHttpService, NoopInterceptorService, NoopInvokeService, NoopKvService, NoopModuleSource,
|
||||||
NoopQueueService, NoopSecretsService, NoopUsersService, NoopVarsService, NoopWorkflowService,
|
NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService, NoopVarsService,
|
||||||
PubsubService, QueueService, SecretsService, ServiceEventEmitter, UsersService, VarsService,
|
NoopWorkflowService, PubsubService, QueueService, SecretsService, ServiceEventEmitter,
|
||||||
WorkflowService,
|
UsersService, VarsService, WorkflowService,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
|
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
|
||||||
@@ -152,6 +152,12 @@ pub struct Services {
|
|||||||
/// run of a named workflow in the caller's app. Wired via
|
/// run of a named workflow in the caller's app. Wired via
|
||||||
/// [`Services::with_workflow`]; defaults to `NoopWorkflowService`.
|
/// [`Services::with_workflow`]; defaults to `NoopWorkflowService`.
|
||||||
pub workflow: Arc<dyn WorkflowService>,
|
pub workflow: Arc<dyn WorkflowService>,
|
||||||
|
|
||||||
|
/// §9.4 Service Interceptors — resolves which (if any) interceptor script
|
||||||
|
/// guards a `(service, op)` before it runs. Wired via
|
||||||
|
/// [`Services::with_interceptors`]; defaults to `NoopInterceptorService`
|
||||||
|
/// (nothing intercepted).
|
||||||
|
pub interceptors: Arc<dyn InterceptorService>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Services {
|
impl Services {
|
||||||
@@ -200,9 +206,18 @@ impl Services {
|
|||||||
group_pubsub: Arc::new(NoopGroupPubsubService),
|
group_pubsub: Arc::new(NoopGroupPubsubService),
|
||||||
group_queue: Arc::new(NoopGroupQueueService),
|
group_queue: Arc::new(NoopGroupQueueService),
|
||||||
workflow: Arc::new(NoopWorkflowService),
|
workflow: Arc::new(NoopWorkflowService),
|
||||||
|
interceptors: Arc::new(NoopInterceptorService),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the §9.4 interceptor resolver (picloud binary wires the
|
||||||
|
/// Postgres-backed impl; tests leave the noop default = nothing hooked).
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_interceptors(mut self, interceptors: Arc<dyn InterceptorService>) -> Self {
|
||||||
|
self.interceptors = interceptors;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the v1.2 Workflows service (picloud binary wires the Postgres-backed
|
/// Set the v1.2 Workflows service (picloud binary wires the Postgres-backed
|
||||||
/// impl; tests leave the noop default).
|
/// impl; tests leave the noop default).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Project Blueprint: Lightweight Event-Based Serverless Cloud
|
# Project Blueprint: Lightweight Event-Based Serverless Cloud
|
||||||
|
|
||||||
**Status**: v1.1 shipped (SDK + services) · v1.2 *Hierarchies* track **complete** · v1.2 *Workflows* track **shipped** (M1–M6: DAG execution, conditional branching, nested sub-workflows, `workflow::start` SDK, dashboard DAG + run-history); §9.4 service interceptors + v1.3 cluster mode are next
|
**Status**: v1.1 shipped (SDK + services) · v1.2 *Hierarchies* track **complete** · v1.2 *Workflows* track **shipped** (M1–M6: DAG execution, conditional branching, nested sub-workflows, `workflow::start` SDK, dashboard DAG + run-history); a §9.4 service-interceptor KV allow/deny slice has shipped (migration 0073); the rest of §9.4 + v1.3 cluster mode are next
|
||||||
**Last Updated**: 2026-07-12 (reconciled to shipped code — CLAUDE.md is the live source of truth)
|
**Last Updated**: 2026-07-12 (reconciled to shipped code — CLAUDE.md is the live source of truth)
|
||||||
**Audience**: Solo developer (DIY self-hosted)
|
**Audience**: Solo developer (DIY self-hosted)
|
||||||
|
|
||||||
@@ -171,8 +171,8 @@ rhai_executor --script $SCRIPT_PATH --request "$REQUEST_JSON"
|
|||||||
|
|
||||||
### 3.4 PostgreSQL Database
|
### 3.4 PostgreSQL Database
|
||||||
**Schema (MVP sketch — NOT authoritative):** the block below is the original MVP shape. The **authoritative
|
**Schema (MVP sketch — NOT authoritative):** the block below is the original MVP shape. The **authoritative
|
||||||
schema is the migration set** in `crates/manager-core/migrations/` (through `0072` as of v1.2, incl. the
|
schema is the migration set** in `crates/manager-core/migrations/` (through `0073` as of v1.2, incl. the
|
||||||
Workflows tables), which has
|
Workflows tables + the §9.4 interceptor markers), which has
|
||||||
since added apps/domains, RBAC (`admin_users`/`app_members`/`api_keys`), the v1.1 data-plane services
|
since added apps/domains, RBAC (`admin_users`/`app_members`/`api_keys`), the v1.1 data-plane services
|
||||||
(KV/docs/files/queues/…), and the v1.2 groups/collections/templates/projects tables. Treat this as
|
(KV/docs/files/queues/…), and the v1.2 groups/collections/templates/projects tables. Treat this as
|
||||||
illustration only.
|
illustration only.
|
||||||
@@ -1697,6 +1697,16 @@ CREATE INDEX idx_execution_parent ON execution_logs(parent_execution_id);
|
|||||||
|
|
||||||
### 9.4 Service Interceptors & Middleware (v1.2+)
|
### 9.4 Service Interceptors & Middleware (v1.2+)
|
||||||
|
|
||||||
|
> **Status — thin KV allow/deny slice SHIPPED** (migration `0073_interceptors.sql`). A `[[interceptors]]`
|
||||||
|
> manifest block (app OR group) binds a script to run BEFORE `kv::set` / `kv::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). Registration is a marker
|
||||||
|
> `(owner, service, op) → script`, resolved **nearest-owner-wins** on the calling app's chain (an app overrides
|
||||||
|
> a group's), mirroring extension points (§5.5); the interceptor script itself is resolved + run through the
|
||||||
|
> `invoke()` re-entry path (shared depth bound, AST cache). **Deferred (the rest of the spec below):** the
|
||||||
|
> `data` transform return, services other than `kv`, `after_*` hooks, interceptor chaining + circular-dependency
|
||||||
|
> guard, and the timeout policy. The remaining subsections describe that full design.
|
||||||
|
|
||||||
**Concept**: A script can act as middleware to intercept and validate/transform service operations before they execute.
|
**Concept**: A script can act as middleware to intercept and validate/transform service operations before they execute.
|
||||||
|
|
||||||
**Use Cases:**
|
**Use Cases:**
|
||||||
|
|||||||
Reference in New Issue
Block a user