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:
@@ -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() }),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user