feat(interceptors): hook set_if (CAS) + pic interceptors ls (§9.4 M12)
Closes the §9.4 track:
- set_if (compare-and-swap), per-app and shared, now runs the same (kv, set)
before/after interceptor as set — otherwise CAS was a silent bypass of a set
policy. The before-hook can transform the new value; the after-hook sees the
swapped bool.
- read-only pic interceptors ls --app|--group: app shows the RESOLVED chain
(every marker guarding its writes, nearest-owner-wins), group its own markers.
New apply_service::interceptor_report + InterceptorInfo, /apps|groups/{id}/
interceptors routes (AppRead/GroupScriptsRead), client + cmd mirroring
extension-points.
CLAUDE.md updated: §9.4 service interceptors are now COMPLETE (M1-M12).
Pinned by a journey: set_if of a guarded key is denied while a free key swaps,
and interceptors ls lists the kv/set guard (12 interceptor journeys green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -215,12 +215,38 @@ fn register_set_if(engine: &mut RhaiEngine) {
|
||||
expected: Dynamic,
|
||||
new: Dynamic|
|
||||
-> Result<bool, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let exp = expected_from_dynamic(&expected)?;
|
||||
let new = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?;
|
||||
block_on("kv", async move {
|
||||
h.service.set_if(&h.cx, &h.collection, key, exp, new).await
|
||||
})
|
||||
let json = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?;
|
||||
// §9.4 M12: CAS is a WRITE, so it runs the `(kv, set)` guard too —
|
||||
// otherwise `set_if` would be a silent bypass of a `set` policy.
|
||||
let write_val = super::interceptor::run_before(
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
"kv",
|
||||
"set",
|
||||
&handle.collection,
|
||||
key,
|
||||
Some(&json),
|
||||
)?
|
||||
.unwrap_or(json);
|
||||
let written = write_val.clone();
|
||||
let h = handle.clone();
|
||||
let swapped = block_on("kv", async move {
|
||||
h.service
|
||||
.set_if(&h.cx, &h.collection, key, exp, write_val)
|
||||
.await
|
||||
})?;
|
||||
super::interceptor::run_after(
|
||||
&handle.ictx,
|
||||
&handle.cx,
|
||||
"kv",
|
||||
"set",
|
||||
&handle.collection,
|
||||
key,
|
||||
Some(&written),
|
||||
serde_json::Value::Bool(swapped),
|
||||
)?;
|
||||
Ok(swapped)
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -395,12 +421,25 @@ fn register_group_set_if(engine: &mut RhaiEngine) {
|
||||
expected: Dynamic,
|
||||
new: Dynamic|
|
||||
-> Result<bool, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let exp = expected_from_dynamic(&expected)?;
|
||||
let new = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?;
|
||||
block_on("kv", async move {
|
||||
h.service.set_if(&h.cx, &h.collection, key, exp, new).await
|
||||
})
|
||||
let json = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?;
|
||||
// §9.4 M12: CAS on a shared collection runs the `(kv, set)` guard too.
|
||||
let write_val = group_run_before(handle, "set", key, Some(&json))?.unwrap_or(json);
|
||||
let written = write_val.clone();
|
||||
let h = handle.clone();
|
||||
let swapped = block_on("kv", async move {
|
||||
h.service
|
||||
.set_if(&h.cx, &h.collection, key, exp, write_val)
|
||||
.await
|
||||
})?;
|
||||
group_run_after(
|
||||
handle,
|
||||
"set",
|
||||
key,
|
||||
Some(&written),
|
||||
serde_json::Value::Bool(swapped),
|
||||
)?;
|
||||
Ok(swapped)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,8 +18,9 @@ use serde_json::json;
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::apply_service::{
|
||||
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo,
|
||||
ExtensionPointInfo, NodeKind, OwnershipClaim, PlanResult, ProjectDecl, RouteTemplateInfo,
|
||||
StructureMode, SuppressionInfo, TreeBundle, TreePlanResult, TriggerTemplateInfo,
|
||||
ExtensionPointInfo, InterceptorInfo, NodeKind, OwnershipClaim, PlanResult, ProjectDecl,
|
||||
RouteTemplateInfo, StructureMode, SuppressionInfo, TreeBundle, TreePlanResult,
|
||||
TriggerTemplateInfo,
|
||||
};
|
||||
use crate::authz::{require, AuthzDenied, Capability};
|
||||
use crate::group_repo::GroupRepository;
|
||||
@@ -46,9 +47,45 @@ pub fn apply_router(service: ApplyService) -> Router {
|
||||
.route("/groups/{id}/routes", get(group_routes_handler))
|
||||
.route("/apps/{id}/suppressions", get(app_suppressions_handler))
|
||||
.route("/groups/{id}/suppressions", get(group_suppressions_handler))
|
||||
.route("/apps/{id}/interceptors", get(app_interceptors_handler))
|
||||
.route("/groups/{id}/interceptors", get(group_interceptors_handler))
|
||||
.with_state(service)
|
||||
}
|
||||
|
||||
/// Read-only §9.4 interceptor report for an app: the RESOLVED chain view (every
|
||||
/// marker guarding its writes, nearest-owner-wins). Viewer-tier `AppRead`. Backs
|
||||
/// `pic interceptors ls --app`.
|
||||
async fn app_interceptors_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<InterceptorInfo>>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let report = svc.interceptor_report(ApplyOwner::App(app_id)).await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
/// Read-only §9.4 interceptor report for a group: its OWN declared markers.
|
||||
async fn group_interceptors_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<InterceptorInfo>>, ApplyError> {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let report = svc.interceptor_report(ApplyOwner::Group(group_id)).await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
/// Read-only §11 tail suppression report for an app: the inherited templates it
|
||||
/// declines (`target_kind`, `reference`). Viewer-tier `AppRead`. Backs
|
||||
/// `pic suppress ls --app`.
|
||||
|
||||
@@ -854,6 +854,19 @@ pub struct CollectionInfo {
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// One row of the read-only §9.4 interceptor report (`pic interceptors ls`).
|
||||
/// For an app it is the RESOLVED chain view (markers visible on its ancestor
|
||||
/// chain, nearest-owner-wins); for a group it is the group's OWN declarations.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InterceptorInfo {
|
||||
pub service: String,
|
||||
pub op: String,
|
||||
pub phase: String,
|
||||
pub script: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_ms: Option<i32>,
|
||||
}
|
||||
|
||||
/// One row of the read-only §11 tail suppression report (`pic suppress ls
|
||||
/// --app`). `target_kind` is `trigger` | `route`; `reference` is the handler
|
||||
/// script name (trigger) or path (route) the app declines.
|
||||
@@ -3671,6 +3684,32 @@ impl ApplyService {
|
||||
/// `inherited default` / `null` = unset). For a **group**: its own declared
|
||||
/// names (no single app to resolve a provider against). Backs the read-only
|
||||
/// `extension-points ls` and the `pull` round-trip.
|
||||
/// §9.4 read-only interceptor report. For an app: the RESOLVED chain view
|
||||
/// (every marker visible on its ancestor chain, nearest-owner-wins) — what
|
||||
/// actually guards its writes. For a group: its OWN declared markers.
|
||||
pub async fn interceptor_report(
|
||||
&self,
|
||||
owner: ApplyOwner,
|
||||
) -> Result<Vec<InterceptorInfo>, ApplyError> {
|
||||
let markers = match owner {
|
||||
ApplyOwner::App(a) => crate::interceptor_repo::list_on_app_chain(&self.pool, a).await,
|
||||
ApplyOwner::Group(g) => {
|
||||
crate::interceptor_repo::list_for_owner(&self.pool, ScriptOwner::Group(g)).await
|
||||
}
|
||||
}
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
Ok(markers
|
||||
.into_iter()
|
||||
.map(|m| InterceptorInfo {
|
||||
service: m.service,
|
||||
op: m.op,
|
||||
phase: m.phase,
|
||||
script: m.script,
|
||||
timeout_ms: m.timeout_ms,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn extension_point_report(
|
||||
&self,
|
||||
owner: ApplyOwner,
|
||||
|
||||
@@ -1608,6 +1608,18 @@ pub struct ExtensionPointInfoDto {
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
/// One row of the §9.4 interceptor report.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InterceptorInfoDto {
|
||||
pub service: String,
|
||||
pub op: String,
|
||||
#[serde(default)]
|
||||
pub phase: String,
|
||||
pub script: String,
|
||||
#[serde(default)]
|
||||
pub timeout_ms: Option<i32>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// `GET /api/v1/admin/{apps|groups}/{ident}/extension-points`.
|
||||
pub async fn extension_points_list(
|
||||
@@ -1626,6 +1638,24 @@ impl Client {
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// §9.4: `GET /api/v1/admin/{apps|groups}/{ident}/interceptors`. For an app,
|
||||
/// the resolved chain view (what guards its writes); for a group, its own.
|
||||
pub async fn interceptors_list(
|
||||
&self,
|
||||
kind: NodeKind,
|
||||
ident: &str,
|
||||
) -> Result<Vec<InterceptorInfoDto>> {
|
||||
let ident = seg(ident);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/{}/{ident}/interceptors", kind.path()),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/groups/{ident}/collections` (§11.6). Group-only —
|
||||
/// shared collections are declared on groups.
|
||||
pub async fn collections_list(&self, group_ident: &str) -> Result<Vec<CollectionInfoDto>> {
|
||||
|
||||
36
crates/picloud-cli/src/cmds/interceptors.rs
Normal file
36
crates/picloud-cli/src/cmds/interceptors.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
//! `pic interceptors ls --app|--group` — read-only view of a node's §9.4
|
||||
//! service interceptors. For an app, the RESOLVED chain view (every marker
|
||||
//! guarding its writes, nearest-owner-wins — what actually runs); for a group,
|
||||
//! its own declared markers. Authoring is declarative only (the manifest
|
||||
//! `[[interceptors]]`).
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::{Client, NodeKind};
|
||||
use crate::cmds::OwnerRef;
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let (kind, ident) = match crate::cmds::require_one_owner(app, group)? {
|
||||
OwnerRef::App(a) => (NodeKind::App, a),
|
||||
OwnerRef::Group(g) => (NodeKind::Group, g),
|
||||
};
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let items = client.interceptors_list(kind, ident).await?;
|
||||
|
||||
let mut table = Table::new(["service", "op", "phase", "script", "timeout_ms"]);
|
||||
for i in &items {
|
||||
table.row([
|
||||
i.service.clone(),
|
||||
i.op.clone(),
|
||||
i.phase.clone(),
|
||||
i.script.clone(),
|
||||
i.timeout_ms
|
||||
.map_or_else(|| "-".to_string(), |t| t.to_string()),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub mod extension_points;
|
||||
pub mod files;
|
||||
pub mod groups;
|
||||
pub mod init;
|
||||
pub mod interceptors;
|
||||
pub mod kv;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
|
||||
@@ -176,6 +176,15 @@ enum Cmd {
|
||||
cmd: ExtensionPointsCmd,
|
||||
},
|
||||
|
||||
/// Service interceptors (§9.4) — read-only view of the before/after hooks
|
||||
/// guarding a node's data-plane writes. Authored declaratively via the
|
||||
/// manifest `[[interceptors]]`; `--app` shows the resolved chain (what
|
||||
/// runs), `--group` the group's own declarations.
|
||||
Interceptors {
|
||||
#[command(subcommand)]
|
||||
cmd: InterceptorsCmd,
|
||||
},
|
||||
|
||||
/// Shared group collections (§11.6) — read-only view of the KV collection
|
||||
/// names a group offers as cross-app-shared. Authored declaratively via the
|
||||
/// `[group]` manifest `collections = [...]`; scripts read/write them with
|
||||
@@ -661,6 +670,19 @@ enum ExtensionPointsCmd {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum InterceptorsCmd {
|
||||
/// List a node's service interceptors. `--app` shows the resolved chain
|
||||
/// (every marker guarding its writes, nearest-owner-wins); `--group` shows
|
||||
/// the group's own declarations.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
#[arg(long, conflicts_with = "app")]
|
||||
group: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum CollectionsCmd {
|
||||
/// List the shared collections a group declares (§11.6). Group-only —
|
||||
@@ -2204,6 +2226,9 @@ async fn main() -> ExitCode {
|
||||
Cmd::ExtensionPoints {
|
||||
cmd: ExtensionPointsCmd::Ls { app, group },
|
||||
} => cmds::extension_points::ls(app.as_deref(), group.as_deref(), mode).await,
|
||||
Cmd::Interceptors {
|
||||
cmd: InterceptorsCmd::Ls { app, group },
|
||||
} => cmds::interceptors::ls(app.as_deref(), group.as_deref(), mode).await,
|
||||
Cmd::Collections {
|
||||
cmd: CollectionsCmd::Ls { group },
|
||||
} => cmds::collections::ls(&group, mode).await,
|
||||
|
||||
@@ -872,3 +872,71 @@ fn a_runaway_interceptor_is_denied_by_its_timeout() {
|
||||
"the timed-out write must NOT persist, got: {k}"
|
||||
);
|
||||
}
|
||||
|
||||
// --- §9.4 M12: set_if (CAS) is guarded + `pic interceptors ls` ------------
|
||||
|
||||
/// A writer that CAS-writes the guarded key (must be denied) and a free key
|
||||
/// (must succeed) — proving `set_if` runs the `(kv, set)` guard, not a bypass.
|
||||
const CAS_WRITER: &str = r#"
|
||||
let denied = false;
|
||||
try { kv::collection("c").set_if("secret", (), 1); } catch(e) { denied = true; }
|
||||
let ok = kv::collection("c").set_if("free", (), 2);
|
||||
#{ denied: denied, ok: ok }
|
||||
"#;
|
||||
|
||||
/// M12: `set_if` (compare-and-swap) is a write, so it runs the same `(kv, set)`
|
||||
/// interceptor as `set` — otherwise CAS would be a silent bypass of a set policy.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn set_if_runs_the_set_interceptor() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let app = common::unique_slug("cas-app");
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = manifest_dir();
|
||||
fs::write(dir.path().join("scripts/guard.rhai"), GUARD).unwrap();
|
||||
fs::write(dir.path().join("scripts/writer.rhai"), CAS_WRITER).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[app]\nslug = \"{app}\"\nname = \"CAS\"\n\n\
|
||||
[[scripts]]\nname = \"guard\"\nfile = \"scripts/guard.rhai\"\n\n\
|
||||
[[scripts]]\nname = \"writer\"\nfile = \"scripts/writer.rhai\"\n\n\
|
||||
[[interceptors]]\nscript = \"guard\"\nops = [\"set\"]\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(dir.path().join("picloud.toml"))
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let body = invoke_body(&env, &app_script_id(&env, &app, "writer"));
|
||||
assert_eq!(
|
||||
body,
|
||||
serde_json::json!({ "denied": true, "ok": true }),
|
||||
"set_if of the guarded key must be denied; a free key must swap"
|
||||
);
|
||||
|
||||
// `pic interceptors ls --app` shows the resolved marker.
|
||||
let ls = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["interceptors", "ls", "--app", &app])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ls.contains("kv") && ls.contains("set") && ls.contains("guard"),
|
||||
"interceptors ls --app must list the kv/set guard:\n{ls}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user