feat(hierarchies): trigger templates — per-app fan-out (M4b)
Completes §4.5 templates (M4a shipped routes). A group declares a trigger once;
the tree apply fans it out into one concrete per-app_id trigger on each
descendant app, reusing the M4a expansion engine over the trigger insert path.
- Migration 0054: `trigger_templates` table (group-owned; the whole
BundleTrigger wire object stored as `spec` JSONB, placeholders unresolved) +
`triggers.from_template` provenance column + index.
- `template_repo.rs`: trigger-template CRUD + chain-load.
- Manifest: `[group]` `[[trigger_templates]]` (flat: name, kind, script, + kind
params); build_bundle emits them; rejected on an app node.
- Apply: group nodes reconcile trigger-template rows; tree Phase B expands each
chain trigger template into a concrete trigger per descendant app —
placeholders resolved in every spec string leaf, then the typed trigger is
rebuilt and inserted through the shared `insert_bundle_trigger_tx` (email
secrets resolved + re-sealed per recipient app). Idempotent via from_template
(one trigger per template per app; semantic-identity compare → no-op /
delete+recreate / reap). Collision with a hand-declared trigger or between
templates is a hard error. Each resolved trigger is re-validated with the
per-kind shape check (cron/queue/etc.) so a {var:}-injected bad
schedule/timeout can't slip in.
- Authz: declaring → GroupScriptsWrite; receiving → AppManageTriggers, plus
AppSecretsRead for email-trigger recipients (parity with hand-declared email).
- Plan/token/report extended for trigger templates; blast radius covers both.
- Tests: tests/templates.rs trigger fan-out (kv + {app_slug}, stable-ids,
prune-reap); resolve_placeholders_in_json unit test; schema golden reblessed.
Reviewed (no CRITICAL; isolation + per-app email-secret sealing verified
sound). Closed a HIGH validation-parity gap (re-validate resolved triggers) and
a MEDIUM authz gap (AppSecretsRead for email expansions). v1.2 Hierarchies
template work (M4) complete.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -266,12 +266,13 @@ async fn authz_tree(
|
||||
Some(prune) => {
|
||||
require_app_node_writes(svc, principal, app_id, &node.bundle, prune)
|
||||
.await?;
|
||||
// Template expansion (§4.5, M4a) writes routes INTO this
|
||||
// app, so the actor needs AppWriteRoute when the app's
|
||||
// chain carries route templates — even if the app itself
|
||||
// declares no routes. Without this, expanding routes into
|
||||
// Template expansion (§4.5) writes routes/triggers INTO
|
||||
// this app, so the actor needs the matching write cap
|
||||
// when the app's chain carries templates — even if the
|
||||
// app itself declares none. Without this, expanding into
|
||||
// an app a principal can't write would slip the gate.
|
||||
if app_receives_template_expansions(svc, app_id, bundle).await? {
|
||||
let recv = app_receives_template_expansions(svc, app_id, bundle).await?;
|
||||
if recv.routes {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
@@ -280,6 +281,27 @@ async fn authz_tree(
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if recv.triggers {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppManageTriggers(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
// Email-trigger expansion resolves + seals the recipient
|
||||
// app's secret server-side — same `AppSecretsRead` gate a
|
||||
// hand-declared email trigger requires.
|
||||
if recv.email {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppSecretsRead(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
||||
@@ -486,12 +508,13 @@ async fn require_group_node_writes(
|
||||
bundle: &Bundle,
|
||||
prune: bool,
|
||||
) -> Result<(), ApplyError> {
|
||||
// Extension points and route templates gate on the script-write tier (see
|
||||
// the app variant) — both are code-binding declarations, not viewer-tier.
|
||||
// Extension points and route/trigger templates gate on the script-write
|
||||
// tier (see the app variant) — all are code-binding declarations.
|
||||
if prune
|
||||
|| !bundle.scripts.is_empty()
|
||||
|| !bundle.extension_points.is_empty()
|
||||
|| !bundle.route_templates.is_empty()
|
||||
|| !bundle.trigger_templates.is_empty()
|
||||
{
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
@@ -513,22 +536,36 @@ async fn require_group_node_writes(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Will route-template expansion (§4.5, M4a) write routes into `app_id` during
|
||||
/// this tree apply? True iff the app's ancestor chain carries a route template,
|
||||
/// counting both committed templates and ones declared by a group node in THIS
|
||||
/// bundle. Drives the `AppWriteRoute` gate for expansion recipients.
|
||||
/// What template expansion (§4.5) will write into `app_id` during this tree
|
||||
/// apply. Returns `Recipient { routes, triggers, email }` — whether the app's
|
||||
/// ancestor chain carries a route / trigger / EMAIL-trigger template (committed,
|
||||
/// or declared by a group node in THIS bundle). Drives the `AppWriteRoute` /
|
||||
/// `AppManageTriggers` / `AppSecretsRead` gates for recipients (email expansion
|
||||
/// resolves + seals the recipient app's secret server-side, like a hand-declared
|
||||
/// email trigger).
|
||||
#[derive(Default)]
|
||||
struct Recipient {
|
||||
routes: bool,
|
||||
triggers: bool,
|
||||
email: bool,
|
||||
}
|
||||
|
||||
fn spec_is_email(spec: &serde_json::Value) -> bool {
|
||||
spec.get("kind").and_then(serde_json::Value::as_str) == Some("email")
|
||||
}
|
||||
|
||||
async fn app_receives_template_expansions(
|
||||
svc: &ApplyService,
|
||||
app_id: AppId,
|
||||
bundle: &TreeBundle,
|
||||
) -> Result<bool, ApplyError> {
|
||||
) -> Result<Recipient, ApplyError> {
|
||||
let Some(app) = svc
|
||||
.apps
|
||||
.get_by_id(app_id)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
else {
|
||||
return Ok(false);
|
||||
return Ok(Recipient::default());
|
||||
};
|
||||
let ancestors = svc
|
||||
.groups
|
||||
@@ -537,33 +574,55 @@ async fn app_receives_template_expansions(
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
let ancestor_ids: std::collections::HashSet<GroupId> = ancestors.iter().map(|g| g.id).collect();
|
||||
|
||||
let mut r = Recipient::default();
|
||||
// Committed templates on any ancestor group.
|
||||
for gid in &ancestor_ids {
|
||||
if !crate::template_repo::list_for_group(&svc.pool, *gid)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.is_empty()
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
// Templates newly declared by a group node in this bundle that is an
|
||||
// ancestor of the app (existing groups only — a to-create group has no apps).
|
||||
for node in &bundle.nodes {
|
||||
if node.kind == NodeKind::Group && !node.bundle.route_templates.is_empty() {
|
||||
if let Some(g) = svc
|
||||
.groups
|
||||
.get_by_slug(&node.slug)
|
||||
if !r.routes
|
||||
&& !crate::template_repo::list_for_group(&svc.pool, *gid)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
if ancestor_ids.contains(&g.id) {
|
||||
return Ok(true);
|
||||
}
|
||||
.is_empty()
|
||||
{
|
||||
r.routes = true;
|
||||
}
|
||||
for tt in crate::template_repo::list_triggers_for_group(&svc.pool, *gid)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
r.triggers = true;
|
||||
r.email = r.email || spec_is_email(&tt.spec);
|
||||
}
|
||||
}
|
||||
// Templates newly declared by a group node in this bundle that is an ancestor
|
||||
// of the app (existing groups only — a to-create group has no apps).
|
||||
for node in &bundle.nodes {
|
||||
if node.kind != NodeKind::Group {
|
||||
continue;
|
||||
}
|
||||
let declares_routes = !node.bundle.route_templates.is_empty();
|
||||
let declares_triggers = !node.bundle.trigger_templates.is_empty();
|
||||
if !declares_routes && !declares_triggers {
|
||||
continue;
|
||||
}
|
||||
if let Some(g) = svc
|
||||
.groups
|
||||
.get_by_slug(&node.slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
if ancestor_ids.contains(&g.id) {
|
||||
r.routes = r.routes || declares_routes;
|
||||
r.triggers = r.triggers || declares_triggers;
|
||||
r.email = r.email
|
||||
|| node
|
||||
.bundle
|
||||
.trigger_templates
|
||||
.iter()
|
||||
.any(|t| spec_is_email(&t.spec));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404.
|
||||
|
||||
Reference in New Issue
Block a user