feat(apply): §7 ownership claim state machine + project/takeover wire + 409
The heart of multi-repo ownership: an apply now claims/verifies node ownership before it reconciles, so two repos can't clobber each other's subtree. - Pure policy (DB-free, unit-tested): `decide_group_claim` (unclaimed→claim, owner→noop, foreign→conflict unless --takeover, no-project-into-claimed→ conflict) and `decide_app_owner` (an app must match its nearest claimed ancestor; an unclaimed subtree stays open — backward-compatible). - Execution under the per-node advisory lock: `upsert_project_tx` (first apply registers the project), `read/write_group_owner_tx`, `claim_group_owner` (takeover additionally requires `GroupAdmin` — ownership ⟂ RBAC), `check_app_owner_single` + tree `nearest_claimed_in_tx` (in-tree Phase-A claim overlays committed ancestors). No structure_version bump — a claim isn't a diff change, so it must not churn a pending bound plan. - `apply`/`apply_owner`/`apply_tree` take an `OwnershipClaim` (project + takeover + principal); the claim slots after the lock, before load_current, so a conflict short-circuits before any diff work. - New `ApplyError::OwnershipConflict` → HTTP 409 (actionable); wired through `ApplyRequest`/`TreeApplyRequest` (both `#[serde(default)]`, so the existing CLI stays compatible until it learns [project]). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,8 +18,8 @@ use serde_json::json;
|
|||||||
use crate::app_repo::AppRepository;
|
use crate::app_repo::AppRepository;
|
||||||
use crate::apply_service::{
|
use crate::apply_service::{
|
||||||
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo,
|
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo,
|
||||||
ExtensionPointInfo, NodeKind, PlanResult, RouteTemplateInfo, SuppressionInfo, TreeBundle,
|
ExtensionPointInfo, NodeKind, OwnershipClaim, PlanResult, ProjectDecl, RouteTemplateInfo,
|
||||||
TreePlanResult, TriggerTemplateInfo,
|
SuppressionInfo, TreeBundle, TreePlanResult, TriggerTemplateInfo,
|
||||||
};
|
};
|
||||||
use crate::authz::{require, AuthzDenied, Capability};
|
use crate::authz::{require, AuthzDenied, Capability};
|
||||||
use crate::group_repo::GroupRepository;
|
use crate::group_repo::GroupRepository;
|
||||||
@@ -188,6 +188,12 @@ pub struct ApplyRequest {
|
|||||||
/// refuses (409) if the app's live state has changed since.
|
/// refuses (409) if the app's live state has changed since.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub expected_token: Option<String>,
|
pub expected_token: Option<String>,
|
||||||
|
/// §7 ownership: the declaring repo's `[project]` (absent = no claim).
|
||||||
|
#[serde(default)]
|
||||||
|
pub project: Option<ProjectDecl>,
|
||||||
|
/// §7 ownership: reassign a node owned by another project (group-admin).
|
||||||
|
#[serde(default)]
|
||||||
|
pub takeover: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn apply_handler(
|
async fn apply_handler(
|
||||||
@@ -198,12 +204,17 @@ async fn apply_handler(
|
|||||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||||
require_app_node_writes(&svc, &principal, app_id, &req.bundle, req.prune).await?;
|
require_app_node_writes(&svc, &principal, app_id, &req.bundle, req.prune).await?;
|
||||||
|
let claim = OwnershipClaim {
|
||||||
|
project: req.project,
|
||||||
|
takeover: req.takeover,
|
||||||
|
principal: &principal,
|
||||||
|
};
|
||||||
let report = svc
|
let report = svc
|
||||||
.apply(
|
.apply(
|
||||||
app_id,
|
app_id,
|
||||||
&req.bundle,
|
&req.bundle,
|
||||||
req.prune,
|
req.prune,
|
||||||
principal.user_id,
|
&claim,
|
||||||
req.expected_token.as_deref(),
|
req.expected_token.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -263,12 +274,17 @@ async fn group_apply_handler(
|
|||||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||||
require_group_node_writes(&svc, &principal, group_id, &req.bundle, req.prune).await?;
|
require_group_node_writes(&svc, &principal, group_id, &req.bundle, req.prune).await?;
|
||||||
|
let claim = OwnershipClaim {
|
||||||
|
project: req.project,
|
||||||
|
takeover: req.takeover,
|
||||||
|
principal: &principal,
|
||||||
|
};
|
||||||
let report = svc
|
let report = svc
|
||||||
.apply_owner(
|
.apply_owner(
|
||||||
ApplyOwner::Group(group_id),
|
ApplyOwner::Group(group_id),
|
||||||
&req.bundle,
|
&req.bundle,
|
||||||
req.prune,
|
req.prune,
|
||||||
principal.user_id,
|
&claim,
|
||||||
req.expected_token.as_deref(),
|
req.expected_token.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -287,6 +303,11 @@ struct TreeApplyRequest {
|
|||||||
prune: bool,
|
prune: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
expected_token: Option<String>,
|
expected_token: Option<String>,
|
||||||
|
/// §7 ownership: one `[project]` for the whole tree (the root manifest's).
|
||||||
|
#[serde(default)]
|
||||||
|
project: Option<ProjectDecl>,
|
||||||
|
#[serde(default)]
|
||||||
|
takeover: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn tree_plan_handler(
|
async fn tree_plan_handler(
|
||||||
@@ -304,11 +325,16 @@ async fn tree_apply_handler(
|
|||||||
Json(req): Json<TreeApplyRequest>,
|
Json(req): Json<TreeApplyRequest>,
|
||||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||||
authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?;
|
authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?;
|
||||||
|
let claim = OwnershipClaim {
|
||||||
|
project: req.project,
|
||||||
|
takeover: req.takeover,
|
||||||
|
principal: &principal,
|
||||||
|
};
|
||||||
let report = svc
|
let report = svc
|
||||||
.apply_tree(
|
.apply_tree(
|
||||||
&req.bundle,
|
&req.bundle,
|
||||||
req.prune,
|
req.prune,
|
||||||
principal.user_id,
|
&claim,
|
||||||
req.expected_token.as_deref(),
|
req.expected_token.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -504,6 +530,10 @@ impl IntoResponse for ApplyError {
|
|||||||
json!({ "error": self.to_string() }),
|
json!({ "error": self.to_string() }),
|
||||||
),
|
),
|
||||||
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
||||||
|
// §7 ownership conflict — actionable (declare [project], --takeover).
|
||||||
|
Self::OwnershipConflict(_) => {
|
||||||
|
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||||
Self::AuthzRepo(e) => {
|
Self::AuthzRepo(e) => {
|
||||||
tracing::error!(error = %e, "apply authz repo error");
|
tracing::error!(error = %e, "apply authz repo error");
|
||||||
|
|||||||
@@ -32,15 +32,15 @@ use std::sync::Arc;
|
|||||||
use picloud_orchestrator_core::routing::{pattern, RouteTable};
|
use picloud_orchestrator_core::routing::{pattern, RouteTable};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AdminUserId, AppId, DispatchMode, DocsEventOp, FilesEventOp, GroupId, HostKind, KvEventOp,
|
AdminUserId, AppId, DispatchMode, DocsEventOp, FilesEventOp, GroupId, HostKind, KvEventOp,
|
||||||
MasterKey, PathKind, Route, Script, ScriptId, ScriptKind, ScriptOwner, ScriptSandbox,
|
MasterKey, PathKind, Principal, ProjectId, Route, Script, ScriptId, ScriptKind, ScriptOwner,
|
||||||
ScriptValidator, TriggerId,
|
ScriptSandbox, ScriptValidator, TriggerId,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use crate::app_domain_repo::AppDomainRepository;
|
use crate::app_domain_repo::AppDomainRepository;
|
||||||
use crate::app_repo::AppRepository;
|
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::config_resolver::CHAIN_LEVELS_CTE;
|
||||||
use crate::repo::{
|
use crate::repo::{
|
||||||
delete_script_tx, insert_script_tx, update_script_tx, NewScript, ScriptPatch, ScriptRepository,
|
delete_script_tx, insert_script_tx, update_script_tx, NewScript, ScriptPatch, ScriptRepository,
|
||||||
@@ -507,6 +507,25 @@ pub struct TreeBundle {
|
|||||||
pub nodes: Vec<TreeNode>,
|
pub nodes: Vec<TreeNode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §7 multi-repo ownership: the `[project]` identity an apply declares. The
|
||||||
|
/// slug is committed in the repo's root manifest (stable across clones); the
|
||||||
|
/// first apply with a new slug registers the project (server-assigned UUID).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ProjectDecl {
|
||||||
|
pub slug: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ownership context threaded through an apply: the declared project (if
|
||||||
|
/// any), whether the caller passed `--takeover`, and the principal (its
|
||||||
|
/// `user_id` is the actor; a takeover additionally requires `GroupAdmin`).
|
||||||
|
pub struct OwnershipClaim<'a> {
|
||||||
|
pub project: Option<ProjectDecl>,
|
||||||
|
pub takeover: bool,
|
||||||
|
pub principal: &'a Principal,
|
||||||
|
}
|
||||||
|
|
||||||
/// One node's diff within a tree plan.
|
/// One node's diff within a tree plan.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct NodePlan {
|
pub struct NodePlan {
|
||||||
@@ -627,6 +646,11 @@ pub enum ApplyError {
|
|||||||
StateMoved,
|
StateMoved,
|
||||||
#[error("forbidden")]
|
#[error("forbidden")]
|
||||||
Forbidden,
|
Forbidden,
|
||||||
|
/// §7 multi-repo ownership: this apply's `[project]` does not own a node it
|
||||||
|
/// touches (or the node is unclaimed and the apply declared no project).
|
||||||
|
/// Maps to 409 — actionable (`use --takeover`, `declare [project]`).
|
||||||
|
#[error("ownership conflict: {0}")]
|
||||||
|
OwnershipConflict(String),
|
||||||
#[error("authorization repo error: {0}")]
|
#[error("authorization repo error: {0}")]
|
||||||
AuthzRepo(String),
|
AuthzRepo(String),
|
||||||
#[error("backend: {0}")]
|
#[error("backend: {0}")]
|
||||||
@@ -1314,14 +1338,14 @@ impl ApplyService {
|
|||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
bundle: &Bundle,
|
bundle: &Bundle,
|
||||||
prune: bool,
|
prune: bool,
|
||||||
actor: AdminUserId,
|
claim: &OwnershipClaim<'_>,
|
||||||
expected_token: Option<&str>,
|
expected_token: Option<&str>,
|
||||||
) -> Result<ApplyReport, ApplyError> {
|
) -> Result<ApplyReport, ApplyError> {
|
||||||
self.apply_owner(
|
self.apply_owner(
|
||||||
ApplyOwner::App(app_id),
|
ApplyOwner::App(app_id),
|
||||||
bundle,
|
bundle,
|
||||||
prune,
|
prune,
|
||||||
actor,
|
claim,
|
||||||
expected_token,
|
expected_token,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1338,9 +1362,10 @@ impl ApplyService {
|
|||||||
owner: ApplyOwner,
|
owner: ApplyOwner,
|
||||||
bundle: &Bundle,
|
bundle: &Bundle,
|
||||||
prune: bool,
|
prune: bool,
|
||||||
actor: AdminUserId,
|
claim: &OwnershipClaim<'_>,
|
||||||
expected_token: Option<&str>,
|
expected_token: Option<&str>,
|
||||||
) -> Result<ApplyReport, ApplyError> {
|
) -> Result<ApplyReport, ApplyError> {
|
||||||
|
let actor = claim.principal.user_id;
|
||||||
let inherited = self.resolve_inherited_targets_for(owner, bundle).await?;
|
let inherited = self.resolve_inherited_targets_for(owner, bundle).await?;
|
||||||
self.validate_bundle_for(owner, bundle, &keys_set(&inherited))?;
|
self.validate_bundle_for(owner, bundle, &keys_set(&inherited))?;
|
||||||
self.check_imports_resolve(owner, bundle).await?;
|
self.check_imports_resolve(owner, bundle).await?;
|
||||||
@@ -1368,6 +1393,32 @@ impl ApplyService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
|
|
||||||
|
// §7 ownership: register the declared project (first apply claims), then
|
||||||
|
// gate this node. A group node claims/verifies itself; an app node must
|
||||||
|
// match its nearest claimed ancestor. Runs under the lock above so two
|
||||||
|
// repos racing to claim the same group serialize. Short-circuits before
|
||||||
|
// any diff work on a conflict (409).
|
||||||
|
let declared_project = match &claim.project {
|
||||||
|
Some(decl) => Some(upsert_project_tx(&mut tx, decl, actor).await?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
match owner {
|
||||||
|
ApplyOwner::Group(gid) => {
|
||||||
|
self.claim_group_owner(
|
||||||
|
&mut tx,
|
||||||
|
gid,
|
||||||
|
declared_project,
|
||||||
|
claim.takeover,
|
||||||
|
claim.principal,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
ApplyOwner::App(app_id) => {
|
||||||
|
self.check_app_owner_single(app_id, declared_project)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let current = self.load_current(owner).await?;
|
let current = self.load_current(owner).await?;
|
||||||
// Phase 4: names of group scripts the current routes/triggers bind to,
|
// Phase 4: names of group scripts the current routes/triggers bind to,
|
||||||
// so the token / diff / prune resolve inherited bindings (see method).
|
// so the token / diff / prune resolve inherited bindings (see method).
|
||||||
@@ -1481,14 +1532,17 @@ impl ApplyService {
|
|||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// `Invalid` for a bad bundle; `StateMoved` if the bound token is stale;
|
/// `Invalid` for a bad bundle; `StateMoved` if the bound token is stale;
|
||||||
|
/// `OwnershipConflict` (§7) if a node is owned by another project;
|
||||||
/// `Backend` for repo/transaction failures.
|
/// `Backend` for repo/transaction failures.
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
pub async fn apply_tree(
|
pub async fn apply_tree(
|
||||||
&self,
|
&self,
|
||||||
bundle: &TreeBundle,
|
bundle: &TreeBundle,
|
||||||
prune: bool,
|
prune: bool,
|
||||||
actor: AdminUserId,
|
claim: &OwnershipClaim<'_>,
|
||||||
expected_token: Option<&str>,
|
expected_token: Option<&str>,
|
||||||
) -> Result<ApplyReport, ApplyError> {
|
) -> Result<ApplyReport, ApplyError> {
|
||||||
|
let actor = claim.principal.user_id;
|
||||||
let (prepared, token) = self.prepare_tree(bundle).await?;
|
let (prepared, token) = self.prepare_tree(bundle).await?;
|
||||||
if let Some(expected) = expected_token {
|
if let Some(expected) = expected_token {
|
||||||
if token != expected {
|
if token != expected {
|
||||||
@@ -1517,10 +1571,32 @@ impl ApplyService {
|
|||||||
let mut report = ApplyReport::default();
|
let mut report = ApplyReport::default();
|
||||||
let mut group_script_index: HashMap<GroupId, HashMap<String, ScriptId>> = HashMap::new();
|
let mut group_script_index: HashMap<GroupId, HashMap<String, ScriptId>> = HashMap::new();
|
||||||
|
|
||||||
|
// §7 ownership: one project for the whole tree; claim/verify every group
|
||||||
|
// node in Phase A (recording it in `claimed_in_tree`), then check every
|
||||||
|
// app in Phase B against its nearest claimed ancestor. A conflict aborts
|
||||||
|
// the whole transaction.
|
||||||
|
let declared_project = match &claim.project {
|
||||||
|
Some(decl) => Some(upsert_project_tx(&mut tx, decl, actor).await?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let declared_slug = claim.project.as_ref().map(|d| d.slug.clone());
|
||||||
|
let mut claimed_in_tree: HashMap<GroupId, (ProjectId, String)> = HashMap::new();
|
||||||
|
|
||||||
// Phase A — groups first: reconcile each and record its post-create
|
// Phase A — groups first: reconcile each and record its post-create
|
||||||
// `name -> id` so descendant app nodes can resolve inherited bindings.
|
// `name -> id` so descendant app nodes can resolve inherited bindings.
|
||||||
for p in &prepared {
|
for p in &prepared {
|
||||||
if let ApplyOwner::Group(gid) = p.owner {
|
if let ApplyOwner::Group(gid) = p.owner {
|
||||||
|
self.claim_group_owner(
|
||||||
|
&mut tx,
|
||||||
|
gid,
|
||||||
|
declared_project,
|
||||||
|
claim.takeover,
|
||||||
|
claim.principal,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if let (Some(pid), Some(slug)) = (declared_project, &declared_slug) {
|
||||||
|
claimed_in_tree.insert(gid, (pid, slug.clone()));
|
||||||
|
}
|
||||||
report
|
report
|
||||||
.warnings
|
.warnings
|
||||||
.extend(disabled_target_warnings(&p.node.bundle));
|
.extend(disabled_target_warnings(&p.node.bundle));
|
||||||
@@ -1546,6 +1622,12 @@ impl ApplyService {
|
|||||||
// ids just created) and pre-existing ancestors, then reconcile.
|
// ids just created) and pre-existing ancestors, then reconcile.
|
||||||
for p in &prepared {
|
for p in &prepared {
|
||||||
if let ApplyOwner::App(_) = p.owner {
|
if let ApplyOwner::App(_) = p.owner {
|
||||||
|
// §7: an app must match its nearest claimed ancestor — an in-tree
|
||||||
|
// group (just claimed in Phase A) or a committed ancestor.
|
||||||
|
let nearest =
|
||||||
|
nearest_claimed_in_tx(&mut tx, &p.app_chain, &claimed_in_tree).await?;
|
||||||
|
decide_app_owner(nearest, declared_project)
|
||||||
|
.map_err(ApplyError::OwnershipConflict)?;
|
||||||
report
|
report
|
||||||
.warnings
|
.warnings
|
||||||
.extend(disabled_target_warnings(&p.node.bundle));
|
.extend(disabled_target_warnings(&p.node.bundle));
|
||||||
@@ -1783,6 +1865,84 @@ impl ApplyService {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// §7 ownership claim — the ApplyService side (executes the pure verdict).
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Claim / verify ownership of a GROUP node inside the apply tx. Reads the
|
||||||
|
/// live owner under the caller's advisory lock, runs the pure policy, and
|
||||||
|
/// applies the verdict: first-apply claims, a re-apply by the owner is a
|
||||||
|
/// no-op, a conflict is a 409, and a `--takeover` reassign additionally
|
||||||
|
/// requires `GroupAdmin` on the target (ownership ⟂ RBAC, §7.4).
|
||||||
|
async fn claim_group_owner(
|
||||||
|
&self,
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
gid: GroupId,
|
||||||
|
declared: Option<ProjectId>,
|
||||||
|
takeover: bool,
|
||||||
|
principal: &Principal,
|
||||||
|
) -> Result<(), ApplyError> {
|
||||||
|
let current = read_group_owner_tx(tx, gid).await?;
|
||||||
|
match decide_group_claim(current, declared, takeover) {
|
||||||
|
GroupClaimAction::Noop => Ok(()),
|
||||||
|
GroupClaimAction::Claim(pid) => write_group_owner_tx(tx, gid, pid).await,
|
||||||
|
GroupClaimAction::Takeover(pid) => {
|
||||||
|
require(self.authz.as_ref(), principal, Capability::GroupAdmin(gid))
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
write_group_owner_tx(tx, gid, pid).await
|
||||||
|
}
|
||||||
|
GroupClaimAction::Conflict(msg) => Err(ApplyError::OwnershipConflict(msg)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify an APP node's ownership (single-node path). The app claims
|
||||||
|
/// nothing — it must match its nearest claimed ancestor group. Resolves the
|
||||||
|
/// chain via `ancestors()` (which now carries `owner_project`); an unclaimed
|
||||||
|
/// subtree is open. The narrow read-then-commit window is accepted (an app
|
||||||
|
/// writes no ownership; hazard c).
|
||||||
|
async fn check_app_owner_single(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
declared: Option<ProjectId>,
|
||||||
|
) -> Result<(), ApplyError> {
|
||||||
|
let Some(app) = self
|
||||||
|
.apps
|
||||||
|
.get_by_id(app_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||||
|
else {
|
||||||
|
return Ok(()); // resolved earlier in the handler; defensive.
|
||||||
|
};
|
||||||
|
let chain = self
|
||||||
|
.groups
|
||||||
|
.ancestors(app.group_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
|
let nearest = self.nearest_claimed_from_chain(&chain).await?;
|
||||||
|
decide_app_owner(nearest, declared).map_err(ApplyError::OwnershipConflict)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The nearest ancestor (nearest-first `chain`) with an owner, as
|
||||||
|
/// `(id, slug)`. Resolves the owning project's slug lazily — only when a
|
||||||
|
/// claim exists — for the conflict message.
|
||||||
|
async fn nearest_claimed_from_chain(
|
||||||
|
&self,
|
||||||
|
chain: &[picloud_shared::Group],
|
||||||
|
) -> Result<Option<(ProjectId, String)>, ApplyError> {
|
||||||
|
let Some(pid) = chain.iter().find_map(|g| g.owner_project) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let slug = self
|
||||||
|
.projects
|
||||||
|
.get_by_id(pid)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||||
|
.map(|p| p.slug)
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(Some((pid, slug)))
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -2893,6 +3053,192 @@ async fn delete_group_var_tx(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// §7 multi-repo ownership — the claim state machine + its tx helpers.
|
||||||
|
//
|
||||||
|
// A project is single-owner-per-NODE: each group node is owned by exactly one
|
||||||
|
// project (`groups.owner_project`); apps inherit ownership from their nearest
|
||||||
|
// claimed ancestor group. The pure `decide_*` fns are the whole policy; the
|
||||||
|
// apply path executes their verdict (the UPDATE / the takeover authz gate)
|
||||||
|
// under the per-node advisory lock. See docs/design/groups-and-project-tool.md §7.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// What a group-node apply should do about ownership, given the current owner,
|
||||||
|
/// the declared project, and whether `--takeover` was passed.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum GroupClaimAction {
|
||||||
|
/// Nothing to write (unclaimed + no project, or already owned by us).
|
||||||
|
Noop,
|
||||||
|
/// First-apply-claims: set `owner_project`.
|
||||||
|
Claim(ProjectId),
|
||||||
|
/// Reassign to us — capability-gated (`GroupAdmin`) at the call site.
|
||||||
|
Takeover(ProjectId),
|
||||||
|
/// Refuse (409); the string is the actionable message.
|
||||||
|
Conflict(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure ownership policy for a GROUP node. `current` is the live owner as
|
||||||
|
/// `(id, slug)`; `declared` is this apply's project id. See §7.3.
|
||||||
|
fn decide_group_claim(
|
||||||
|
current: Option<(ProjectId, String)>,
|
||||||
|
declared: Option<ProjectId>,
|
||||||
|
takeover: bool,
|
||||||
|
) -> GroupClaimAction {
|
||||||
|
match (current, declared) {
|
||||||
|
// Unclaimed: a declared project claims it; no project is a no-op (the
|
||||||
|
// node stays UI/API-owned — the backward-compatible path).
|
||||||
|
(None, Some(p)) => GroupClaimAction::Claim(p),
|
||||||
|
(None, None) => GroupClaimAction::Noop,
|
||||||
|
// Already ours: re-apply by the owning repo.
|
||||||
|
(Some((c, _)), Some(p)) if c == p => GroupClaimAction::Noop,
|
||||||
|
// Owned by someone else: takeover (gated) or refuse.
|
||||||
|
(Some((_, cslug)), Some(p)) => {
|
||||||
|
if takeover {
|
||||||
|
GroupClaimAction::Takeover(p)
|
||||||
|
} else {
|
||||||
|
GroupClaimAction::Conflict(format!(
|
||||||
|
"group is owned by project '{cslug}'; use --takeover (requires group-admin) \
|
||||||
|
to reassign it"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A no-project apply must not silently write into a claimed subtree.
|
||||||
|
(Some((_, cslug)), None) => GroupClaimAction::Conflict(format!(
|
||||||
|
"group is owned by project '{cslug}'; declare a matching [project] (or --takeover)"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure ownership policy for an APP node. Apps never claim — they must match
|
||||||
|
/// their nearest claimed ancestor group (`nearest`, as `(id, slug)`); an
|
||||||
|
/// unclaimed subtree is open (backward-compatible). See §7.
|
||||||
|
fn decide_app_owner(
|
||||||
|
nearest: Option<(ProjectId, String)>,
|
||||||
|
declared: Option<ProjectId>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
match (nearest, declared) {
|
||||||
|
(None, _) => Ok(()),
|
||||||
|
(Some((c, _)), Some(p)) if c == p => Ok(()),
|
||||||
|
(Some((_, cslug)), Some(_)) => Err(format!(
|
||||||
|
"the nearest owning group belongs to project '{cslug}', not this apply's project; \
|
||||||
|
apply from '{cslug}', or --takeover its group from a [group] node"
|
||||||
|
)),
|
||||||
|
(Some((_, cslug)), None) => Err(format!(
|
||||||
|
"this app is under a subtree owned by project '{cslug}'; declare [project] '{cslug}'"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §7: validate a `[project]` slug — the same rule as app/group slugs
|
||||||
|
/// (`^[a-z0-9][a-z0-9-]{0,62}$`).
|
||||||
|
fn validate_project_slug(slug: &str) -> Result<(), ApplyError> {
|
||||||
|
let bad = |why: &str| ApplyError::Invalid(format!("project slug {slug:?}: {why}"));
|
||||||
|
if slug.is_empty() || slug.len() > 63 {
|
||||||
|
return Err(bad("must be 1–63 characters"));
|
||||||
|
}
|
||||||
|
let first = slug.chars().next().expect("non-empty checked above");
|
||||||
|
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
|
||||||
|
return Err(bad("must start with a lowercase letter or digit"));
|
||||||
|
}
|
||||||
|
if !slug
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||||
|
{
|
||||||
|
return Err(bad(
|
||||||
|
"may contain only lowercase letters, digits, and hyphens",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register (or fetch) a project by slug inside the apply tx, returning its id.
|
||||||
|
/// First apply with a new slug inserts; a re-apply updates the display name
|
||||||
|
/// (the manifest is authoritative) but preserves the original `created_by`.
|
||||||
|
async fn upsert_project_tx(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
decl: &ProjectDecl,
|
||||||
|
actor: AdminUserId,
|
||||||
|
) -> Result<ProjectId, ApplyError> {
|
||||||
|
validate_project_slug(&decl.slug)?;
|
||||||
|
let name = decl.name.clone().unwrap_or_else(|| decl.slug.clone());
|
||||||
|
let (id,): (uuid::Uuid,) = sqlx::query_as(
|
||||||
|
"INSERT INTO projects (slug, name, created_by) VALUES ($1, $2, $3) \
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name \
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(&decl.slug)
|
||||||
|
.bind(&name)
|
||||||
|
.bind(actor.into_inner())
|
||||||
|
.fetch_one(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
|
Ok(id.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a group's current owner as `(project id, project slug)` inside the tx,
|
||||||
|
/// or `None` when unclaimed. Serialized with the write via the caller's
|
||||||
|
/// per-node advisory lock.
|
||||||
|
async fn read_group_owner_tx(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
gid: GroupId,
|
||||||
|
) -> Result<Option<(ProjectId, String)>, ApplyError> {
|
||||||
|
let row: Option<(Option<uuid::Uuid>, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT g.owner_project, p.slug FROM groups g \
|
||||||
|
LEFT JOIN projects p ON p.id = g.owner_project WHERE g.id = $1",
|
||||||
|
)
|
||||||
|
.bind(gid.into_inner())
|
||||||
|
.fetch_optional(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
|
Ok(row.and_then(|(pid, slug)| pid.map(|p| (p.into(), slug.unwrap_or_default()))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assign `owner_project` for a group inside the tx. Deliberately does NOT bump
|
||||||
|
/// `structure_version` — a claim is not a diff/inheritance change, so it must
|
||||||
|
/// not invalidate a pending bound plan (hazard b in the design).
|
||||||
|
async fn write_group_owner_tx(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
gid: GroupId,
|
||||||
|
pid: ProjectId,
|
||||||
|
) -> Result<(), ApplyError> {
|
||||||
|
sqlx::query("UPDATE groups SET owner_project = $1 WHERE id = $2")
|
||||||
|
.bind(pid.into_inner())
|
||||||
|
.bind(gid.into_inner())
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nearest claimed ancestor for an app, resolved in-tx: an in-tree group
|
||||||
|
/// (Phase-A overlay) wins over a committed ancestor. `app_chain` is nearest-
|
||||||
|
/// first. Used by the tree path (the single-node path folds `ancestors()`).
|
||||||
|
async fn nearest_claimed_in_tx(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
app_chain: &[GroupId],
|
||||||
|
in_tree: &HashMap<GroupId, (ProjectId, String)>,
|
||||||
|
) -> Result<Option<(ProjectId, String)>, ApplyError> {
|
||||||
|
for gid in app_chain {
|
||||||
|
if let Some(hit) = in_tree.get(gid) {
|
||||||
|
return Ok(Some(hit.clone()));
|
||||||
|
}
|
||||||
|
if let Some(hit) = read_group_owner_tx(tx, *gid).await? {
|
||||||
|
return Ok(Some(hit));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map an authz denial to the apply surface (`Denied` → 403, repo error → 500).
|
||||||
|
/// Used by the takeover capability gate, keeping "lacks group-admin" (403)
|
||||||
|
/// distinct from a 409 ownership conflict.
|
||||||
|
fn map_authz(d: AuthzDenied) -> ApplyError {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => ApplyError::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => ApplyError::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Owner of an apply node (Phase 5): an app or a group. The apply engine is
|
/// Owner of an apply node (Phase 5): an app or a group. The apply engine is
|
||||||
/// owner-generic — only script-create ownership, var writes, the current-state
|
/// owner-generic — only script-create ownership, var writes, the current-state
|
||||||
/// load, and the advisory lock differ; the diff and all other CRUD are shared.
|
/// load, and the advisory lock differ; the diff and all other CRUD are shared.
|
||||||
@@ -4736,4 +5082,66 @@ mod tests {
|
|||||||
"queue identity is queue_name-only: {p:?}"
|
"queue identity is queue_name-only: {p:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// §7 ownership — the pure claim policy (the whole decision, DB-free).
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_claim_unclaimed_claims_only_with_a_project() {
|
||||||
|
let p = ProjectId::new();
|
||||||
|
// First apply with a project claims; without a project it stays UI-owned.
|
||||||
|
assert_eq!(
|
||||||
|
decide_group_claim(None, Some(p), false),
|
||||||
|
GroupClaimAction::Claim(p)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decide_group_claim(None, None, false),
|
||||||
|
GroupClaimAction::Noop
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_claim_by_owner_is_a_noop() {
|
||||||
|
let p = ProjectId::new();
|
||||||
|
assert_eq!(
|
||||||
|
decide_group_claim(Some((p, "acme".into())), Some(p), false),
|
||||||
|
GroupClaimAction::Noop
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_claim_foreign_owner_conflicts_unless_takeover() {
|
||||||
|
let owner = ProjectId::new();
|
||||||
|
let me = ProjectId::new();
|
||||||
|
// A different project is refused without --takeover…
|
||||||
|
assert!(matches!(
|
||||||
|
decide_group_claim(Some((owner, "platform".into())), Some(me), false),
|
||||||
|
GroupClaimAction::Conflict(_)
|
||||||
|
));
|
||||||
|
// …and reassigns with it (the authz gate is applied at the call site).
|
||||||
|
assert_eq!(
|
||||||
|
decide_group_claim(Some((owner, "platform".into())), Some(me), true),
|
||||||
|
GroupClaimAction::Takeover(me)
|
||||||
|
);
|
||||||
|
// A no-project apply must not silently write into a claimed node.
|
||||||
|
assert!(matches!(
|
||||||
|
decide_group_claim(Some((owner, "platform".into())), None, false),
|
||||||
|
GroupClaimAction::Conflict(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_owner_matches_nearest_or_conflicts() {
|
||||||
|
let owner = ProjectId::new();
|
||||||
|
let me = ProjectId::new();
|
||||||
|
// An unclaimed subtree is open (backward-compatible).
|
||||||
|
assert!(decide_app_owner(None, Some(me)).is_ok());
|
||||||
|
assert!(decide_app_owner(None, None).is_ok());
|
||||||
|
// Same project as the nearest claimed ancestor → ok.
|
||||||
|
assert!(decide_app_owner(Some((owner, "x".into())), Some(owner)).is_ok());
|
||||||
|
// A different project, or none, under a claimed subtree → conflict.
|
||||||
|
assert!(decide_app_owner(Some((owner, "platform".into())), Some(me)).is_err());
|
||||||
|
assert!(decide_app_owner(Some((owner, "platform".into())), None).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user