feat(hierarchies): multi-repo single-owner ownership + structural prune (M3)
§7 of the groups/project-tool design. A group node is now authoritatively managed by exactly one project-root; `pic apply --dir` claims, conflicts, takes over, and (with --prune) structurally reaps owned nodes. - Migration 0052: `projects(id, key UNIQUE)` + promote the inert `groups.owner_project` (0047) to a real FK (ON DELETE SET NULL) + index. - CLI mints a stable, gitignored project key in `.picloud/project.json` (`pic init`, or lazily on first tree plan/apply) and presents it on every tree request; `pic apply --dir --takeover` flag. - Server: prepare_tree resolves ownership read-only (plan surfaces conflicts + prune candidates; token folds each group's owner key). apply_tree upserts the project in-tx, claims created groups on insert, reconciles existing-group ownership under the per-node advisory lock (first-commit-wins), and prunes owned-but-undeclared groups leaf-first (delete=RESTRICT, never another repo's or a UI-owned node). - Authz (§7.4, ownership ⟂ RBAC): takeover requires GroupAdmin per contested node — enforced in authz_tree (pre-tx) AND re-verified in-tx at the ownership decision, so --force (which waives the staleness token) can't open a takeover-without-admin window. The attacker-supplied project key is length/charset-validated server-side. - Tests: tests/ownership.rs (claim → conflict → takeover → flip; non-admin takeover → 403; prune-owned-only); format_conflicts unit test; schema golden reblessed. tree_shape M2 journey updated to reparent in-place (a fresh dir is now a distinct project and would correctly conflict). Closes the M3 milestone; reviewed (4 findings: 1 authz-bypass via --force + key validation + 2 LOW, all fixed). M4 (trigger/route templates) remains. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -178,6 +178,16 @@ async fn group_apply_handler(
|
||||
// must hold the relevant read/write caps for EVERY node touched.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TreePlanRequest {
|
||||
bundle: TreeBundle,
|
||||
/// Stable project key from the repo's `.picloud/` link state (§7, M3).
|
||||
/// Lets `plan` surface ownership conflicts and prune candidates. Optional
|
||||
/// for legacy callers (then no ownership diagnosis is reported).
|
||||
#[serde(default)]
|
||||
project_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TreeApplyRequest {
|
||||
bundle: TreeBundle,
|
||||
@@ -185,15 +195,25 @@ struct TreeApplyRequest {
|
||||
prune: bool,
|
||||
#[serde(default)]
|
||||
expected_token: Option<String>,
|
||||
/// Stable project key (§7, M3): the apply claims unclaimed declared groups
|
||||
/// for this project and refuses ones another project owns.
|
||||
#[serde(default)]
|
||||
project_key: Option<String>,
|
||||
/// Take over declared groups owned by another project (group-admin gated).
|
||||
#[serde(default)]
|
||||
allow_takeover: bool,
|
||||
}
|
||||
|
||||
async fn tree_plan_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(bundle): Json<TreeBundle>,
|
||||
Json(req): Json<TreePlanRequest>,
|
||||
) -> Result<Json<TreePlanResult>, ApplyError> {
|
||||
authz_tree(&svc, &principal, &bundle, None).await?;
|
||||
Ok(Json(svc.plan_tree(&bundle).await?))
|
||||
authz_tree(&svc, &principal, &req.bundle, None, false).await?;
|
||||
Ok(Json(
|
||||
svc.plan_tree(&req.bundle, req.project_key.as_deref())
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn tree_apply_handler(
|
||||
@@ -201,13 +221,22 @@ async fn tree_apply_handler(
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(req): Json<TreeApplyRequest>,
|
||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||
authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?;
|
||||
authz_tree(
|
||||
&svc,
|
||||
&principal,
|
||||
&req.bundle,
|
||||
Some(req.prune),
|
||||
req.allow_takeover,
|
||||
)
|
||||
.await?;
|
||||
let report = svc
|
||||
.apply_tree(
|
||||
&req.bundle,
|
||||
req.prune,
|
||||
principal.user_id,
|
||||
&principal,
|
||||
req.expected_token.as_deref(),
|
||||
req.project_key.as_deref(),
|
||||
req.allow_takeover,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
@@ -222,6 +251,7 @@ async fn authz_tree(
|
||||
principal: &Principal,
|
||||
bundle: &TreeBundle,
|
||||
apply_prune: Option<bool>,
|
||||
allow_takeover: bool,
|
||||
) -> Result<(), ApplyError> {
|
||||
for node in &bundle.nodes {
|
||||
match node.kind {
|
||||
@@ -283,6 +313,28 @@ async fn authz_tree(
|
||||
Some(prune) => {
|
||||
require_group_node_writes(svc, principal, group_id, &node.bundle, prune)
|
||||
.await?;
|
||||
// Takeover gate (§7.4): seizing a group that another
|
||||
// project owns needs GroupAdmin specifically — a second,
|
||||
// independent gate on top of the write caps. We gate it
|
||||
// for any already-claimed node under `--takeover` (a
|
||||
// superset of genuinely-contested, never laxer); claiming
|
||||
// an unclaimed node, or applying without `--takeover`,
|
||||
// does not require admin. The authoritative owner rewrite
|
||||
// happens in-tx in `apply_tree` (which knows our project).
|
||||
if allow_takeover
|
||||
&& crate::group_repo::get_group_owner(&svc.pool, group_id)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.is_some()
|
||||
{
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupAdmin(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
// Reparent: an explicit parent differing from the live
|
||||
// one needs GroupAdmin at the group, the SOURCE parent,
|
||||
// and the DESTINATION parent — mirroring the interactive
|
||||
@@ -484,6 +536,9 @@ impl IntoResponse for ApplyError {
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
||||
Self::OwnershipConflict(_) => {
|
||||
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "apply authz repo error");
|
||||
|
||||
Reference in New Issue
Block a user