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:
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
|
||||
|
||||
Reference in New Issue
Block a user