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:
MechaCat02
2026-06-26 23:04:53 +02:00
parent c18ce7c2c4
commit 193336a8a6
17 changed files with 1031 additions and 53 deletions

View File

@@ -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");

View File

@@ -32,15 +32,16 @@ use std::sync::Arc;
use picloud_orchestrator_core::routing::{pattern, RouteTable};
use picloud_shared::{
AdminUserId, AppId, DispatchMode, DocsEventOp, FilesEventOp, GroupId, HostKind, KvEventOp,
MasterKey, PathKind, Route, Script, ScriptId, ScriptKind, ScriptSandbox, ScriptValidator,
TriggerId,
MasterKey, PathKind, Principal, Route, Script, ScriptId, ScriptKind, ScriptSandbox,
ScriptValidator, TriggerId,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::app_domain_repo::AppDomainRepository;
use crate::app_repo::AppRepository;
use crate::authz::AuthzRepo;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::repo::{
delete_script_tx, insert_script_tx, update_script_tx, NewScript, ScriptPatch, ScriptRepository,
ScriptRepositoryError,
@@ -426,6 +427,23 @@ pub struct NodePlan {
pub struct TreePlanResult {
pub nodes: Vec<NodePlan>,
pub state_token: String,
/// Group nodes this manifest declares that another project already owns
/// (§7). A read-only `plan` surfaces them so CI sees the conflict before an
/// `apply` refuses; `apply` proceeds only with `--takeover`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub conflicts: Vec<OwnershipConflict>,
/// Slugs of groups this project owns that are absent from the manifest —
/// what `apply --prune` would delete (leaf-first, RESTRICT-guarded).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub structural_prunes: Vec<String>,
}
/// One ownership conflict: a declared group already claimed by another project.
#[derive(Debug, Clone, Serialize)]
pub struct OwnershipConflict {
pub slug: String,
/// The owning project's key (so the operator knows whose node it is).
pub owner_key: String,
}
// ----------------------------------------------------------------------------
@@ -467,6 +485,11 @@ pub enum ApplyError {
StateMoved,
#[error("forbidden")]
Forbidden,
#[error(
"one or more declared groups are owned by another project; \
re-run with `--takeover` to claim them (group-admin required): {0}"
)]
OwnershipConflict(String),
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("backend: {0}")]
@@ -1077,15 +1100,19 @@ impl ApplyService {
///
/// # Errors
/// `Invalid` for a malformed/unknown bundle; `Backend` for repo failures.
pub async fn plan_tree(&self, bundle: &TreeBundle) -> Result<TreePlanResult, ApplyError> {
let pt = self.prepare_tree(bundle).await?;
pub async fn plan_tree(
&self,
bundle: &TreeBundle,
project_key: Option<&str>,
) -> Result<TreePlanResult, ApplyError> {
let pt = self.prepare_tree(bundle, project_key).await?;
let mut nodes: Vec<NodePlan> = pt
.prepared
.into_iter()
.iter()
.map(|p| NodePlan {
kind: p.node.kind,
slug: p.node.slug.clone(),
plan: p.plan,
plan: p.plan.clone(),
})
.collect();
// To-create group nodes show all-Create content — their structural
@@ -1100,6 +1127,8 @@ impl ApplyService {
Ok(TreePlanResult {
nodes,
state_token: pt.token,
conflicts: pt.conflicts,
structural_prunes: pt.prune_candidates.into_iter().map(|(_, s)| s).collect(),
})
}
@@ -1112,24 +1141,37 @@ impl ApplyService {
/// # Errors
/// `Invalid` for a bad bundle; `StateMoved` if the bound token is stale;
/// `Backend` for repo/transaction failures.
#[allow(clippy::too_many_lines)]
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
pub async fn apply_tree(
&self,
bundle: &TreeBundle,
prune: bool,
actor: AdminUserId,
principal: &Principal,
expected_token: Option<&str>,
project_key: Option<&str>,
allow_takeover: bool,
) -> Result<ApplyReport, ApplyError> {
let pt = self.prepare_tree(bundle).await?;
let actor = principal.user_id;
let pt = self.prepare_tree(bundle, project_key).await?;
if let Some(expected) = expected_token {
if pt.token != expected {
return Err(ApplyError::StateMoved);
}
}
// Ownership pre-check (§7): refuse, before opening the tx, if any
// declared group is owned by another project and `--takeover` was not
// given. The in-tx re-read below is the authoritative gate against a
// concurrent claim; this is the fast, clear path for the common case.
if !allow_takeover && !pt.conflicts.is_empty() {
return Err(ApplyError::OwnershipConflict(format_conflicts(
&pt.conflicts,
)));
}
let PreparedTree {
prepared,
creates,
reparents,
existing_group_nodes,
..
} = pt;
@@ -1155,8 +1197,21 @@ impl ApplyService {
let mut group_script_index: HashMap<GroupId, HashMap<String, ScriptId>> = HashMap::new();
let mut any_app = false;
// Resolve (upsert) this repo's project row inside the tx, so created and
// claimed group nodes can be stamped with a stable owner id (§7, M3). A
// tree apply with no project key (legacy callers) skips all ownership.
let our_project_id = match project_key {
Some(k) => Some(
crate::group_repo::upsert_project_tx(&mut tx, k)
.await
.map_err(map_group_err)?,
),
None => None,
};
// Phase 0 — structure reconcile (M2): create absent group nodes and
// reparent moved ones, in-tx so the whole apply stays all-or-nothing.
// Created groups are claimed by this project on insert (M3).
if !creates.is_empty() || !reparents.is_empty() {
self.reconcile_tree_structure_tx(
&mut tx,
@@ -1164,12 +1219,54 @@ impl ApplyService {
&reparents,
prune,
actor,
our_project_id,
&mut group_script_index,
&mut report,
)
.await?;
}
// Phase 0.5 — ownership reconcile (§7, M3): for each EXISTING declared
// group, re-read the owner under this tx's lock (authoritative) and
// claim it (NULL owner) or take it over (`--takeover`). A conflict that
// appeared after `plan` and wasn't authorized aborts the whole apply.
if let Some(pid) = our_project_id {
for (gid, slug) in &existing_group_nodes {
let owner = crate::group_repo::get_group_owner_tx(&mut tx, *gid)
.await
.map_err(map_group_err)?;
match owner {
Some((oid, _)) if oid == pid => {} // already ours — no write
Some((_, okey)) => {
if !allow_takeover {
return Err(ApplyError::OwnershipConflict(format!(
"{slug} (owned by project {okey})"
)));
}
// Authoritative takeover gate (§7.4): GroupAdmin is
// re-verified HERE, at the in-tx ownership decision, not
// only in the pre-tx `authz_tree` pool read — otherwise
// `--force` (which waives the staleness token) would let
// a non-admin seize a group that another project claimed
// after the pre-tx check saw it unowned. Ownership ⟂ RBAC.
require(self.authz.as_ref(), principal, Capability::GroupAdmin(*gid))
.await
.map_err(map_authz_denied)?;
crate::group_repo::set_group_owner_tx(&mut tx, *gid, pid)
.await
.map_err(map_group_err)?;
report.groups_taken_over += 1;
}
None => {
crate::group_repo::set_group_owner_tx(&mut tx, *gid, pid)
.await
.map_err(map_group_err)?;
report.groups_claimed += 1;
}
}
}
}
// Phase A — groups first: reconcile each and record its post-create
// `name -> id` so descendant app nodes can resolve inherited bindings.
for p in &prepared {
@@ -1225,6 +1322,23 @@ impl ApplyService {
}
}
// Phase C — structural prune (§7, M3): with `--prune`, delete groups
// THIS project owns that the manifest no longer declares, leaf-first
// (delete_tx is RESTRICT, so a group still holding apps/child-groups
// aborts the apply with a clear error rather than orphaning data).
if prune {
if let Some(pid) = our_project_id {
self.structural_prune_tx(
&mut tx,
pid,
&existing_group_nodes,
&creates,
&mut report,
)
.await?;
}
}
tx.commit()
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
@@ -1246,11 +1360,10 @@ impl ApplyService {
/// `name -> id` is recorded so a descendant app can bind to it later in the
/// same tx. Runs under the coarse structural lock so the cycle guard is sound.
///
/// Scope: create + reparent only. Deleting a server group that's absent
/// from the manifest is NOT done here even under `--prune` (group delete is
/// RESTRICT + destructive, and one repo shouldn't reap another's nodes) —
/// structural prune lands with the ownership layer (M3). Use
/// `pic groups rm` to delete a group explicitly.
/// Scope: create + reparent. A created group is stamped with `owner_project`
/// (this repo claims what it creates, §7 M3). Deleting an owned group that
/// the manifest dropped is a separate prune phase (`structural_prune_tx`),
/// scoped to project-owned, now-empty nodes.
#[allow(clippy::too_many_arguments)]
async fn reconcile_tree_structure_tx(
&self,
@@ -1259,6 +1372,7 @@ impl ApplyService {
reparents: &[(GroupId, Option<GroupId>)],
prune: bool,
actor: AdminUserId,
owner_project: Option<Uuid>,
group_script_index: &mut HashMap<GroupId, HashMap<String, ScriptId>>,
report: &mut ApplyReport,
) -> Result<(), ApplyError> {
@@ -1299,9 +1413,16 @@ impl ApplyService {
}
}
};
let g = crate::group_repo::create_group_tx(tx, &c.slug, &c.name, None, parent_id)
.await
.map_err(map_group_err)?;
let g = crate::group_repo::create_group_tx(
tx,
&c.slug,
&c.name,
None,
parent_id,
owner_project,
)
.await
.map_err(map_group_err)?;
created_ids.insert(c.slug.to_lowercase(), g.id);
let empty = CurrentState::default();
let plan = compute_diff(&empty, &c.node.bundle);
@@ -1341,6 +1462,80 @@ impl ApplyService {
Ok(())
}
/// Phase C of a tree apply (§7, M3): delete groups THIS project owns that the
/// manifest no longer declares, leaf-first. `delete_group_tx` is RESTRICT, so
/// a candidate still holding apps or non-pruned child groups aborts the whole
/// apply (no silent orphaning). Scoped to project-owned nodes: another repo's
/// groups and UI-owned (unclaimed) groups are never touched.
async fn structural_prune_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
project_id: Uuid,
existing_group_nodes: &[(GroupId, String)],
creates: &[GroupCreate<'_>],
report: &mut ApplyReport,
) -> Result<(), ApplyError> {
// Declared groups (existing nodes + just-created) are kept; every other
// group this project owns is a prune candidate.
let mut declared: HashSet<String> = existing_group_nodes
.iter()
.map(|(_, s)| s.to_lowercase())
.collect();
declared.extend(creates.iter().map(|c| c.slug.to_lowercase()));
let owned = crate::group_repo::list_owned_groups_tx(tx, project_id)
.await
.map_err(map_group_err)?;
let mut candidates: Vec<(GroupId, String, Option<GroupId>)> = owned
.into_iter()
.filter(|(_, slug, _)| !declared.contains(&slug.to_lowercase()))
.collect();
if candidates.is_empty() {
return Ok(());
}
// Serialize against concurrent reparents while we delete.
crate::group_repo::acquire_structural_lock_tx(tx)
.await
.map_err(map_group_err)?;
// Delete leaf-first: defer any candidate that is still the parent of
// another candidate until that child is gone.
while !candidates.is_empty() {
let parents: HashSet<GroupId> = candidates.iter().filter_map(|(_, _, p)| *p).collect();
let before = candidates.len();
let mut remaining: Vec<(GroupId, String, Option<GroupId>)> = Vec::new();
for (gid, slug, parent) in std::mem::take(&mut candidates) {
if parents.contains(&gid) {
remaining.push((gid, slug, parent));
continue;
}
// Re-confirm ownership under the lock: a concurrent `--takeover`
// could have flipped this candidate to another project between
// the candidate SELECT and now (prune nodes are undeclared, so
// the bound-plan token doesn't cover them). Skip if no longer ours.
let still_owned = crate::group_repo::get_group_owner_tx(tx, gid)
.await
.map_err(map_group_err)?
.map(|(oid, _)| oid)
== Some(project_id);
if !still_owned {
continue;
}
crate::group_repo::delete_group_tx(tx, gid)
.await
.map_err(map_group_err)?;
report.groups_pruned += 1;
}
if remaining.len() == before {
return Err(ApplyError::Invalid(
"structural prune could not order owned groups for deletion (cycle?)".into(),
));
}
candidates = remaining;
}
Ok(())
}
/// Resolve nodes to owners, validate each (apps' route/trigger targets may
/// bind to in-tree ancestor group scripts), compute each node's plan, and
/// fold a combined bound-plan token. Shared by `plan_tree` / `apply_tree`.
@@ -1348,10 +1543,14 @@ impl ApplyService {
async fn prepare_tree<'a>(
&self,
bundle: &'a TreeBundle,
project_key: Option<&str>,
) -> Result<PreparedTree<'a>, ApplyError> {
if bundle.nodes.is_empty() {
return Err(ApplyError::Invalid("project tree has no nodes".into()));
}
if let Some(k) = project_key {
validate_project_key(k)?;
}
// Classify group nodes: existing (resolved id + live parent) vs to-create
// (absent → created in apply Phase 0). App nodes always pre-exist.
let mut group_id_by_slug: HashMap<String, GroupId> = HashMap::new();
@@ -1510,12 +1709,72 @@ impl ApplyService {
token_parts.push(format!("gs|{gid}|{}", g.structure_version));
}
}
// Ownership (§7, M3): resolve this repo's project, then classify each
// EXISTING group node as ours / unclaimed / owned-by-another. Folding
// the owner key into the token means a claim or takeover by a different
// repo between plan and apply trips StateMoved. To-create groups carry
// no conflict (a brand-new node is claimed by whoever creates it).
let our_project_id = match project_key {
Some(k) => crate::group_repo::get_project_id_by_key(&self.pool, k)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?,
None => None,
};
let mut existing_group_nodes: Vec<(GroupId, String)> = Vec::new();
let mut declared_group_slugs: HashSet<String> = to_create_slugs.clone();
let mut conflicts: Vec<OwnershipConflict> = Vec::new();
for n in &bundle.nodes {
if n.kind != NodeKind::Group {
continue;
}
let lc = n.slug.to_lowercase();
declared_group_slugs.insert(lc.clone());
let Some(gid) = group_id_by_slug.get(&lc).copied() else {
continue; // to-create node, handled in Phase 0
};
existing_group_nodes.push((gid, n.slug.clone()));
let owner = crate::group_repo::get_group_owner(&self.pool, gid)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
match &owner {
Some((_, key)) => token_parts.push(format!("own|{lc}|{key}")),
None => token_parts.push(format!("own|{lc}|none")),
}
if let Some((oid, okey)) = owner {
if Some(oid) != our_project_id {
conflicts.push(OwnershipConflict {
slug: n.slug.clone(),
owner_key: okey,
});
}
}
}
// Structural-prune candidates: groups THIS project owns that the
// manifest omits. Computed read-only here (so `plan` can list them);
// `apply --prune` re-derives the set in-tx and deletes leaf-first.
let mut prune_candidates: Vec<(GroupId, String)> = Vec::new();
if let Some(pid) = our_project_id {
for (gid, slug) in crate::group_repo::list_owned_groups(&self.pool, pid)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
if !declared_group_slugs.contains(&slug.to_lowercase()) {
prune_candidates.push((gid, slug));
}
}
}
token_parts.sort_unstable();
Ok(PreparedTree {
prepared,
creates,
reparents,
token: combine_tokens(&token_parts),
existing_group_nodes,
conflicts,
prune_candidates,
})
}
@@ -3105,6 +3364,15 @@ pub struct ApplyReport {
pub groups_created: u32,
#[serde(default)]
pub groups_reparented: u32,
/// Group nodes this apply claimed (NULL owner → this project), M3 §7.
#[serde(default)]
pub groups_claimed: u32,
/// Group nodes this apply took over from another project (`--takeover`).
#[serde(default)]
pub groups_taken_over: u32,
/// Owned-but-undeclared groups deleted by structural prune (`--prune`).
#[serde(default)]
pub groups_pruned: u32,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
@@ -3145,6 +3413,14 @@ struct PreparedTree<'a> {
/// Existing groups to reparent: `(group id, new parent id)`.
reparents: Vec<(GroupId, Option<GroupId>)>,
token: String,
/// Ownership (§7, M3). Existing group nodes in the bundle: `(id, slug)` — the
/// apply path re-reads each owner in-tx (authoritative) to claim / detect
/// takeover. (This repo's own project id is re-derived in-tx via an upsert.)
existing_group_nodes: Vec<(GroupId, String)>,
/// Declared groups already owned by ANOTHER project (read-only diagnosis).
conflicts: Vec<OwnershipConflict>,
/// Owned-but-undeclared groups: `(id, slug)` — structural-prune candidates.
prune_candidates: Vec<(GroupId, String)>,
}
/// FNV-1a over a set of already-sorted per-resource token parts — the same
@@ -3254,6 +3530,45 @@ fn map_repo(e: ScriptRepositoryError) -> ApplyError {
}
}
/// Validate an attacker-supplied project key (§7, M3). The key is a bearer
/// handle for ownership, so it must be bounded and opaque before it's reflected
/// into error messages or upserted. The CLI mints a v4 UUID; we accept any
/// short, printable, delimiter-free token (ASCII alphanumeric plus `-`/`_`).
fn validate_project_key(key: &str) -> Result<(), ApplyError> {
if key.is_empty() || key.len() > 128 {
return Err(ApplyError::Invalid(
"project key must be 1128 characters".into(),
));
}
if !key
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return Err(ApplyError::Invalid(
"project key may contain only ASCII letters, digits, '-' and '_'".into(),
));
}
Ok(())
}
/// Render an ownership-conflict list as `slug (owned by project key), …` for
/// the `OwnershipConflict` error message.
fn format_conflicts(conflicts: &[OwnershipConflict]) -> String {
conflicts
.iter()
.map(|c| format!("{} (owned by project {})", c.slug, c.owner_key))
.collect::<Vec<_>>()
.join(", ")
}
/// Map an in-service authz denial to the apply error surface (403 vs 500).
fn map_authz_denied(e: AuthzDenied) -> ApplyError {
match e {
AuthzDenied::Denied => ApplyError::Forbidden,
AuthzDenied::Repo(r) => ApplyError::AuthzRepo(r.to_string()),
}
}
fn map_group_err(e: crate::group_repo::GroupRepositoryError) -> ApplyError {
use crate::group_repo::GroupRepositoryError as E;
// Conflict (cycle, slug clash, non-empty delete) and not-found are caller
@@ -3879,6 +4194,26 @@ mod tests {
assert_ne!(state_token(&base), state_token(&added));
}
#[test]
fn format_conflicts_lists_each_node_and_owner() {
// The conflict message drives the CLI error a developer sees; pin it.
let c = vec![
OwnershipConflict {
slug: "team-a".into(),
owner_key: "key-x".into(),
},
OwnershipConflict {
slug: "team-b".into(),
owner_key: "key-y".into(),
},
];
assert_eq!(
format_conflicts(&c),
"team-a (owned by project key-x), team-b (owned by project key-y)"
);
assert_eq!(format_conflicts(&[]), "");
}
#[test]
fn state_token_ignores_dead_letter_triggers() {
// The diff ignores dead-letter triggers (not manifest-representable),

View File

@@ -192,7 +192,8 @@ impl GroupRepository for PostgresGroupRepository {
parent_id: Option<GroupId>,
) -> Result<Group, GroupRepositoryError> {
let mut tx = self.pool.begin().await?;
let g = create_group_tx(&mut tx, slug, name, description, parent_id).await?;
// Interactive create: UI/API-owned (no project claim — §7.5).
let g = create_group_tx(&mut tx, slug, name, description, parent_id, None).await?;
tx.commit().await?;
Ok(g)
}
@@ -278,15 +279,17 @@ pub(crate) async fn create_group_tx(
name: &str,
description: Option<&str>,
parent_id: Option<GroupId>,
owner_project: Option<Uuid>,
) -> Result<Group, GroupRepositoryError> {
let res = sqlx::query_as::<_, GroupRow>(&format!(
"INSERT INTO groups (slug, name, description, parent_id) \
VALUES ($1, $2, $3, $4) RETURNING {GROUP_COLS}"
"INSERT INTO groups (slug, name, description, parent_id, owner_project) \
VALUES ($1, $2, $3, $4, $5) RETURNING {GROUP_COLS}"
))
.bind(slug)
.bind(name)
.bind(description)
.bind(parent_id.map(GroupId::into_inner))
.bind(owner_project)
.fetch_one(&mut **tx)
.await;
match res {
@@ -391,6 +394,135 @@ pub(crate) async fn delete_group_tx(
}
}
// ----------------------------------------------------------------------------
// Project ownership (§7, M3). A `projects` row is keyed by the stable, opaque
// key the CLI mints in `.picloud/`; `groups.owner_project` references it. These
// helpers are free functions (pool + tx variants) so the apply path can claim
// ownership inside the single apply transaction (first-commit-wins), while the
// read-only plan path can surface conflicts without writing.
// ----------------------------------------------------------------------------
/// Resolve a project key to its id, if the project has ever been persisted.
/// `None` means no apply has claimed under this key yet (so `plan` treats every
/// node as claimable). Read-only — used by the plan path.
pub(crate) async fn get_project_id_by_key(
pool: &PgPool,
key: &str,
) -> Result<Option<Uuid>, GroupRepositoryError> {
let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM projects WHERE key = $1")
.bind(key)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| r.0))
}
/// Upsert the project keyed by `key` inside the apply tx and return its id.
/// Idempotent: re-applying the same repo reuses the same project identity.
pub(crate) async fn upsert_project_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
key: &str,
) -> Result<Uuid, GroupRepositoryError> {
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO projects (key) VALUES ($1) \
ON CONFLICT (key) DO UPDATE SET key = EXCLUDED.key RETURNING id",
)
.bind(key)
.fetch_one(&mut **tx)
.await?;
Ok(row.0)
}
/// The project that owns `group_id`, as `(project_id, project_key)`. `None` when
/// the node is UI/API-owned (no claim). Read-only — used by plan + the in-tx
/// authoritative re-check. Joins through `projects` so the caller gets the
/// human-facing key for a conflict message.
pub(crate) async fn get_group_owner(
pool: &PgPool,
group_id: GroupId,
) -> Result<Option<(Uuid, String)>, GroupRepositoryError> {
let row: Option<(Uuid, String)> = sqlx::query_as(
"SELECT p.id, p.key FROM groups g \
JOIN projects p ON p.id = g.owner_project WHERE g.id = $1",
)
.bind(group_id.into_inner())
.fetch_optional(pool)
.await?;
Ok(row)
}
/// The same owner lookup, but inside the apply tx — the authoritative read that
/// the claim/conflict decision keys on (so a concurrent claim that committed
/// after `plan` is seen under this tx's per-node advisory lock).
pub(crate) async fn get_group_owner_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
) -> Result<Option<(Uuid, String)>, GroupRepositoryError> {
let row: Option<(Uuid, String)> = sqlx::query_as(
"SELECT p.id, p.key FROM groups g \
JOIN projects p ON p.id = g.owner_project WHERE g.id = $1",
)
.bind(group_id.into_inner())
.fetch_optional(&mut **tx)
.await?;
Ok(row)
}
/// Stamp (claim or take over) `group_id`'s owning project inside the apply tx.
/// Bumps `structure_version` so an ownership flip trips a bound plan token.
pub(crate) async fn set_group_owner_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
project_id: Uuid,
) -> Result<(), GroupRepositoryError> {
let res = sqlx::query(
"UPDATE groups SET owner_project = $2, \
structure_version = structure_version + 1, updated_at = NOW() WHERE id = $1",
)
.bind(group_id.into_inner())
.bind(project_id)
.execute(&mut **tx)
.await?;
if res.rows_affected() == 0 {
return Err(GroupRepositoryError::NotFound(group_id));
}
Ok(())
}
/// Every group `(id, slug)` owned by `project_id` — the candidate set for
/// structural prune (those absent from the manifest are the ones to delete).
/// Read-only; the apply path filters and deletes leaf-first under `delete_tx`.
pub(crate) async fn list_owned_groups(
pool: &PgPool,
project_id: Uuid,
) -> Result<Vec<(GroupId, String)>, GroupRepositoryError> {
let rows: Vec<(Uuid, String)> =
sqlx::query_as("SELECT id, slug FROM groups WHERE owner_project = $1")
.bind(project_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(id, slug)| (id.into(), slug))
.collect())
}
/// The same owned-group enumeration inside the apply tx, with each node's
/// `parent_id` so the prune can delete leaf-first (children before parents).
pub(crate) async fn list_owned_groups_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
project_id: Uuid,
) -> Result<Vec<(GroupId, String, Option<GroupId>)>, GroupRepositoryError> {
let rows: Vec<(Uuid, String, Option<Uuid>)> =
sqlx::query_as("SELECT id, slug, parent_id FROM groups WHERE owner_project = $1")
.bind(project_id)
.fetch_all(&mut **tx)
.await?;
Ok(rows
.into_iter()
.map(|(id, slug, parent)| (id.into(), slug, parent.map(GroupId::from)))
.collect())
}
#[derive(sqlx::FromRow)]
struct GroupRow {
id: Uuid,