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 {
|
fn into_response(self) -> Response {
|
||||||
let (status, body) = match &self {
|
let (status, body) = match &self {
|
||||||
Self::AppNotFound(_) => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
|
Self::AppNotFound(_) => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
|
||||||
Self::Invalid(_) => (
|
Self::Invalid(_) | Self::OutsideAttachPoint(_) => (
|
||||||
StatusCode::UNPROCESSABLE_ENTITY,
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
json!({ "error": self.to_string() }),
|
json!({ "error": self.to_string() }),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -515,6 +515,11 @@ pub struct ProjectDecl {
|
|||||||
pub slug: String,
|
pub slug: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub name: Option<String>,
|
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
|
/// 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]`).
|
/// Maps to 409 — actionable (`use --takeover`, `declare [project]`).
|
||||||
#[error("ownership conflict: {0}")]
|
#[error("ownership conflict: {0}")]
|
||||||
OwnershipConflict(String),
|
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}")]
|
#[error("authorization repo error: {0}")]
|
||||||
AuthzRepo(String),
|
AuthzRepo(String),
|
||||||
#[error("backend: {0}")]
|
#[error("backend: {0}")]
|
||||||
@@ -1373,6 +1382,16 @@ impl ApplyService {
|
|||||||
if let ApplyOwner::App(app_id) = owner {
|
if let ApplyOwner::App(app_id) = owner {
|
||||||
self.validate_route_hosts(app_id, bundle).await?;
|
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
|
let mut tx = self
|
||||||
.pool
|
.pool
|
||||||
@@ -1549,6 +1568,18 @@ impl ApplyService {
|
|||||||
return Err(ApplyError::StateMoved);
|
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
|
let mut tx = self
|
||||||
.pool
|
.pool
|
||||||
@@ -1943,6 +1974,49 @@ impl ApplyService {
|
|||||||
Ok(Some((pid, slug)))
|
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> {
|
async fn resolve_app(&self, ident: &str) -> Result<picloud_shared::App, ApplyError> {
|
||||||
crate::app_repo::resolve_app(self.apps.as_ref(), ident)
|
crate::app_repo::resolve_app(self.apps.as_ref(), ident)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -278,6 +278,11 @@ pub struct ManifestProject {
|
|||||||
pub slug: String,
|
pub slug: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
/// `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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The store kind of a shared collection (§11.6).
|
/// The store kind of a shared collection (§11.6).
|
||||||
@@ -973,6 +978,14 @@ mod tests {
|
|||||||
// Unknown key inside [project] is rejected (deny_unknown_fields).
|
// Unknown key inside [project] is rejected (deny_unknown_fields).
|
||||||
Manifest::parse("[project]\nslug=\"p\"\nbogus=1\n[app]\nslug=\"w\"\nname=\"W\"\n")
|
Manifest::parse("[project]\nslug=\"p\"\nbogus=1\n[app]\nslug=\"w\"\nname=\"W\"\n")
|
||||||
.expect_err("unknown [project] key rejected");
|
.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]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user