feat(project-tool): multi-repo single-owner ownership + takeover (§7 M3)
Ports the M3 milestone from the superseded feat/hierarchies branch onto main's evolved apply engine. A group node is authoritatively managed by at most one project-root; `pic apply --dir` claims, conflicts, takes over, and (with --prune) structurally reaps owned nodes. - Migration 0066: `projects(id, key UNIQUE)` + promote the inert `groups.owner_project` (0047) to a real FK (ON DELETE SET NULL) + index. - CLI mints a stable, gitignored project key in `.picloud/project.json` (`pic init`, or lazily on first tree plan/apply) and presents it on every tree request; `pic apply --dir --takeover` flag; `pic plan --dir` surfaces ownership conflicts + structural-prune candidates read-only. - Server: prepare_tree resolves ownership read-only (token folds each group's owner key, so a claim/takeover by another repo between plan and apply trips StateMoved). apply_tree upserts the project in-tx, claims unclaimed declared groups, and takes over owned ones under `--takeover`. --prune reaps owned-but-undeclared groups leaf-first (delete_group_tx RESTRICT — never another repo's or a UI-owned node). - Authz (§7.4, ownership ⟂ RBAC): takeover requires GroupAdmin per contested node — enforced in authz_tree (pre-tx) AND re-verified in-tx at the ownership decision, so --force (which waives the staleness token) can't open a takeover-without-admin window. The attacker-supplied project key is length/charset-validated server-side. Adaptation to main (which lacks the superseded branch's M2 declarative group create/reparent): groups pre-exist, so ownership stamps existing declared nodes rather than claim-on-create; the declarative attach-point is deferred. Review fixes (adversarial pass, 3 findings, all closed): - HIGH: an empty `[group]` node was claimed capability-free — require a baseline GroupScriptsWrite (editor) on every group apply node, so claiming ownership needs write authority (Ownership ⟂ RBAC). - MEDIUM: structural prune would silently CASCADE a group's §11.6 shared collections + secrets (which postdate M3's original design). Guard: a prune candidate holding shared data/secrets is KEPT with a warning (plus its candidate ancestors, so a preserved child never aborts a parent delete). - LOW: a corrupt `.picloud/project.json` silently re-minted a new key (orphaning ownership) — now fails loudly if the file exists but won't parse. Tests: tests/ownership.rs (claim → conflict → takeover → flip; non-admin takeover → 403; prune-owned-only; prune-refuses-to-cascade-shared-data); format_conflicts + validate_project_key unit tests; schema golden reblessed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -1262,17 +1262,29 @@ impl Client {
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/tree/plan` — diff a whole project tree (Phase 5).
|
||||
pub async fn plan_tree(&self, bundle: &serde_json::Value) -> Result<TreePlanDto> {
|
||||
/// `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_key: Option<&str>,
|
||||
) -> Result<TreePlanDto> {
|
||||
let body = serde_json::json!({
|
||||
"bundle": bundle,
|
||||
"project_key": project_key,
|
||||
});
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/tree/plan")
|
||||
.json(bundle)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `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,
|
||||
@@ -1280,6 +1292,8 @@ impl Client {
|
||||
expected_token: Option<&str>,
|
||||
env: Option<&str>,
|
||||
approved_envs: &[String],
|
||||
project_key: Option<&str>,
|
||||
allow_takeover: bool,
|
||||
) -> Result<ApplyReportDto> {
|
||||
let body = serde_json::json!({
|
||||
"bundle": bundle,
|
||||
@@ -1287,6 +1301,8 @@ impl Client {
|
||||
"expected_token": expected_token,
|
||||
"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")
|
||||
@@ -1296,6 +1312,12 @@ impl Client {
|
||||
if resp.status() == reqwest::StatusCode::CONFLICT {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let msg = parse_error_body(&body).unwrap_or(body);
|
||||
// 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`."
|
||||
@@ -1538,6 +1560,19 @@ pub struct TreePlanDto {
|
||||
/// 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)]
|
||||
@@ -1599,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>,
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ pub async fn run_tree(
|
||||
force: bool,
|
||||
env: Option<&str>,
|
||||
approve: &[String],
|
||||
takeover: bool,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
@@ -159,6 +160,11 @@ pub async fn run_tree(
|
||||
// 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
|
||||
@@ -169,13 +175,28 @@ pub async fn run_tree(
|
||||
};
|
||||
|
||||
let report = client
|
||||
.apply_tree(&bundle, prune, expected_token.as_deref(), env, &approved)
|
||||
.apply_tree(
|
||||
&bundle,
|
||||
prune,
|
||||
expected_token.as_deref(),
|
||||
env,
|
||||
&approved,
|
||||
Some(&project_key),
|
||||
takeover,
|
||||
)
|
||||
.await?;
|
||||
crate::linkstate::clear_plan(dir, token_key);
|
||||
|
||||
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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,7 +50,10 @@ 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, _envs) = crate::discover::build_tree(dir, env)?;
|
||||
let plan = client.plan_tree(&bundle).await?;
|
||||
// Mint/read this repo's stable project key (§7, M3) so the plan can surface
|
||||
// ownership conflicts + structural-prune candidates for us.
|
||||
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}");
|
||||
@@ -96,6 +99,24 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
||||
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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -255,6 +255,11 @@ struct ApplyArgs {
|
||||
/// 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)]
|
||||
@@ -1428,6 +1433,7 @@ async fn main() -> ExitCode {
|
||||
args.force,
|
||||
args.env.as_deref(),
|
||||
&args.approve,
|
||||
args.takeover,
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -36,6 +36,7 @@ mod init;
|
||||
mod invoke;
|
||||
mod logs;
|
||||
mod output;
|
||||
mod ownership;
|
||||
mod plan;
|
||||
mod prune;
|
||||
mod pull;
|
||||
|
||||
288
crates/picloud-cli/tests/ownership.rs
Normal file
288
crates/picloud-cli/tests/ownership.rs
Normal file
@@ -0,0 +1,288 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user