feat(apply): [project] parent_group attach-point ceiling

§6/§7 M2: a project's attach point is its ceiling — applies are refused for
any node not strictly within the declared subtree, so a repo can't reach (or
claim) above its local root even with the RBAC to do so.

- `[project] parent_group = "<slug>"` (ManifestProject + ProjectDecl); absent
  = instance root = no ceiling (the default, backward-compatible).
- `check_within_attach`: the attach group must be a PROPER ancestor of a group
  node (you can't apply the attach point itself) or an ancestor (inclusive) of
  an app node's group; resolved via `groups.ancestors`. Enforced read-only in
  apply_owner (prologue) + apply_tree (per node), before the claim.
- `ApplyError::OutsideAttachPoint` → 422 (a scope error, like Invalid).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-06 20:57:03 +02:00
parent 5bd72956b1
commit 5bb1ccad5e
3 changed files with 88 additions and 1 deletions

View File

@@ -525,7 +525,7 @@ impl IntoResponse for ApplyError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound(_) => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
Self::Invalid(_) => (
Self::Invalid(_) | Self::OutsideAttachPoint(_) => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),

View File

@@ -515,6 +515,11 @@ pub struct ProjectDecl {
pub slug: String,
#[serde(default)]
pub name: Option<String>,
/// §7/§6 attach point: the pre-existing group this repo binds under. Every
/// applied node must be strictly within its subtree; `None` = instance root
/// (no ceiling).
#[serde(default)]
pub parent_group: Option<String>,
}
/// The ownership context threaded through an apply: the declared project (if
@@ -651,6 +656,10 @@ pub enum ApplyError {
/// Maps to 409 — actionable (`use --takeover`, `declare [project]`).
#[error("ownership conflict: {0}")]
OwnershipConflict(String),
/// §6/§7: a node is above/outside this project's declared `parent_group`
/// attach point — you can only apply within its subtree. Maps to 422.
#[error("outside attach point: {0}")]
OutsideAttachPoint(String),
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("backend: {0}")]
@@ -1373,6 +1382,16 @@ impl ApplyService {
if let ApplyOwner::App(app_id) = owner {
self.validate_route_hosts(app_id, bundle).await?;
}
// §6/§7 attach point: refuse a node outside the project's declared
// `parent_group` subtree (read-only, before the tx).
if let Some(pg_slug) = claim
.project
.as_ref()
.and_then(|d| d.parent_group.as_deref())
{
let pg = self.resolve_group(pg_slug).await?;
self.check_within_attach(owner, pg, pg_slug).await?;
}
let mut tx = self
.pool
@@ -1549,6 +1568,18 @@ impl ApplyService {
return Err(ApplyError::StateMoved);
}
}
// §6/§7 attach point: every node in the tree must be within the
// project's declared `parent_group` subtree (read-only, before the tx).
if let Some(pg_slug) = claim
.project
.as_ref()
.and_then(|d| d.parent_group.as_deref())
{
let pg = self.resolve_group(pg_slug).await?;
for p in &prepared {
self.check_within_attach(p.owner, pg, pg_slug).await?;
}
}
let mut tx = self
.pool
@@ -1943,6 +1974,49 @@ impl ApplyService {
Ok(Some((pid, slug)))
}
// ------------------------------------------------------------------------
// §6/§7 attach point — a project's ceiling. Every applied node must live
// strictly within the subtree rooted at the declared `parent_group`.
// ------------------------------------------------------------------------
/// Enforce the ceiling for one node: `pg` must be a PROPER ancestor of a
/// group node (you can't apply the attach point itself), or an ancestor
/// (inclusive) of an app node's group. Reject otherwise.
async fn check_within_attach(
&self,
owner: ApplyOwner,
pg: GroupId,
pg_slug: &str,
) -> Result<(), ApplyError> {
let (anchor, is_group) = match owner {
ApplyOwner::Group(g) => (g, true),
ApplyOwner::App(a) => {
let app = self
.apps
.get_by_id(a)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.ok_or_else(|| ApplyError::AppNotFound(a.to_string()))?;
(app.group_id, false)
}
};
let chain = self
.groups
.ancestors(anchor)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let in_subtree = chain.iter().any(|g| g.id == pg);
// A group node equal to the attach point is the ceiling itself → above.
let is_attach_itself = is_group && anchor == pg;
if !in_subtree || is_attach_itself {
return Err(ApplyError::OutsideAttachPoint(format!(
"node is not within this project's attach point '{pg_slug}' — apply only \
to '{pg_slug}' and its descendants"
)));
}
Ok(())
}
async fn resolve_app(&self, ident: &str) -> Result<picloud_shared::App, ApplyError> {
crate::app_repo::resolve_app(self.apps.as_ref(), ident)
.await