feat(interceptors): ordered before-chains + cycle guard + ctx plumbing (§9.4 M1+M2)
M1: introduces a clone-cheap InterceptorCtx { interceptors, self_engine,
limits } threaded into every SDK register fn (kv/docs/files/queue/pubsub/http)
so the non-KV services carry the hook seam (unused until M7-M11); KvHandle
collapses its three fields into one ictx.
M2: replaces the nearest-only resolver with ordered before/after chains. The
trait becomes resolve(cx, service, op) -> InterceptorChain { before, after }
(migration 0074 adds a phase column + phase-aware unique indexes). The before
-chain runs ancestor->app (depth DESC) so a group compliance guard can't be
bypassed by a descendant; single-marker behavior is byte-identical to before.
An identity cycle guard (thread-local visited-set keyed by script_id) denies a
detected cycle, alongside the existing binary re-entrancy break. Fail-closed
verdict preserved (allow only on #{ allowed: true }; a Dangling entry or a
missing engine back-ref denies); app_id still derives from cx.app_id only.
after-chains resolve but stay unused until M3. Pinned by two new interceptor
journeys (ancestor->app chain ordering; self-referential no-recurse).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
20
crates/manager-core/migrations/0074_interceptor_phase.sql
Normal file
20
crates/manager-core/migrations/0074_interceptor_phase.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- §9.4 Service Interceptors M2 — before + after phases.
|
||||
--
|
||||
-- The 0073 marker guarded only the BEFORE-op allow/deny hook. M2 introduces an
|
||||
-- explicit `phase` so an owner can declare a `before` AND an `after` interceptor
|
||||
-- for one `(service, op)`. Existing markers default to `phase='before'` — the
|
||||
-- 0073 behaviour is unchanged. The partial-unique indexes gain `phase` so the
|
||||
-- two phases are distinct rows per owner (one before + one after each).
|
||||
|
||||
ALTER TABLE interceptors
|
||||
ADD COLUMN phase TEXT NOT NULL DEFAULT 'before'
|
||||
CHECK (phase IN ('before', 'after'));
|
||||
|
||||
-- Recreate the per-owner uniqueness to be per (owner, service, op, PHASE).
|
||||
DROP INDEX interceptors_group_uidx;
|
||||
DROP INDEX interceptors_app_uidx;
|
||||
|
||||
CREATE UNIQUE INDEX interceptors_group_uidx
|
||||
ON interceptors (group_id, service, op, phase) WHERE group_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX interceptors_app_uidx
|
||||
ON interceptors (app_id, service, op, phase) WHERE app_id IS NOT NULL;
|
||||
@@ -5,7 +5,7 @@
|
||||
//! interceptor's behaviour is a normal script resolved + run through `invoke()`
|
||||
//! re-entry. This module holds the read + transactional-write helpers (free
|
||||
//! functions over `&PgPool` / `&mut Transaction`, keyed by [`ScriptOwner`]),
|
||||
//! plus the runtime [`resolve_before`] chain walk (nearest-owner-wins).
|
||||
//! plus the runtime [`resolve_chain`] walk (all markers on the app's chain).
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AppId, ScriptOwner};
|
||||
@@ -117,55 +117,77 @@ impl SealedInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the interceptor guarding `(service, op)` for a calling app — the
|
||||
/// nearest MARKER on the app's chain (app, then nearest ancestor group), with
|
||||
/// its script **sealed to the owner that declared the marker** (a descendant
|
||||
/// app cannot shadow it with a same-named local script). `None` when un-hooked.
|
||||
/// The ordered before + after interceptors guarding a `(service, op)` for a
|
||||
/// calling app, each sealed to its declaring owner (see [`SealedInterceptor`]).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SealedChain {
|
||||
/// Ordered ancestor→app (outermost/group guard first).
|
||||
pub before: Vec<SealedInterceptor>,
|
||||
/// Ordered app→ancestor.
|
||||
pub after: Vec<SealedInterceptor>,
|
||||
}
|
||||
|
||||
/// Resolve ALL interceptor markers guarding `(service, op)` for a calling app —
|
||||
/// every marker visible on the app's chain (app + each ancestor group), each
|
||||
/// with its script **sealed to the owner that declared the marker** (a
|
||||
/// descendant app cannot shadow it with a same-named local script). Split by
|
||||
/// `phase` and ordered: `before` ancestor→app (an outer/group guard runs first,
|
||||
/// so a group denial cannot be bypassed by a descendant), `after` app→ancestor.
|
||||
/// **This chain walk is the resolution boundary** — a sibling-subtree app never
|
||||
/// sees another subtree's interceptor. The script join is `LEFT` so a marker
|
||||
/// whose script is missing/disabled still returns a row (with `script: None`)
|
||||
/// rather than silently falling through to a farther, weaker marker.
|
||||
pub async fn resolve_before(
|
||||
/// whose script is missing/disabled still returns an entry (with `script:
|
||||
/// None`) the caller fails closed on, rather than being silently dropped.
|
||||
///
|
||||
/// Per the `(owner, service, op, phase)` partial-unique index there is at most
|
||||
/// one marker per owner per phase, so no per-owner dedup is needed.
|
||||
pub async fn resolve_chain(
|
||||
pool: &PgPool,
|
||||
app_id: AppId,
|
||||
service: &str,
|
||||
op: &str,
|
||||
) -> Result<Option<SealedInterceptor>, sqlx::Error> {
|
||||
) -> Result<SealedChain, sqlx::Error> {
|
||||
#[allow(clippy::type_complexity)]
|
||||
let row: Option<(
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
String,
|
||||
Option<Uuid>,
|
||||
Option<String>,
|
||||
Option<DateTime<Utc>>,
|
||||
let rows: Vec<(
|
||||
Option<Uuid>, // marker_app
|
||||
Option<Uuid>, // marker_group
|
||||
String, // script_name
|
||||
String, // phase
|
||||
i32, // depth (0 = app, larger = ancestor)
|
||||
Option<Uuid>, // script id
|
||||
Option<String>, // script source
|
||||
Option<DateTime<Utc>>, // script updated_at
|
||||
)> = sqlx::query_as(&format!(
|
||||
"{CHAIN_LEVELS_CTE}, \
|
||||
nearest AS ( \
|
||||
markers AS ( \
|
||||
SELECT i.interceptor_script AS script_name, \
|
||||
i.app_id AS marker_app, i.group_id AS marker_group, c.depth AS depth \
|
||||
i.app_id AS marker_app, i.group_id AS marker_group, \
|
||||
i.phase AS phase, c.depth AS depth \
|
||||
FROM chain c \
|
||||
JOIN interceptors i ON (i.app_id = c.app_owner OR i.group_id = c.group_owner) \
|
||||
WHERE i.service = $2 AND i.op = $3 \
|
||||
ORDER BY c.depth ASC LIMIT 1 \
|
||||
) \
|
||||
SELECT n.marker_app, n.marker_group, n.script_name, \
|
||||
SELECT m.marker_app, m.marker_group, m.script_name, m.phase, m.depth, \
|
||||
s.id, s.source, s.updated_at \
|
||||
FROM nearest n \
|
||||
FROM markers m \
|
||||
LEFT JOIN scripts s ON ( \
|
||||
(n.marker_app IS NOT NULL AND s.app_id = n.marker_app \
|
||||
AND s.name = n.script_name AND s.enabled) \
|
||||
OR (n.marker_group IS NOT NULL AND s.group_id = n.marker_group \
|
||||
AND s.name = n.script_name AND s.enabled) \
|
||||
(m.marker_app IS NOT NULL AND s.app_id = m.marker_app \
|
||||
AND s.name = m.script_name AND s.enabled) \
|
||||
OR (m.marker_group IS NOT NULL AND s.group_id = m.marker_group \
|
||||
AND s.name = m.script_name AND s.enabled) \
|
||||
)",
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.bind(service)
|
||||
.bind(op)
|
||||
.fetch_optional(pool)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(row.map(
|
||||
|(marker_app, marker_group, script_name, sid, src, upd)| SealedInterceptor {
|
||||
|
||||
// Split by phase and order: `before` ancestor→app = depth DESC (larger
|
||||
// depth = ancestor runs first); `after` app→ancestor = depth ASC.
|
||||
let mut before: Vec<(i32, SealedInterceptor)> = Vec::new();
|
||||
let mut after: Vec<(i32, SealedInterceptor)> = Vec::new();
|
||||
for (marker_app, marker_group, script_name, phase, depth, sid, src, upd) in rows {
|
||||
let sealed = SealedInterceptor {
|
||||
marker_app,
|
||||
marker_group,
|
||||
script_name,
|
||||
@@ -173,8 +195,19 @@ pub async fn resolve_before(
|
||||
(Some(id), Some(source), Some(updated_at)) => Some((id, source, updated_at)),
|
||||
_ => None,
|
||||
},
|
||||
},
|
||||
))
|
||||
};
|
||||
if phase == "after" {
|
||||
after.push((depth, sealed));
|
||||
} else {
|
||||
before.push((depth, sealed));
|
||||
}
|
||||
}
|
||||
before.sort_by(|a, b| b.0.cmp(&a.0)); // depth DESC → ancestor first
|
||||
after.sort_by(|a, b| a.0.cmp(&b.0)); // depth ASC → app first
|
||||
Ok(SealedChain {
|
||||
before: before.into_iter().map(|(_, s)| s).collect(),
|
||||
after: after.into_iter().map(|(_, s)| s).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Upsert a marker at `owner` in the apply transaction. A re-apply that changes
|
||||
@@ -190,9 +223,11 @@ pub async fn insert_interceptor_tx(
|
||||
match owner {
|
||||
ScriptOwner::App(a) => {
|
||||
sqlx::query(
|
||||
// `phase` defaults to 'before' (0074); the conflict arbiter must
|
||||
// include it to match the per-(owner, service, op, phase) index.
|
||||
"INSERT INTO interceptors (app_id, service, op, interceptor_script) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
ON CONFLICT (app_id, service, op) WHERE app_id IS NOT NULL \
|
||||
ON CONFLICT (app_id, service, op, phase) WHERE app_id IS NOT NULL \
|
||||
DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, updated_at = NOW()",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
@@ -204,9 +239,11 @@ pub async fn insert_interceptor_tx(
|
||||
}
|
||||
ScriptOwner::Group(g) => {
|
||||
sqlx::query(
|
||||
// `phase` defaults to 'before' (0074); the conflict arbiter must
|
||||
// include it to match the per-(owner, service, op, phase) index.
|
||||
"INSERT INTO interceptors (group_id, service, op, interceptor_script) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
ON CONFLICT (group_id, service, op) WHERE group_id IS NOT NULL \
|
||||
ON CONFLICT (group_id, service, op, phase) WHERE group_id IS NOT NULL \
|
||||
DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, updated_at = NOW()",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
InterceptorResolution, InterceptorService, ResolvedScript, ScriptId, SdkCallCx,
|
||||
InterceptorChain, InterceptorEntry, InterceptorService, ResolvedInterceptor, ResolvedScript,
|
||||
ScriptId, SdkCallCx,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::interceptor_repo::SealedInterceptor;
|
||||
|
||||
pub struct InterceptorServiceImpl {
|
||||
pool: PgPool,
|
||||
}
|
||||
@@ -22,38 +25,55 @@ impl InterceptorServiceImpl {
|
||||
}
|
||||
}
|
||||
|
||||
impl InterceptorServiceImpl {
|
||||
/// Map one sealed marker to a chain entry: a runnable [`ResolvedScript`]
|
||||
/// (sealed to the declaring owner — the lexical `import` origin, §5.5) or a
|
||||
/// [`Dangling`](InterceptorEntry::Dangling) entry when the marker's script
|
||||
/// is missing/disabled. The execution boundary is always the CALLER's app
|
||||
/// (`cx.app_id`); only the `owner` is the sealing owner.
|
||||
fn map_entry(app_id: picloud_shared::AppId, sealed: SealedInterceptor) -> InterceptorEntry {
|
||||
let owner = sealed.sealing_owner();
|
||||
match sealed.script {
|
||||
Some((sid, source, updated_at)) => InterceptorEntry::Run(ResolvedInterceptor {
|
||||
script: Box::new(ResolvedScript {
|
||||
script_id: ScriptId::from(sid),
|
||||
app_id,
|
||||
owner: Some(owner),
|
||||
source,
|
||||
updated_at,
|
||||
name: sealed.script_name,
|
||||
}),
|
||||
timeout_ms: None,
|
||||
}),
|
||||
None => InterceptorEntry::Dangling(sealed.script_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl InterceptorService for InterceptorServiceImpl {
|
||||
async fn resolve_before(
|
||||
async fn resolve(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
service: &str,
|
||||
op: &str,
|
||||
) -> Result<InterceptorResolution, String> {
|
||||
) -> Result<InterceptorChain, String> {
|
||||
// `app_id` derives from `cx` (never a script arg) — the isolation
|
||||
// boundary; the chain walk then scopes resolution to this app's subtree.
|
||||
let sealed = crate::interceptor_repo::resolve_before(&self.pool, cx.app_id, service, op)
|
||||
let chain = crate::interceptor_repo::resolve_chain(&self.pool, cx.app_id, service, op)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let Some(sealed) = sealed else {
|
||||
return Ok(InterceptorResolution::None);
|
||||
};
|
||||
// A marker exists but its sealed script is missing/disabled → dangling,
|
||||
// the executor fails closed (denies) rather than silently allowing.
|
||||
let Some((sid, source, updated_at)) = sealed.script.clone() else {
|
||||
return Ok(InterceptorResolution::Dangling(sealed.script_name));
|
||||
};
|
||||
// The execution boundary is always the CALLER's app; the `owner` is the
|
||||
// sealing (declaring) owner, the lexical origin for the interceptor's
|
||||
// own `import`s (§5.5) — a group marker's script runs its imports from
|
||||
// the group.
|
||||
Ok(InterceptorResolution::Run(Box::new(ResolvedScript {
|
||||
script_id: ScriptId::from(sid),
|
||||
app_id: cx.app_id,
|
||||
owner: Some(sealed.sealing_owner()),
|
||||
source,
|
||||
updated_at,
|
||||
name: sealed.script_name,
|
||||
})))
|
||||
Ok(InterceptorChain {
|
||||
before: chain
|
||||
.before
|
||||
.into_iter()
|
||||
.map(|s| Self::map_entry(cx.app_id, s))
|
||||
.collect(),
|
||||
after: chain
|
||||
.after
|
||||
.into_iter()
|
||||
.map(|s| Self::map_entry(cx.app_id, s))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +317,7 @@ table: interceptors
|
||||
interceptor_script: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
phase: text NOT NULL default='before'::text
|
||||
|
||||
table: kv_entries
|
||||
app_id: uuid NOT NULL
|
||||
@@ -675,9 +676,9 @@ indexes on groups:
|
||||
|
||||
indexes on interceptors:
|
||||
interceptors_app_id_idx: public.interceptors USING btree (app_id) WHERE (app_id IS NOT NULL)
|
||||
interceptors_app_uidx: public.interceptors USING btree (app_id, service, op) WHERE (app_id IS NOT NULL)
|
||||
interceptors_app_uidx: public.interceptors USING btree (app_id, service, op, phase) WHERE (app_id IS NOT NULL)
|
||||
interceptors_group_id_idx: public.interceptors USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||
interceptors_group_uidx: public.interceptors USING btree (group_id, service, op) WHERE (group_id IS NOT NULL)
|
||||
interceptors_group_uidx: public.interceptors USING btree (group_id, service, op, phase) WHERE (group_id IS NOT NULL)
|
||||
interceptors_pkey: public.interceptors USING btree (id)
|
||||
|
||||
indexes on kv_entries:
|
||||
@@ -952,6 +953,7 @@ constraints on groups:
|
||||
|
||||
constraints on interceptors:
|
||||
[CHECK] interceptors_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[CHECK] interceptors_phase_check: CHECK ((phase = ANY (ARRAY['before'::text, 'after'::text])))
|
||||
[FOREIGN KEY] interceptors_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] interceptors_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] interceptors_pkey: PRIMARY KEY (id)
|
||||
@@ -1145,3 +1147,4 @@ constraints on workflows:
|
||||
0071: workflows
|
||||
0072: execution source workflow
|
||||
0073: interceptors
|
||||
0074: interceptor phase
|
||||
|
||||
Reference in New Issue
Block a user