feat(interceptors): KV after-hooks + before-hook data transform (§9.4 M3+M4)
M4: a before-interceptor returning #{ allowed: true, data: ... } rewrites the
value actually written (threaded through the chain; each hook sees the prior
transform), size-capped at MAX_JSON_MATERIALIZE_BYTES. Delete never transforms.
M3: an 'after' phase interceptor runs once the write has committed, with the
write's result in its payload. After-hooks observe/deny but CANNOT roll back —
a deny surfaces as an operation error while the write persists (documented at
the call site).
Adds phase authoring end to end: a [[interceptors]] entry gains phase =
before|after (default before), threaded through the plan wire, BundleInterceptor,
validate (phase in {before,after}), the reconcile diff/insert/prune key
(service/op/phase), and the repo (insert/delete/list_for_owner/list_on_app_chain,
+ the marker's phase). run_before now returns the transformed value; run_after is
new; both share one fail-closed per-entry runner. Pinned by two journeys: a
before-hook transforms the stored value, and an after-delete hook sees
result==true, denies, yet the key stays deleted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -121,9 +121,17 @@ pub struct Bundle {
|
||||
pub struct BundleInterceptor {
|
||||
pub service: String,
|
||||
pub op: String,
|
||||
/// `before` (allow/deny + transform) or `after` (observe; §9.4 M3).
|
||||
/// Defaults to `before` so a pre-M3 CLI (which omits it) still applies.
|
||||
#[serde(default = "default_before_phase")]
|
||||
pub phase: String,
|
||||
pub script: String,
|
||||
}
|
||||
|
||||
fn default_before_phase() -> String {
|
||||
"before".to_string()
|
||||
}
|
||||
|
||||
/// One declared workflow on the wire: a name + its (already-parsed) DAG.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct BundleWorkflow {
|
||||
@@ -820,8 +828,8 @@ pub struct CurrentState {
|
||||
/// 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)>,
|
||||
/// `(service, op, phase, script)` tuples.
|
||||
pub interceptors: Vec<(String, String, String, String)>,
|
||||
}
|
||||
|
||||
/// One row of the read-only extension-point report (§5.5).
|
||||
@@ -1281,9 +1289,10 @@ impl ApplyService {
|
||||
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();
|
||||
// `phase ∈ {before, after}` (§9.4 M3), authored on app OR group. One
|
||||
// marker per `(service, op, phase)` — a duplicate would collide on the
|
||||
// reconcile key and the DB's partial-unique index.
|
||||
let mut seen_hooks: HashSet<(String, String, String)> = HashSet::new();
|
||||
for i in &bundle.interceptors {
|
||||
if i.service != "kv" {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
@@ -1297,15 +1306,21 @@ impl ApplyService {
|
||||
i.op
|
||||
)));
|
||||
}
|
||||
if i.phase != "before" && i.phase != "after" {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"interceptor phase `{}` is not supported — only `before` / `after`",
|
||||
i.phase
|
||||
)));
|
||||
}
|
||||
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())) {
|
||||
if !seen_hooks.insert((i.service.clone(), i.op.clone(), i.phase.clone())) {
|
||||
return Err(ApplyError::Invalid(format!(
|
||||
"duplicate interceptor for `{}/{}`",
|
||||
i.service, i.op
|
||||
"duplicate interceptor for `{}/{}/{}`",
|
||||
i.service, i.op, i.phase
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -1594,7 +1609,7 @@ impl ApplyService {
|
||||
let bi = bundle
|
||||
.interceptors
|
||||
.iter()
|
||||
.find(|i| format!("{}/{}", i.service, i.op) == ch.key)
|
||||
.find(|i| format!("{}/{}/{}", i.service, i.op, i.phase) == ch.key)
|
||||
.ok_or_else(|| {
|
||||
ApplyError::Backend("internal: interceptor plan/bundle mismatch".into())
|
||||
})?;
|
||||
@@ -1603,6 +1618,7 @@ impl ApplyService {
|
||||
owner.as_script_owner(),
|
||||
&bi.service,
|
||||
&bi.op,
|
||||
&bi.phase,
|
||||
&bi.script,
|
||||
)
|
||||
.await
|
||||
@@ -1746,16 +1762,20 @@ 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.
|
||||
// by `(service, op, phase)` (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(), ""));
|
||||
let mut parts = ch.key.splitn(3, '/');
|
||||
let service = parts.next().unwrap_or("");
|
||||
let op = parts.next().unwrap_or("");
|
||||
let phase = parts.next().unwrap_or("before");
|
||||
crate::interceptor_repo::delete_interceptor_tx(
|
||||
&mut *tx,
|
||||
owner.as_script_owner(),
|
||||
service,
|
||||
op,
|
||||
phase,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
@@ -4243,7 +4263,7 @@ impl ApplyService {
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.into_iter()
|
||||
.map(|m| (m.service, m.op, m.script))
|
||||
.map(|m| (m.service, m.op, m.phase, m.script))
|
||||
.collect();
|
||||
Ok(CurrentState {
|
||||
scripts,
|
||||
@@ -4331,15 +4351,15 @@ fn compute_diff_with_names(
|
||||
/// 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 key = |service: &str, op: &str, phase: &str| format!("{service}/{op}/{phase}");
|
||||
let live: HashMap<String, &str> = current
|
||||
.interceptors
|
||||
.iter()
|
||||
.map(|(s, o, script)| (key(s, o), script.as_str()))
|
||||
.map(|(s, o, p, script)| (key(s, o, p), script.as_str()))
|
||||
.collect();
|
||||
let mut out = Vec::new();
|
||||
for bi in &bundle.interceptors {
|
||||
let k = key(&bi.service, &bi.op);
|
||||
let k = key(&bi.service, &bi.op, &bi.phase);
|
||||
match live.get(&k) {
|
||||
Some(cur) if *cur == bi.script => out.push(ResourceChange {
|
||||
op: Op::NoOp,
|
||||
@@ -4358,15 +4378,15 @@ fn diff_interceptors(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceCha
|
||||
}),
|
||||
}
|
||||
}
|
||||
for (s, o, _) in ¤t.interceptors {
|
||||
for (s, o, p, _) in ¤t.interceptors {
|
||||
let present = bundle
|
||||
.interceptors
|
||||
.iter()
|
||||
.any(|bi| bi.service == *s && bi.op == *o);
|
||||
.any(|bi| bi.service == *s && bi.op == *o && bi.phase == *p);
|
||||
if !present {
|
||||
out.push(ResourceChange {
|
||||
op: Op::Delete,
|
||||
key: key(s, o),
|
||||
key: key(s, o, p),
|
||||
detail: Some("on server, not declared".into()),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ use crate::config_resolver::CHAIN_LEVELS_CTE;
|
||||
pub struct InterceptorMarker {
|
||||
pub service: String,
|
||||
pub op: String,
|
||||
/// `before` (allow/deny + transform) or `after` (observe; §9.4 M3).
|
||||
pub phase: String,
|
||||
pub script: String,
|
||||
}
|
||||
|
||||
@@ -29,11 +31,11 @@ pub async fn list_for_owner(
|
||||
pool: &PgPool,
|
||||
owner: ScriptOwner,
|
||||
) -> Result<Vec<InterceptorMarker>, sqlx::Error> {
|
||||
let rows: Vec<(String, String, String)> = match owner {
|
||||
let rows: Vec<(String, 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",
|
||||
"SELECT service, op, phase, interceptor_script FROM interceptors \
|
||||
WHERE app_id = $1 ORDER BY service, op, phase",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.fetch_all(pool)
|
||||
@@ -41,8 +43,8 @@ pub async fn list_for_owner(
|
||||
}
|
||||
ScriptOwner::Group(g) => {
|
||||
sqlx::query_as(
|
||||
"SELECT service, op, interceptor_script FROM interceptors \
|
||||
WHERE group_id = $1 ORDER BY service, op",
|
||||
"SELECT service, op, phase, interceptor_script FROM interceptors \
|
||||
WHERE group_id = $1 ORDER BY service, op, phase",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.fetch_all(pool)
|
||||
@@ -51,9 +53,10 @@ pub async fn list_for_owner(
|
||||
};
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(service, op, script)| InterceptorMarker {
|
||||
.map(|(service, op, phase, script)| InterceptorMarker {
|
||||
service,
|
||||
op,
|
||||
phase,
|
||||
script,
|
||||
})
|
||||
.collect())
|
||||
@@ -66,21 +69,23 @@ 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!(
|
||||
let rows: Vec<(String, String, String, String)> = sqlx::query_as(&format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT DISTINCT ON (i.service, i.op) i.service, i.op, i.interceptor_script \
|
||||
SELECT DISTINCT ON (i.service, i.op, i.phase) \
|
||||
i.service, i.op, i.phase, 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",
|
||||
ORDER BY i.service, i.op, i.phase, c.depth ASC",
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(service, op, script)| InterceptorMarker {
|
||||
.map(|(service, op, phase, script)| InterceptorMarker {
|
||||
service,
|
||||
op,
|
||||
phase,
|
||||
script,
|
||||
})
|
||||
.collect())
|
||||
@@ -218,37 +223,38 @@ pub async fn insert_interceptor_tx(
|
||||
owner: ScriptOwner,
|
||||
service: &str,
|
||||
op: &str,
|
||||
phase: &str,
|
||||
script: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
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) \
|
||||
// The conflict arbiter includes `phase` to match the
|
||||
// per-(owner, service, op, phase) index (§9.4 M3).
|
||||
"INSERT INTO interceptors (app_id, service, op, phase, interceptor_script) \
|
||||
VALUES ($1, $2, $3, $4, $5) \
|
||||
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())
|
||||
.bind(service)
|
||||
.bind(op)
|
||||
.bind(phase)
|
||||
.bind(script)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
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) \
|
||||
"INSERT INTO interceptors (group_id, service, op, phase, interceptor_script) \
|
||||
VALUES ($1, $2, $3, $4, $5) \
|
||||
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())
|
||||
.bind(service)
|
||||
.bind(op)
|
||||
.bind(phase)
|
||||
.bind(script)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
@@ -257,30 +263,37 @@ pub async fn insert_interceptor_tx(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a marker at `owner` (by `(service, op)`), in the apply transaction.
|
||||
/// Used by `--prune` when the manifest stops declaring it.
|
||||
/// Delete a marker at `owner` (by `(service, op, phase)`), 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,
|
||||
phase: &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?;
|
||||
sqlx::query(
|
||||
"DELETE FROM interceptors \
|
||||
WHERE app_id = $1 AND service = $2 AND op = $3 AND phase = $4",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.bind(service)
|
||||
.bind(op)
|
||||
.bind(phase)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
ScriptOwner::Group(g) => {
|
||||
sqlx::query(
|
||||
"DELETE FROM interceptors WHERE group_id = $1 AND service = $2 AND op = $3",
|
||||
"DELETE FROM interceptors \
|
||||
WHERE group_id = $1 AND service = $2 AND op = $3 AND phase = $4",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.bind(service)
|
||||
.bind(op)
|
||||
.bind(phase)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user