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>
133 lines
5.2 KiB
Rust
133 lines
5.2 KiB
Rust
//! `.picloud/` link state — gitignored, per-project metadata the project tool
|
|
//! carries between CLI invocations. Today it holds just the bound-plan token:
|
|
//! `pic plan` records the fingerprint of the live state it diffed against, and
|
|
//! `pic apply` replays it so the server can refuse if the app moved underneath.
|
|
//!
|
|
//! All paths are relative to the manifest's directory (the project root).
|
|
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Context, Result};
|
|
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)]
|
|
pub struct PlanLink {
|
|
/// App slug the token belongs to — guards against replaying a token from a
|
|
/// different app if the manifest's `slug` changed.
|
|
pub app: String,
|
|
pub state_token: String,
|
|
}
|
|
|
|
fn plan_path(base: &Path) -> PathBuf {
|
|
base.join(DIR).join(PLAN_FILE)
|
|
}
|
|
|
|
/// Record the bound-plan token for `app` under `base/.picloud/`.
|
|
pub fn write_plan(base: &Path, app: &str, state_token: &str) -> Result<()> {
|
|
let dir = base.join(DIR);
|
|
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
|
|
// Self-ignore: a `.gitignore` of `*` inside `.picloud/` keeps the whole
|
|
// dir out of git regardless of the project root's `.gitignore` — so this
|
|
// is safe even when reached via `pic plan`/`pull` (which, unlike `init`,
|
|
// don't touch the root `.gitignore`).
|
|
let ignore = dir.join(".gitignore");
|
|
if !ignore.exists() {
|
|
fs::write(&ignore, "*\n").context("writing .picloud/.gitignore")?;
|
|
}
|
|
let link = PlanLink {
|
|
app: app.to_string(),
|
|
state_token: state_token.to_string(),
|
|
};
|
|
let body = serde_json::to_vec_pretty(&link).context("encoding .picloud/plan.json")?;
|
|
fs::write(plan_path(base), body).context("writing .picloud/plan.json")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Read the recorded plan token, if any. Returns `None` when absent or
|
|
/// unreadable (treated as "no prior plan" — never an error).
|
|
#[must_use]
|
|
pub fn read_plan(base: &Path) -> Option<PlanLink> {
|
|
let body = fs::read(plan_path(base)).ok()?;
|
|
serde_json::from_slice(&body).ok()
|
|
}
|
|
|
|
/// Remove the recorded plan token (best-effort) **iff it belongs to `app`**.
|
|
/// Called after a successful apply consumes it, so the next apply requires a
|
|
/// fresh plan — without clobbering a token recorded for a different app that
|
|
/// happens to share the directory.
|
|
pub fn clear_plan(base: &Path, app: &str) {
|
|
if read_plan(base).is_some_and(|l| l.app == app) {
|
|
let _ = fs::remove_file(plan_path(base));
|
|
}
|
|
}
|