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()
|
||||
})?;
|
||||
|
||||
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 {
|
||||
execution_id,
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: cx.request_id,
|
||||
script_id: resolved.script_id,
|
||||
script_name: resolved.name.clone(),
|
||||
@@ -173,7 +192,7 @@ fn invoke_blocking(
|
||||
path: "/invoke".into(),
|
||||
method: String::new(),
|
||||
headers: BTreeMap::new(),
|
||||
body: args_json,
|
||||
body: body_json,
|
||||
params: BTreeMap::new(),
|
||||
query: BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
@@ -183,42 +202,24 @@ fn invoke_blocking(
|
||||
// script's imports resolve from the group even when invoked by an
|
||||
// app. `None` falls back to `App(cx.app_id)` in the engine.
|
||||
script_owner: resolved.owner,
|
||||
// Same-app invoke is a function call, not a re-auth boundary —
|
||||
// inherit the caller's principal.
|
||||
// Same-app re-entry is not a re-auth boundary — inherit the principal.
|
||||
principal: cx.principal.clone(),
|
||||
trigger_depth: cx.trigger_depth + 1,
|
||||
root_execution_id: cx.root_execution_id,
|
||||
is_dead_letter_handler: cx.is_dead_letter_handler,
|
||||
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
|
||||
.compile_for_identity(resolved.script_id, resolved.updated_at, &resolved.source)
|
||||
.map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("invoke({target_label}): {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
EvalAltResult::ErrorRuntime(format!("{label}: {e}").into(), rhai::Position::NONE).into()
|
||||
})?;
|
||||
let resp = self_engine
|
||||
.execute_ast(&ast, req)
|
||||
.map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("invoke({target_label}): {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
EvalAltResult::ErrorRuntime(format!("{label}: {e}").into(), rhai::Position::NONE).into()
|
||||
})?;
|
||||
|
||||
// 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))
|
||||
Ok(resp.body)
|
||||
}
|
||||
|
||||
/// Accept a string (route path OR script name) or a Rhai script-id
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod docs;
|
||||
pub mod email;
|
||||
pub mod files;
|
||||
pub mod http;
|
||||
pub mod interceptor;
|
||||
pub mod invoke;
|
||||
pub mod kv;
|
||||
pub mod pubsub;
|
||||
@@ -55,7 +56,7 @@ pub fn register_all(
|
||||
limits: Limits,
|
||||
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());
|
||||
dead_letters::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`.
|
||||
#[serde(default)]
|
||||
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.
|
||||
@@ -467,6 +482,10 @@ pub struct Plan {
|
||||
/// v1.2 Workflows: workflow definitions, keyed by name.
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@@ -483,6 +502,7 @@ impl Plan {
|
||||
.chain(&self.collections)
|
||||
.chain(&self.suppressions)
|
||||
.chain(&self.workflows)
|
||||
.chain(&self.interceptors)
|
||||
.all(|c| c.op == Op::NoOp)
|
||||
}
|
||||
}
|
||||
@@ -765,6 +785,9 @@ pub struct CurrentState {
|
||||
pub suppressions: Vec<(String, String)>,
|
||||
/// v1.2 Workflows owned directly by this node (app-owned; empty for a group).
|
||||
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).
|
||||
@@ -1040,6 +1063,7 @@ impl ApplyService {
|
||||
/// `validate_bundle`, plus a group-node guard: a group owns only scripts +
|
||||
/// vars (+ declared secret names), so a `[group]` manifest carrying routes
|
||||
/// or triggers is a 422 — those are app concerns.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn validate_bundle_for(
|
||||
&self,
|
||||
is_group: bool,
|
||||
@@ -1173,6 +1197,35 @@ impl ApplyService {
|
||||
for w in &bundle.workflows {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// (idempotent). Name-only identity; deletes happen in prune. Pruning a
|
||||
// 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).
|
||||
// Only the marker is removed here — the data survives until the
|
||||
// owning group is deleted.
|
||||
@@ -4048,6 +4148,14 @@ impl ApplyService {
|
||||
}
|
||||
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 {
|
||||
scripts,
|
||||
routes,
|
||||
@@ -4058,6 +4166,7 @@ impl ApplyService {
|
||||
collections,
|
||||
suppressions,
|
||||
workflows,
|
||||
interceptors,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4124,9 +4233,58 @@ fn compute_diff_with_names(
|
||||
collections: diff_collections(current, bundle),
|
||||
suppressions: diff_suppressions(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
|
||||
/// 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
|
||||
@@ -5472,6 +5630,12 @@ pub struct ApplyReport {
|
||||
pub workflows_updated: u32,
|
||||
#[serde(default)]
|
||||
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")]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
@@ -5816,6 +5980,7 @@ mod tests {
|
||||
suppress_triggers: Vec::new(),
|
||||
suppress_routes: Vec::new(),
|
||||
workflows: Vec::new(),
|
||||
interceptors: Vec::new(),
|
||||
};
|
||||
let plan = compute_diff(¤t, &bundle);
|
||||
assert_eq!(plan.scripts.len(), 1);
|
||||
@@ -5843,6 +6008,7 @@ mod tests {
|
||||
suppress_triggers: Vec::new(),
|
||||
suppress_routes: Vec::new(),
|
||||
workflows: Vec::new(),
|
||||
interceptors: Vec::new(),
|
||||
};
|
||||
let plan = compute_diff(¤t, &bundle);
|
||||
assert!(plan.is_noop(), "expected all no-op, got {plan:?}");
|
||||
@@ -5865,6 +6031,7 @@ mod tests {
|
||||
suppress_triggers: Vec::new(),
|
||||
suppress_routes: Vec::new(),
|
||||
workflows: Vec::new(),
|
||||
interceptors: Vec::new(),
|
||||
};
|
||||
let plan = compute_diff(¤t, &bundle);
|
||||
assert_eq!(plan.scripts[0].op, Op::Update);
|
||||
@@ -5888,6 +6055,7 @@ mod tests {
|
||||
suppress_triggers: Vec::new(),
|
||||
suppress_routes: Vec::new(),
|
||||
workflows: Vec::new(),
|
||||
interceptors: Vec::new(),
|
||||
};
|
||||
let plan = compute_diff(¤t, &bundle);
|
||||
assert_eq!(plan.scripts[0].op, Op::Delete);
|
||||
@@ -5942,6 +6110,7 @@ mod tests {
|
||||
suppress_triggers: Vec::new(),
|
||||
suppress_routes: Vec::new(),
|
||||
workflows: Vec::new(),
|
||||
interceptors: Vec::new(),
|
||||
};
|
||||
let plan = compute_diff(¤t, &bundle);
|
||||
assert_eq!(plan.routes[0].op, Op::Update);
|
||||
@@ -5959,6 +6128,7 @@ mod tests {
|
||||
suppress_triggers: Vec::new(),
|
||||
suppress_routes: 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 groups_api;
|
||||
pub mod http_service;
|
||||
pub mod interceptor_repo;
|
||||
pub mod interceptor_service;
|
||||
pub mod invoke_service;
|
||||
pub mod kv_api;
|
||||
pub mod kv_repo;
|
||||
|
||||
@@ -308,6 +308,16 @@ table: groups
|
||||
created_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
|
||||
app_id: uuid NOT NULL
|
||||
collection: text NOT NULL
|
||||
@@ -663,6 +673,13 @@ indexes on groups:
|
||||
groups_pkey: public.groups USING btree (id)
|
||||
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:
|
||||
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)
|
||||
@@ -933,6 +950,12 @@ constraints on groups:
|
||||
[PRIMARY KEY] groups_pkey: PRIMARY KEY (id)
|
||||
[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:
|
||||
[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)
|
||||
@@ -1121,3 +1144,4 @@ constraints on workflows:
|
||||
0070: admin session absolute expiry
|
||||
0071: workflows
|
||||
0072: execution source workflow
|
||||
0073: interceptors
|
||||
|
||||
@@ -1738,6 +1738,8 @@ pub struct PlanDto {
|
||||
pub suppressions: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub workflows: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub interceptors: Vec<ChangeDto>,
|
||||
/// Fingerprint of the live state this plan was computed against; carried
|
||||
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
|
||||
#[serde(default)]
|
||||
@@ -1815,6 +1817,8 @@ pub struct NodePlanDto {
|
||||
pub suppressions: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub workflows: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub interceptors: Vec<ChangeDto>,
|
||||
/// §7 M3: this node's ownership outcome under the tree's `[project]`.
|
||||
#[serde(default)]
|
||||
pub ownership: Option<OwnershipPreviewDto>,
|
||||
@@ -1880,6 +1884,12 @@ pub struct ApplyReportDto {
|
||||
#[serde(default)]
|
||||
pub workflows_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub interceptors_created: u32,
|
||||
#[serde(default)]
|
||||
pub interceptors_updated: u32,
|
||||
#[serde(default)]
|
||||
pub interceptors_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -155,6 +155,15 @@ pub async fn run(
|
||||
"+{} ~{} -{}",
|
||||
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 {
|
||||
block.field("warning", w.clone());
|
||||
@@ -283,6 +292,15 @@ pub async fn run_tree(
|
||||
"+{} ~{} -{}",
|
||||
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 {
|
||||
block.field("warning", w.clone());
|
||||
|
||||
@@ -144,6 +144,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
suppress: crate::manifest::ManifestSuppress::default(),
|
||||
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),
|
||||
("route", &n.routes),
|
||||
("trigger", &n.triggers),
|
||||
@@ -116,6 +116,7 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
||||
("collection", &n.collections),
|
||||
("suppression", &n.suppressions),
|
||||
("workflow", &n.workflows),
|
||||
("interceptor", &n.interceptors),
|
||||
];
|
||||
for (rk, changes) in groups {
|
||||
for c in changes {
|
||||
@@ -252,6 +253,17 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
.iter()
|
||||
.map(workflow_to_wire)
|
||||
.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),
|
||||
("route", &plan.routes),
|
||||
("trigger", &plan.triggers),
|
||||
@@ -305,6 +317,7 @@ fn render(plan: &PlanDto, mode: OutputMode) {
|
||||
("collection", &plan.collections),
|
||||
("suppression", &plan.suppressions),
|
||||
("workflow", &plan.workflows),
|
||||
("interceptor", &plan.interceptors),
|
||||
];
|
||||
for (kind, changes) in groups {
|
||||
for c in changes {
|
||||
|
||||
@@ -268,6 +268,9 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
vars: manifest_vars,
|
||||
suppress: crate::manifest::ManifestSuppress::default(),
|
||||
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()?)
|
||||
|
||||
@@ -64,6 +64,29 @@ pub struct Manifest {
|
||||
/// App-owned; a `[group]` carrying them is rejected server-side.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
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 {
|
||||
@@ -783,6 +806,7 @@ mod tests {
|
||||
]),
|
||||
suppress: ManifestSuppress::default(),
|
||||
workflows: Vec::new(),
|
||||
interceptors: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -890,6 +914,7 @@ mod tests {
|
||||
vars: BTreeMap::new(),
|
||||
suppress: ManifestSuppress::default(),
|
||||
workflows: Vec::new(),
|
||||
interceptors: Vec::new(),
|
||||
};
|
||||
let text = m.to_toml().unwrap();
|
||||
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
||||
|
||||
@@ -35,6 +35,7 @@ mod group_secrets;
|
||||
mod group_triggers;
|
||||
mod groups;
|
||||
mod init;
|
||||
mod interceptors;
|
||||
mod invoke;
|
||||
mod logs;
|
||||
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(
|
||||
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(
|
||||
kv,
|
||||
docs,
|
||||
@@ -437,7 +442,8 @@ pub async fn build_app(
|
||||
.with_group_files(group_files)
|
||||
.with_group_pubsub(group_pubsub)
|
||||
.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
|
||||
// trigger-depth bound (same counter under the hood).
|
||||
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 ids;
|
||||
pub mod inbox;
|
||||
pub mod interceptor;
|
||||
pub mod invoke;
|
||||
pub mod kv;
|
||||
pub mod log_sink;
|
||||
@@ -86,6 +87,7 @@ pub use ids::{
|
||||
pub use inbox::{
|
||||
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
|
||||
};
|
||||
pub use interceptor::{InterceptorService, NoopInterceptorService};
|
||||
pub use invoke::{InvokeError, InvokeService, InvokeTarget, NoopInvokeService, ResolvedScript};
|
||||
pub use kv::{KvError, KvListPage, KvService, NoopKvService};
|
||||
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
||||
|
||||
@@ -22,13 +22,13 @@ use std::sync::Arc;
|
||||
use crate::{
|
||||
DeadLetterService, DocsService, EmailService, FilesService, GroupDocsService,
|
||||
GroupFilesService, GroupKvService, GroupPubsubService, GroupQueueService, HttpService,
|
||||
InvokeService, KvService, ModuleSource, NoopDeadLetterService, NoopDocsService,
|
||||
NoopEmailService, NoopEventEmitter, NoopFilesService, NoopGroupDocsService,
|
||||
InterceptorService, InvokeService, KvService, ModuleSource, NoopDeadLetterService,
|
||||
NoopDocsService, NoopEmailService, NoopEventEmitter, NoopFilesService, NoopGroupDocsService,
|
||||
NoopGroupFilesService, NoopGroupKvService, NoopGroupPubsubService, NoopGroupQueueService,
|
||||
NoopHttpService, NoopInvokeService, NoopKvService, NoopModuleSource, NoopPubsubService,
|
||||
NoopQueueService, NoopSecretsService, NoopUsersService, NoopVarsService, NoopWorkflowService,
|
||||
PubsubService, QueueService, SecretsService, ServiceEventEmitter, UsersService, VarsService,
|
||||
WorkflowService,
|
||||
NoopHttpService, NoopInterceptorService, NoopInvokeService, NoopKvService, NoopModuleSource,
|
||||
NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService, NoopVarsService,
|
||||
NoopWorkflowService, PubsubService, QueueService, SecretsService, ServiceEventEmitter,
|
||||
UsersService, VarsService, WorkflowService,
|
||||
};
|
||||
|
||||
/// 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
|
||||
/// [`Services::with_workflow`]; defaults to `NoopWorkflowService`.
|
||||
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 {
|
||||
@@ -200,9 +206,18 @@ impl Services {
|
||||
group_pubsub: Arc::new(NoopGroupPubsubService),
|
||||
group_queue: Arc::new(NoopGroupQueueService),
|
||||
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
|
||||
/// impl; tests leave the noop default).
|
||||
#[must_use]
|
||||
|
||||
Reference in New Issue
Block a user