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:
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