feat(hierarchies): route templates — group-declared, per-app fan-out (M4a)

§4.5 of the groups/project-tool design. A group declares a route ONCE; the
tree apply fans it out into one concrete per-app_id route on every descendant
app — instantiation, not inheritance (the cross-app isolation boundary is
unchanged; expanded rows stay app-owned).

- Migration 0053: `route_templates` table (group-owned) + `routes.from_template`
  provenance column + index.
- `template_repo.rs`: tx-aware CRUD + chain-load (mirrors group_repo/route_repo).
- Manifest: `[group]` `[[route_templates]]`; build_bundle emits them; rejected
  on an app node (422).
- Apply: group nodes reconcile template rows (create/update/delete via the
  plan); tree Phase B expands each chain template into a concrete route per
  descendant app node — placeholders {app_slug}/{env}/{var:NAME} resolved per
  app (unknown var/placeholder = hard error), script bound nearest-owner-wins.
  Idempotent via from_template (diffed create/update-as-replace/delete; re-apply
  is no-churn, --prune reaps expansions). Collision with a hand-declared route,
  or between two templates, is a hard error. Each RESOLVED expansion is
  validated like a hand-declared route (reserved-path, path/host parse,
  host-claim) so a {var:}-injected path or unclaimed strict host can't slip in.
- Plan: route-template diff at the group node + blast-radius (descendant app
  count in this apply); state_token folds each template (edit trips StateMoved).
- Authz: declaring → GroupScriptsWrite; an app receiving expansions →
  AppWriteRoute (gated even with no hand-declared routes).
- Tests: tests/templates.rs (fan-out + per-slug + idempotent-stable-ids +
  prune-reap + collision-rejected); placeholder/validation unit tests; schema
  golden reblessed.

Reviewed (no CRITICAL/HIGH; isolation core verified sound). Closed the two
validation-parity MEDIUMs (host-claim + reserved-path/pattern on expansions)
and added an out-of-apply-descendant prune warning. Scope: expansion targets
app nodes in the tree apply (not yet cross-repo descendants); {var:NAME} uses
committed vars. M4b (trigger templates) reuses this engine next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-28 15:58:41 +02:00
parent 193336a8a6
commit 0396866698
18 changed files with 1504 additions and 37 deletions

View File

@@ -202,6 +202,10 @@ struct TreeApplyRequest {
/// Take over declared groups owned by another project (group-admin gated).
#[serde(default)]
allow_takeover: bool,
/// Selected environment (§4.5, M4a): the value substituted for the `{env}`
/// placeholder when expanding route templates. The CLI passes `--env`.
#[serde(default)]
env: Option<String>,
}
async fn tree_plan_handler(
@@ -237,6 +241,7 @@ async fn tree_apply_handler(
req.expected_token.as_deref(),
req.project_key.as_deref(),
req.allow_takeover,
req.env.as_deref(),
)
.await?;
Ok(Json(report))
@@ -261,6 +266,20 @@ 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
// an app a principal can't write would slip the gate.
if app_receives_template_expansions(svc, app_id, bundle).await? {
require(
svc.authz.as_ref(),
principal,
Capability::AppWriteRoute(app_id),
)
.await
.map_err(map_authz)?;
}
}
None => {
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
@@ -467,8 +486,13 @@ async fn require_group_node_writes(
bundle: &Bundle,
prune: bool,
) -> Result<(), ApplyError> {
// Extension points gate on the script-write tier (see the app variant).
if prune || !bundle.scripts.is_empty() || !bundle.extension_points.is_empty() {
// Extension points and route templates gate on the script-write tier (see
// the app variant) — both are code-binding declarations, not viewer-tier.
if prune
|| !bundle.scripts.is_empty()
|| !bundle.extension_points.is_empty()
|| !bundle.route_templates.is_empty()
{
require(
svc.authz.as_ref(),
principal,
@@ -489,6 +513,59 @@ 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.
async fn app_receives_template_expansions(
svc: &ApplyService,
app_id: AppId,
bundle: &TreeBundle,
) -> Result<bool, ApplyError> {
let Some(app) = svc
.apps
.get_by_id(app_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
else {
return Ok(false);
};
let ancestors = svc
.groups
.ancestors(app.group_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let ancestor_ids: std::collections::HashSet<GroupId> = ancestors.iter().map(|g| g.id).collect();
// 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)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
if ancestor_ids.contains(&g.id) {
return Ok(true);
}
}
}
}
Ok(false)
}
/// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404.
async fn resolve_group_id(
groups: &dyn GroupRepository,