From 5bb1ccad5e1e9c4453bcea8f0d9111d47444fd46 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 6 Jul 2026 20:57:03 +0200 Subject: [PATCH] feat(apply): [project] parent_group attach-point ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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 = ""` (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) --- crates/manager-core/src/apply_api.rs | 2 +- crates/manager-core/src/apply_service.rs | 74 ++++++++++++++++++++++++ crates/picloud-cli/src/manifest.rs | 13 +++++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/crates/manager-core/src/apply_api.rs b/crates/manager-core/src/apply_api.rs index 5f85bc8..d81cdfa 100644 --- a/crates/manager-core/src/apply_api.rs +++ b/crates/manager-core/src/apply_api.rs @@ -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() }), ), diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index a69319f..cb3bf1f 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -515,6 +515,11 @@ pub struct ProjectDecl { pub slug: String, #[serde(default)] pub name: Option, + /// §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, } /// 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 { crate::app_repo::resolve_app(self.apps.as_ref(), ident) .await diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index 9bfa8a0..f10c5dd 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -278,6 +278,11 @@ pub struct ManifestProject { pub slug: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, + /// `parent_group` (§7/§6) — the pre-existing group this repo binds UNDER + /// (its attach point). Applies are refused for any node not strictly within + /// that subtree. Absent = instance root = no ceiling (the default). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_group: Option, } /// The store kind of a shared collection (§11.6). @@ -973,6 +978,14 @@ mod tests { // Unknown key inside [project] is rejected (deny_unknown_fields). Manifest::parse("[project]\nslug=\"p\"\nbogus=1\n[app]\nslug=\"w\"\nname=\"W\"\n") .expect_err("unknown [project] key rejected"); + + // §6/§7 M2: the optional attach-point `parent_group`. + let m = Manifest::parse( + "[project]\nslug = \"p\"\nparent_group = \"acme\"\n\n\ + [group]\nslug = \"team\"\nname = \"T\"\n", + ) + .expect("parent_group parses"); + assert_eq!(m.project.unwrap().parent_group.as_deref(), Some("acme")); } #[test]