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