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