feat(project-tool): multi-repo single-owner ownership + takeover (§7 M3)
Ports the M3 milestone from the superseded feat/hierarchies branch onto main's evolved apply engine. A group node is authoritatively managed by at most one project-root; `pic apply --dir` claims, conflicts, takes over, and (with --prune) structurally reaps owned nodes. - Migration 0066: `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; `pic plan --dir` surfaces ownership conflicts + structural-prune candidates read-only. - Server: prepare_tree resolves ownership read-only (token folds each group's owner key, so a claim/takeover by another repo between plan and apply trips StateMoved). apply_tree upserts the project in-tx, claims unclaimed declared groups, and takes over owned ones under `--takeover`. --prune reaps owned-but-undeclared groups leaf-first (delete_group_tx 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. Adaptation to main (which lacks the superseded branch's M2 declarative group create/reparent): groups pre-exist, so ownership stamps existing declared nodes rather than claim-on-create; the declarative attach-point is deferred. Review fixes (adversarial pass, 3 findings, all closed): - HIGH: an empty `[group]` node was claimed capability-free — require a baseline GroupScriptsWrite (editor) on every group apply node, so claiming ownership needs write authority (Ownership ⟂ RBAC). - MEDIUM: structural prune would silently CASCADE a group's §11.6 shared collections + secrets (which postdate M3's original design). Guard: a prune candidate holding shared data/secrets is KEPT with a warning (plus its candidate ancestors, so a preserved child never aborts a parent delete). - LOW: a corrupt `.picloud/project.json` silently re-minted a new key (orphaning ownership) — now fails loudly if the file exists but won't parse. Tests: tests/ownership.rs (claim → conflict → takeover → flip; non-admin takeover → 403; prune-owned-only; prune-refuses-to-cascade-shared-data); format_conflicts + validate_project_key unit tests; schema golden reblessed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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, ScriptOwner, ScriptSandbox,
|
||||
ScriptValidator, TriggerId,
|
||||
MasterKey, PathKind, Principal, Route, Script, ScriptId, ScriptKind, ScriptOwner,
|
||||
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::config_resolver::CHAIN_LEVELS_CTE;
|
||||
use crate::repo::{
|
||||
delete_script_tx, insert_script_tx, update_script_tx, NewScript, ScriptPatch, ScriptRepository,
|
||||
@@ -568,6 +569,23 @@ pub struct TreePlanResult {
|
||||
/// the gate at `plan` time, before an `apply` refuses.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub approvals_required: Vec<String>,
|
||||
/// Group nodes this manifest declares that another project already owns
|
||||
/// (§7, M3). 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,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -677,6 +695,11 @@ pub enum ApplyError {
|
||||
re-run with `--approve {0}` (a blanket `--yes` does not cover it)"
|
||||
)]
|
||||
ApprovalRequired(String),
|
||||
#[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}")]
|
||||
@@ -1503,14 +1526,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 (prepared, token) = self.prepare_tree(bundle).await?;
|
||||
let nodes = prepared
|
||||
.into_iter()
|
||||
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 nodes = pt
|
||||
.prepared
|
||||
.iter()
|
||||
.map(|p| NodePlan {
|
||||
kind: p.node.kind,
|
||||
slug: p.node.slug.clone(),
|
||||
plan: p.plan,
|
||||
plan: p.plan.clone(),
|
||||
})
|
||||
.collect();
|
||||
let approvals_required = bundle
|
||||
@@ -1520,8 +1548,10 @@ impl ApplyService {
|
||||
.unwrap_or_default();
|
||||
Ok(TreePlanResult {
|
||||
nodes,
|
||||
state_token: token,
|
||||
state_token: pt.token,
|
||||
approvals_required,
|
||||
conflicts: pt.conflicts,
|
||||
structural_prunes: pt.prune_candidates.into_iter().map(|(_, s)| s).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1534,19 +1564,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_arguments, clippy::too_many_lines)]
|
||||
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 (prepared, token) = 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 token != expected {
|
||||
if pt.token != expected {
|
||||
return Err(ApplyError::StateMoved);
|
||||
}
|
||||
}
|
||||
// Ownership pre-check (§7, M3): 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,
|
||||
existing_group_nodes,
|
||||
..
|
||||
} = pt;
|
||||
|
||||
let mut tx = self
|
||||
.pool
|
||||
@@ -1569,6 +1617,59 @@ impl ApplyService {
|
||||
let mut report = ApplyReport::default();
|
||||
let mut group_script_index: HashMap<GroupId, HashMap<String, ScriptId>> = HashMap::new();
|
||||
|
||||
// Resolve (upsert) this repo's project row inside the tx, so declared
|
||||
// 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.5 — ownership reconcile (§7, M3): for each 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 {
|
||||
@@ -1623,6 +1724,17 @@ impl ApplyService {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase C — structural prune (§7, M3): with `--prune`, delete groups
|
||||
// THIS project owns that the manifest no longer declares, leaf-first
|
||||
// (delete_group_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, &mut report)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
@@ -1641,6 +1753,107 @@ impl ApplyService {
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// 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)],
|
||||
report: &mut ApplyReport,
|
||||
) -> Result<(), ApplyError> {
|
||||
// Declared groups are kept; every other group this project owns is a
|
||||
// prune candidate.
|
||||
let declared: HashSet<String> = existing_group_nodes
|
||||
.iter()
|
||||
.map(|(_, s)| s.to_lowercase())
|
||||
.collect();
|
||||
|
||||
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(());
|
||||
}
|
||||
|
||||
// Data-loss guard (§7 M3): the apps/child-group FKs are RESTRICT (a
|
||||
// non-empty group aborts loudly via delete_group_tx), but the §11.6
|
||||
// shared surfaces (group_collections + secrets) CASCADE. Refuse to
|
||||
// silently vaporize a tenant's shared data/secrets just because a
|
||||
// manifest node was dropped — mark such a group KEEP (with a warning),
|
||||
// AND keep every candidate ancestor of it (RESTRICT would abort the
|
||||
// apply if we tried to delete a parent whose protected child we're
|
||||
// preserving). Also re-confirm ownership under the tx here — a
|
||||
// concurrent `--takeover` could have flipped a candidate (undeclared, so
|
||||
// the bound-plan token doesn't cover it) since the candidate SELECT.
|
||||
let parent_of: HashMap<GroupId, Option<GroupId>> =
|
||||
candidates.iter().map(|(g, _, p)| (*g, *p)).collect();
|
||||
let mut keep: HashSet<GroupId> = HashSet::new();
|
||||
for (gid, slug, _) in &candidates {
|
||||
let still_owned = crate::group_repo::get_group_owner_tx(tx, *gid)
|
||||
.await
|
||||
.map_err(map_group_err)?
|
||||
.map(|(oid, _)| oid)
|
||||
== Some(project_id);
|
||||
let protected = crate::group_repo::group_has_protected_data_tx(tx, *gid)
|
||||
.await
|
||||
.map_err(map_group_err)?;
|
||||
if !still_owned || protected {
|
||||
if protected {
|
||||
report.warnings.push(format!(
|
||||
"group `{slug}` owns shared collections or secrets; \
|
||||
not pruned (delete it explicitly with `pic groups rm`)"
|
||||
));
|
||||
}
|
||||
// Walk up the candidate chain, keeping this node + every
|
||||
// candidate ancestor (a kept descendant RESTRICT-pins them).
|
||||
let mut cur = Some(*gid);
|
||||
while let Some(g) = cur {
|
||||
if !keep.insert(g) {
|
||||
break; // already walked from here
|
||||
}
|
||||
cur = parent_of.get(&g).copied().flatten();
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.retain(|(g, _, _)| !keep.contains(g));
|
||||
if candidates.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
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`.
|
||||
@@ -1648,10 +1861,14 @@ impl ApplyService {
|
||||
async fn prepare_tree<'a>(
|
||||
&self,
|
||||
bundle: &'a TreeBundle,
|
||||
) -> Result<(Vec<PreparedNode<'a>>, String), ApplyError> {
|
||||
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)?;
|
||||
}
|
||||
// Register in-tree group nodes first: their (resolved) ids and endpoint
|
||||
// script names, which an app node's binding validation may reference.
|
||||
let mut group_id_by_slug: HashMap<String, GroupId> = HashMap::new();
|
||||
@@ -1787,8 +2004,70 @@ impl ApplyService {
|
||||
envs.sort_unstable();
|
||||
token_parts.push(format!("proj|{}", envs.join(",")));
|
||||
}
|
||||
|
||||
// Ownership (§7, M3): resolve this repo's project, then classify each
|
||||
// declared 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. (Every group node on the
|
||||
// tree-apply path pre-exists — `resolve_group` errors otherwise — so
|
||||
// there is no to-create node to skip, unlike a create-capable engine.)
|
||||
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> = HashSet::new();
|
||||
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 gid = group_id_by_slug[&lc];
|
||||
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((prepared, combine_tokens(&token_parts)))
|
||||
Ok(PreparedTree {
|
||||
prepared,
|
||||
token: combine_tokens(&token_parts),
|
||||
existing_group_nodes,
|
||||
conflicts,
|
||||
prune_candidates,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve an app node's inherited route/trigger targets to script ids,
|
||||
@@ -3723,6 +4002,15 @@ pub struct ApplyReport {
|
||||
pub suppressions_created: u32,
|
||||
#[serde(default)]
|
||||
pub suppressions_deleted: u32,
|
||||
/// Group nodes this apply claimed (NULL owner → this project), §7 M3.
|
||||
#[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>,
|
||||
}
|
||||
@@ -3745,6 +4033,21 @@ struct PreparedNode<'a> {
|
||||
app_chain: Vec<GroupId>,
|
||||
}
|
||||
|
||||
/// The read-only result of `prepare_tree`: per-node plans + one combined token,
|
||||
/// plus the ownership classification (§7, M3) the plan surfaces and the apply
|
||||
/// acts on.
|
||||
struct PreparedTree<'a> {
|
||||
prepared: Vec<PreparedNode<'a>>,
|
||||
token: String,
|
||||
/// Declared group nodes in the bundle: `(id, slug)` — the apply path
|
||||
/// re-reads each owner in-tx (authoritative) to claim / detect takeover.
|
||||
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
|
||||
/// hashing `state_token` uses, lifted to combine many nodes into one tree
|
||||
/// token. Order-independent because the caller sorts `parts`.
|
||||
@@ -3863,6 +4166,57 @@ fn map_trig(e: TriggerRepoError) -> ApplyError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a group-repo error to the apply surface (§7, M3). A `Conflict` (e.g. a
|
||||
/// structural-prune candidate still holding descendants) is caller-actionable →
|
||||
/// `Invalid`; a `NotFound` mid-apply is a lost race; `Db` is backend.
|
||||
fn map_group_err(e: crate::group_repo::GroupRepositoryError) -> ApplyError {
|
||||
use crate::group_repo::GroupRepositoryError as E;
|
||||
match e {
|
||||
E::Conflict(m) => ApplyError::Invalid(m),
|
||||
E::NotFound(_) => ApplyError::Invalid("a referenced group was not found".into()),
|
||||
E::Db(e) => ApplyError::Backend(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 1–128 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(", ")
|
||||
}
|
||||
|
||||
/// A stable fingerprint of an app's live state, covering exactly what the
|
||||
/// reconcile diff keys on: script identity + write-counter (`version` bumps on
|
||||
/// any script edit), route identity + binding/attrs, trigger semantic identity
|
||||
@@ -4820,4 +5174,34 @@ mod tests {
|
||||
assert!(!policy.confirm_required("unknown"));
|
||||
assert_eq!(policy.gated_envs(), vec!["production".to_string()]);
|
||||
}
|
||||
|
||||
#[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 validate_project_key_bounds_and_charset() {
|
||||
assert!(validate_project_key("a1b2-c3_d4").is_ok());
|
||||
assert!(validate_project_key(&uuid::Uuid::nil().to_string()).is_ok());
|
||||
assert!(validate_project_key("").is_err());
|
||||
assert!(validate_project_key(&"x".repeat(129)).is_err());
|
||||
assert!(validate_project_key("has space").is_err());
|
||||
assert!(validate_project_key("semi;colon").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user