Compare commits

..

3 Commits

Author SHA1 Message Date
MechaCat02
4c68d2fa33 fix(project-tool): close M5+M3 review findings (authz + data-loss + None-path)
A fresh two-lens end-to-end review (security + correctness) of the committed
M5/M3 diff surfaced four real defects; all fixed here with regressions.

- HIGH (M5 authz bypass) — `enforce_env_approval` resolved a group node with a
  bare `get_by_slug` and SKIPPED the GroupAdmin gate on miss. A node addressed
  by the group's UUID (which the apply still resolves) let a group EDITOR apply
  to a confirm-required env without admin. Now resolves UUID-or-slug and fails
  closed, mirroring authz_tree / prepare_tree. Raw-wire regression added.

- FIX-FIRST (M3 correctness) — the legacy `project_key=None` path was not inert:
  the conflict loop pushed a conflict for any owned group (`Some(oid) != None`),
  so a keyless/direct-API tree apply touching an already-claimed group was
  spuriously 409'd. Ownership classification is now gated on a key being present
  (the discriminator is key-present, not project-persisted, so a first-ever
  keyed apply still sees an already-owned group as a conflict). Regression added.

- FIX-FIRST (M3 data-loss) — the structural-prune guard checked collection
  MARKERS (`group_collections`) but data outlives its marker: un-declaring a
  `collections=[...]` entry drops the marker while the kv/docs/files/queue rows
  survive until group delete. A dropped-then-pruned group would silently CASCADE
  that orphaned data. The guard now EXISTS-checks the actual data tables
  (group_kv_entries/docs/files/queue_messages) plus secrets + markers.

- MEDIUM (audit) — group ownership takeover (and claim) now emit a
  tracing::info! audit line with the actor + ousted owner, matching the M5
  gated-env audit. Previously only a report counter.

Also: documented WHY `pic plan --dir` mints the project key (a rare write for a
read-only command) — plan and apply must present the same key or the ownership
token folds diverge and trip a spurious StateMoved.

Re-verified: 411 manager-core lib tests; 28 CLI journeys (incl. 2 new
regressions: uuid-slug admin gate, keyless legacy apply); workspace clippy
-D warnings + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:45:23 +02:00
MechaCat02
5c33b7490b 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>
2026-07-05 20:29:12 +02:00
MechaCat02
654e38752d feat(project-tool): per-env approval-policy gating (§4.2/§6)
Salvaged from the superseded feat/hierarchies-extension-points branch (M5),
re-ported onto main's evolved tree-apply engine. Fills the §7/§11.1 "per-env
approval-policy gating" that main's design doc still lists as intended but
unbuilt.

A project's root manifest declares which environments require explicit
approval; `pic apply --dir --env <e>` to a confirm-required env needs an
explicit `--approve <e>` (a blanket `--yes` does NOT cover it). The approval
is admin-gated (admin on every declared node) and audited, and the policy
folds into the bound-plan token.

- manifest: `[project]` block → ManifestProject { environments[{name,confirm}] },
  root-manifest-only (rejected elsewhere by build_tree).
- discover: build_tree emits `project` into the bundle from the root manifest.
- apply_service: TreeBundle.project + ProjectPolicy; policy folds into the
  state_token; plan surfaces approvals_required; ApplyError::ApprovalRequired
  → 409.
- apply_api: enforce_env_approval (after authz_tree) — refuses a gated env not
  in approved_envs, requires AppAdmin/GroupAdmin on every declared node on
  approval, audits. Server re-derives policy from the bundle (authoritative).
- CLI: --approve (repeatable); resolve_approvals refuses a gated env
  non-interactively, prompts on a TTY; plan renders gated envs. Single-node
  apply --file refuses a confirm-required env and directs to --dir.
- approval journey + manifest/ProjectPolicy unit tests. No migration (policy
  lives in the manifest, like takeover/blast-radius).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:51:11 +02:00
39 changed files with 1949 additions and 3807 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,39 @@
-- Phase 5 / M3 (v1.2 Hierarchies): multi-repo single-owner ownership (§7).
--
-- A group node is authoritatively MANAGED by at most one project-root (the
-- repo whose manifest declares it as something it applies, not merely
-- references). `groups.owner_project` has carried this seam since 0047 as an
-- inert, FK-less UUID. M3 makes it a real reference: a `projects` table keyed
-- by a stable, gitignored project key the CLI mints in `.picloud/`, and a
-- foreign key from `groups.owner_project` to it.
--
-- Ownership is recorded at GROUP granularity; apps inherit their owning project
-- from their group (apps are never claimed directly). A NULL `owner_project`
-- means the node is UI/API-owned — no manifest fights it (§7.5). First apply
-- claims; a second project's apply to an owned node is refused unless
-- `--takeover` (group-admin gated). Ownership ⟂ RBAC: owning the manifest does
-- NOT grant write — the actor still needs the usual group capabilities (§7.4).
--
-- ON DELETE SET NULL: deleting a project row (not something the platform does
-- today) reverts its nodes to UI-owned rather than cascading away real groups.
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Stable, opaque key minted by `pic init` and persisted (gitignored) in the
-- repo's `.picloud/`. The CLI presents it on every tree apply; the server
-- maps it to this row (upsert-by-key), so the same repo keeps the same
-- project identity across clones/CI without committing any server id.
key TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Promote the inert `groups.owner_project` (0047) to a real FK now that the
-- referent exists. Existing rows are all NULL (no projects yet), so this adds
-- no validation burden.
ALTER TABLE groups
ADD CONSTRAINT groups_owner_project_fkey
FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL;
-- The ownership reconcile reads "which groups does project P own?" (to find
-- owned-but-undeclared nodes for structural prune) and "who owns group G?".
CREATE INDEX groups_owner_project_idx ON groups (owner_project);

View File

@@ -1,41 +0,0 @@
-- §7 multi-repo ownership: the `projects` registry + wiring the inert
-- `owner_project` seam.
--
-- A "project" is a repo-root that declaratively MANAGES a slice of the shared
-- group tree (contains its manifest as something it applies, not merely
-- references). §7's model is SINGLE-OWNER-PER-NODE: each group node is owned by
-- exactly one project; the first `pic apply` that declares a `[project]` claims
-- the node, and a second repo applying to an owned node is refused unless it
-- passes a group-admin-gated `--takeover`. Ownership ⟂ RBAC — it records which
-- manifest is authoritative, not whether a principal may act.
--
-- Identity is a committed `[project] slug` (stable across clones); the server
-- assigns the UUID (server-internal). The `owner_project UUID` column already
-- exists on `groups` (0047, inert) — this migration creates the table it was
-- always meant to reference and wires the FK. Apps carry NO owner column: an
-- app inherits ownership from its NEAREST claimed ancestor group (the ancestor
-- walk is the isolation boundary), faithful to §7's "don't co-own a node —
-- split config downward" corollary.
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Instance-global identity, declared in the repo's committed root manifest
-- and frozen. First apply with a new slug registers the project.
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
-- The admin who first registered it. SET NULL (not CASCADE) — losing the
-- creator's account must not orphan-delete the project or un-claim its tree.
created_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Wire the inert 0047 seam. ON DELETE SET NULL un-claims the owned nodes (they
-- fall back to UI/API-owned) rather than cascading a destructive tree delete —
-- deleting a project must never destroy the groups/apps it happened to manage.
ALTER TABLE groups
ADD CONSTRAINT groups_owner_project_fk
FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL;
-- Ownership lookups: "what does project X own?" (pic projects ls) and the
-- LEFT JOIN behind pic groups ls' owner column.
CREATE INDEX groups_owner_project_idx ON groups (owner_project);

View File

@@ -18,8 +18,8 @@ use serde_json::json;
use crate::app_repo::AppRepository;
use crate::apply_service::{
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo,
ExtensionPointInfo, NodeKind, OwnershipClaim, PlanResult, ProjectDecl, RouteTemplateInfo,
StructureMode, SuppressionInfo, TreeBundle, TreePlanResult, TriggerTemplateInfo,
ExtensionPointInfo, NodeKind, PlanResult, RouteTemplateInfo, SuppressionInfo, TreeBundle,
TreePlanResult, TriggerTemplateInfo,
};
use crate::authz::{require, AuthzDenied, Capability};
use crate::group_repo::GroupRepository;
@@ -179,23 +179,6 @@ async fn group_extension_points_handler(
Ok(Json(report))
}
/// A `plan` request (§7 M3): the desired bundle plus the optional declaring
/// `[project]`, so the plan can preview the ownership outcome + attach ceiling.
#[derive(Deserialize)]
pub struct PlanRequest {
// Flattened so the bundle fields sit at the JSON top level — restoring the
// pre-§7 plan wire, which took a BARE `Json<Bundle>`. An older `pic` that
// POSTs a bare bundle still deserializes (`project` defaults to `None`), and
// a newer client sends the same bundle fields plus a top-level `project`
// key. (Apply is deliberately NOT flattened — its pre-§7 wire was already
// `{bundle, prune, …}`, so flattening it would instead break old apply
// clients; the two endpoints are asymmetric because their histories are.)
#[serde(flatten)]
pub bundle: Bundle,
#[serde(default)]
pub project: Option<ProjectDecl>,
}
#[derive(Deserialize)]
pub struct ApplyRequest {
pub bundle: Bundle,
@@ -205,12 +188,6 @@ pub struct ApplyRequest {
/// refuses (409) if the app's live state has changed since.
#[serde(default)]
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(
@@ -221,17 +198,12 @@ async fn apply_handler(
) -> Result<Json<ApplyReport>, ApplyError> {
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?;
let claim = OwnershipClaim {
project: req.project,
takeover: req.takeover,
principal: &principal,
};
let report = svc
.apply(
app_id,
&req.bundle,
req.prune,
&claim,
principal.user_id,
req.expected_token.as_deref(),
)
.await?;
@@ -242,7 +214,7 @@ async fn plan_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(req): Json<PlanRequest>,
Json(bundle): Json<Bundle>,
) -> Result<Json<PlanResult>, ApplyError> {
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
// NOTE: the returned `Plan` discloses live secret NAMES (not values). That
@@ -254,7 +226,7 @@ async fn plan_handler(
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
.await
.map_err(map_authz)?;
let plan = svc.plan(app_id, &req.bundle, req.project.as_ref()).await?;
let plan = svc.plan(app_id, &bundle).await?;
Ok(Json(plan))
}
@@ -268,7 +240,7 @@ async fn group_plan_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(req): Json<PlanRequest>,
Json(bundle): Json<Bundle>,
) -> Result<Json<PlanResult>, ApplyError> {
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
// Plan discloses the group's script + var + secret names; viewer-tier reads.
@@ -279,13 +251,7 @@ async fn group_plan_handler(
)
.await
.map_err(map_authz)?;
let plan = svc
.plan_owner(
ApplyOwner::Group(group_id),
&req.bundle,
req.project.as_ref(),
)
.await?;
let plan = svc.plan_owner(ApplyOwner::Group(group_id), &bundle).await?;
Ok(Json(plan))
}
@@ -296,19 +262,13 @@ async fn group_apply_handler(
Json(req): Json<ApplyRequest>,
) -> Result<Json<ApplyReport>, ApplyError> {
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
svc.require_group_node_writes(&principal, group_id, &req.bundle, req.prune)
.await?;
let claim = OwnershipClaim {
project: req.project,
takeover: req.takeover,
principal: &principal,
};
require_group_node_writes(&svc, &principal, group_id, &req.bundle, req.prune).await?;
let report = svc
.apply_owner(
ApplyOwner::Group(group_id),
&req.bundle,
req.prune,
&claim,
principal.user_id,
req.expected_token.as_deref(),
)
.await?;
@@ -320,6 +280,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,
@@ -327,24 +297,22 @@ struct TreeApplyRequest {
prune: bool,
#[serde(default)]
expected_token: Option<String>,
/// §7 ownership: one `[project]` for the whole tree (the root manifest's).
/// Selected environment (the overlay applied). Drives per-env approval
/// gating (§4.2, M5).
#[serde(default)]
project: Option<ProjectDecl>,
env: Option<String>,
/// Environments the actor explicitly approved this apply for (§4.2, M5). A
/// confirm-required env (per the bundle's `[project]` policy) is refused
/// unless it appears here; `--yes` alone does NOT add it.
#[serde(default)]
takeover: bool,
/// §6 M2: how to resolve a group whose server parent diverges from the
/// manifest (default `Refuse`; a pre-M2 CLI omits it).
approved_envs: Vec<String>,
/// Stable project key (§7, M3): the apply claims unclaimed declared groups
/// for this project and refuses ones another project owns.
#[serde(default)]
structure_mode: StructureMode,
}
#[derive(Deserialize)]
struct TreePlanRequest {
// Flattened for the same bare-bundle wire compatibility as `PlanRequest`.
#[serde(flatten)]
bundle: TreeBundle,
project_key: Option<String>,
/// Take over declared groups owned by another project (group-admin gated).
#[serde(default)]
project: Option<ProjectDecl>,
allow_takeover: bool,
}
async fn tree_plan_handler(
@@ -352,9 +320,10 @@ async fn tree_plan_handler(
Extension(principal): Extension<Principal>,
Json(req): Json<TreePlanRequest>,
) -> Result<Json<TreePlanResult>, ApplyError> {
authz_tree(&svc, &principal, &req.bundle, None).await?;
authz_tree(&svc, &principal, &req.bundle, None, false).await?;
Ok(Json(
svc.plan_tree(&req.bundle, req.project.as_ref()).await?,
svc.plan_tree(&req.bundle, req.project_key.as_deref())
.await?,
))
}
@@ -363,24 +332,95 @@ 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?;
let claim = OwnershipClaim {
project: req.project,
takeover: req.takeover,
principal: &principal,
};
authz_tree(
&svc,
&principal,
&req.bundle,
Some(req.prune),
req.allow_takeover,
)
.await?;
enforce_env_approval(
&svc,
&principal,
&req.bundle,
req.env.as_deref(),
&req.approved_envs,
)
.await?;
let report = svc
.apply_tree(
&req.bundle,
req.prune,
&claim,
&principal,
req.expected_token.as_deref(),
req.structure_mode,
req.project_key.as_deref(),
req.allow_takeover,
)
.await?;
Ok(Json(report))
}
/// Per-env approval gate (§4.2, §6, M5). If the apply selects a confirm-required
/// environment (per the bundle's `[project]` policy, re-derived here — the CLI's
/// enforcement is convenience, this is authoritative), it must be explicitly
/// approved via `approved_envs` (`pic apply --approve <env>`); a blanket `--yes`
/// does NOT satisfy it. An approved gated apply additionally requires ADMIN
/// authority on every declared node — the "override a gate" capability (§4.2), a
/// deliberate step up from the editor-level write caps an ordinary apply needs —
/// and is audited.
async fn enforce_env_approval(
svc: &ApplyService,
principal: &Principal,
bundle: &TreeBundle,
env: Option<&str>,
approved_envs: &[String],
) -> Result<(), ApplyError> {
let (Some(policy), Some(env)) = (&bundle.project, env) else {
return Ok(());
};
if !policy.confirm_required(env) {
return Ok(());
}
if !approved_envs.iter().any(|e| e == env) {
return Err(ApplyError::ApprovalRequired(env.to_string()));
}
// Approved: require admin authority on every declared node.
for node in &bundle.nodes {
match node.kind {
NodeKind::App => {
let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?;
require(svc.authz.as_ref(), principal, Capability::AppAdmin(app_id))
.await
.map_err(map_authz)?;
}
NodeKind::Group => {
// Resolve UUID-or-slug and FAIL CLOSED on miss — mirror
// `authz_tree`/`prepare_tree`. A bare `get_by_slug` skips this
// admin gate when the node addresses the group by its UUID (which
// `resolve_group`/`resolve_group_id` still resolve for the apply),
// letting a group editor apply to a confirm-required env without
// the admin authority M5 requires.
let group_id = resolve_group_id(svc.groups.as_ref(), &node.slug).await?;
require(
svc.authz.as_ref(),
principal,
Capability::GroupAdmin(group_id),
)
.await
.map_err(map_authz)?;
}
}
}
tracing::info!(
user_id = ?principal.user_id,
env = %env,
nodes = bundle.nodes.len(),
"apply: approved gated-environment apply (audit)"
);
Ok(())
}
/// Per-node capability check for a tree plan/apply. `prune` widens an apply's
/// write requirement to every kind. A plan (`prune` ignored, no writes) needs
/// only the per-node read cap.
@@ -389,6 +429,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 {
@@ -407,23 +448,33 @@ async fn authz_tree(
}
}
NodeKind::Group => {
// §6: a group node that doesn't exist yet is to-CREATE — its
// authorization (InstanceCreateGroup / GroupAdmin(parent)) is
// enforced in the service's create path, so skip the
// existing-group capability check here rather than 404.
let Some(group) = svc
.groups
.get_by_slug(&node.slug)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
else {
continue;
};
let group_id = group.id;
let group_id = resolve_group_id(svc.groups.as_ref(), &node.slug).await?;
match apply_prune {
Some(prune) => {
svc.require_group_node_writes(principal, group_id, &node.bundle, prune)
require_group_node_writes(svc, principal, group_id, &node.bundle, prune)
.await?;
// Takeover gate (§7.4): seizing a group 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 re-checks under the tx lock).
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)?;
}
}
None => {
require(
@@ -505,6 +556,40 @@ async fn require_app_node_writes(
Ok(())
}
/// Group-node write caps (editor-tier): scripts → `GroupScriptsWrite`, vars →
/// `GroupVarsWrite`, both on prune.
async fn require_group_node_writes(
svc: &ApplyService,
principal: &Principal,
group_id: GroupId,
bundle: &Bundle,
prune: bool,
) -> Result<(), ApplyError> {
// Baseline (§7.4, M3): applying a group node CLAIMS its ownership
// (`owner_project`) in-tx — a management write — even when the bundle has no
// scripts/vars to reconcile. Require an editor-level group write cap floor so
// an EMPTY `[group]` node can't be claimed capability-free (Ownership ⟂ RBAC:
// owning the manifest never grants write, and claiming still needs write).
// Subsumes the per-resource GroupScriptsWrite below.
require(
svc.authz.as_ref(),
principal,
Capability::GroupScriptsWrite(group_id),
)
.await
.map_err(map_authz)?;
if prune || !bundle.vars.is_empty() {
require(
svc.authz.as_ref(),
principal,
Capability::GroupVarsWrite(group_id),
)
.await
.map_err(map_authz)?;
}
Ok(())
}
/// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404.
async fn resolve_group_id(
groups: &dyn GroupRepository,
@@ -547,13 +632,11 @@ impl IntoResponse for ApplyError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound(_) => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
Self::Invalid(_) | Self::OutsideAttachPoint(_) | Self::StructuralDivergence(_) => (
Self::Invalid(_) => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
// §7 ownership conflict — actionable (declare [project], --takeover).
Self::OwnershipConflict(_) => {
Self::StateMoved | Self::ApprovalRequired(_) | Self::OwnershipConflict(_) => {
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
}
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
@@ -575,41 +658,3 @@ impl IntoResponse for ApplyError {
(status, Json(body)).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
// §7 wire compat: the plan endpoints `#[serde(flatten)]` the bundle, so a
// pre-§7 client that POSTs a BARE bundle (no `{bundle: ...}` wrapper, no
// `project`) must still deserialize — `project` defaults to `None`.
#[test]
fn plan_request_accepts_a_bare_bundle() {
let bare = serde_json::json!({
"scripts": [],
"secrets": ["TOKEN"],
});
let req: PlanRequest = serde_json::from_value(bare).expect("bare bundle deserializes");
assert!(req.project.is_none());
assert_eq!(req.bundle.secrets, vec!["TOKEN".to_string()]);
}
// The newer wire shape — the same bundle fields at the top level plus a
// `project` key (what the current CLI sends) — also deserializes.
#[test]
fn plan_request_accepts_a_flattened_project() {
let with_proj = serde_json::json!({
"scripts": [],
"project": { "slug": "acme" },
});
let req: PlanRequest = serde_json::from_value(with_proj).expect("flattened project");
assert_eq!(req.project.expect("project").slug, "acme");
}
#[test]
fn tree_plan_request_accepts_a_bare_bundle() {
let bare = serde_json::json!({ "nodes": [] });
let req: TreePlanRequest = serde_json::from_value(bare).expect("bare tree bundle");
assert!(req.project.is_none());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@ use uuid::Uuid;
/// cycle guard and the `parent_id` write run serialized — two concurrent
/// reparents can't race into a cycle. Distinct from the per-app
/// `apply_lock_key` space (a fixed sentinel, hashed-namespace-free).
pub(crate) const GROUP_STRUCTURAL_LOCK_KEY: i64 = 0x6701_0047_0000_0001;
const GROUP_STRUCTURAL_LOCK_KEY: i64 = 0x6701_0047_0000_0001;
/// Well-known slug of the instance root group seeded by migration 0047.
pub const ROOT_GROUP_SLUG: &str = "root";
@@ -53,141 +53,11 @@ impl GroupChildCounts {
}
}
/// §6: create a group INSIDE an existing apply transaction, returning its id.
/// Mirrors [`PostgresGroupRepository::create`]'s error mapping. Used by the
/// declarative tree apply to create a group the manifest declares (by directory
/// nesting) but the server doesn't have yet. `structure_version` starts at the
/// column default (a fresh node has no structural history to bump).
pub(crate) async fn create_group_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
slug: &str,
name: &str,
description: Option<&str>,
parent_id: Option<GroupId>,
) -> Result<GroupId, GroupRepositoryError> {
let res = sqlx::query_as::<_, (Uuid,)>(
"INSERT INTO groups (slug, name, description, parent_id) \
VALUES ($1, $2, $3, $4) RETURNING id",
)
.bind(slug)
.bind(name)
.bind(description)
.bind(parent_id.map(GroupId::into_inner))
.fetch_one(&mut **tx)
.await;
match res {
Ok((id,)) => Ok(id.into()),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
GroupRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
),
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => Err(
GroupRepositoryError::Conflict("parent group does not exist".into()),
),
Err(e) => Err(e.into()),
}
}
/// Reparent a group INSIDE an existing tx (the caller holds
/// [`GROUP_STRUCTURAL_LOCK_KEY`]): the ancestor-walk cycle guard + the
/// `parent_id` write + `structure_version` bump. Used by the imperative
/// `reparent` (its own tx) and §6 M2's declarative structural reconcile (the
/// apply tx). A same-tx just-created ancestor is visible to the cycle walk.
pub(crate) async fn reparent_group_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
id: GroupId,
new_parent: Option<GroupId>,
) -> Result<Group, GroupRepositoryError> {
if let Some(parent) = new_parent {
if parent == id {
return Err(GroupRepositoryError::Conflict(
"a group cannot be its own parent".into(),
));
}
// Cycle guard: walk from the destination up to the root; if we reach
// `id`, the move would place `id` beneath itself.
let mut cursor = Some(parent);
let mut hops = 0u32;
while let Some(node) = cursor {
if node == id {
return Err(GroupRepositoryError::Conflict(
"cannot reparent a group beneath one of its own descendants".into(),
));
}
hops += 1;
if hops > 64 {
return Err(GroupRepositoryError::Conflict(
"group ancestry exceeds the maximum depth".into(),
));
}
let parent_of: Option<(Option<Uuid>,)> =
sqlx::query_as("SELECT parent_id FROM groups WHERE id = $1")
.bind(node.into_inner())
.fetch_optional(&mut **tx)
.await?;
match parent_of {
Some((p,)) => cursor = p.map(GroupId::from),
None => {
return Err(GroupRepositoryError::Conflict(
"destination parent group does not exist".into(),
));
}
}
}
}
let row = sqlx::query_as::<_, GroupRow>(&format!(
"UPDATE groups SET \
parent_id = $2, \
structure_version = structure_version + 1, \
updated_at = NOW() \
WHERE id = $1 \
RETURNING {GROUP_COLS}"
))
.bind(id.into_inner())
.bind(new_parent.map(GroupId::into_inner))
.fetch_optional(&mut **tx)
.await?;
row.map(Into::into)
.ok_or(GroupRepositoryError::NotFound(id))
}
/// §6: resolve a group slug → id inside the apply tx, so a group CREATED earlier
/// in the same transaction is visible (a pool read would miss the uncommitted
/// row). `None` if absent.
pub(crate) async fn read_group_id_by_slug_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
slug: &str,
) -> Result<Option<GroupId>, GroupRepositoryError> {
let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM groups WHERE slug = $1")
.bind(slug)
.fetch_optional(&mut **tx)
.await?;
Ok(row.map(|(id,)| id.into()))
}
/// §6 M2: read a group's `(id, parent_id)` by slug inside the tx — the input to
/// the structural-divergence check (server parent vs manifest parent). `None`
/// if the group doesn't exist (it is to-create).
pub(crate) async fn read_group_node_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
slug: &str,
) -> Result<Option<(GroupId, Option<GroupId>)>, GroupRepositoryError> {
let row: Option<(Uuid, Option<Uuid>)> =
sqlx::query_as("SELECT id, parent_id FROM groups WHERE slug = $1")
.bind(slug)
.fetch_optional(&mut **tx)
.await?;
Ok(row.map(|(id, parent)| (id.into(), parent.map(GroupId::from))))
}
#[async_trait]
pub trait GroupRepository: Send + Sync {
/// Every group on the instance, ordered by name. The tree is small
/// (org structure), so callers assemble the hierarchy in memory.
async fn list(&self) -> Result<Vec<Group>, GroupRepositoryError>;
/// Every group paired with its owning project's slug (`None` when the node
/// is unclaimed — no manifest owns it). One LEFT JOIN instead of an N+1
/// per-group project lookup; backs `pic groups ls`' owner column (§7).
async fn list_with_owner(&self) -> Result<Vec<(Group, Option<String>)>, GroupRepositoryError>;
async fn get_by_id(&self, id: GroupId) -> Result<Option<Group>, GroupRepositoryError>;
async fn get_by_slug(&self, slug: &str) -> Result<Option<Group>, GroupRepositoryError>;
/// Direct children groups of `parent`.
@@ -235,8 +105,8 @@ impl PostgresGroupRepository {
}
}
const GROUP_COLS: &str = "id, parent_id, slug, name, description, structure_version, created_at, \
updated_at, owner_project";
const GROUP_COLS: &str =
"id, parent_id, slug, name, description, structure_version, created_at, updated_at";
#[async_trait]
impl GroupRepository for PostgresGroupRepository {
@@ -249,24 +119,6 @@ impl GroupRepository for PostgresGroupRepository {
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_with_owner(&self) -> Result<Vec<(Group, Option<String>)>, GroupRepositoryError> {
// Columns are qualified (`g.`) because `id`/`slug`/`name` exist on both
// groups and projects — a bare GROUP_COLS would be ambiguous here.
let rows = sqlx::query_as::<_, GroupWithOwnerRow>(
"SELECT g.id, g.parent_id, g.slug, g.name, g.description, \
g.structure_version, g.created_at, g.updated_at, g.owner_project, \
p.slug AS owner_slug \
FROM groups g LEFT JOIN projects p ON p.id = g.owner_project \
ORDER BY g.name",
)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| (r.group.into(), r.owner_slug))
.collect())
}
async fn get_by_id(&self, id: GroupId) -> Result<Option<Group>, GroupRepositoryError> {
let row = sqlx::query_as::<_, GroupRow>(&format!(
"SELECT {GROUP_COLS} FROM groups WHERE id = $1"
@@ -305,8 +157,7 @@ impl GroupRepository for PostgresGroupRepository {
SELECT {GROUP_COLS}, 0 AS depth FROM groups WHERE id = $1
UNION ALL
SELECT g.id, g.parent_id, g.slug, g.name, g.description, \
g.structure_version, g.created_at, g.updated_at, g.owner_project, \
c.depth + 1 \
g.structure_version, g.created_at, g.updated_at, c.depth + 1 \
FROM groups g JOIN chain c ON g.id = c.parent_id \
WHERE c.depth < 64
)
@@ -402,9 +253,63 @@ impl GroupRepository for PostgresGroupRepository {
.bind(GROUP_STRUCTURAL_LOCK_KEY)
.execute(&mut *tx)
.await?;
let group = reparent_group_tx(&mut tx, id, new_parent).await?;
if let Some(parent) = new_parent {
if parent == id {
return Err(GroupRepositoryError::Conflict(
"a group cannot be its own parent".into(),
));
}
// Cycle guard: walk from the destination up to the root; if we
// reach `id`, the move would place `id` beneath itself.
let mut cursor = Some(parent);
let mut hops = 0u32;
while let Some(node) = cursor {
if node == id {
return Err(GroupRepositoryError::Conflict(
"cannot reparent a group beneath one of its own descendants".into(),
));
}
hops += 1;
if hops > 64 {
return Err(GroupRepositoryError::Conflict(
"group ancestry exceeds the maximum depth".into(),
));
}
let parent_of: Option<(Option<Uuid>,)> =
sqlx::query_as("SELECT parent_id FROM groups WHERE id = $1")
.bind(node.into_inner())
.fetch_optional(&mut *tx)
.await?;
match parent_of {
Some((p,)) => cursor = p.map(GroupId::from),
// Destination parent doesn't exist.
None => {
return Err(GroupRepositoryError::Conflict(
"destination parent group does not exist".into(),
));
}
}
}
}
let row = sqlx::query_as::<_, GroupRow>(&format!(
"UPDATE groups SET \
parent_id = $2, \
structure_version = structure_version + 1, \
updated_at = NOW() \
WHERE id = $1 \
RETURNING {GROUP_COLS}"
))
.bind(id.into_inner())
.bind(new_parent.map(GroupId::into_inner))
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
return Err(GroupRepositoryError::NotFound(id));
};
tx.commit().await?;
Ok(group)
Ok(row.into())
}
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError> {
@@ -434,6 +339,192 @@ impl GroupRepository for PostgresGroupRepository {
}
}
// ----------------------------------------------------------------------------
// 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 takeover
/// pre-gate. 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_group_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())
}
/// Does this group hold data a structural prune would SILENTLY cascade away
/// (§7 M3 safety guard)? The `apps`/child-group/group-script FKs are RESTRICT
/// (delete aborts loudly), but the §11.6 group-owned data surfaces are
/// `ON DELETE CASCADE`. Rather than let `apply --prune` vaporize a tenant's
/// shared data + secrets because a manifest node was dropped, the prune skips
/// such a group with a warning — the operator must delete it explicitly.
///
/// We check the actual DATA rows, not just the collection MARKER: un-declaring a
/// `collections = [...]` entry deletes only the `group_collections` marker while
/// the `group_kv_entries`/`group_docs`/`group_files`/`group_queue_messages` rows
/// survive (they're reaped only on group delete). Checking markers alone would
/// miss that orphaned-but-live data and cascade it away. Returns true if the
/// group has any shared-collection marker, any secret, or any shared data row.
pub(crate) async fn group_has_protected_data_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
) -> Result<bool, GroupRepositoryError> {
let row: (bool,) = sqlx::query_as(
"SELECT EXISTS (SELECT 1 FROM group_collections WHERE group_id = $1) \
OR EXISTS (SELECT 1 FROM secrets WHERE group_id = $1) \
OR EXISTS (SELECT 1 FROM group_kv_entries WHERE group_id = $1) \
OR EXISTS (SELECT 1 FROM group_docs WHERE group_id = $1) \
OR EXISTS (SELECT 1 FROM group_files WHERE group_id = $1) \
OR EXISTS (SELECT 1 FROM group_queue_messages WHERE group_id = $1)",
)
.bind(group_id.into_inner())
.fetch_one(&mut **tx)
.await?;
Ok(row.0)
}
/// Delete a group inside the apply tx (structural prune, §7 M3). RESTRICT is the
/// real guard: the FK from apps/child-groups aborts the whole apply (mapped to a
/// clean `Conflict`) rather than orphaning data, so the caller must delete
/// leaf-first and never reaches a group that still holds live descendants.
pub(crate) async fn delete_group_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
) -> Result<(), GroupRepositoryError> {
let res = sqlx::query("DELETE FROM groups WHERE id = $1")
.bind(group_id.into_inner())
.execute(&mut **tx)
.await;
match res {
Ok(r) if r.rows_affected() == 0 => Err(GroupRepositoryError::NotFound(group_id)),
Ok(_) => Ok(()),
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
Err(GroupRepositoryError::Conflict(
"group still has descendants (apps or subgroups); \
structural prune refuses to orphan them"
.into(),
))
}
Err(e) => Err(e.into()),
}
}
#[derive(sqlx::FromRow)]
struct GroupRow {
id: Uuid,
@@ -444,16 +535,6 @@ struct GroupRow {
structure_version: i64,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
owner_project: Option<Uuid>,
}
/// `list_with_owner` row: a `GroupRow` flattened with its owning project's
/// slug (`NULL` when the group is unclaimed).
#[derive(sqlx::FromRow)]
struct GroupWithOwnerRow {
#[sqlx(flatten)]
group: GroupRow,
owner_slug: Option<String>,
}
impl From<GroupRow> for Group {
@@ -465,7 +546,6 @@ impl From<GroupRow> for Group {
name: r.name,
description: r.description,
structure_version: r.structure_version,
owner_project: r.owner_project.map(Into::into),
created_at: r.created_at,
updated_at: r.updated_at,
}

View File

@@ -158,28 +158,11 @@ pub struct PatchMemberRequest {
/// any authenticated admin sees the full tree; per-action authz still
/// gates every mutation and all app access. Tighten in Phase 3 when groups
/// carry inheritable config.
/// §7: a group plus its owning project's slug (`None` = unclaimed / UI-owned).
/// The group fields are flattened, so existing consumers (dashboard, `pic
/// groups tree`) keep working; `owner` is a new, optional field.
#[derive(Serialize)]
struct GroupListItem {
#[serde(flatten)]
group: Group,
owner: Option<String>,
}
async fn list_groups(
State(s): State<GroupsState>,
Extension(_principal): Extension<Principal>,
) -> Result<Json<Vec<GroupListItem>>, GroupsApiError> {
let items = s
.groups
.list_with_owner()
.await?
.into_iter()
.map(|(group, owner)| GroupListItem { group, owner })
.collect();
Ok(Json(items))
) -> Result<Json<Vec<Group>>, GroupsApiError> {
Ok(Json(s.groups.list().await?))
}
async fn create_group(
@@ -451,32 +434,25 @@ async fn resolve_group(groups: &dyn GroupRepository, ident: &str) -> Result<Grou
found.ok_or_else(|| GroupsApiError::GroupNotFound(ident.to_string()))
}
/// The shared slug FORMAT rule (`^[a-z0-9][a-z0-9-]{0,62}$`), returning the
/// human reason on failure so each caller can wrap it in its own error type.
/// Shared by group slugs and §7 project slugs; the reserved-word check is
/// layered on separately by callers that route slugs (groups), not by projects
/// (never routed). See [`crate::apply_service`]'s `validate_project_slug`.
pub(crate) fn validate_slug_format(slug: &str) -> Result<(), String> {
/// Same rule as app slugs: `^[a-z0-9][a-z0-9-]{0,62}$`, no reserved words.
fn validate_slug(slug: &str) -> Result<(), GroupsApiError> {
let invalid = |reason: &str| GroupsApiError::InvalidSlug(format!("{slug:?}: {reason}"));
if slug.is_empty() || slug.len() > SLUG_MAX {
return Err("must be 163 characters".to_string());
return Err(invalid("must be 163 characters"));
}
let first = slug.chars().next().expect("non-empty checked above");
let mut chars = slug.chars();
let first = chars.next().unwrap();
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Err("must start with a lowercase letter or digit".to_string());
return Err(invalid("must start with a lowercase letter or digit"));
}
if !slug
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err("may contain only lowercase letters, digits, and hyphens".to_string());
return Err(invalid(
"may contain only lowercase letters, digits, and hyphens",
));
}
Ok(())
}
/// Same rule as app slugs: `^[a-z0-9][a-z0-9-]{0,62}$`, no reserved words.
fn validate_slug(slug: &str) -> Result<(), GroupsApiError> {
let invalid = |reason: &str| GroupsApiError::InvalidSlug(format!("{slug:?}: {reason}"));
validate_slug_format(slug).map_err(|reason| invalid(&reason))?;
if RESERVED_SLUGS.contains(&slug) {
return Err(invalid("is a reserved word"));
}

View File

@@ -79,8 +79,6 @@ pub mod module_source;
pub mod outbox_event_emitter;
pub mod outbox_repo;
pub mod principal_resolver;
pub mod project_repo;
pub mod projects_api;
pub mod pubsub_repo;
pub mod pubsub_service;
pub mod queue_repo;
@@ -225,8 +223,6 @@ pub use outbox_repo::{
NewOutboxRow, OutboxRepo, OutboxRepoError, OutboxRow, OutboxSourceKind, PostgresOutboxRepo,
};
pub use principal_resolver::{AdminPrincipalResolver, PrincipalResolver, PrincipalResolverError};
pub use project_repo::{PostgresProjectRepository, ProjectRepository, ProjectRepositoryError};
pub use projects_api::{projects_router, ProjectsApiError, ProjectsState};
pub use pubsub_repo::{PostgresPubsubRepo, PublishCtx, PubsubRepo, PubsubRepoError};
pub use pubsub_service::{PubsubServiceImpl, SubscriberTokenConfig};
pub use realtime_authority::RealtimeAuthorityImpl;

View File

@@ -1,118 +0,0 @@
//! Read access over the `projects` registry (§7 multi-repo ownership).
//!
//! A project is registered by the APPLY path — an upsert-by-slug inside the
//! reconcile transaction (see `apply_service`), so a claim registers the
//! project atomically with the write. This repo is the READ side only, used
//! for ownership visibility (`pic projects ls`, the `pic groups ls` owner
//! column) and tests. Writes deliberately do not live here.
use async_trait::async_trait;
use picloud_shared::{Project, ProjectId};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum ProjectRepositoryError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
}
#[async_trait]
pub trait ProjectRepository: Send + Sync {
/// Every project on the instance, ordered by slug.
async fn list(&self) -> Result<Vec<Project>, ProjectRepositoryError>;
/// Every project paired with how many group nodes it owns — backs
/// `pic projects ls`. One GROUP BY instead of N per-project counts.
async fn list_with_counts(&self) -> Result<Vec<(Project, i64)>, ProjectRepositoryError>;
async fn get_by_id(&self, id: ProjectId) -> Result<Option<Project>, ProjectRepositoryError>;
async fn get_by_slug(&self, slug: &str) -> Result<Option<Project>, ProjectRepositoryError>;
}
pub struct PostgresProjectRepository {
pool: PgPool,
}
impl PostgresProjectRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
const PROJECT_COLS: &str = "id, slug, name, created_by, created_at";
#[async_trait]
impl ProjectRepository for PostgresProjectRepository {
async fn list_with_counts(&self) -> Result<Vec<(Project, i64)>, ProjectRepositoryError> {
let rows = sqlx::query_as::<_, ProjectCountRow>(
"SELECT p.id, p.slug, p.name, p.created_by, p.created_at, \
COUNT(g.id) AS owned_groups \
FROM projects p LEFT JOIN groups g ON g.owner_project = p.id \
GROUP BY p.id ORDER BY p.slug",
)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| (r.project.into(), r.owned_groups))
.collect())
}
async fn list(&self) -> Result<Vec<Project>, ProjectRepositoryError> {
let rows = sqlx::query_as::<_, ProjectRow>(&format!(
"SELECT {PROJECT_COLS} FROM projects ORDER BY slug"
))
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn get_by_id(&self, id: ProjectId) -> Result<Option<Project>, ProjectRepositoryError> {
let row = sqlx::query_as::<_, ProjectRow>(&format!(
"SELECT {PROJECT_COLS} FROM projects WHERE id = $1"
))
.bind(id.into_inner())
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn get_by_slug(&self, slug: &str) -> Result<Option<Project>, ProjectRepositoryError> {
let row = sqlx::query_as::<_, ProjectRow>(&format!(
"SELECT {PROJECT_COLS} FROM projects WHERE slug = $1"
))
.bind(slug)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
}
#[derive(sqlx::FromRow)]
struct ProjectRow {
id: Uuid,
slug: String,
name: String,
created_by: Option<Uuid>,
created_at: chrono::DateTime<chrono::Utc>,
}
/// `list_with_counts` row: a project flattened with its owned-group count.
#[derive(sqlx::FromRow)]
struct ProjectCountRow {
#[sqlx(flatten)]
project: ProjectRow,
owned_groups: i64,
}
impl From<ProjectRow> for Project {
fn from(r: ProjectRow) -> Self {
Self {
id: r.id.into(),
slug: r.slug,
name: r.name,
created_by: r.created_by.map(Into::into),
created_at: r.created_at,
}
}
}

View File

@@ -1,81 +0,0 @@
//! §7 M3 read-only projects surface: `GET /api/v1/admin/projects` — every
//! project (repo-root) with the count of group nodes it owns. Backs
//! `pic projects ls`. Behind the `/admin` middleware, so any authenticated
//! admin can list them (a project reveals only org structure, like the groups
//! list); ownership WRITES stay in the apply path.
use std::sync::Arc;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::get;
use axum::{Extension, Router};
use chrono::{DateTime, Utc};
use picloud_shared::Principal;
use serde::Serialize;
use serde_json::json;
use crate::project_repo::{ProjectRepository, ProjectRepositoryError};
#[derive(Clone)]
pub struct ProjectsState {
pub projects: Arc<dyn ProjectRepository>,
}
/// Mounted under `/api/v1/admin`.
pub fn projects_router(state: ProjectsState) -> Router {
Router::new()
.route("/projects", get(list_projects))
.with_state(state)
}
#[derive(Serialize)]
struct ProjectListItem {
slug: String,
name: String,
owned_groups: i64,
created_at: DateTime<Utc>,
}
async fn list_projects(
State(s): State<ProjectsState>,
Extension(_principal): Extension<Principal>,
) -> Result<Json<Vec<ProjectListItem>>, ProjectsApiError> {
let items = s
.projects
.list_with_counts()
.await?
.into_iter()
.map(|(p, owned_groups)| ProjectListItem {
slug: p.slug,
name: p.name,
owned_groups,
created_at: p.created_at,
})
.collect();
Ok(Json(items))
}
#[derive(Debug, thiserror::Error)]
pub enum ProjectsApiError {
#[error("backend: {0}")]
Backend(String),
}
impl From<ProjectRepositoryError> for ProjectsApiError {
fn from(e: ProjectRepositoryError) -> Self {
Self::Backend(e.to_string())
}
}
impl IntoResponse for ProjectsApiError {
fn into_response(self) -> Response {
tracing::error!(error = %self, "projects api error");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "internal error" })),
)
.into_response()
}
}

View File

@@ -320,9 +320,7 @@ table: outbox
table: projects
id: uuid NOT NULL default=gen_random_uuid()
slug: text NOT NULL
name: text NOT NULL
created_by: uuid NULL
key: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
table: pubsub_trigger_details
@@ -604,8 +602,8 @@ indexes on outbox:
outbox_pkey: public.outbox USING btree (id)
indexes on projects:
projects_key_key: public.projects USING btree (key)
projects_pkey: public.projects USING btree (id)
projects_slug_key: public.projects USING btree (slug)
indexes on pubsub_trigger_details:
pubsub_trigger_details_pkey: public.pubsub_trigger_details USING btree (trigger_id)
@@ -829,7 +827,7 @@ constraints on group_queue_messages:
[PRIMARY KEY] group_queue_messages_pkey: PRIMARY KEY (id)
constraints on groups:
[FOREIGN KEY] groups_owner_project_fk: FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL
[FOREIGN KEY] groups_owner_project_fkey: FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL
[FOREIGN KEY] groups_parent_id_fkey: FOREIGN KEY (parent_id) REFERENCES groups(id) ON DELETE RESTRICT
[PRIMARY KEY] groups_pkey: PRIMARY KEY (id)
[UNIQUE] groups_slug_key: UNIQUE (slug)
@@ -848,9 +846,8 @@ constraints on outbox:
[PRIMARY KEY] outbox_pkey: PRIMARY KEY (id)
constraints on projects:
[FOREIGN KEY] projects_created_by_fkey: FOREIGN KEY (created_by) REFERENCES admin_users(id) ON DELETE SET NULL
[PRIMARY KEY] projects_pkey: PRIMARY KEY (id)
[UNIQUE] projects_slug_key: UNIQUE (slug)
[UNIQUE] projects_key_key: UNIQUE (key)
constraints on pubsub_trigger_details:
[FOREIGN KEY] pubsub_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
@@ -991,4 +988,4 @@ constraints on vars:
0063: materialized unique
0064: shared topic queue kinds
0065: group queues
0066: projects
0066: owner project

View File

@@ -1,106 +0,0 @@
//! §7 repo round-trip: `list_with_owner` pairs each group with its owning
//! project's slug (`None` when unclaimed), and `ancestors()` surfaces
//! `owner_project` for every node — the nearest-claimed fold the ownership
//! claim path (M1.3) relies on.
//!
//! Deterministic; skips cleanly when `DATABASE_URL` is unset.
use picloud_manager_core::group_repo::{GroupRepository, PostgresGroupRepository};
use picloud_manager_core::project_repo::{PostgresProjectRepository, ProjectRepository};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use uuid::Uuid;
async fn pool_or_skip() -> Option<PgPool> {
let Ok(url) = std::env::var("DATABASE_URL") else {
eprintln!("projects_repo: DATABASE_URL unset — skipping");
return None;
};
let pool = PgPoolOptions::new()
.max_connections(2)
.connect(&url)
.await
.expect("connect to DATABASE_URL");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("apply migrations");
Some(pool)
}
fn uniq(prefix: &str) -> String {
format!("{prefix}-{}", &Uuid::new_v4().to_string()[..8])
}
#[tokio::test]
async fn list_with_owner_and_ancestors_surface_ownership() {
let Some(pool) = pool_or_skip().await else {
return;
};
let groups = PostgresGroupRepository::new(pool.clone());
let projects = PostgresProjectRepository::new(pool.clone());
// A project + a parent/child group pair; claim the CHILD only (a parent is
// deliberately left unclaimed to pin the `None` path).
let proj_slug = uniq("proj");
let (proj_id,): (Uuid,) =
sqlx::query_as("INSERT INTO projects (slug, name) VALUES ($1, $1) RETURNING id")
.bind(&proj_slug)
.fetch_one(&pool)
.await
.expect("insert project");
let parent = groups
.create(&uniq("parent"), "Parent", None, None)
.await
.expect("create parent");
let child_slug = uniq("child");
let child = groups
.create(&child_slug, "Child", None, Some(parent.id))
.await
.expect("create child");
sqlx::query("UPDATE groups SET owner_project = $1 WHERE id = $2")
.bind(proj_id)
.bind(child.id.into_inner())
.execute(&pool)
.await
.expect("claim child");
// list_with_owner: the child pairs with the project slug; the parent is
// unclaimed (None). (The list spans the whole instance — filter by slug.)
let listed = groups.list_with_owner().await.expect("list_with_owner");
let child_row = listed
.iter()
.find(|(g, _)| g.slug == child_slug)
.expect("child present in list_with_owner");
assert_eq!(child_row.1.as_deref(), Some(proj_slug.as_str()));
let parent_row = listed
.iter()
.find(|(g, _)| g.id == parent.id)
.expect("parent present");
assert_eq!(parent_row.1, None, "unclaimed group has no owner slug");
// ancestors(child) is nearest-first and carries owner_project on each node —
// the child holds its claim, the parent is None. This is the exact fold
// input the nearest-claimed resolution walks.
let chain = groups.ancestors(child.id).await.expect("ancestors");
assert_eq!(chain[0].id, child.id, "ancestors is nearest-first");
assert!(
chain[0].owner_project.is_some(),
"child ancestor row carries its claim"
);
let parent_in_chain = chain
.iter()
.find(|g| g.id == parent.id)
.expect("parent in chain");
assert!(parent_in_chain.owner_project.is_none());
// Project reads round-trip by slug and by id.
let by_slug = projects.get_by_slug(&proj_slug).await.expect("get_by_slug");
assert_eq!(by_slug.as_ref().map(|p| p.id.into_inner()), Some(proj_id));
let by_id = projects
.get_by_id(by_slug.unwrap().id)
.await
.expect("get_by_id");
assert_eq!(by_id.map(|p| p.slug), Some(proj_slug));
}

View File

@@ -34,6 +34,8 @@ toml = "0.8"
directories = "5"
rpassword = "7"
anyhow = "1"
# §7 M3: minting the stable project key in `.picloud/project.json`.
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
assert_cmd = "2"

View File

@@ -39,25 +39,6 @@ fn seg(s: &str) -> String {
utf8_percent_encode(s, PATH_SEGMENT).to_string()
}
/// Build a `plan` request body: the bundle fields at the JSON top level plus an
/// optional `project` key (§7). The plan endpoints `#[serde(flatten)]` the
/// bundle, so a bare bundle (no `[project]`) is the pre-§7 wire shape — this
/// keeps `pic plan` compatible across a CLI/server version skew in either
/// direction (an old server ignores the extra `project`; a new server accepts
/// the bare bundle).
fn plan_body(
bundle: &serde_json::Value,
project: Option<&crate::manifest::ManifestProject>,
) -> Result<serde_json::Value> {
let mut body = bundle.clone();
if let Some(p) = project {
if let Some(obj) = body.as_object_mut() {
obj.insert("project".to_string(), serde_json::to_value(p)?);
}
}
Ok(body)
}
pub struct Client {
http: reqwest::Client,
url: String,
@@ -172,8 +153,7 @@ impl Client {
// --- Groups (Phase 2) -------------------------------------------------
/// `GET /api/v1/admin/groups` — the full flat list (assemble the tree
/// client-side from `parent_id`). Ignores the §7 `owner` field the server
/// also returns (see [`Self::groups_list_with_owner`]).
/// client-side from `parent_id`).
pub async fn groups_list(&self) -> Result<Vec<Group>> {
let resp = self
.request(Method::GET, "/api/v1/admin/groups")
@@ -182,27 +162,6 @@ impl Client {
decode(resp).await
}
/// Same endpoint as [`Self::groups_list`], but captures the §7 owning
/// project's slug alongside each group — backs `pic groups ls`' owner
/// column.
pub async fn groups_list_with_owner(&self) -> Result<Vec<GroupListItem>> {
let resp = self
.request(Method::GET, "/api/v1/admin/groups")
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/projects` — every §7 project + its owned-group count.
/// Backs `pic projects ls`.
pub async fn projects_list(&self) -> Result<Vec<ProjectDto>> {
let resp = self
.request(Method::GET, "/api/v1/admin/projects")
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/groups/{id_or_slug}` — group + path + children.
pub async fn groups_get(&self, ident: &str) -> Result<GroupDetailDto> {
let ident = seg(ident);
@@ -1249,16 +1208,14 @@ impl Client {
kind: NodeKind,
slug: &str,
bundle: &serde_json::Value,
project: Option<&crate::manifest::ManifestProject>,
) -> Result<PlanDto> {
let slug = seg(slug);
let body = plan_body(bundle, project)?;
let resp = self
.request(
Method::POST,
&format!("/api/v1/admin/{}/{slug}/plan", kind.path()),
)
.json(&body)
.json(bundle)
.send()
.await?;
decode(resp).await
@@ -1266,15 +1223,12 @@ impl Client {
/// `POST /api/v1/admin/{apps|groups}/{id_or_slug}/apply` — reconcile an
/// app OR group node in one transaction (Phase 5).
#[allow(clippy::too_many_arguments)]
pub async fn apply_node(
&self,
kind: NodeKind,
slug: &str,
bundle: &serde_json::Value,
prune: bool,
project: Option<&crate::manifest::ManifestProject>,
takeover: bool,
expected_token: Option<&str>,
) -> Result<ApplyReportDto> {
let slug = seg(slug);
@@ -1282,8 +1236,6 @@ impl Client {
"bundle": bundle,
"prune": prune,
"expected_token": expected_token,
"project": project,
"takeover": takeover,
});
let resp = self
.request(
@@ -1293,24 +1245,34 @@ impl Client {
.json(&body)
.send()
.await?;
// The apply endpoint returns 409 for a stale bound plan (`StateMoved`)
// OR a §7 ownership conflict. Both server messages are self-contained
// and actionable, so surface the message verbatim.
// The apply endpoint returns 409 only for a stale bound plan
// (`StateMoved`): the app changed since `pic plan` recorded its
// token. Surface an actionable next step instead of a bare
// `HTTP 409`.
if resp.status() == reqwest::StatusCode::CONFLICT {
let body = resp.text().await.unwrap_or_default();
let msg = parse_error_body(&body).unwrap_or(body);
return Err(anyhow!("{msg}"));
return Err(anyhow!(
"{msg}\nThe live state changed since your last `pic plan`. Re-run \
`pic plan` to review the new diff, then `pic apply` — or \
`pic apply --force` to apply without re-reviewing."
));
}
decode(resp).await
}
/// `POST /api/v1/admin/tree/plan` — diff a whole project tree (Phase 5).
/// `project_key` (§7, M3) lets the plan surface ownership conflicts + prune
/// candidates for this repo.
pub async fn plan_tree(
&self,
bundle: &serde_json::Value,
project: Option<&crate::manifest::ManifestProject>,
project_key: Option<&str>,
) -> Result<TreePlanDto> {
let body = plan_body(bundle, project)?;
let body = serde_json::json!({
"bundle": bundle,
"project_key": project_key,
});
let resp = self
.request(Method::POST, "/api/v1/admin/tree/plan")
.json(&body)
@@ -1320,40 +1282,46 @@ impl Client {
}
/// `POST /api/v1/admin/tree/apply` — reconcile a whole project tree in one
/// transaction (Phase 5).
/// transaction (Phase 5). `project_key` + `allow_takeover` drive the §7 M3
/// ownership claim / takeover.
#[allow(clippy::too_many_arguments)]
pub async fn apply_tree(
&self,
bundle: &serde_json::Value,
prune: bool,
project: Option<&crate::manifest::ManifestProject>,
takeover: bool,
expected_token: Option<&str>,
structure_mode: &str,
env: Option<&str>,
approved_envs: &[String],
project_key: Option<&str>,
allow_takeover: bool,
) -> Result<ApplyReportDto> {
let body = serde_json::json!({
"bundle": bundle,
"prune": prune,
"expected_token": expected_token,
"project": project,
"takeover": takeover,
"structure_mode": structure_mode,
"env": env,
"approved_envs": approved_envs,
"project_key": project_key,
"allow_takeover": allow_takeover,
});
let resp = self
.request(Method::POST, "/api/v1/admin/tree/apply")
.json(&body)
.send()
.await?;
// 409 = stale bound plan OR a §7 ownership conflict; 422 = a §6 M2
// structural divergence (or an invalid bundle). Surface the server
// message verbatim — it names the resolution flags.
let status = resp.status();
if status == reqwest::StatusCode::CONFLICT
|| status == reqwest::StatusCode::UNPROCESSABLE_ENTITY
{
if resp.status() == reqwest::StatusCode::CONFLICT {
let body = resp.text().await.unwrap_or_default();
let msg = parse_error_body(&body).unwrap_or(body);
return Err(anyhow!("{msg}"));
// An ownership conflict (§7) is self-explanatory ("re-run with
// --takeover"); only a staleness (token) conflict warrants the
// re-plan hint. Distinguish on the server's message.
if msg.contains("owned by another project") {
return Err(anyhow!("{msg}"));
}
return Err(anyhow!(
"{msg}\nThe project tree changed since your last `pic plan`. Re-run \
`pic plan --dir` to review, then `pic apply --dir` — or add `--force`."
));
}
decode(resp).await
}
@@ -1571,9 +1539,6 @@ pub struct PlanDto {
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
#[serde(default)]
pub state_token: String,
/// §7 M3: the ownership outcome this apply would produce.
#[serde(default)]
pub ownership: Option<OwnershipPreviewDto>,
}
#[derive(Debug, Deserialize)]
@@ -1584,24 +1549,6 @@ pub struct ChangeDto {
pub detail: Option<String>,
}
/// §7 M3 plan preview: `action` ∈ claim/owned/conflict/unclaimed; `owner` is
/// the current owner's slug (for owned/conflict); `blast_radius` (M3.2) lists
/// other projects' apps a group change fans out to.
#[derive(Debug, Deserialize)]
pub struct OwnershipPreviewDto {
pub action: String,
#[serde(default)]
pub owner: Option<String>,
#[serde(default)]
pub blast_radius: Vec<ProjectImpactDto>,
}
#[derive(Debug, Deserialize)]
pub struct ProjectImpactDto {
pub project: String,
pub apps: u32,
}
/// Response of `POST .../tree/plan` (Phase 5): a per-node diff + one combined
/// bound-plan token for the whole subtree.
#[derive(Debug, Deserialize)]
@@ -1610,6 +1557,22 @@ pub struct TreePlanDto {
pub nodes: Vec<NodePlanDto>,
#[serde(default)]
pub state_token: String,
/// Environments the project marks confirm-required (§4.2, M5).
#[serde(default)]
pub approvals_required: Vec<String>,
/// Declared groups another project already owns (§7, M3).
#[serde(default)]
pub conflicts: Vec<OwnershipConflictDto>,
/// Owned-but-undeclared group slugs `apply --prune` would delete (§7, M3).
#[serde(default)]
pub structural_prunes: Vec<String>,
}
/// A group declared by this manifest that another project owns (§7, M3).
#[derive(Debug, Deserialize)]
pub struct OwnershipConflictDto {
pub slug: String,
pub owner_key: String,
}
#[derive(Debug, Deserialize)]
@@ -1632,22 +1595,6 @@ pub struct NodePlanDto {
pub collections: Vec<ChangeDto>,
#[serde(default)]
pub suppressions: Vec<ChangeDto>,
/// §7 M3: this node's ownership outcome under the tree's `[project]`.
#[serde(default)]
pub ownership: Option<OwnershipPreviewDto>,
/// §6 M2: for a group node, its structural state (in-sync omitted; diverged
/// names the server + manifest parents).
#[serde(default)]
pub structure: Option<StructurePreviewDto>,
}
#[derive(Debug, Deserialize)]
pub struct StructurePreviewDto {
pub status: String,
#[serde(default)]
pub server_parent: Option<String>,
#[serde(default)]
pub manifest_parent: Option<String>,
}
/// Response of `POST .../apply`: counts of what changed.
@@ -1687,6 +1634,15 @@ pub struct ApplyReportDto {
pub suppressions_created: u32,
#[serde(default)]
pub suppressions_deleted: u32,
/// Group nodes claimed this apply (§7, M3).
#[serde(default)]
pub groups_claimed: u32,
/// Group nodes taken over from another project this apply (§7, M3).
#[serde(default)]
pub groups_taken_over: u32,
/// Owned-but-undeclared groups deleted by structural prune (§7, M3).
#[serde(default)]
pub groups_pruned: u32,
#[serde(default)]
pub warnings: Vec<String>,
}
@@ -1747,28 +1703,6 @@ pub struct GroupDetailDto {
pub apps: Vec<App>,
}
/// One row of `pic groups ls`: a group plus its §7 owning project's slug
/// (`None` = unclaimed / UI-owned). The group fields flatten in, so `.group.*`
/// reaches them.
#[derive(Debug, Deserialize)]
pub struct GroupListItem {
#[serde(flatten)]
pub group: Group,
#[serde(default)]
pub owner: Option<String>,
}
/// One row of `pic projects ls` (§7 M3): a project + how many group nodes it
/// owns.
#[derive(Debug, Deserialize)]
pub struct ProjectDto {
pub slug: String,
pub name: String,
#[serde(default)]
pub owned_groups: i64,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize)]
pub struct CreateRouteBody<'a> {
pub host_kind: HostKind,

View File

@@ -11,32 +11,41 @@ use anyhow::{Context, Result};
use crate::client::{Client, NodeKind};
use crate::cmds::plan::build_bundle;
use crate::config;
use crate::manifest::{Manifest, ManifestProject};
use crate::manifest::Manifest;
use crate::output::{KvBlock, OutputMode};
#[allow(clippy::too_many_arguments)]
pub async fn run(
manifest_path: &Path,
env: Option<&str>,
prune: bool,
yes: bool,
force: bool,
takeover: bool,
approve: &[String],
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let manifest = Manifest::load_with_env(manifest_path, env)?;
// §3 M3: the env-approval gate is a property of the PROJECT, which lives at
// the repo root. A leaf manifest carries no `[project]`, so find the
// governing one (own block, else the nearest ancestor's) before gating.
let governing = manifest
.project
.clone()
.or_else(|| find_governing_project(manifest_path));
require_env_approval(governing.as_ref(), env, approve)?;
// Env approval gating (§4.2, M5) is a project-tree (`--dir`) feature: the
// admin-gated, audited per-env approval is enforced server-side only on the
// tree apply path. Single-node `apply --file` cannot provide that guarantee,
// so rather than silently bypass a confirm-required env, refuse and direct
// the user to `--dir`.
if let (Some(env), Some(project)) = (env, &manifest.project) {
if project
.environments
.iter()
.any(|e| e.name == env && e.confirm)
{
anyhow::bail!(
"environment `{env}` is confirm-required by this project's [project] policy; \
single-node `pic apply --file` cannot provide the admin-gated, audited approval. \
Use `pic apply --dir <root> --env {env} --approve {env}` instead."
);
}
}
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
let bundle = build_bundle(&manifest, base_dir)?;
let kind = if manifest.is_group() {
@@ -63,15 +72,7 @@ pub async fn run(
};
let report = client
.apply_node(
kind,
&slug,
&bundle,
prune,
manifest.project.as_ref(),
takeover,
expected_token.as_deref(),
)
.apply_node(kind, &slug, &bundle, prune, expected_token.as_deref())
.await?;
crate::linkstate::clear_plan(base_dir, &slug);
@@ -140,21 +141,30 @@ pub async fn run_tree(
prune: bool,
yes: bool,
force: bool,
takeover: bool,
structure_mode: &str,
approve: &[String],
env: Option<&str>,
approve: &[String],
takeover: bool,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let (bundle, node_count, project) = crate::discover::build_tree(dir, env)?;
require_env_approval(project.as_ref(), env, approve)?;
let (bundle, node_count, envs) = crate::discover::build_tree(dir, env)?;
if prune && !yes {
confirm_prune(&format!("{node_count} node(s) under {}", dir.display()))?;
}
// Per-env approval gate (§4.2, M5): if the selected env is confirm-required,
// it needs an explicit `--approve <env>` (a TTY may confirm interactively;
// `--yes` does NOT cover it). The server re-checks this authoritatively — the
// local check just fails fast with a clear message.
let approved = resolve_approvals(env, &envs, approve)?;
// Mint/read this repo's stable project key (§7, M3): the apply claims the
// declared groups for this project and refuses ones another project owns
// (unless `--takeover`, group-admin gated).
let project_key = crate::linkstate::ensure_project_key(dir)?;
let token_key = crate::cmds::plan::TREE_TOKEN_KEY;
let expected_token = if force {
None
@@ -168,10 +178,11 @@ pub async fn run_tree(
.apply_tree(
&bundle,
prune,
project.as_ref(),
takeover,
expected_token.as_deref(),
structure_mode,
env,
&approved,
Some(&project_key),
takeover,
)
.await?;
crate::linkstate::clear_plan(dir, token_key);
@@ -179,6 +190,13 @@ pub async fn run_tree(
let mut block = KvBlock::new();
block
.field("nodes", node_count.to_string())
.field(
"groups",
format!(
"claimed {} taken-over {} pruned {}",
report.groups_claimed, report.groups_taken_over, report.groups_pruned
),
)
.field(
"scripts",
format!(
@@ -236,44 +254,47 @@ pub async fn run_tree(
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
/// for non-interactive/CI use. Refuse a non-interactive prune without `--yes`
/// rather than silently deleting (review the deletions first with `pic plan`).
/// §3 M3: refuse `pic apply --env <e>` to a confirm-required env
/// (`[project.environments]` with `confirm = true`) unless it was explicitly
/// `--approve <e>`d. A blanket `--yes` does NOT cover a gated env (§4.2 "CI must
/// opt in per environment"). An unlisted or `confirm = false` env applies freely.
/// §3 M3: find the `[project]` that governs a single-file apply. The applied
/// manifest's own block wins (the caller checks that first); otherwise walk up
/// from its directory to the nearest ancestor `picloud.toml` that declares one
/// (the repo root), so `pic apply --file services/api/picloud.toml` still
/// honors the repo's env-approval gate. Loaded WITHOUT the env overlay — only
/// the `[project]` block matters here, and `load_with_env` would require an
/// overlay next to every ancestor and spuriously skip the governing manifest.
fn find_governing_project(manifest_path: &Path) -> Option<ManifestProject> {
let dir = manifest_path.parent()?;
dir.ancestors().skip(1).find_map(|anc| {
let candidate = anc.join(crate::manifest::MANIFEST_FILE);
Manifest::load(&candidate).ok().and_then(|m| m.project)
})
}
fn require_env_approval(
project: Option<&ManifestProject>,
/// Resolve the set of environments the actor approves for this apply (§4.2, M5).
/// If the selected `env` is confirm-required (per the root manifest's
/// `[project]` policy) it must be approved — via `--approve <env>`, or an
/// interactive TTY confirmation that requires re-typing the env name. A blanket
/// `--yes` does NOT satisfy it. Returns the full approved set to send on the
/// wire (the server re-checks authoritatively). A non-gated env passes through.
fn resolve_approvals(
env: Option<&str>,
policy: &[crate::manifest::ManifestEnvironment],
approve: &[String],
) -> Result<()> {
let (Some(env), Some(project)) = (env, project) else {
return Ok(());
) -> Result<Vec<String>> {
let mut approved: Vec<String> = approve.to_vec();
let Some(env) = env else {
return Ok(approved); // no env selected → nothing env-specific to gate
};
let Some(policy) = project.environments.get(env) else {
return Ok(());
};
if policy.confirm && !approve.iter().any(|a| a == env) {
anyhow::bail!(
"environment `{env}` is confirm-required (`[project.environments]`); \
re-run with `--approve {env}` to apply to it — a blanket `--yes` does \
not cover a gated environment"
);
let gated = policy.iter().any(|e| e.name == env && e.confirm);
if !gated || approved.iter().any(|e| e == env) {
return Ok(approved);
}
Ok(())
// Gated and not pre-approved: prompt on a TTY, else refuse (CI must pass
// --approve explicitly; --yes is deliberately insufficient).
if std::io::stdin().is_terminal() {
eprint!(
"environment `{env}` requires explicit approval to apply. \
Type the environment name to approve, or anything else to abort: "
);
std::io::stderr().flush().ok();
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("read approval")?;
if answer.trim() == env {
approved.push(env.to_string());
return Ok(approved);
}
anyhow::bail!("aborted: environment `{env}` not approved");
}
anyhow::bail!(
"environment `{env}` requires explicit approval: re-run with `--approve {env}` \
(a blanket `--yes` does not cover a confirm-required environment)"
);
}
fn confirm_prune(slug: &str) -> Result<()> {

View File

@@ -16,24 +16,19 @@ use crate::output::{KvBlock, OutputMode, Table};
pub async fn ls(mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let groups = client.groups_list_with_owner().await?;
let mut table = Table::new(["slug", "name", "parent", "owner", "created_at"]);
let by_id: BTreeMap<_, _> = groups
.iter()
.map(|g| (g.group.id, g.group.slug.clone()))
.collect();
let groups = client.groups_list().await?;
let mut table = Table::new(["slug", "name", "parent", "created_at"]);
let by_id: BTreeMap<_, _> = groups.iter().map(|g| (g.id, g.slug.clone())).collect();
for g in &groups {
let parent = g
.group
.parent_id
.and_then(|p| by_id.get(&p).cloned())
.unwrap_or_else(|| "-".into());
table.row([
g.group.slug.clone(),
g.group.name.clone(),
g.slug.clone(),
g.name.clone(),
parent,
g.owner.clone().unwrap_or_else(|| "".into()),
g.group.created_at.to_rfc3339(),
g.created_at.to_rfc3339(),
]);
}
table.print(mode);

View File

@@ -87,6 +87,9 @@ pub fn run(
}
fs::write(&manifest_path, body).with_context(|| format!("writing {MANIFEST_FILE}"))?;
ensure_gitignored(dir)?;
// Mint this repo's stable, gitignored project identity (§7, M3) up front so
// the first tree apply already has an ownership handle to claim under.
crate::linkstate::ensure_project_key(dir)?;
let mut block = KvBlock::new();
block

View File

@@ -16,7 +16,6 @@ pub mod logout;
pub mod logs;
pub mod members;
pub mod plan;
pub mod projects;
pub mod pull;
pub mod queues;
pub mod routes;

View File

@@ -31,9 +31,7 @@ pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> R
NodeKind::App
};
let plan = client
.plan_node(kind, manifest.slug(), &bundle, manifest.project.as_ref())
.await?;
let plan = client.plan_node(kind, manifest.slug(), &bundle).await?;
// Record the bound-plan token so a subsequent `pic apply` can detect the
// node changing underneath the reviewed plan (best-effort — a read-only
// plan still succeeds if the project dir isn't writable).
@@ -51,8 +49,16 @@ pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> R
pub async fn run_tree(dir: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let (bundle, _count, project) = crate::discover::build_tree(dir, env)?;
let plan = client.plan_tree(&bundle, project.as_ref()).await?;
let (bundle, _count, _envs) = crate::discover::build_tree(dir, env)?;
// Mint/read this repo's project key (§7, M3) so the plan can surface
// ownership conflicts + prune candidates. `plan` DOES mint it (a rare
// write for an otherwise read-only command) rather than read-only-peek,
// because the bound-plan token folds the ownership state gated on a key
// being present: a keyless plan and a keyed apply would fold DIFFERENT
// token parts and trip a spurious `StateMoved`. Minting here keeps plan and
// apply keyed identically. The key is local + gitignored + idempotent.
let project_key = crate::linkstate::ensure_project_key(dir)?;
let plan = client.plan_tree(&bundle, Some(&project_key)).await?;
if !plan.state_token.is_empty() {
if let Err(e) = crate::linkstate::write_plan(dir, TREE_TOKEN_KEY, &plan.state_token) {
eprintln!("warning: could not record plan state for `pic apply`: {e}");
@@ -66,39 +72,6 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
for n in &plan.nodes {
let node = format!("{}:{}", n.kind, n.slug);
if let Some(s) = &n.structure {
table.row([
node.clone(),
"structure".to_string(),
s.status.clone(),
format!(
"server={}",
s.server_parent.clone().unwrap_or_else(|| "<root>".into())
),
format!(
"manifest={}",
s.manifest_parent.clone().unwrap_or_else(|| "<root>".into())
),
]);
}
if let Some(o) = &n.ownership {
table.row([
node.clone(),
"ownership".to_string(),
o.action.clone(),
o.owner.clone().unwrap_or_default(),
String::new(),
]);
for b in &o.blast_radius {
table.row([
node.clone(),
"blast_radius".to_string(),
b.project.clone(),
format!("{} app(s)", b.apps),
String::new(),
]);
}
}
let groups: [(&str, &Vec<ChangeDto>); 8] = [
("script", &n.scripts),
("route", &n.routes),
@@ -122,6 +95,33 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
}
}
table.print(mode);
// Per-env approval gating (§4.2, M5): surface the confirm-required envs so
// CI sees the gate at plan time, before an apply refuses.
if mode != OutputMode::Json && !plan.approvals_required.is_empty() {
eprintln!("\nenvironments requiring explicit approval (apply with --approve <env>):");
for e in &plan.approvals_required {
eprintln!(" - {e}");
}
}
// Ownership (§7, M3): surface groups another project owns (apply refuses
// without `--takeover`) and owned-but-undeclared groups `--prune` would
// delete, so both are visible before an apply acts on them.
if mode != OutputMode::Json {
if !plan.conflicts.is_empty() {
eprintln!("\ngroups owned by another project (apply with --takeover to claim):");
for c in &plan.conflicts {
eprintln!(" - {} (owned by project {})", c.slug, c.owner_key);
}
}
if !plan.structural_prunes.is_empty() {
eprintln!("\ngroups this project owns but no longer declares (--prune deletes):");
for s in &plan.structural_prunes {
eprintln!(" - {s}");
}
}
}
}
/// Assemble the wire bundle: scripts carry inlined source (read from
@@ -220,22 +220,6 @@ fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
fn render(plan: &PlanDto, mode: OutputMode) {
let mut table = Table::new(["kind", "op", "resource", "detail"]);
if let Some(o) = &plan.ownership {
table.row([
"ownership".to_string(),
o.action.clone(),
o.owner.clone().unwrap_or_default(),
String::new(),
]);
for b in &o.blast_radius {
table.row([
"blast_radius".to_string(),
b.project.clone(),
format!("{} app(s)", b.apps),
String::new(),
]);
}
}
let groups: [(&str, &Vec<ChangeDto>); 8] = [
("script", &plan.scripts),
("route", &plan.routes),

View File

@@ -1,29 +0,0 @@
//! `pic projects` — read-only view of §7 multi-repo ownership projects.
//!
//! A project is the repo-root that declaratively owns a slice of the group
//! tree. Ownership is CLAIMED by `pic apply` (first apply with a `[project]`
//! slug registers it); this command only lists what exists.
use anyhow::Result;
use crate::client::Client;
use crate::config;
use crate::output::{OutputMode, Table};
/// `pic projects ls` — every project + how many group nodes it owns.
pub async fn ls(mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let projects = client.projects_list().await?;
let mut table = Table::new(["slug", "name", "owned_groups", "created_at"]);
for p in &projects {
table.row([
p.slug.clone(),
p.name.clone(),
p.owned_groups.to_string(),
p.created_at.to_rfc3339(),
]);
}
table.print(mode);
Ok(())
}

View File

@@ -1,22 +1,17 @@
//! Phase 5: discover a project *tree* — every `picloud.toml` under a root
//! directory — and assemble the wire tree bundle the server's `/tree/{plan,
//! apply}` endpoints consume. Each manifest becomes one node (app or group).
//!
//! §6: a GROUP node's PARENT is inferred from directory nesting — its parent is
//! the nearest ancestor directory that also holds a `[group]` manifest; the
//! topmost group binds to the repo's `[project] parent_group` (attach point) or
//! the instance root. The server uses this declared parent to create a missing
//! group and to detect structural divergence (M2). An app node declares no
//! parent (an app pre-exists with a fixed group).
//! apply}` endpoints consume. Each manifest becomes one node (app or group);
//! the server resolves ancestry from its own group tree, so the on-disk
//! nesting is organizational, not authoritative here.
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
use crate::cmds::plan::build_bundle;
use crate::manifest::{Manifest, ManifestProject, MANIFEST_FILE};
use crate::manifest::{Manifest, MANIFEST_FILE};
/// Find every `picloud.toml` under `root` (recursively), skipping the
/// `.picloud/` link-state dir, `.git`, and `scripts/` source dirs. Per-env
@@ -50,15 +45,15 @@ fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
Ok(())
}
/// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}] }`) from every
/// manifest under `root`. Returns the JSON, the node count, and the owning
/// `[project]` (§7) declared in the tree ROOT's `picloud.toml` (if any) — one
/// project owns the whole tree; a `[project]` in any other node is ignored with
/// a note. Rejects a tree that names the same (kind, slug) twice.
/// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}], project }`)
/// from every manifest under `root`. Returns the JSON, the node count, and the
/// root manifest's `[project]` environment policy (M5, empty if none). Rejects a
/// tree that names the same (kind, slug) twice, or declares `[project]` anywhere
/// but the root manifest.
pub fn build_tree(
root: &Path,
env: Option<&str>,
) -> Result<(Value, usize, Option<ManifestProject>)> {
) -> Result<(Value, usize, Vec<crate::manifest::ManifestEnvironment>)> {
let paths = find_manifests(root)?;
if paths.is_empty() {
bail!(
@@ -67,92 +62,48 @@ pub fn build_tree(
);
}
let root_manifest = root.join(MANIFEST_FILE);
// Load every manifest once, remembering its directory so we can infer group
// parentage from the nesting.
let mut loaded: Vec<(PathBuf, Manifest)> = Vec::with_capacity(paths.len());
let mut project: Option<ManifestProject> = None;
let mut nodes = Vec::with_capacity(paths.len());
let mut seen: HashSet<(String, String)> = HashSet::new();
let mut project: Option<crate::manifest::ManifestProject> = None;
for path in &paths {
let manifest = Manifest::load_with_env(path, env)
.with_context(|| format!("loading {}", path.display()))?;
// `[project]` is project-level — valid only on the tree's root manifest.
if let Some(p) = &manifest.project {
if path.as_path() == root_manifest {
if path == &root_manifest {
project = Some(p.clone());
} else {
eprintln!(
"note: [project] in {} is ignored — declare it in the tree root {}",
bail!(
"{} declares [project], which is only valid on the root manifest ({})",
path.display(),
root_manifest.display()
);
// §3 M3: a non-root [project] that carries env-approval policy is
// a silent-safety trap — the gate is dropped with the block. Call
// it out explicitly so a misplaced gate isn't mistaken for active.
if !p.environments.is_empty() {
eprintln!(
"warning: its [project.environments] approval gating is NOT enforced — \
only the tree root's [project] governs env approval"
);
}
}
}
loaded.push((path.clone(), manifest));
}
// Map each GROUP manifest's DIRECTORY → its slug, so a group node can find
// its nearest ancestor-directory group (its declared parent).
let group_dir_to_slug: HashMap<PathBuf, String> = loaded
.iter()
.filter(|(_, m)| m.is_group())
.map(|(path, m)| {
let dir = path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
(dir, m.slug().to_string())
})
.collect();
let attach_point = project.as_ref().and_then(|p| p.parent_group.clone());
// Build (sort_key, node) pairs. Emit groups before apps, and shallower
// groups before deeper ones (a parent's directory has fewer components than
// its child's), so the server creates a parent group before the child that
// nests under it.
let mut keyed: Vec<((bool, usize), Value)> = Vec::with_capacity(loaded.len());
let mut seen: HashSet<(String, String)> = HashSet::new();
for (path, manifest) in &loaded {
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
let bundle = build_bundle(manifest, base_dir)?;
let is_group = manifest.is_group();
let kind = if is_group { "group" } else { "app" };
let bundle = build_bundle(&manifest, base_dir)?;
let kind = if manifest.is_group() { "group" } else { "app" };
let slug = manifest.slug().to_string();
if !seen.insert((kind.to_string(), slug.clone())) {
bail!("project tree declares {kind} `{slug}` more than once");
}
let depth = base_dir.components().count();
let node = if is_group {
let parent = nearest_group_ancestor(base_dir, &group_dir_to_slug)
.or_else(|| attach_point.clone());
let g = manifest.group.as_ref();
let name = g.map(|g| g.name.clone());
let description = g.and_then(|g| g.description.clone());
json!({
"kind": kind, "slug": slug, "parent": parent,
"name": name, "description": description, "bundle": bundle,
})
} else {
json!({ "kind": kind, "slug": slug, "bundle": bundle })
};
keyed.push(((!is_group, depth), node));
nodes.push(json!({ "kind": kind, "slug": slug, "bundle": bundle }));
}
keyed.sort_by(|a, b| a.0.cmp(&b.0));
let nodes: Vec<Value> = keyed.into_iter().map(|(_, n)| n).collect();
let count = nodes.len();
Ok((json!({ "nodes": nodes }), count, project))
}
/// The slug of the nearest STRICT ancestor directory of `dir` that holds a
/// `[group]` manifest, or `None` if there is no group above it in the tree.
fn nearest_group_ancestor(dir: &Path, group_dirs: &HashMap<PathBuf, String>) -> Option<String> {
dir.ancestors()
.skip(1) // skip `dir` itself — we want a strict ancestor
.find_map(|anc| group_dirs.get(anc).cloned())
let envs = project
.as_ref()
.map(|p| p.environments.clone())
.unwrap_or_default();
let mut bundle = json!({ "nodes": nodes });
if let Some(p) = &project {
bundle.as_object_mut().expect("json object").insert(
"project".into(),
json!({
"environments": p.environments.iter()
.map(|e| json!({ "name": e.name, "confirm": e.confirm }))
.collect::<Vec<_>>(),
}),
);
}
Ok((bundle, count, envs))
}

View File

@@ -13,6 +13,71 @@ use serde::{Deserialize, Serialize};
const DIR: &str = ".picloud";
const PLAN_FILE: &str = "plan.json";
const PROJECT_FILE: &str = "project.json";
/// The repo's stable project identity (§7, M3): a random, opaque key minted on
/// first need and persisted (gitignored) in `.picloud/`. It is the server-side
/// ownership handle — the same repo keeps the same project across clones/CI
/// because the key travels in the working tree, never in a committed file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectLink {
pub key: String,
}
fn project_path(base: &Path) -> PathBuf {
base.join(DIR).join(PROJECT_FILE)
}
/// Read the project key, if one has been minted under `base/.picloud/`.
#[must_use]
pub fn read_project_key(base: &Path) -> Option<String> {
let body = fs::read(project_path(base)).ok()?;
serde_json::from_slice::<ProjectLink>(&body)
.ok()
.map(|l| l.key)
}
/// Read the project key, minting and persisting a fresh one if absent. Used by
/// the tree `plan`/`apply` paths so a repo that never ran `pic init` still gets
/// a stable ownership identity on first use. The new key is a random v4 UUID.
pub fn ensure_project_key(base: &Path) -> Result<String> {
if let Some(k) = read_project_key(base) {
return Ok(k);
}
// A present-but-unreadable/corrupt `project.json` must NOT be silently
// re-minted: a fresh key would orphan every group this repo already claimed
// (the old key is lost, so the next apply hits an ownership conflict). Fail
// loudly and let the operator restore or intentionally re-key it.
let path = project_path(base);
if path.exists() {
anyhow::bail!(
"{} exists but could not be parsed as a project key; refusing to mint a new one \
(a new key would orphan this repo's group ownership). Restore or delete it.",
path.display()
);
}
let key = uuid::Uuid::new_v4().to_string();
write_project_key(base, &key)?;
Ok(key)
}
/// Persist `key` as the project identity, creating `.picloud/` (self-ignored)
/// if needed. Idempotent overwrite — callers usually go through
/// [`ensure_project_key`].
pub fn write_project_key(base: &Path, key: &str) -> Result<()> {
let dir = base.join(DIR);
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
let ignore = dir.join(".gitignore");
if !ignore.exists() {
fs::write(&ignore, "*\n").context("writing .picloud/.gitignore")?;
}
let body = serde_json::to_vec_pretty(&ProjectLink {
key: key.to_string(),
})
.context("encoding .picloud/project.json")?;
fs::write(project_path(base), body).context("writing .picloud/project.json")?;
Ok(())
}
/// The recorded result of the last `pic plan`, scoped to the app it was for.
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -58,12 +58,6 @@ enum Cmd {
cmd: GroupsCmd,
},
/// §7 multi-repo ownership projects (read-only).
Projects {
#[command(subcommand)]
cmd: ProjectsCmd,
},
/// Script management.
Scripts {
#[command(subcommand)]
@@ -252,27 +246,20 @@ struct ApplyArgs {
/// since the last `pic plan`).
#[arg(long)]
force: bool,
/// §7 multi-repo ownership: reassign a group node owned by another
/// `[project]` to this manifest's project. Requires group-admin on the
/// node; without it an apply to an owned node is refused (409).
#[arg(long)]
takeover: bool,
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
/// top of the base manifest before applying.
#[arg(long)]
env: Option<String>,
/// §6 M2: reparent a group whose server parent diverges from the
/// manifest's directory nesting (default: refuse on divergence).
#[arg(long, conflicts_with = "adopt_server_structure")]
force_local_structure: bool,
/// §6 M2: accept the server's group placement on a structural
/// divergence and reconcile content in place (no reparent).
#[arg(long)]
adopt_server_structure: bool,
/// §3 M3: approve applying to a confirm-required environment
/// (`[project.environments]`). Repeatable; each must match the `--env`.
#[arg(long = "approve")]
/// Tree apply only (`--dir`): explicitly approve applying to a
/// confirm-required environment (per the root manifest's `[project]`
/// policy, §4.2). Repeatable. A blanket `--yes` does NOT cover it.
#[arg(long = "approve", requires = "dir")]
approve: Vec<String>,
/// Tree apply only (`--dir`): claim declared groups another project owns
/// (§7, M3). Group-admin gated. Without it, an owned-elsewhere group is a
/// hard conflict.
#[arg(long, requires = "dir")]
takeover: bool,
}
#[derive(Args)]
@@ -508,12 +495,6 @@ enum AppsCmd {
},
}
#[derive(Subcommand)]
enum ProjectsCmd {
/// List all projects + how many group nodes each owns.
Ls,
}
#[derive(Subcommand)]
enum GroupsCmd {
/// List all groups (flat).
@@ -1445,22 +1426,14 @@ async fn main() -> ExitCode {
Cmd::Whoami => cmds::whoami::run(mode).await,
Cmd::Apply(args) => match &args.dir {
Some(dir) => {
let structure_mode = if args.force_local_structure {
"force-local"
} else if args.adopt_server_structure {
"adopt-server"
} else {
"refuse"
};
cmds::apply::run_tree(
dir,
args.prune,
args.yes,
args.force,
args.takeover,
structure_mode,
&args.approve,
args.env.as_deref(),
&args.approve,
args.takeover,
mode,
)
.await
@@ -1472,8 +1445,6 @@ async fn main() -> ExitCode {
args.prune,
args.yes,
args.force,
args.takeover,
&args.approve,
mode,
)
.await
@@ -1543,9 +1514,6 @@ async fn main() -> ExitCode {
cmd: DomainsCmd::Rm { app, domain_id },
},
} => cmds::apps_domains::rm(&app, &domain_id).await,
Cmd::Projects {
cmd: ProjectsCmd::Ls,
} => cmds::projects::ls(mode).await,
Cmd::Groups { cmd: GroupsCmd::Ls } => cmds::groups::ls(mode).await,
Cmd::Groups {
cmd: GroupsCmd::Tree,

View File

@@ -37,10 +37,9 @@ pub struct Manifest {
pub app: Option<ManifestApp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<ManifestGroup>,
/// `[project]` (§7 multi-repo ownership) — the repo-root that OWNS the nodes
/// it applies. Independent of the app/group node kind; declared in the
/// repo's root manifest and threaded onto every apply from it. The first
/// apply with a new slug claims each group node it touches.
/// `[project]` — project-level policy (per-env approval gating, §4.2/§6).
/// Consumed only by `apply --dir` and only on the tree's ROOT manifest
/// (enforced in `build_tree`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<ManifestProject>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -269,38 +268,23 @@ pub struct ManifestGroup {
pub collections: Vec<CollectionDecl>,
}
/// A `[project]` block (§7 multi-repo ownership): the identity of the repo-root
/// managing these nodes. The `slug` is committed (stable across clones); the
/// server assigns the UUID and the first apply registers it.
/// `[project]` — project-level policy (§4.2/§6). Valid ONLY on the root
/// manifest of a `pic apply --dir` tree; rejected elsewhere by [`build_tree`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestProject {
pub slug: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// `parent_group` (§7/§6) — the pre-existing group this repo binds UNDER
/// (its attach point). Applies are refused for any node not strictly within
/// that subtree. Absent = instance root = no ceiling (the default).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_group: Option<String>,
/// §3 M3 per-env approval policy: `[project.environments]` maps an env name
/// to its policy. A `confirm = true` env requires an explicit `--approve
/// <env>` on `pic apply --env <env>` (a blanket `--yes` does NOT cover it —
/// §4.2 "CI must opt in per environment"). Client-side gating only; never
/// sent to the server.
#[serde(default, skip_serializing)]
pub environments: std::collections::BTreeMap<String, EnvPolicy>,
/// `[[project.environments]]` — per-env apply policy.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub environments: Vec<ManifestEnvironment>,
}
/// §3 M3: the apply-gating policy for one environment.
/// One `[[project.environments]]` entry. `confirm = true` gates applying to this
/// env behind an explicit `pic apply --approve <name>`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EnvPolicy {
/// Require an explicit `--approve <env>` to apply to this env. NOT
/// `#[serde(default)]`: an env listed with an empty policy (`prod = {}`)
/// must fail to load (`missing field 'confirm'`) rather than silently
/// parse as un-gated — listing an env to protect it, then having the gate
/// quietly absent, is the failure mode to avoid.
pub struct ManifestEnvironment {
pub name: String,
#[serde(default)]
pub confirm: bool,
}
@@ -918,6 +902,23 @@ mod tests {
);
}
#[test]
fn project_block_parses_environment_policy() {
let m = Manifest::parse(
"[app]\nslug = \"a\"\nname = \"A\"\n\n\
[[project.environments]]\nname = \"production\"\nconfirm = true\n\n\
[[project.environments]]\nname = \"staging\"\n",
)
.expect("manifest with [project] parses");
let p = m.project.expect("project present");
assert_eq!(p.environments.len(), 2);
assert_eq!(p.environments[0].name, "production");
assert!(p.environments[0].confirm);
// `confirm` defaults to false when omitted.
assert_eq!(p.environments[1].name, "staging");
assert!(!p.environments[1].confirm);
}
#[test]
fn group_manifest_parses_and_rejects_app_only_blocks() {
// A [group] node: scripts + vars, no [app].
@@ -947,16 +948,13 @@ mod tests {
)
.expect("group cron template parses");
assert_eq!(cron.triggers.cron.len(), 1);
// §4.5 M5.5: a group email template is now ALLOWED too — it materializes
// per descendant app and resolves its `inbound_secret_ref` against the
// GROUP's own secret store at apply (the server rejects it there if the
// secret is unset). Parse no longer refuses it.
let email = Manifest::parse(
// ...but an email template on a group is still rejected (per-app secret).
let err = Manifest::parse(
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
[[triggers.email]]\nscript = \"shared\"\ninbound_secret_ref = \"sig\"\n",
)
.expect("group email template parses");
assert_eq!(email.triggers.email.len(), 1);
.expect_err("group email template is rejected");
assert!(err.to_string().contains("email"), "got: {err}");
// Neither / both is rejected.
Manifest::parse("[vars]\nx = 1\n").expect_err("no [app] or [group]");
@@ -964,49 +962,6 @@ mod tests {
.expect_err("both [app] and [group]");
}
#[test]
fn project_block_parses_independently_of_node_kind() {
// §7: [project] is orthogonal to the app/group XOR — either node kind
// may declare the owning project.
let a = Manifest::parse(
"[project]\nslug = \"platform\"\nname = \"Platform\"\n\n\
[app]\nslug = \"web\"\nname = \"Web\"\n",
)
.expect("[app] + [project] parses");
assert_eq!(a.project.as_ref().unwrap().slug, "platform");
assert_eq!(
a.project.as_ref().unwrap().name.as_deref(),
Some("Platform")
);
let g = Manifest::parse(
"[project]\nslug = \"platform\"\n\n[group]\nslug = \"acme\"\nname = \"ACME\"\n",
)
.expect("[group] + [project] parses");
assert_eq!(g.project.as_ref().unwrap().slug, "platform");
assert!(
g.project.as_ref().unwrap().name.is_none(),
"name is optional"
);
// Absent [project] → None (backward-compatible).
assert!(Manifest::parse("[app]\nslug=\"w\"\nname=\"W\"\n")
.unwrap()
.project
.is_none());
// Unknown key inside [project] is rejected (deny_unknown_fields).
Manifest::parse("[project]\nslug=\"p\"\nbogus=1\n[app]\nslug=\"w\"\nname=\"W\"\n")
.expect_err("unknown [project] key rejected");
// §6/§7 M2: the optional attach-point `parent_group`.
let m = Manifest::parse(
"[project]\nslug = \"p\"\nparent_group = \"acme\"\n\n\
[group]\nslug = \"team\"\nname = \"T\"\n",
)
.expect("parent_group parses");
assert_eq!(m.project.unwrap().parent_group.as_deref(), Some("acme"));
}
#[test]
fn overlay_vars_override_base_per_key() {
let mut base = sample();

View File

@@ -1,489 +0,0 @@
//! §7 multi-repo ownership — the claim, end to end via `pic`.
//!
//! A `[project]` claims a group node on first apply; the SAME project re-applies
//! freely; a DIFFERENT project applying to that node is refused (409) unless it
//! `--takeover`s (which requires group-admin). An app inherits ownership from
//! its nearest claimed ancestor group. `pic groups ls` shows the owning
//! project's slug. The pure claim policy is unit-tested in manager-core; this
//! journey pins the wire + CLI + authz interaction.
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
fn manifest_dir() -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
fs::write(
dir.path().join("scripts/shared.rhai"),
r#"log::info("shared"); "ok""#,
)
.unwrap();
dir
}
/// A `[project]` + `[group]` manifest (with one group script) in `dir`.
fn write_group_manifest(dir: &Path, project: &str, group: &str) {
let m = format!(
"[project]\nslug = \"{project}\"\nname = \"Proj\"\n\n\
[group]\nslug = \"{group}\"\nname = \"Grp\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n"
);
fs::write(dir.join("picloud.toml"), m).unwrap();
}
fn group_script_id(env: &common::TestEnv, group: &str) -> String {
let ls = common::pic_as(env)
.args(["scripts", "ls", "--group", group])
.output()
.expect("scripts ls");
let table = String::from_utf8(ls.stdout).unwrap();
table
.lines()
.map(common::cells)
.find(|c| c.get(2) == Some(&"shared"))
.and_then(|c| c.first().map(|s| (*s).to_string()))
.unwrap_or_else(|| panic!("group script not found:\n{table}"))
}
/// The `owner` cell (index 3: slug, name, parent, OWNER, created_at) for
/// `group` in `pic groups ls`.
fn owner_cell(env: &common::TestEnv, group: &str) -> String {
let ls = common::pic_as(env)
.args(["groups", "ls"])
.output()
.expect("groups ls");
let table = String::from_utf8(ls.stdout).unwrap();
table
.lines()
.map(common::cells)
.find(|c| c.first() == Some(&group))
.and_then(|c| c.get(3).map(|s| (*s).to_string()))
.unwrap_or_else(|| panic!("group `{group}` not in groups ls:\n{table}"))
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn claim_conflict_takeover_and_app_inheritance() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("own-grp");
let proj_a = common::unique_slug("plat");
let proj_b = common::unique_slug("teamb");
// The group must pre-exist (groups pre-exist; the manifest owns content).
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
// (1) First apply by project A CLAIMS the group.
let dir_a = manifest_dir();
write_group_manifest(dir_a.path(), &proj_a, &group);
common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir_a.path().join("picloud.toml"))
.assert()
.success();
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
assert_eq!(
owner_cell(&env, &group),
proj_a,
"the owner column must show the claiming project"
);
// (2) The SAME project re-applies with no conflict (idempotent).
common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir_a.path().join("picloud.toml"))
.assert()
.success();
// (3) A DIFFERENT project applying to the owned group is refused (409),
// naming the current owner.
let dir_b = manifest_dir();
write_group_manifest(dir_b.path(), &proj_b, &group);
let out = common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir_b.path().join("picloud.toml"))
.output()
.expect("apply B");
assert!(!out.status.success(), "a second project must be refused");
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("owned by project") && err.contains(&proj_a),
"the conflict must name the owner `{proj_a}`:\n{err}"
);
// (4) --takeover (admin holds group-admin implicitly) reassigns to B.
common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir_b.path().join("picloud.toml"))
.arg("--takeover")
.assert()
.success();
assert_eq!(
owner_cell(&env, &group),
proj_b,
"takeover must reassign the owner"
);
// (5) An app under the claimed group INHERITS ownership: the owning project
// applies fine; a foreign or absent project is refused.
let app = common::unique_slug("own-app");
let _a = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &group])
.assert()
.success();
let app_dir = manifest_dir();
let app_manifest = |proj: Option<&str>| -> String {
let head = proj
.map(|p| format!("[project]\nslug = \"{p}\"\n\n"))
.unwrap_or_default();
format!("{head}[app]\nslug = \"{app}\"\nname = \"App\"\n")
};
// Project B owns the group → ok.
fs::write(
app_dir.path().join("picloud.toml"),
app_manifest(Some(&proj_b)),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(app_dir.path().join("picloud.toml"))
.assert()
.success();
// Project A no longer owns the group → refused.
fs::write(
app_dir.path().join("picloud.toml"),
app_manifest(Some(&proj_a)),
)
.unwrap();
let out = common::pic_as(&env)
.args(["apply", "--file"])
.arg(app_dir.path().join("picloud.toml"))
.output()
.expect("apply app A");
assert!(
!out.status.success(),
"an app under a claimed group must match its owner"
);
// No [project] under a claimed subtree → refused.
fs::write(app_dir.path().join("picloud.toml"), app_manifest(None)).unwrap();
let out = common::pic_as(&env)
.args(["apply", "--file"])
.arg(app_dir.path().join("picloud.toml"))
.output()
.expect("apply app none");
assert!(
!out.status.success(),
"a no-project app under a claimed subtree is refused"
);
// (6) --takeover is capability-gated: a member with only EDITOR on the group
// can reconcile it but must NOT be able to take it over (needs group-admin).
let mem = common::member::member_user(fx, &common::unique_slug("own-mem"));
common::member::grant_group_membership(fx, &group, &mem.id, "editor");
let menv = common::custom_env(&env.url, &mem.token);
common::seed_credentials(&menv, &mem.username);
let proj_c = common::unique_slug("teamc");
let dir_c = manifest_dir();
write_group_manifest(dir_c.path(), &proj_c, &group);
let out = common::pic_as(&menv)
.args(["apply", "--file"])
.arg(dir_c.path().join("picloud.toml"))
.arg("--takeover")
.output()
.expect("member takeover");
assert!(
!out.status.success(),
"a member without group-admin must not be able to --takeover"
);
// The owner is unchanged — the failed takeover rolled back.
assert_eq!(
owner_cell(&env, &group),
proj_b,
"a refused takeover must not change ownership"
);
}
/// The `name` cell (index 1: slug, NAME, owned_groups, created_at) for `project`
/// in `pic projects ls`.
fn project_name_cell(env: &common::TestEnv, project: &str) -> String {
let ls = common::pic_as(env)
.args(["projects", "ls"])
.output()
.expect("projects ls");
let table = String::from_utf8(ls.stdout).unwrap();
table
.lines()
.map(common::cells)
.find(|c| c.first() == Some(&project))
.and_then(|c| c.get(1).map(|s| (*s).to_string()))
.unwrap_or_else(|| panic!("project `{project}` not in projects ls:\n{table}"))
}
/// A name-less `[project]` re-apply must PRESERVE the display name set on the
/// first apply — an omitted optional field never clobbers the stored name back
/// to the slug (regression for the `ON CONFLICT DO UPDATE SET name` path).
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn reapply_without_name_preserves_project_name() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("name-grp");
let proj = common::unique_slug("name-proj");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let dir = manifest_dir();
let apply = |m: &str| {
fs::write(dir.path().join("picloud.toml"), m).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir.path().join("picloud.toml"))
.assert()
.success();
};
// First apply declares a display name.
apply(&format!(
"[project]\nslug = \"{proj}\"\nname = \"Acme Platform\"\n\n\
[group]\nslug = \"{group}\"\nname = \"Grp\"\n"
));
assert_eq!(
project_name_cell(&env, &proj),
"Acme Platform",
"the first apply records the declared name"
);
// A re-apply that OMITS `name` must not overwrite it with the slug.
apply(&format!(
"[project]\nslug = \"{proj}\"\n\n\
[group]\nslug = \"{group}\"\nname = \"Grp\"\n"
));
assert_eq!(
project_name_cell(&env, &proj),
"Acme Platform",
"a name-less re-apply must preserve the stored name, not clobber it to the slug"
);
}
/// §6/§7 M2 — `[project] parent_group` is the ceiling: applies are refused for
/// any node not strictly within the attach point's subtree.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn attach_point_ceiling_bounds_the_subtree() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
// acme (root) → team (child); plus a sibling `outsider` root.
let acme = common::unique_slug("acme");
let team = common::unique_slug("team");
let outsider = common::unique_slug("outsider");
let _a = GroupGuard::new(&env.url, &env.token, &acme);
let _t = GroupGuard::new(&env.url, &env.token, &team);
let _o = GroupGuard::new(&env.url, &env.token, &outsider);
common::pic_as(&env)
.args(["groups", "create", &acme])
.assert()
.success();
common::pic_as(&env)
.args(["groups", "create", &team, "--parent", &acme])
.assert()
.success();
common::pic_as(&env)
.args(["groups", "create", &outsider])
.assert()
.success();
let proj = common::unique_slug("attach-p");
let dir = manifest_dir();
let apply = |m: &str| -> std::process::Output {
fs::write(dir.path().join("picloud.toml"), m).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir.path().join("picloud.toml"))
.output()
.expect("apply")
};
// A group node strictly BELOW the attach point → ok.
let out = apply(&format!(
"[project]\nslug = \"{proj}\"\nparent_group = \"{acme}\"\n\n\
[group]\nslug = \"{team}\"\nname = \"Team\"\n"
));
assert!(
out.status.success(),
"a node below the attach point applies: {}",
String::from_utf8_lossy(&out.stderr)
);
// The attach point ITSELF → refused (you can't apply above your local root).
let out = apply(&format!(
"[project]\nslug = \"{proj}\"\nparent_group = \"{acme}\"\n\n\
[group]\nslug = \"{acme}\"\nname = \"Acme\"\n"
));
assert!(
!out.status.success(),
"applying the attach point itself must be refused"
);
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("attach point"),
"the refusal must mention the attach point:\n{err}"
);
// A SIBLING subtree (not under acme) → refused.
let out = apply(&format!(
"[project]\nslug = \"{proj}\"\nparent_group = \"{acme}\"\n\n\
[group]\nslug = \"{outsider}\"\nname = \"Out\"\n"
));
assert!(
!out.status.success(),
"a sibling subtree is outside the attach point"
);
}
/// §7 M3 — `pic plan` previews the ownership outcome (claim / conflict) and the
/// cross-repo blast radius (descendant apps owned by other projects) BEFORE any
/// apply.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn plan_previews_ownership_and_blast_radius() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
// acme (root) → team_a, team_b; an app under each, each team claimed by a
// DIFFERENT project.
let acme = common::unique_slug("br-acme");
let team_a = common::unique_slug("br-a");
let team_b = common::unique_slug("br-b");
let app_a = common::unique_slug("br-app-a");
let app_b = common::unique_slug("br-app-b");
let plat = common::unique_slug("plat");
let teamb = common::unique_slug("teamb");
let platform = common::unique_slug("platform");
let _g0 = GroupGuard::new(&env.url, &env.token, &acme);
let _g1 = GroupGuard::new(&env.url, &env.token, &team_a);
let _g2 = GroupGuard::new(&env.url, &env.token, &team_b);
let _a1 = AppGuard::new(&env.url, &env.token, &app_a);
let _a2 = AppGuard::new(&env.url, &env.token, &app_b);
for (slug, parent) in [
(&acme, None),
(&team_a, Some(&acme)),
(&team_b, Some(&acme)),
] {
let mut c = common::pic_as(&env);
c.args(["groups", "create", slug]);
if let Some(p) = parent {
c.args(["--parent", p]);
}
c.assert().success();
}
common::pic_as(&env)
.args(["apps", "create", &app_a, "--group", &team_a])
.assert()
.success();
common::pic_as(&env)
.args(["apps", "create", &app_b, "--group", &team_b])
.assert()
.success();
// Claim team_a by `plat`, team_b by `teamb` (empty group applies).
let dir = manifest_dir();
let claim = |project: &str, group: &str| {
let m = format!(
"[project]\nslug = \"{project}\"\n\n[group]\nslug = \"{group}\"\nname = \"G\"\n"
);
fs::write(dir.path().join("picloud.toml"), m).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir.path().join("picloud.toml"))
.assert()
.success();
};
claim(&plat, &team_a);
claim(&teamb, &team_b);
// `pic projects ls` lists both registered projects with their owned-group
// counts (plat owns exactly team_a).
let projects = String::from_utf8(
common::pic_as(&env)
.args(["projects", "ls"])
.output()
.unwrap()
.stdout,
)
.unwrap();
let plat_row = projects
.lines()
.map(common::cells)
.find(|c| c.first() == Some(&plat.as_str()))
.unwrap_or_else(|| panic!("plat not in projects ls:\n{projects}"));
assert_eq!(
plat_row.get(2).copied(),
Some("1"),
"plat owns exactly one group:\n{projects}"
);
assert!(
projects.contains(teamb.as_str()),
"teamb must be listed:\n{projects}"
);
// Plan acme with project `platform`: acme is unclaimed → action `claim`; the
// blast radius lists the OTHER projects' descendant apps (plat + teamb).
fs::write(
dir.path().join("picloud.toml"),
format!(
"[project]\nslug = \"{platform}\"\n\n[group]\nslug = \"{acme}\"\nname = \"Acme\"\n"
),
)
.unwrap();
let out = common::pic_as(&env)
.args(["plan", "--file"])
.arg(dir.path().join("picloud.toml"))
.output()
.expect("plan acme");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("claim"),
"acme is unclaimed → the preview must show `claim`:\n{stdout}"
);
assert!(
stdout.contains("blast_radius") && stdout.contains(&plat) && stdout.contains(&teamb),
"the blast radius must list the other projects' apps (plat + teamb):\n{stdout}"
);
// Plan team_a with `platform`: it is owned by `plat` → action `conflict`.
fs::write(
dir.path().join("picloud.toml"),
format!("[project]\nslug = \"{platform}\"\n\n[group]\nslug = \"{team_a}\"\nname = \"A\"\n"),
)
.unwrap();
let out = common::pic_as(&env)
.args(["plan", "--file"])
.arg(dir.path().join("picloud.toml"))
.output()
.expect("plan team_a");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("conflict") && stdout.contains(&plat),
"planning a foreign-owned group must preview a conflict naming the owner:\n{stdout}"
);
}

View File

@@ -0,0 +1,240 @@
//! M5 per-env approval gating (§4.2, §6) via `pic apply --dir`:
//! * a root manifest `[project]` block marks an environment confirm-required,
//! * applying to that env WITHOUT `--approve` is refused (a blanket `--yes`
//! does not cover it) — refused non-interactively at the CLI,
//! * `--approve <env>` (as an admin) lets it through,
//! * a non-gated environment applies with plain `--yes`,
//! * single-node `apply --file` to a gated env is refused (no silent bypass),
//! * approving a gated apply needs ADMIN authority on the node — a non-admin
//! editor with `--approve` is refused server-side (403).
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard};
use crate::common::member;
/// A single-app project dir whose root manifest declares a `[project]` policy:
/// `production` is confirm-required, `staging` is not. A `[vars]` entry gives
/// the app write-requiring content so an `editor` member's AppVarsWrite is
/// exercised (proving the admin gate is ABOVE editor-write). Vars cascade with
/// the app; scripts are ON DELETE RESTRICT and would break AppGuard teardown.
fn project_dir(app: &str) -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::write(
dir.path().join("picloud.toml"),
format!(
"[app]\nslug = \"{app}\"\nname = \"Gated App\"\n\n\
[[project.environments]]\nname = \"production\"\nconfirm = true\n\n\
[[project.environments]]\nname = \"staging\"\nconfirm = false\n\n\
[vars]\nregion = \"eu\"\n"
),
)
.unwrap();
// `--env <e>` requires the overlay file to exist; empty overlays keep the
// base slug (same app across envs — we're testing the gate, not env routing).
fs::write(dir.path().join("picloud.production.toml"), "").unwrap();
fs::write(dir.path().join("picloud.staging.toml"), "").unwrap();
dir
}
fn apply(env: &common::TestEnv, dir: &Path, extra: &[&str]) -> std::process::Output {
let mut cmd = common::pic_as(env);
cmd.args(["apply", "--dir"]).arg(dir).args(extra);
cmd.output().expect("apply --dir")
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn confirm_required_env_needs_explicit_approval() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("appr-g");
let app = common::unique_slug("appr-a");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _a = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &group])
.assert()
.success();
let dir = project_dir(&app);
// --- production is confirm-required: --yes alone is refused. ---
let out = apply(&env, dir.path(), &["--env", "production", "--yes"]);
assert!(
!out.status.success(),
"production apply without --approve must be refused"
);
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("approve"),
"refusal should mention --approve:\n{err}"
);
// --- with --approve production, it applies. ---
let ok = apply(
&env,
dir.path(),
&["--env", "production", "--approve", "production"],
);
assert!(
ok.status.success(),
"approved production apply should succeed: {}",
String::from_utf8_lossy(&ok.stderr)
);
// --- staging is NOT gated: plain --yes applies. ---
let ok2 = apply(&env, dir.path(), &["--env", "staging", "--yes"]);
assert!(
ok2.status.success(),
"non-gated staging apply should succeed: {}",
String::from_utf8_lossy(&ok2.stderr)
);
// --- single-node `apply --file` to a gated env is refused (no bypass). ---
let single = common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir.path().join("picloud.toml"))
.args(["--env", "production", "--yes"])
.output()
.expect("apply --file");
assert!(
!single.status.success(),
"single-node apply to a confirm-required env must be refused"
);
let serr = String::from_utf8_lossy(&single.stderr).to_lowercase();
assert!(
serr.contains("confirm-required") || serr.contains("--dir"),
"single-node refusal should point at --dir:\n{serr}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn approving_a_gated_apply_requires_admin() {
// §4.2: approving a confirm-required env is admin-gated — a second gate on
// top of the editor-level write caps an ordinary apply needs. An editor who
// CAN write the app (its `[vars]`) still cannot approve a gated apply.
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("appr2-g");
let app = common::unique_slug("appr2-a");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _a = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &group])
.assert()
.success();
// A member with `editor` (write) on the app — enough for an ordinary apply,
// not enough to approve a gated environment.
let m = member::member_user(fx, &common::unique_username("appr"));
member::grant_membership(fx, &app, &m.id, "editor");
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
let dir = project_dir(&app);
let out = apply(
&member_env,
dir.path(),
&["--env", "production", "--approve", "production"],
);
assert!(
!out.status.success(),
"a non-admin editor must not be able to approve a gated apply"
);
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("forbidden") || err.contains("403"),
"approval denial should be an authz error:\n{err}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn gated_group_node_admin_gate_is_not_bypassable_by_uuid_slug() {
// Regression (§4.2): `enforce_env_approval` must resolve a group node by
// UUID-or-slug and FAIL CLOSED — a bare `get_by_slug` skipped the GroupAdmin
// gate when the node addressed the group by its UUID (which the apply still
// resolves), letting a group EDITOR apply to a confirm-required env without
// admin. We drive the raw `/tree/apply` wire directly (the CLI only ever
// sends slugs) with the group node's UUID as its slug.
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("appr3-g");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
// Resolve the group's UUID.
let http = reqwest::blocking::Client::new();
let detail: serde_json::Value = http
.get(format!("{}/api/v1/admin/groups/{}", env.url, group))
.bearer_auth(&env.token)
.send()
.expect("get group")
.json()
.expect("group json");
let group_uuid = detail["id"].as_str().expect("group id").to_string();
// A group `editor` — write caps, but NOT GroupAdmin.
let m = member::member_user(fx, &common::unique_username("appr3"));
common::pic_as(&env)
.args([
"groups", "members", "add", &group, &m.id, "--role", "editor",
])
.assert()
.success();
// A gated bundle whose single group node is addressed by UUID. A `vars`
// entry makes the node non-empty (and exercises the editor's write cap).
let body = serde_json::json!({
"bundle": {
"nodes": [{
"kind": "group",
"slug": group_uuid,
"bundle": { "vars": { "region": "eu" } }
}],
"project": { "environments": [{ "name": "production", "confirm": true }] }
},
"prune": false,
"env": "production",
"approved_envs": ["production"],
"allow_takeover": false,
});
let resp = http
.post(format!("{}/api/v1/admin/tree/apply", env.url))
.bearer_auth(&m.token)
.json(&body)
.send()
.expect("tree apply");
assert_eq!(
resp.status().as_u16(),
403,
"a group editor must be 403'd approving a gated apply even when the node \
is addressed by UUID (got {}: {})",
resp.status(),
resp.text().unwrap_or_default()
);
}

View File

@@ -16,7 +16,7 @@ mod common;
mod admins;
mod api_keys;
mod apply;
mod apply_ownership;
mod approval;
mod apps;
mod auth;
mod collections;
@@ -24,10 +24,8 @@ mod config;
mod dead_letters;
mod email_queue;
mod enabled;
mod env_approval;
mod env_overlay;
mod extension_points;
mod group_create;
mod group_modules;
mod group_routes;
mod group_scripts;
@@ -38,6 +36,7 @@ mod init;
mod invoke;
mod logs;
mod output;
mod ownership;
mod plan;
mod prune;
mod pull;
@@ -51,7 +50,6 @@ mod shared_topics;
mod shared_triggers;
mod staleness;
mod stateful_templates;
mod structural_divergence;
mod suppress;
mod tree;
mod triggers;

View File

@@ -78,29 +78,6 @@ pub fn grant_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str)
);
}
/// `POST /api/v1/admin/groups/{slug}/members` — grant `role` to `user_id` on a
/// GROUP (hierarchy-aware). §7 takeover tests use this to give a member an
/// `editor` group role — enough to reconcile a group node, but NOT to
/// `--takeover` (which needs `GroupAdmin`).
pub fn grant_group_membership(fx: &Fixture, group_slug: &str, user_id: &str, role: &str) {
let client = reqwest::blocking::Client::new();
let resp = client
.post(format!(
"{}/api/v1/admin/groups/{}/members",
fx.url, group_slug
))
.bearer_auth(&fx.admin_token)
.json(&json!({ "user_id": user_id, "role": role }))
.send()
.expect("grant group membership");
assert!(
resp.status().is_success(),
"grant group membership failed: {} {}",
resp.status(),
resp.text().unwrap_or_default(),
);
}
/// `PATCH /api/v1/admin/apps/{slug}/members/{user_id}` — promote/demote.
pub fn update_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) {
let client = reqwest::blocking::Client::new();

View File

@@ -1,206 +0,0 @@
//! §3 M3 — per-env approval gating (`[project.environments]`).
//!
//! A `[project.environments]` block marks some envs confirm-required. Applying
//! to such an env with `pic apply --env <e>` is refused unless it is explicitly
//! `--approve <e>`d — and a blanket `--yes` does NOT cover it (§4.2, "CI must
//! opt in per environment"). An unlisted or `confirm = false` env applies
//! freely. The gate is client-side, so a refused apply never reaches the server.
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::GroupGuard;
/// A repo whose `[project]` gates `production` (confirm-required) but not
/// `staging`, managing one pre-existing `[group]`. Minimal per-env overlay files
/// exist so `--env` can load them.
fn gated_repo(dir: &Path, project: &str, group: &str) {
fs::write(
dir.join("picloud.toml"),
format!(
"[project]\nslug = \"{project}\"\n\n\
[project.environments]\nproduction = {{ confirm = true }}\n\
staging = {{ confirm = false }}\n\n\
[group]\nslug = \"{group}\"\nname = \"Env Gated\"\n"
),
)
.unwrap();
fs::write(
dir.join("picloud.production.toml"),
"# production overlay\n",
)
.unwrap();
fs::write(dir.join("picloud.staging.toml"), "# staging overlay\n").unwrap();
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn confirm_required_env_needs_explicit_approve() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("ea-grp");
let project = common::unique_slug("ea-proj");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let dir = TempDir::new().unwrap();
gated_repo(dir.path(), &project, &group);
let manifest = dir.path().join("picloud.toml");
let apply = |args: &[&str]| -> std::process::Output {
let mut c = common::pic_as(&env);
c.args(["apply", "--file"]).arg(&manifest).args(args);
c.output().expect("apply")
};
// production is confirm-required → a bare `--env production` is refused,
// and the message names `--approve`. (Client-side: never hits the server.)
let out = apply(&["--env", "production"]);
assert!(!out.status.success(), "production must require approval");
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("approve") && err.contains("production"),
"the refusal must point at --approve production:\n{err}"
);
// A blanket `--yes` does NOT cover a gated env.
assert!(
!apply(&["--env", "production", "--yes"]).status.success(),
"--yes must not bypass a confirm-required env"
);
// `--approve production` lets it through.
assert!(
apply(&["--env", "production", "--approve", "production"])
.status
.success(),
"an explicit --approve production must apply"
);
// staging is `confirm = false` → applies freely, no approval needed.
assert!(
apply(&["--env", "staging"]).status.success(),
"an un-gated env must apply without --approve"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn an_env_listed_with_an_empty_policy_is_a_hard_error() {
// `production = {}` (no `confirm`) must FAIL to load, not silently parse as
// un-gated — you can't half-declare a gate. `confirm` is a required field.
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("eae-grp");
let project = common::unique_slug("eae-proj");
let _g = GroupGuard::new(&env.url, &env.token, &group);
let dir = TempDir::new().unwrap();
let manifest = dir.path().join("picloud.toml");
fs::write(
&manifest,
format!(
"[project]\nslug = \"{project}\"\n\n\
[project.environments]\nproduction = {{}}\n\n\
[group]\nslug = \"{group}\"\nname = \"Empty Policy\"\n"
),
)
.unwrap();
fs::write(dir.path().join("picloud.production.toml"), "# overlay\n").unwrap();
let out = common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest)
.args(["--env", "production"])
.output()
.expect("apply");
assert!(
!out.status.success(),
"an env with an empty policy must be a load error, not a silent no-gate"
);
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("confirm"),
"the error must name the missing `confirm` field:\n{err}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn leaf_file_apply_honors_the_root_env_gate() {
// `pic apply --file <leaf>` where the leaf carries no `[project]` still
// honors the gate declared in the repo root (found by walking up).
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let base = common::unique_slug("eal-base");
let leaf = common::unique_slug("eal-leaf");
let project = common::unique_slug("eal-proj");
let _b = GroupGuard::new(&env.url, &env.token, &base);
let _l = GroupGuard::new(&env.url, &env.token, &leaf);
common::pic_as(&env)
.args(["groups", "create", &base])
.assert()
.success();
common::pic_as(&env)
.args(["groups", "create", &leaf, "--parent", &base])
.assert()
.success();
// Root manifest carries the gating `[project]`; the leaf (in a subdir) has
// no `[project]` of its own and is applied on its own with `--file`.
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[project]\nslug = \"{project}\"\n\n\
[project.environments]\nproduction = {{ confirm = true }}\n\n\
[group]\nslug = \"{base}\"\nname = \"Base\"\n"
),
)
.unwrap();
let sub = dir.path().join("sub");
fs::create_dir_all(&sub).unwrap();
let leaf_manifest = sub.join("picloud.toml");
fs::write(
&leaf_manifest,
format!("[group]\nslug = \"{leaf}\"\nname = \"Leaf\"\n"),
)
.unwrap();
fs::write(sub.join("picloud.production.toml"), "# overlay\n").unwrap();
// Without --approve: refused, because the gate is discovered up-tree.
let out = common::pic_as(&env)
.args(["apply", "--file"])
.arg(&leaf_manifest)
.args(["--env", "production"])
.output()
.expect("apply");
assert!(
!out.status.success(),
"a leaf --file apply must honor the root's env gate"
);
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("approve") && err.contains("production"),
"the refusal must point at --approve production:\n{err}"
);
// With --approve production: passes the gate and applies (leaf pre-exists).
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&leaf_manifest)
.args(["--env", "production", "--approve", "production"])
.assert()
.success();
}

View File

@@ -1,265 +0,0 @@
//! §6 M1 — declarative group CREATE, end to end via `pic apply --dir`.
//!
//! A repo declares its group subtree by directory nesting: a `[group]` node's
//! parent is the nearest ancestor directory that also holds a `[group]`, and the
//! topmost group binds to the repo's `[project] parent_group` (attach point).
//! The tree apply creates the missing groups (parents-first) in one transaction,
//! claims them for the project, and is a no-op on re-apply. Creating a group is
//! capability-gated (a member without group-admin is refused).
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::GroupGuard;
/// A two-level repo on disk: root `[group] {top}` (claimed by `{project}`,
/// attached under `{attach}`) with a nested `sub/` `[group] {sub}`.
fn nested_repo(dir: &Path, project: &str, attach: &str, top: &str, sub: &str) {
fs::write(
dir.join("picloud.toml"),
format!(
"[project]\nslug = \"{project}\"\nparent_group = \"{attach}\"\n\n\
[group]\nslug = \"{top}\"\nname = \"Top\"\n"
),
)
.unwrap();
let subdir = dir.join("sub");
fs::create_dir_all(&subdir).unwrap();
fs::write(
subdir.join("picloud.toml"),
format!("[group]\nslug = \"{sub}\"\nname = \"Sub\"\n"),
)
.unwrap();
}
/// The `parent` (index 2) and `owner` (index 3) cells for `group` in
/// `pic groups ls` — columns: slug, name, parent, owner, created_at.
fn parent_and_owner(env: &common::TestEnv, group: &str) -> (String, String) {
let ls = common::pic_as(env)
.args(["groups", "ls"])
.output()
.expect("groups ls");
let table = String::from_utf8(ls.stdout).unwrap();
let row = table
.lines()
.map(common::cells)
.find(|c| c.first() == Some(&group))
.unwrap_or_else(|| panic!("group `{group}` not in groups ls:\n{table}"));
(
row.get(2).copied().unwrap_or_default().to_string(),
row.get(3).copied().unwrap_or_default().to_string(),
)
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn tree_apply_creates_nested_groups_under_the_attach_point() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let base = common::unique_slug("gc-base");
let top = common::unique_slug("gc-top");
let sub = common::unique_slug("gc-sub");
let project = common::unique_slug("gc-proj");
// The attach point pre-exists; `top` and `sub` do NOT — the tree creates them.
let _b = GroupGuard::new(&env.url, &env.token, &base);
common::pic_as(&env)
.args(["groups", "create", &base])
.assert()
.success();
// Teardown order (reverse of declaration): sub → top → base.
let _gtop = GroupGuard::new(&env.url, &env.token, &top);
let _gsub = GroupGuard::new(&env.url, &env.token, &sub);
let dir = TempDir::new().unwrap();
nested_repo(dir.path(), &project, &base, &top, &sub);
// Plan previews both group CREATES (and, with a [project], a claim).
let out = common::pic_as(&env)
.args(["plan", "--dir"])
.arg(dir.path())
.output()
.unwrap();
let plan = String::from_utf8(out.stdout).unwrap();
let plan_err = String::from_utf8_lossy(&out.stderr);
assert!(
plan.contains(&top) && plan.contains(&sub),
"plan should mention both to-create groups:\nSTDOUT:\n{plan}\nSTDERR:\n{plan_err}"
);
// Apply creates `top` under `base`, then `sub` under `top`, in one tx.
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
let (top_parent, top_owner) = parent_and_owner(&env, &top);
assert_eq!(top_parent, base, "top's parent is the attach point");
assert_eq!(
top_owner, project,
"a created group is claimed by the project"
);
let (sub_parent, sub_owner) = parent_and_owner(&env, &sub);
assert_eq!(
sub_parent, top,
"sub's parent is its same-tree ancestor group"
);
assert_eq!(
sub_owner, project,
"the nested created group is also claimed"
);
// Re-apply is a clean no-op (both groups already exist, parents match).
let replan = String::from_utf8(
common::pic_as(&env)
.args(["plan", "--dir"])
.arg(dir.path())
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
!replan.contains("create") && !replan.contains("update"),
"re-plan of an already-created tree must be a no-op:\n{replan}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn creating_a_group_requires_group_admin_on_the_parent() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let base = common::unique_slug("gcm-base");
let top = common::unique_slug("gcm-top");
let project = common::unique_slug("gcm-proj");
let _b = GroupGuard::new(&env.url, &env.token, &base);
common::pic_as(&env)
.args(["groups", "create", &base])
.assert()
.success();
// A member with only EDITOR on `base` may reconcile content but must not be
// able to CREATE a subgroup under it (that needs group-admin).
let mem = common::member::member_user(fx, &common::unique_slug("gcm-mem"));
common::member::grant_group_membership(fx, &base, &mem.id, "editor");
let menv = common::custom_env(&env.url, &mem.token);
common::seed_credentials(&menv, &mem.username);
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[project]\nslug = \"{project}\"\nparent_group = \"{base}\"\n\n\
[group]\nslug = \"{top}\"\nname = \"Top\"\n"
),
)
.unwrap();
let out = common::pic_as(&menv)
.args(["apply", "--dir"])
.arg(dir.path())
.output()
.expect("member apply");
assert!(
!out.status.success(),
"an editor without group-admin must not create a group"
);
// The group was NOT created (the tx rolled back).
let ls = String::from_utf8(
common::pic_as(&env)
.args(["groups", "ls"])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
!ls.lines()
.map(common::cells)
.any(|c| c.first() == Some(&top.as_str())),
"a denied create must leave no group behind:\n{ls}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn tree_apply_denies_group_content_write_without_the_cap() {
// A tree apply that writes content (here a `[vars]` entry) into a
// PRE-EXISTING group requires the caller hold that group's write cap. This
// pins the invariant the in-tx content-write re-check hardens: a group that
// isn't structurally created by this apply must pass content authz. (The
// race the re-check closes — a group appearing between the API-layer check
// and reconcile — isn't reproducible at the journey level; end to end a
// member without the cap is refused and the var is not written.)
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let base = common::unique_slug("gcw-base");
let grp = common::unique_slug("gcw-grp");
let project = common::unique_slug("gcw-proj");
let _b = GroupGuard::new(&env.url, &env.token, &base);
let _g = GroupGuard::new(&env.url, &env.token, &grp);
// `base` is the attach point; `grp` pre-exists directly under it.
common::pic_as(&env)
.args(["groups", "create", &base])
.assert()
.success();
common::pic_as(&env)
.args(["groups", "create", &grp, "--parent", &base])
.assert()
.success();
// A member with NO membership on `grp` cannot write its vars.
let mem = common::member::member_user(fx, &common::unique_slug("gcw-mem"));
let menv = common::custom_env(&env.url, &mem.token);
common::seed_credentials(&menv, &mem.username);
// A repo that declares the pre-existing `grp` (attached under `base`) with a
// var — a group content write.
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[project]\nslug = \"{project}\"\nparent_group = \"{base}\"\n\n\
[group]\nslug = \"{grp}\"\nname = \"Grp\"\n\n[vars]\nregion = \"eu\"\n"
),
)
.unwrap();
let out = common::pic_as(&menv)
.args(["apply", "--dir"])
.arg(dir.path())
.output()
.expect("member apply");
assert!(
!out.status.success(),
"a member without the group's write cap must be refused a content write"
);
// The var was NOT written (the tx rolled back / never ran).
let vars = String::from_utf8(
common::pic_as(&env)
.args(["vars", "ls", "--group", &grp])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
!vars.contains("region"),
"a denied content write must leave no var behind:\n{vars}"
);
}

View File

@@ -0,0 +1,332 @@
//! M3 multi-repo single-owner ownership (§7) via `pic apply --dir`.
//!
//! On this build groups pre-exist (created via `pic groups create`; the tree
//! apply reconciles a node's CONTENT and CLAIMS its ownership, it does not
//! create the group). So each test first creates the group(s), then:
//! * the first repo to `apply` a group CLAIMS it (its `.picloud/` project key
//! becomes the group's `owner_project`),
//! * a SECOND repo (a distinct dir → a distinct project key) applying the
//! same group is REJECTED with an ownership conflict,
//! * `--takeover` lets the second repo seize it — admin-gated, so the fixture
//! instance-owner succeeds, flipping ownership (proven by the first repo now
//! being the one rejected), but a group `editor` is 403'd (ownership ⟂ RBAC,
//! §7.4),
//! * `apply --prune` deletes an owned, now-undeclared, empty group; a group
//! this project never claimed is never touched.
//!
//! Each project lives in its own `TempDir`, so `pic` mints a distinct project
//! key per repo (`.picloud/project.json`) — the two-repo topology §7 governs.
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::GroupGuard;
use crate::common::member;
/// A single-group project dir declaring the (pre-existing) group `slug`. No
/// scripts, so a structural prune can delete it (a group holding scripts/apps
/// is RESTRICT-pinned). `extra` is appended to the `[group]` table verbatim.
fn group_dir(slug: &str, extra: &str) -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::write(
dir.path().join("picloud.toml"),
format!("[group]\nslug = \"{slug}\"\nname = \"Owned Group\"\n{extra}"),
)
.unwrap();
dir
}
fn apply(env: &common::TestEnv, dir: &Path, extra: &[&str]) -> std::process::Output {
let mut cmd = common::pic_as(env);
cmd.args(["apply", "--dir"]).arg(dir).args(extra);
cmd.output().expect("apply --dir")
}
fn create_group(env: &common::TestEnv, slug: &str, parent: Option<&str>) {
let mut cmd = common::pic_as(env);
cmd.args(["groups", "create", slug]);
if let Some(p) = parent {
cmd.args(["--parent", p]);
}
let out = cmd.output().expect("groups create");
assert!(
out.status.success(),
"groups create {slug} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn ls_groups(env: &common::TestEnv) -> String {
String::from_utf8(
common::pic_as(env)
.args(["groups", "ls"])
.output()
.expect("groups ls")
.stdout,
)
.unwrap()
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn first_repo_claims_second_is_rejected_then_takeover_flips_ownership() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("own-g");
let _g = GroupGuard::new(&env.url, &env.token, &slug);
create_group(&env, &slug, None);
// Repo A claims the group on first apply (owner NULL → project A).
let repo_a = group_dir(&slug, "");
let a1 = apply(&env, repo_a.path(), &[]);
assert!(
a1.status.success(),
"repo A claim apply failed: {}",
String::from_utf8_lossy(&a1.stderr)
);
// Repo B (a different dir → a different project key) is rejected: the group
// is owned by project A.
let repo_b = group_dir(&slug, "");
let b1 = apply(&env, repo_b.path(), &[]);
assert!(
!b1.status.success(),
"repo B apply must be rejected (group owned by A)"
);
let b1_err = String::from_utf8_lossy(&b1.stderr).to_lowercase();
assert!(
b1_err.contains("owned by another project") || b1_err.contains("takeover"),
"conflict message should name the ownership clash:\n{b1_err}"
);
// Repo B with --takeover succeeds (the fixture token is instance owner, so
// it holds GroupAdmin on every node) and flips ownership to project B.
let b2 = apply(&env, repo_b.path(), &["--takeover"]);
assert!(
b2.status.success(),
"repo B --takeover should succeed: {}",
String::from_utf8_lossy(&b2.stderr)
);
// Proof the flip took: repo A — formerly the owner — is now the one
// rejected without --takeover.
let a2 = apply(&env, repo_a.path(), &[]);
assert!(
!a2.status.success(),
"after takeover, repo A must now be rejected (B owns the group)"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn takeover_without_group_admin_is_forbidden() {
// §7.4 — ownership ⟂ RBAC: `--takeover` needs GroupAdmin specifically, a
// second gate beyond the write caps. A group `editor` (who CAN write group
// vars) still cannot seize the group for their repo.
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("own-ta");
let _g = GroupGuard::new(&env.url, &env.token, &slug);
create_group(&env, &slug, None);
// Admin (repo A) claims the group.
let repo_a = group_dir(&slug, "");
assert!(
apply(&env, repo_a.path(), &[]).status.success(),
"admin claim apply should succeed"
);
// A member granted `editor` (not app_admin/group_admin) on the group.
let m = member::member_user(fx, &common::unique_username("ta"));
common::pic_as(&env)
.args(["groups", "members", "add", &slug, &m.id, "--role", "editor"])
.output()
.expect("add group member");
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
// The member's repo B (a distinct project key) declares a group var (so its
// write cap is satisfied) and attempts a takeover → 403 at the GroupAdmin
// gate, before any write lands.
let repo_b = group_dir(&slug, "\n[vars]\nregion = \"eu\"\n");
let out = apply(&member_env, repo_b.path(), &["--takeover"]);
assert!(
!out.status.success(),
"non-admin --takeover must be forbidden"
);
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("forbidden") || err.contains("403"),
"takeover denial should be an authz error:\n{err}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn prune_deletes_owned_undeclared_group_only() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let parent = common::unique_slug("own-par");
let child = common::unique_slug("own-child");
// Drop order: child (registered last → dropped first), then parent.
let _gp = GroupGuard::new(&env.url, &env.token, &parent);
let _gc = GroupGuard::new(&env.url, &env.token, &child);
create_group(&env, &parent, None);
create_group(&env, &child, Some(&parent));
// Repo declares parent (root manifest) + child (a nested dir); apply claims
// both for this project.
let dir = TempDir::new().expect("tempdir");
fs::write(
dir.path().join("picloud.toml"),
format!("[group]\nslug = \"{parent}\"\nname = \"Parent\"\n"),
)
.unwrap();
fs::create_dir_all(dir.path().join("sub")).unwrap();
fs::write(
dir.path().join("sub/picloud.toml"),
format!("[group]\nslug = \"{child}\"\nname = \"Child\"\n"),
)
.unwrap();
let out = apply(&env, dir.path(), &[]);
assert!(
out.status.success(),
"initial claim apply failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let groups = ls_groups(&env);
assert!(
groups.contains(&parent) && groups.contains(&child),
"both groups should exist after claim"
);
// Drop the child manifest from the SAME repo (keeping its project key) so
// the child becomes owned-but-undeclared, then apply --prune: the empty
// child is deleted; the parent (still declared) is kept.
fs::remove_dir_all(dir.path().join("sub")).unwrap();
let out2 = apply(&env, dir.path(), &["--prune", "--yes"]);
assert!(
out2.status.success(),
"prune apply failed: {}",
String::from_utf8_lossy(&out2.stderr)
);
let groups = ls_groups(&env);
assert!(groups.contains(&parent), "declared parent must be kept");
assert!(
!groups.contains(&child),
"owned-but-undeclared child must be pruned"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn prune_refuses_to_cascade_a_groups_shared_data() {
// §7 M3 data-loss guard: an owned-but-undeclared group is NOT structurally
// pruned if it holds §11.6 shared collections/secrets (those CASCADE) — it
// is kept with a warning instead of being silently vaporized.
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let parent = common::unique_slug("own-dpar");
let child = common::unique_slug("own-dchild");
let _gp = GroupGuard::new(&env.url, &env.token, &parent);
let _gc = GroupGuard::new(&env.url, &env.token, &child);
create_group(&env, &parent, None);
create_group(&env, &child, Some(&parent));
// Declare parent + child; the child declares a shared KV collection, so the
// first apply claims both AND creates a group-owned collection marker.
let dir = TempDir::new().expect("tempdir");
fs::write(
dir.path().join("picloud.toml"),
format!("[group]\nslug = \"{parent}\"\nname = \"Parent\"\n"),
)
.unwrap();
fs::create_dir_all(dir.path().join("sub")).unwrap();
fs::write(
dir.path().join("sub/picloud.toml"),
format!("[group]\nslug = \"{child}\"\nname = \"Child\"\n\ncollections = [\"catalog\"]\n"),
)
.unwrap();
assert!(
apply(&env, dir.path(), &[]).status.success(),
"initial claim+collection apply should succeed"
);
// Drop the child node, then prune: the child owns a shared collection, so it
// must be KEPT (with a warning), not cascaded away.
fs::remove_dir_all(dir.path().join("sub")).unwrap();
let out = apply(&env, dir.path(), &["--prune", "--yes"]);
assert!(
out.status.success(),
"prune apply should succeed (skipping the protected group): {}",
String::from_utf8_lossy(&out.stderr)
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("shared collections"),
"prune should warn it skipped a group owning shared data:\n{combined}"
);
assert!(
ls_groups(&env).contains(&child),
"a group owning shared collections must NOT be pruned"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn legacy_apply_without_project_key_ignores_ownership() {
// Regression (§7): a tree apply with NO project key (legacy / direct-API
// caller) must skip ALL ownership — no conflicts, no claims. A bug gated the
// conflict classification on `our_project_id` (None for a keyless caller),
// so ANY already-owned declared group was spuriously rejected. Drive the raw
// wire without `project_key` (the CLI always sends one).
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("own-legacy");
let _g = GroupGuard::new(&env.url, &env.token, &slug);
create_group(&env, &slug, None);
// Repo A claims the group (a normal CLI apply — sends a project key).
let repo_a = group_dir(&slug, "");
assert!(
apply(&env, repo_a.path(), &[]).status.success(),
"claim apply should succeed"
);
// A keyless raw apply of the same (now-owned) group must NOT 409 — ownership
// is simply not evaluated without a project key.
let http = reqwest::blocking::Client::new();
let body = serde_json::json!({
"bundle": { "nodes": [{ "kind": "group", "slug": slug, "bundle": {} }] },
"prune": false,
});
let resp = http
.post(format!("{}/api/v1/admin/tree/apply", env.url))
.bearer_auth(&env.token)
.json(&body)
.send()
.expect("tree apply");
assert!(
resp.status().is_success(),
"a keyless (legacy) apply must ignore ownership, not conflict (got {}: {})",
resp.status(),
resp.text().unwrap_or_default()
);
}

View File

@@ -1,169 +0,0 @@
//! §6 M2 — structural-divergence detection + declarative reparent.
//!
//! Once the manifest declares a group's parent (by directory nesting), a
//! `pic apply --dir` compares it to the server's actual parent. A divergence is
//! REFUSED by default (Terraform detect-and-refuse); `--force-local-structure`
//! reparents the group to match the manifest, `--adopt-server-structure` keeps
//! the server's placement and reconciles content in place. `pic plan` previews
//! the divergence first.
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::GroupGuard;
/// A repo that declares `mover` NESTED under `alt` (so its manifest parent is
/// `alt`), with `alt` at the attach point `base`.
fn diverging_repo(dir: &Path, project: &str, base: &str, alt: &str, mover: &str) {
fs::write(
dir.join("picloud.toml"),
format!(
"[project]\nslug = \"{project}\"\nparent_group = \"{base}\"\n\n\
[group]\nslug = \"{alt}\"\nname = \"Alt\"\n"
),
)
.unwrap();
let sub = dir.join("mover");
fs::create_dir_all(&sub).unwrap();
fs::write(
sub.join("picloud.toml"),
format!("[group]\nslug = \"{mover}\"\nname = \"Mover\"\n"),
)
.unwrap();
}
fn server_parent(env: &common::TestEnv, group: &str) -> String {
let ls = common::pic_as(env)
.args(["groups", "ls"])
.output()
.expect("groups ls");
let table = String::from_utf8(ls.stdout).unwrap();
table
.lines()
.map(common::cells)
.find(|c| c.first() == Some(&group))
.and_then(|c| c.get(2).map(|s| (*s).to_string()))
.unwrap_or_else(|| panic!("group `{group}` not in groups ls:\n{table}"))
}
/// Pre-create base → {alt, mover} (both directly under base), so a repo that
/// nests `mover` under `alt` diverges.
fn seed(env: &common::TestEnv, base: &str, alt: &str, mover: &str) {
common::pic_as(env)
.args(["groups", "create", base])
.assert()
.success();
common::pic_as(env)
.args(["groups", "create", alt, "--parent", base])
.assert()
.success();
common::pic_as(env)
.args(["groups", "create", mover, "--parent", base])
.assert()
.success();
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn divergence_refused_by_default_then_force_local_reparents() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let base = common::unique_slug("sd-base");
let alt = common::unique_slug("sd-alt");
let mover = common::unique_slug("sd-mover");
let project = common::unique_slug("sd-proj");
let _b = GroupGuard::new(&env.url, &env.token, &base);
let _a = GroupGuard::new(&env.url, &env.token, &alt);
let _m = GroupGuard::new(&env.url, &env.token, &mover);
seed(&env, &base, &alt, &mover);
let dir = TempDir::new().unwrap();
diverging_repo(dir.path(), &project, &base, &alt, &mover);
// Plan previews the divergence (server parent `base`, manifest parent `alt`).
let plan = String::from_utf8(
common::pic_as(&env)
.args(["plan", "--dir"])
.arg(dir.path())
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
plan.contains("diverged") && plan.contains(&base) && plan.contains(&alt),
"plan should show `mover` diverged (server={base}, manifest={alt}):\n{plan}"
);
// A bare apply is REFUSED on the divergence (422).
let out = common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.output()
.expect("apply");
assert!(!out.status.success(), "a divergence must refuse by default");
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("structural")
|| err.contains("force-local")
|| err.contains("different parent"),
"the refusal must name the resolution flags:\n{err}"
);
assert_eq!(
server_parent(&env, &mover),
base,
"a refused apply must not move the group"
);
// `--force-local-structure` reparents `mover` to `alt` (the manifest shape).
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.arg("--force-local-structure")
.assert()
.success();
assert_eq!(
server_parent(&env, &mover),
alt,
"--force-local-structure must reparent to the manifest's parent"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn adopt_server_structure_keeps_the_server_placement() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let base = common::unique_slug("sda-base");
let alt = common::unique_slug("sda-alt");
let mover = common::unique_slug("sda-mover");
let project = common::unique_slug("sda-proj");
let _b = GroupGuard::new(&env.url, &env.token, &base);
let _a = GroupGuard::new(&env.url, &env.token, &alt);
let _m = GroupGuard::new(&env.url, &env.token, &mover);
seed(&env, &base, &alt, &mover);
let dir = TempDir::new().unwrap();
diverging_repo(dir.path(), &project, &base, &alt, &mover);
// `--adopt-server-structure` proceeds without reparenting — `mover` stays
// under `base` (its server placement), and content reconciles in place.
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.arg("--adopt-server-structure")
.assert()
.success();
assert_eq!(
server_parent(&env, &mover),
base,
"--adopt-server-structure must keep the server's parent"
);
}

View File

@@ -13,31 +13,30 @@ use picloud_manager_core::{
admin_router, admins_router, api_keys_router, app_members_router, apply_router, apps_api,
apps_router, attach_principal_if_present, auth_router, dead_letters_router, dev_emails_router,
email_inbound_router, files_admin_router, groups_router, kv_admin_router, migrations,
projects_router, rebuild_route_table, require_authenticated, route_admin_router,
secrets_router, topics_router, triggers_router, vars_router, AbandonedRepo,
AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState,
AppRepository, ApplyService, AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState,
DevEmailState, Dispatcher, DocsServiceImpl, EmailInboundState, EmailServiceImpl,
FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, FsGroupFilesRepo,
GroupDocsServiceImpl, GroupFilesServiceImpl, GroupKvServiceImpl, GroupMembersRepository,
GroupPubsubServiceImpl, GroupQueueServiceImpl, GroupRepository, GroupsState, HttpConfig,
HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter,
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo,
PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo,
PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo,
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
PostgresExecutionLogSink, PostgresGroupCollectionResolver, PostgresGroupDocsRepo,
PostgresGroupKvRepo, PostgresGroupMembersRepository, PostgresGroupQueueRepo,
PostgresGroupRepository, PostgresKvRepo, PostgresOutboxRepo, PostgresProjectRepository,
PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo,
PostgresTopicRepo, PostgresTriggerRepo, PostgresVarsRepo, PrincipalResolver, ProjectRepository,
ProjectsState, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState,
SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState,
SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState,
UsersServiceConfig, UsersServiceImpl, VarsApiState, VarsServiceImpl,
rebuild_route_table, require_authenticated, route_admin_router, secrets_router, topics_router,
triggers_router, vars_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository,
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApplyService,
AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher,
DocsServiceImpl, EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig,
FilesServiceImpl, FsFilesRepo, FsGroupFilesRepo, GroupDocsServiceImpl, GroupFilesServiceImpl,
GroupKvServiceImpl, GroupMembersRepository, GroupPubsubServiceImpl, GroupQueueServiceImpl,
GroupRepository, GroupsState, HttpConfig, HttpServiceImpl, InboundNonceDedup, KvAdminState,
KvServiceImpl, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo,
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository,
PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository,
PostgresAppSecretsRepo, PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo,
PostgresAppUserRepository, PostgresAppUserRoleRepo, PostgresAppUserSessionRepository,
PostgresAppUserVerificationRepo, PostgresDeadLetterRepo, PostgresDeadLetterService,
PostgresDocsRepo, PostgresExecutionLogRepository, PostgresExecutionLogSink,
PostgresGroupCollectionResolver, PostgresGroupDocsRepo, PostgresGroupKvRepo,
PostgresGroupMembersRepository, PostgresGroupQueueRepo, PostgresGroupRepository,
PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository,
PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo,
PostgresVarsRepo, PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver,
RouteAdminState, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl,
SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo,
TriggersState, UsersServiceConfig, UsersServiceImpl, VarsApiState, VarsServiceImpl,
};
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
@@ -117,8 +116,6 @@ pub async fn build_app(
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
let groups_repo: Arc<dyn GroupRepository> =
Arc::new(PostgresGroupRepository::new(pool.clone()));
let projects_repo: Arc<dyn ProjectRepository> =
Arc::new(PostgresProjectRepository::new(pool.clone()));
let group_members_repo: Arc<dyn GroupMembersRepository> =
Arc::new(PostgresGroupMembersRepository::new(pool.clone()));
let domains_repo: Arc<dyn AppDomainRepository> =
@@ -540,7 +537,6 @@ pub async fn build_app(
vars: Arc::new(PostgresVarsRepo::new(pool.clone())),
apps: apps_repo.clone(),
groups: groups_repo.clone(),
projects: projects_repo.clone(),
modules: modules.clone(),
domains: domains_repo.clone(),
authz: authz.clone(),
@@ -698,9 +694,6 @@ pub async fn build_app(
.merge(api_keys_router(api_keys_state))
.merge(triggers_router(triggers_state))
.merge(apply_router(apply_service))
.merge(projects_router(ProjectsState {
projects: projects_repo.clone(),
}))
.merge(picloud_manager_core::queues_api::queues_router(
picloud_manager_core::queues_api::QueuesState {
queues: queue_repo.clone(),

View File

@@ -891,12 +891,12 @@ async fn version_includes_public_base_url(pool: PgPool) {
assert!(v["public_base_url"].is_string());
assert_eq!(v["api"], 1);
// `schema` is migrations::latest_version() — the highest embedded
// migration number, currently 66 (…0065_group_queues, then
// 0066_projects wiring the §7 multi-repo ownership seam). This test is
// migration number, currently 65 (…0064_shared_topic_queue_kinds,
// 0065_group_queues from the v1.2 D2/D3 deferrals). This test is
// #[ignore]-gated so it doesn't run in the default `cargo test`; pinned to
// current reality so an unintended schema change is still caught. Bump it
// whenever a migration lands.
assert_eq!(v["schema"], 66);
assert_eq!(v["schema"], 65);
assert_eq!(v["sdk"], "1.10");
}

View File

@@ -11,7 +11,7 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{GroupId, ProjectId};
use crate::GroupId;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Group {
@@ -26,10 +26,6 @@ pub struct Group {
/// Per-subtree structure version, bumped on every structural mutation
/// (reparent/rename/delete). Not an authz input.
pub structure_version: i64,
/// §7 multi-repo ownership: the project that declaratively manages this
/// node, or `None` when the node is UI/API-owned (no manifest claims it).
/// Apps inherit ownership from their nearest claimed ancestor group.
pub owner_project: Option<ProjectId>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

View File

@@ -58,4 +58,3 @@ id_type!(AppUserId);
id_type!(InvitationId);
id_type!(QueueMessageId);
id_type!(GroupId);
id_type!(ProjectId);

View File

@@ -29,7 +29,6 @@ pub mod kv;
pub mod log_sink;
pub mod modules;
pub mod outbox_writer;
pub mod project;
pub mod pubsub;
pub mod queue;
pub mod realtime;
@@ -78,8 +77,8 @@ pub use group_pubsub::{GroupPubsubError, GroupPubsubService, NoopGroupPubsubServ
pub use group_queue::{GroupQueueError, GroupQueueService, NoopGroupQueueService};
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
pub use ids::{
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, ProjectId,
QueueMessageId, RequestId, ScriptId, TriggerId,
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, QueueMessageId,
RequestId, ScriptId, TriggerId,
};
pub use inbox::{
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
@@ -91,7 +90,6 @@ pub use modules::{
ModuleResolution, ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource,
};
pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError};
pub use project::Project;
pub use pubsub::{
topic_matches, validate_topic_pattern, NoopPubsubService, PubsubError, PubsubService,
};

View File

@@ -1,27 +0,0 @@
//! Projects: the §7 multi-repo ownership registry.
//!
//! A project is a repo-root that declaratively MANAGES a slice of the shared
//! group tree. Each group node is owned by exactly one project (recorded in
//! `groups.owner_project`); apps inherit ownership from their nearest claimed
//! ancestor group. Identity is a committed `[project] slug` — stable across
//! clones; the server assigns the UUID.
//!
//! See docs/design/groups-and-project-tool.md §7.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{AdminUserId, ProjectId};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub id: ProjectId,
/// Instance-global identity, declared in the repo's root manifest and
/// frozen at registration.
pub slug: String,
pub name: String,
/// The admin who first registered the project (`None` once their account
/// is deleted — the FK is `ON DELETE SET NULL`).
pub created_by: Option<AdminUserId>,
pub created_at: DateTime<Utc>,
}

View File

@@ -764,94 +764,6 @@ non-orphaning:
**Corollary:** don't co-own a node — split config downward. Shared config lives *higher* (owned by a
platform/shared repo attaching at root); team-specific bits go into subgroups each team owns.
**Status — M1 shipped (the ownership claim).** The `owner_project` seam (0047) is now live, backed by a
first-class `projects` table (`0066`, UUID pk + unique slug; `owner_project` FKs it `ON DELETE SET NULL`).
A `[project]` block (slug + optional name) in the repo's root manifest declares identity; the first apply
with a new slug registers the project and **claims** each group node it touches. The claim is a gate run
inside the apply transaction, under the per-node advisory lock, *before* the diff — so a conflict
short-circuits with a 409 before any write. Pure policy (`decide_group_claim` / `decide_app_owner` in
`apply_service`): unclaimed→claim, owner→no-op, foreign→conflict unless `--takeover` (which additionally
requires `GroupAdmin` — ownership ⟂ RBAC), no-project-into-a-claimed-subtree→conflict. Apps carry no
`owner_project`; an app inherits ownership from its **nearest claimed ancestor group** (the ancestor walk
is the boundary), and an unclaimed subtree stays open (backward-compatible — nothing changes until a repo
first declares `[project]`). Visibility: `pic groups ls` shows an `owner` column; `--takeover` on `pic
apply`. Pinned by `apply_service` unit tests + the `apply_ownership` journey.
**Status — M2 shipped (the attach-point ceiling, point 2).** A `[project] parent_group = "<slug>"` binds the
repo UNDER a pre-existing group; applies are refused (422 `OutsideAttachPoint`) for any node not strictly
within that subtree. `check_within_attach` requires the attach group be a **proper** ancestor of a group node
(so you can't apply the attach point itself, only its descendants) or an ancestor (inclusive) of an app node's
group — resolved via `groups.ancestors`, enforced read-only before the claim in both `apply_owner` and
`apply_tree`. Absent = instance root = no ceiling (default). Pinned by the `apply_ownership` journey's
attach-ceiling case.
**Status — M3 shipped (plan preview + `pic projects ls`).** `pic plan` now carries the declaring `[project]`
and returns an `ownership` preview per node — `claim` / `owned` / `conflict` (owner named) / `unclaimed`
(pure `preview_ownership`, unit-tested) — plus, for a group node, the cross-repo **blast radius**: descendant
apps owned by OTHER projects the change fans out to (`group_blast_radius`, a subtree CTE with per-group
memoized nearest-claimed resolution). The attach ceiling is previewed at plan too (consistent with apply).
Read-only `pic projects ls` lists every project + its owned-group count (`GET /api/v1/admin/projects`,
`list_with_counts`). Pinned by the `apply_ownership` journey's plan-preview case. **With M3, the multi-repo
ownership track (§7 M1M3) is complete. Deferred (separate later work):** §6 structural-divergence detection
(`--adopt-server-structure` / `--force-local-structure`); declarative group create/reparent (lifting "groups
pre-exist"); per-env approval gating (`[project.environments]`).
**§6 group-tree M1 shipped — declarative group CREATE.** A `[group]` node's PARENT is now inferred from
directory nesting (the nearest ancestor directory holding a `[group]`; the topmost group binds to the repo's
`[project] parent_group` attach point, else the instance root). `discover::build_tree` emits `parent` (plus the
group's `name`/`description`) per group node, sorted parents-first by directory depth; `TreeNode` gained those
fields (`#[serde(default)]`, so a pre-§6 CLI still reconciles existing groups). Server-side, `apply_tree` runs a
**Phase 0** (`create_missing_groups_tx`) that resolves-or-creates every group node IN the apply tx
(parents-first, a same-tree parent resolved before its child via `read_group_id_by_slug_tx`), under the coarse
`GROUP_STRUCTURAL_LOCK_KEY` (taken only when it creates), enforcing the **attach-point ceiling** and **RBAC**
(`InstanceCreateGroup` at the root / `GroupAdmin(parent)` for a subgroup) per create; the group is then CLAIMED
in Phase A alongside existing groups (a fresh group is `decide_group_claim``Claim`). `prepare_tree` gained a
caller-supplied `resolved_ids` map (existing + just-created) and returns a `to_create` list the plan path
previews (empty current → a full-create plan; ownership → `claim`) — a to-create group's token part is identical
to its post-create part, so plan/apply tokens match across a create. `authz_tree` skips a to-create group (its
authorization is the service-side create gate). Reparent of an EXISTING diverged group + the detect-and-refuse
flags are M2. Pinned by `tests/group_create.rs`.
**§6 group-tree M2 shipped — structural-divergence detection + declarative reparent.** With the declared parent now
on the wire, `apply_tree` compares each EXISTING group node's server parent against the manifest's (directory-
nesting) parent. A divergence is resolved by a request `StructureMode` (default `Refuse`, so a pre-M2 CLI never
reshapes the tree): `Refuse``ApplyError::StructuralDivergence` (422, naming the resolution flags); `ForceLocal`
→ reparent to the declared parent IN the apply tx via the extracted `group_repo::reparent_group_tx` (cycle guard +
`structure_version` bump), §5.6-gated (`GroupAdmin` on the node + source + destination) and attach-ceilinged;
`AdoptServer` → keep the server shape, reconcile content in place. M1's `create_missing_groups_tx` generalized to
`reconcile_group_structure_tx` (create + reparent share the coarse-lock / attach / RBAC path; both are recorded as
`structurally_changed` so the pool-based attach re-check skips their uncommitted rows). `pic plan` previews the
divergence (`divergence_preview` → a `structure` row: `diverged`, server + manifest parents). CLI:
`--force-local-structure` / `--adopt-server-structure` (mutually exclusive) → the `structure_mode` wire field; the
422 surfaces verbatim. A concurrent `pic groups reparent` between plan and apply still trips `StateMoved` (the tree
token folds `structure_version`). Pinned by `tests/structural_divergence.rs`.
**§3 M3 shipped — per-env approval gating (`[project.environments]`).** A `[project.environments]` block on the
root `[project]` maps an env name to `{ confirm = bool }` (`ManifestProject.environments`, `#[serde(skip_serializing)]`
— purely CLI-side). `pic apply --env <e>` is REFUSED (client-side, before any request) when `<e>` is confirm-required
unless it is explicitly `--approve <e>`d; a blanket `--yes` does NOT cover a gated env (§4.2 "CI must opt in per
environment"); an unlisted / `confirm = false` env applies freely. `require_env_approval` gates both the single
(`run`) and tree (`run_tree`) apply paths; `--approve <env>` is a repeatable flag. Value overlays
(`picloud.<env>.toml`) already merge client-side, so this milestone is the confirm-policy gate only. Pinned by
`tests/env_approval.rs`. **Deferred (documented, not built):** server-side audited approval-override capability
(§4.2 → `authz::can`); env-as-app mapping + declarative server-side `@E` config scoping.
**§6/§3 group-tree Tier 1 (M1 create · M2 divergence/reparent · M3 per-env approval) is COMPLETE.**
**Review follow-ups (post-Tier-1 hardening).** A code review of the Tier-1 branch surfaced four fixes: (1)
M1's `authz_tree` skip for a to-create group opened a race window (a group created by a racer between the
API-layer authz check and the in-tx reconcile could have its content written unauthorized) — closed by an
in-tx content-write re-check in `apply_tree` for every group NOT structurally created/reparented by that apply
(`require_group_node_writes` moved from `apply_api` onto `ApplyService`; a group WE create is covered by
create-RBAC and its uncommitted row is invisible to the authz cascade, so it's skipped). (2) The M3 approval
gate could silently fail open — now `EnvPolicy.confirm` is a REQUIRED field (an empty `prod = {}` is a load
error, not a silent no-gate), a single-file `pic apply --file <leaf>` discovers the governing `[project]` by
walking up the directory tree, and a non-root `[project].environments` is dropped with an explicit warning.
(3) Divergence detection is unified — the plan-path `divergence_preview` reuses the already-loaded group rows
(no per-node re-fetch) and compares parent slugs case-insensitively so it can't disagree with the apply path's
id-based check. (4) `reconcile_group_structure_tx`'s create/reparent branches are extracted into helpers.
Pinned by new cases in `tests/group_create.rs` + `tests/env_approval.rs`.
---
## 8. Diagrams
@@ -1208,14 +1120,34 @@ Resolved items now live inline next to their topic. What genuinely remains:
5. **Project tool maps onto groups.** Nested manifests, attach point, single-owner, server-computed
tree plan, per-env approval gating.
> **Status (Phase 5): ✅ shipped — single-repo nested tree apply, atomic.** A directory tree of
> `picloud.toml` manifests (each declaring an `[app]` or `[group]` node) applies as ONE
> server-computed plan in ONE Postgres transaction. Per the §11.1 review, the **multi-repo
> single-owner / attach-point / takeover** layer (§7) is **deferred** for the solo-dev / single-repo
> start, as is **per-env approval-policy gating** (`[project.environments]`); env overlays
> (`picloud.<env>.toml`) already exist. Groups must **pre-exist** (`pic groups create`) — a manifest
> owns each node's *content*, not the tree *shape* (declarative group create/reparent is a later
> add).
> **Status (Phase 5): ✅ shipped — nested tree apply, atomic, with multi-repo single-owner
> ownership.** A directory tree of `picloud.toml` manifests (each declaring an `[app]` or `[group]`
> node) applies as ONE server-computed plan in ONE Postgres transaction. **Multi-repo single-owner
> ownership + takeover shipped (§7, M3, 2026-07-05)** — each repo mints a stable, gitignored project
> key in `.picloud/project.json`; the first `pic apply --dir` to touch a group **claims** it
> (`groups.owner_project` FK → the new `projects` table, migration 0066 promoting the inert 0047
> column). A second repo (a distinct key) applying an owned group is **refused** (`owned by another
> project; use --takeover`) unless it passes `--takeover`, which is **GroupAdmin-gated per contested
> node** — re-verified **in-tx** at the ownership decision (not only in the pre-tx pool read), so
> `--force` (which waives the staleness token) can't open a takeover-without-admin window. Ownership ⟂
> RBAC: owning the manifest never grants write — the actor still needs the usual group caps. The owner
> key folds into the bound-plan token (a claim/takeover by another repo between plan and apply trips
> `StateMoved`), and the attacker-supplied key is length/charset-validated server-side. `apply --prune`
> **structurally reaps** groups THIS project owns that the manifest no longer declares, leaf-first
> (`delete_group_tx` RESTRICT — a group still holding apps/subgroups aborts the apply, never orphans),
> and never touches another repo's or an unclaimed (UI-owned) group. `pic plan --dir` surfaces
> conflicts + prune candidates read-only. **Deferred:** the declarative **attach-point** / a single
> repo *creating* the group tree (groups still pre-exist — a manifest owns each node's *content*, not
> the tree *shape*). **Per-env approval-policy gating shipped (`[project.environments]`, M5, 2026-07-05)** — the
> root manifest declares which environments are confirm-required; `pic apply --dir --env <e>` to such
> an env needs an explicit `--approve <e>` (a blanket `--yes` does NOT cover it), the approval is
> admin-gated (the actor needs admin on every declared node, not just write) + audited, and the policy
> folds into the bound-plan token. The server re-derives the policy from the bundle (authoritative);
> single-node `apply --file --env <e>` refuses a gated env (can't carry the admin-gated approval) and
> directs to `--dir`. Like `--takeover`/blast-radius the policy lives in the manifest (not persisted),
> an accepted trust boundary. Env overlays (`picloud.<env>.toml`) already exist. Groups must
> **pre-exist** (`pic groups create`) — a manifest owns each node's *content*, not the tree *shape*
> (declarative group create/reparent is a later add).
>
> Shipped surface:
> - **Engine:** the reconcile engine generalized from app-only to an `ApplyOwner { App | Group }`
@@ -1331,10 +1263,12 @@ to take, not yet taken:
- *Trigger/route templates (§4.5):* bundled into Phase 6 as "much later," but the per-app binding
tax bites in Phase 5 at high tenant cardinality (100 tenants × 5 triggers = 500 declarations).
Decide against **actual** target tenant counts before defaulting it late.
- *Multi-repo ownership (§7):* the single-owner / attach-point / takeover machinery is substantial
and only earns its keep with a **second** managing repo. For a solo-dev / single-repo start, a
"one repo owns the whole subtree" model covers the near term; confirm the multi-repo need exists
before building the ownership layer.
- *Multi-repo ownership (§7):* the single-owner / takeover machinery is substantial and only earns
its keep with a **second** managing repo. For a solo-dev / single-repo start, a "one repo owns the
whole subtree" model covers the near term. **Resolved: shipped as M3 (2026-07-05)** — single-owner
claim / conflict / `--takeover` (GroupAdmin-gated) + structural prune, over a `projects` table and
the promoted `groups.owner_project` FK. The declarative **attach-point** (a repo *creating* the
group tree, not just claiming pre-existing groups) remains deferred.
---