Compare commits
3 Commits
main
...
feat/owner
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c68d2fa33 | ||
|
|
5c33b7490b | ||
|
|
654e38752d |
39
crates/manager-core/migrations/0066_owner_project.sql
Normal file
39
crates/manager-core/migrations/0066_owner_project.sql
Normal 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);
|
||||||
@@ -280,6 +280,16 @@ async fn group_apply_handler(
|
|||||||
// must hold the relevant read/write caps for EVERY node touched.
|
// 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)]
|
#[derive(Deserialize)]
|
||||||
struct TreeApplyRequest {
|
struct TreeApplyRequest {
|
||||||
bundle: TreeBundle,
|
bundle: TreeBundle,
|
||||||
@@ -287,15 +297,34 @@ struct TreeApplyRequest {
|
|||||||
prune: bool,
|
prune: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
expected_token: Option<String>,
|
expected_token: Option<String>,
|
||||||
|
/// Selected environment (the overlay applied). Drives per-env approval
|
||||||
|
/// gating (§4.2, M5).
|
||||||
|
#[serde(default)]
|
||||||
|
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)]
|
||||||
|
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)]
|
||||||
|
project_key: Option<String>,
|
||||||
|
/// Take over declared groups owned by another project (group-admin gated).
|
||||||
|
#[serde(default)]
|
||||||
|
allow_takeover: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn tree_plan_handler(
|
async fn tree_plan_handler(
|
||||||
State(svc): State<ApplyService>,
|
State(svc): State<ApplyService>,
|
||||||
Extension(principal): Extension<Principal>,
|
Extension(principal): Extension<Principal>,
|
||||||
Json(bundle): Json<TreeBundle>,
|
Json(req): Json<TreePlanRequest>,
|
||||||
) -> Result<Json<TreePlanResult>, ApplyError> {
|
) -> Result<Json<TreePlanResult>, ApplyError> {
|
||||||
authz_tree(&svc, &principal, &bundle, None).await?;
|
authz_tree(&svc, &principal, &req.bundle, None, false).await?;
|
||||||
Ok(Json(svc.plan_tree(&bundle).await?))
|
Ok(Json(
|
||||||
|
svc.plan_tree(&req.bundle, req.project_key.as_deref())
|
||||||
|
.await?,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn tree_apply_handler(
|
async fn tree_apply_handler(
|
||||||
@@ -303,18 +332,95 @@ async fn tree_apply_handler(
|
|||||||
Extension(principal): Extension<Principal>,
|
Extension(principal): Extension<Principal>,
|
||||||
Json(req): Json<TreeApplyRequest>,
|
Json(req): Json<TreeApplyRequest>,
|
||||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||||
authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?;
|
authz_tree(
|
||||||
|
&svc,
|
||||||
|
&principal,
|
||||||
|
&req.bundle,
|
||||||
|
Some(req.prune),
|
||||||
|
req.allow_takeover,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
enforce_env_approval(
|
||||||
|
&svc,
|
||||||
|
&principal,
|
||||||
|
&req.bundle,
|
||||||
|
req.env.as_deref(),
|
||||||
|
&req.approved_envs,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let report = svc
|
let report = svc
|
||||||
.apply_tree(
|
.apply_tree(
|
||||||
&req.bundle,
|
&req.bundle,
|
||||||
req.prune,
|
req.prune,
|
||||||
principal.user_id,
|
&principal,
|
||||||
req.expected_token.as_deref(),
|
req.expected_token.as_deref(),
|
||||||
|
req.project_key.as_deref(),
|
||||||
|
req.allow_takeover,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(report))
|
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
|
/// 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
|
/// write requirement to every kind. A plan (`prune` ignored, no writes) needs
|
||||||
/// only the per-node read cap.
|
/// only the per-node read cap.
|
||||||
@@ -323,6 +429,7 @@ async fn authz_tree(
|
|||||||
principal: &Principal,
|
principal: &Principal,
|
||||||
bundle: &TreeBundle,
|
bundle: &TreeBundle,
|
||||||
apply_prune: Option<bool>,
|
apply_prune: Option<bool>,
|
||||||
|
allow_takeover: bool,
|
||||||
) -> Result<(), ApplyError> {
|
) -> Result<(), ApplyError> {
|
||||||
for node in &bundle.nodes {
|
for node in &bundle.nodes {
|
||||||
match node.kind {
|
match node.kind {
|
||||||
@@ -346,6 +453,28 @@ async fn authz_tree(
|
|||||||
Some(prune) => {
|
Some(prune) => {
|
||||||
require_group_node_writes(svc, principal, group_id, &node.bundle, prune)
|
require_group_node_writes(svc, principal, group_id, &node.bundle, prune)
|
||||||
.await?;
|
.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 => {
|
None => {
|
||||||
require(
|
require(
|
||||||
@@ -436,7 +565,12 @@ async fn require_group_node_writes(
|
|||||||
bundle: &Bundle,
|
bundle: &Bundle,
|
||||||
prune: bool,
|
prune: bool,
|
||||||
) -> Result<(), ApplyError> {
|
) -> Result<(), ApplyError> {
|
||||||
if prune || !bundle.scripts.is_empty() {
|
// 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(
|
require(
|
||||||
svc.authz.as_ref(),
|
svc.authz.as_ref(),
|
||||||
principal,
|
principal,
|
||||||
@@ -444,7 +578,6 @@ async fn require_group_node_writes(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(map_authz)?;
|
.map_err(map_authz)?;
|
||||||
}
|
|
||||||
if prune || !bundle.vars.is_empty() {
|
if prune || !bundle.vars.is_empty() {
|
||||||
require(
|
require(
|
||||||
svc.authz.as_ref(),
|
svc.authz.as_ref(),
|
||||||
@@ -503,7 +636,9 @@ impl IntoResponse for ApplyError {
|
|||||||
StatusCode::UNPROCESSABLE_ENTITY,
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
json!({ "error": self.to_string() }),
|
json!({ "error": self.to_string() }),
|
||||||
),
|
),
|
||||||
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
Self::StateMoved | Self::ApprovalRequired(_) | Self::OwnershipConflict(_) => {
|
||||||
|
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||||
Self::AuthzRepo(e) => {
|
Self::AuthzRepo(e) => {
|
||||||
tracing::error!(error = %e, "apply authz repo error");
|
tracing::error!(error = %e, "apply authz repo error");
|
||||||
|
|||||||
@@ -32,15 +32,16 @@ use std::sync::Arc;
|
|||||||
use picloud_orchestrator_core::routing::{pattern, RouteTable};
|
use picloud_orchestrator_core::routing::{pattern, RouteTable};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AdminUserId, AppId, DispatchMode, DocsEventOp, FilesEventOp, GroupId, HostKind, KvEventOp,
|
AdminUserId, AppId, DispatchMode, DocsEventOp, FilesEventOp, GroupId, HostKind, KvEventOp,
|
||||||
MasterKey, PathKind, Route, Script, ScriptId, ScriptKind, ScriptOwner, ScriptSandbox,
|
MasterKey, PathKind, Principal, Route, Script, ScriptId, ScriptKind, ScriptOwner,
|
||||||
ScriptValidator, TriggerId,
|
ScriptSandbox, ScriptValidator, TriggerId,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::app_domain_repo::AppDomainRepository;
|
use crate::app_domain_repo::AppDomainRepository;
|
||||||
use crate::app_repo::AppRepository;
|
use crate::app_repo::AppRepository;
|
||||||
use crate::authz::AuthzRepo;
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
use crate::config_resolver::CHAIN_LEVELS_CTE;
|
use crate::config_resolver::CHAIN_LEVELS_CTE;
|
||||||
use crate::repo::{
|
use crate::repo::{
|
||||||
delete_script_tx, insert_script_tx, update_script_tx, NewScript, ScriptPatch, ScriptRepository,
|
delete_script_tx, insert_script_tx, update_script_tx, NewScript, ScriptPatch, ScriptRepository,
|
||||||
@@ -505,6 +506,46 @@ pub struct TreeNode {
|
|||||||
pub struct TreeBundle {
|
pub struct TreeBundle {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub nodes: Vec<TreeNode>,
|
pub nodes: Vec<TreeNode>,
|
||||||
|
/// Project-level policy from the repo's root manifest `[project]` block
|
||||||
|
/// (§4.2/§6, M5). The server re-derives env approval policy from here — the
|
||||||
|
/// CLI's enforcement is convenience, this is authoritative.
|
||||||
|
#[serde(default)]
|
||||||
|
pub project: Option<ProjectPolicy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project-level policy (the root manifest's `[project]` block, M5).
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
pub struct ProjectPolicy {
|
||||||
|
#[serde(default)]
|
||||||
|
pub environments: Vec<EnvPolicy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One environment's apply policy. `confirm = true` makes applying to this env a
|
||||||
|
/// gated, default-off step: a per-env `--approve <name>` is required (a blanket
|
||||||
|
/// `--yes` does NOT cover it) and the act is admin-gated + audited (§4.2).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct EnvPolicy {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub confirm: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectPolicy {
|
||||||
|
/// Does environment `env` require explicit approval to apply?
|
||||||
|
#[must_use]
|
||||||
|
pub fn confirm_required(&self, env: &str) -> bool {
|
||||||
|
self.environments.iter().any(|e| e.name == env && e.confirm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Names of every confirm-required environment (for `plan` to surface).
|
||||||
|
#[must_use]
|
||||||
|
pub fn gated_envs(&self) -> Vec<String> {
|
||||||
|
self.environments
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.confirm)
|
||||||
|
.map(|e| e.name.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One node's diff within a tree plan.
|
/// One node's diff within a tree plan.
|
||||||
@@ -523,6 +564,28 @@ pub struct NodePlan {
|
|||||||
pub struct TreePlanResult {
|
pub struct TreePlanResult {
|
||||||
pub nodes: Vec<NodePlan>,
|
pub nodes: Vec<NodePlan>,
|
||||||
pub state_token: String,
|
pub state_token: String,
|
||||||
|
/// Environments the project marks confirm-required (§4.2, M5): applying to
|
||||||
|
/// one needs an explicit `pic apply --approve <env>`. Surfaced so CI sees
|
||||||
|
/// the gate at `plan` time, before an `apply` refuses.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub approvals_required: Vec<String>,
|
||||||
|
/// Group nodes this manifest declares that another project already owns
|
||||||
|
/// (§7, M3). A read-only `plan` surfaces them so CI sees the conflict before
|
||||||
|
/// an `apply` refuses; `apply` proceeds only with `--takeover`.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub conflicts: Vec<OwnershipConflict>,
|
||||||
|
/// Slugs of groups this project owns that are absent from the manifest —
|
||||||
|
/// what `apply --prune` would delete (leaf-first, RESTRICT-guarded).
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub structural_prunes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One ownership conflict: a declared group already claimed by another project.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct OwnershipConflict {
|
||||||
|
pub slug: String,
|
||||||
|
/// The owning project's key (so the operator knows whose node it is).
|
||||||
|
pub owner_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -627,6 +690,16 @@ pub enum ApplyError {
|
|||||||
StateMoved,
|
StateMoved,
|
||||||
#[error("forbidden")]
|
#[error("forbidden")]
|
||||||
Forbidden,
|
Forbidden,
|
||||||
|
#[error(
|
||||||
|
"environment `{0}` requires explicit approval to apply; \
|
||||||
|
re-run with `--approve {0}` (a blanket `--yes` does not cover it)"
|
||||||
|
)]
|
||||||
|
ApprovalRequired(String),
|
||||||
|
#[error(
|
||||||
|
"one or more declared groups are owned by another project; \
|
||||||
|
re-run with `--takeover` to claim them (group-admin required): {0}"
|
||||||
|
)]
|
||||||
|
OwnershipConflict(String),
|
||||||
#[error("authorization repo error: {0}")]
|
#[error("authorization repo error: {0}")]
|
||||||
AuthzRepo(String),
|
AuthzRepo(String),
|
||||||
#[error("backend: {0}")]
|
#[error("backend: {0}")]
|
||||||
@@ -1453,19 +1526,32 @@ impl ApplyService {
|
|||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// `Invalid` for a malformed/unknown bundle; `Backend` for repo failures.
|
/// `Invalid` for a malformed/unknown bundle; `Backend` for repo failures.
|
||||||
pub async fn plan_tree(&self, bundle: &TreeBundle) -> Result<TreePlanResult, ApplyError> {
|
pub async fn plan_tree(
|
||||||
let (prepared, token) = self.prepare_tree(bundle).await?;
|
&self,
|
||||||
let nodes = prepared
|
bundle: &TreeBundle,
|
||||||
.into_iter()
|
project_key: Option<&str>,
|
||||||
|
) -> Result<TreePlanResult, ApplyError> {
|
||||||
|
let pt = self.prepare_tree(bundle, project_key).await?;
|
||||||
|
let nodes = pt
|
||||||
|
.prepared
|
||||||
|
.iter()
|
||||||
.map(|p| NodePlan {
|
.map(|p| NodePlan {
|
||||||
kind: p.node.kind,
|
kind: p.node.kind,
|
||||||
slug: p.node.slug.clone(),
|
slug: p.node.slug.clone(),
|
||||||
plan: p.plan,
|
plan: p.plan.clone(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
let approvals_required = bundle
|
||||||
|
.project
|
||||||
|
.as_ref()
|
||||||
|
.map(ProjectPolicy::gated_envs)
|
||||||
|
.unwrap_or_default();
|
||||||
Ok(TreePlanResult {
|
Ok(TreePlanResult {
|
||||||
nodes,
|
nodes,
|
||||||
state_token: token,
|
state_token: pt.token,
|
||||||
|
approvals_required,
|
||||||
|
conflicts: pt.conflicts,
|
||||||
|
structural_prunes: pt.prune_candidates.into_iter().map(|(_, s)| s).collect(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1478,19 +1564,37 @@ impl ApplyService {
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
/// `Invalid` for a bad bundle; `StateMoved` if the bound token is stale;
|
/// `Invalid` for a bad bundle; `StateMoved` if the bound token is stale;
|
||||||
/// `Backend` for repo/transaction failures.
|
/// `Backend` for repo/transaction failures.
|
||||||
|
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
||||||
pub async fn apply_tree(
|
pub async fn apply_tree(
|
||||||
&self,
|
&self,
|
||||||
bundle: &TreeBundle,
|
bundle: &TreeBundle,
|
||||||
prune: bool,
|
prune: bool,
|
||||||
actor: AdminUserId,
|
principal: &Principal,
|
||||||
expected_token: Option<&str>,
|
expected_token: Option<&str>,
|
||||||
|
project_key: Option<&str>,
|
||||||
|
allow_takeover: bool,
|
||||||
) -> Result<ApplyReport, ApplyError> {
|
) -> Result<ApplyReport, ApplyError> {
|
||||||
let (prepared, token) = self.prepare_tree(bundle).await?;
|
let actor = principal.user_id;
|
||||||
|
let pt = self.prepare_tree(bundle, project_key).await?;
|
||||||
if let Some(expected) = expected_token {
|
if let Some(expected) = expected_token {
|
||||||
if token != expected {
|
if pt.token != expected {
|
||||||
return Err(ApplyError::StateMoved);
|
return Err(ApplyError::StateMoved);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Ownership pre-check (§7, M3): refuse, before opening the tx, if any
|
||||||
|
// declared group is owned by another project and `--takeover` was not
|
||||||
|
// given. The in-tx re-read below is the authoritative gate against a
|
||||||
|
// concurrent claim; this is the fast, clear path for the common case.
|
||||||
|
if !allow_takeover && !pt.conflicts.is_empty() {
|
||||||
|
return Err(ApplyError::OwnershipConflict(format_conflicts(
|
||||||
|
&pt.conflicts,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let PreparedTree {
|
||||||
|
prepared,
|
||||||
|
existing_group_nodes,
|
||||||
|
..
|
||||||
|
} = pt;
|
||||||
|
|
||||||
let mut tx = self
|
let mut tx = self
|
||||||
.pool
|
.pool
|
||||||
@@ -1513,6 +1617,73 @@ impl ApplyService {
|
|||||||
let mut report = ApplyReport::default();
|
let mut report = ApplyReport::default();
|
||||||
let mut group_script_index: HashMap<GroupId, HashMap<String, ScriptId>> = HashMap::new();
|
let mut group_script_index: HashMap<GroupId, HashMap<String, ScriptId>> = HashMap::new();
|
||||||
|
|
||||||
|
// Resolve (upsert) this repo's project row inside the tx, so declared
|
||||||
|
// group nodes can be stamped with a stable owner id (§7, M3). A tree
|
||||||
|
// apply with no project key (legacy callers) skips all ownership.
|
||||||
|
let our_project_id = match project_key {
|
||||||
|
Some(k) => Some(
|
||||||
|
crate::group_repo::upsert_project_tx(&mut tx, k)
|
||||||
|
.await
|
||||||
|
.map_err(map_group_err)?,
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phase 0.5 — ownership reconcile (§7, M3): for each declared group,
|
||||||
|
// re-read the owner under this tx's lock (authoritative) and claim it
|
||||||
|
// (NULL owner) or take it over (`--takeover`). A conflict that appeared
|
||||||
|
// after `plan` and wasn't authorized aborts the whole apply.
|
||||||
|
if let Some(pid) = our_project_id {
|
||||||
|
for (gid, slug) in &existing_group_nodes {
|
||||||
|
let owner = crate::group_repo::get_group_owner_tx(&mut tx, *gid)
|
||||||
|
.await
|
||||||
|
.map_err(map_group_err)?;
|
||||||
|
match owner {
|
||||||
|
Some((oid, _)) if oid == pid => {} // already ours — no write
|
||||||
|
Some((_, okey)) => {
|
||||||
|
if !allow_takeover {
|
||||||
|
return Err(ApplyError::OwnershipConflict(format!(
|
||||||
|
"{slug} (owned by project {okey})"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
// Authoritative takeover gate (§7.4): GroupAdmin is
|
||||||
|
// re-verified HERE, at the in-tx ownership decision, not
|
||||||
|
// only in the pre-tx `authz_tree` pool read — otherwise
|
||||||
|
// `--force` (which waives the staleness token) would let
|
||||||
|
// a non-admin seize a group that another project claimed
|
||||||
|
// after the pre-tx check saw it unowned. Ownership ⟂ RBAC.
|
||||||
|
require(self.authz.as_ref(), principal, Capability::GroupAdmin(*gid))
|
||||||
|
.await
|
||||||
|
.map_err(map_authz_denied)?;
|
||||||
|
crate::group_repo::set_group_owner_tx(&mut tx, *gid, pid)
|
||||||
|
.await
|
||||||
|
.map_err(map_group_err)?;
|
||||||
|
// Audit (§7.4): seizing another project's group is at
|
||||||
|
// least as sensitive as the M5 gated-env override — record
|
||||||
|
// the actor + the ousted owner.
|
||||||
|
tracing::info!(
|
||||||
|
user_id = ?actor,
|
||||||
|
group = %slug,
|
||||||
|
prev_owner_key = %okey,
|
||||||
|
"apply: group ownership TAKEN OVER (audit)"
|
||||||
|
);
|
||||||
|
report.groups_taken_over += 1;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
crate::group_repo::set_group_owner_tx(&mut tx, *gid, pid)
|
||||||
|
.await
|
||||||
|
.map_err(map_group_err)?;
|
||||||
|
tracing::info!(
|
||||||
|
user_id = ?actor,
|
||||||
|
group = %slug,
|
||||||
|
"apply: group ownership CLAIMED (audit)"
|
||||||
|
);
|
||||||
|
report.groups_claimed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Phase A — groups first: reconcile each and record its post-create
|
// Phase A — groups first: reconcile each and record its post-create
|
||||||
// `name -> id` so descendant app nodes can resolve inherited bindings.
|
// `name -> id` so descendant app nodes can resolve inherited bindings.
|
||||||
for p in &prepared {
|
for p in &prepared {
|
||||||
@@ -1567,6 +1738,17 @@ impl ApplyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase C — structural prune (§7, M3): with `--prune`, delete groups
|
||||||
|
// THIS project owns that the manifest no longer declares, leaf-first
|
||||||
|
// (delete_group_tx is RESTRICT, so a group still holding apps/child-groups
|
||||||
|
// aborts the apply with a clear error rather than orphaning data).
|
||||||
|
if prune {
|
||||||
|
if let Some(pid) = our_project_id {
|
||||||
|
self.structural_prune_tx(&mut tx, pid, &existing_group_nodes, &mut report)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tx.commit()
|
tx.commit()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
@@ -1585,6 +1767,107 @@ impl ApplyService {
|
|||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase C of a tree apply (§7, M3): delete groups THIS project owns that the
|
||||||
|
/// manifest no longer declares, leaf-first. `delete_group_tx` is RESTRICT, so
|
||||||
|
/// a candidate still holding apps or non-pruned child groups aborts the whole
|
||||||
|
/// apply (no silent orphaning). Scoped to project-owned nodes: another repo's
|
||||||
|
/// groups and UI-owned (unclaimed) groups are never touched.
|
||||||
|
async fn structural_prune_tx(
|
||||||
|
&self,
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
project_id: Uuid,
|
||||||
|
existing_group_nodes: &[(GroupId, String)],
|
||||||
|
report: &mut ApplyReport,
|
||||||
|
) -> Result<(), ApplyError> {
|
||||||
|
// Declared groups are kept; every other group this project owns is a
|
||||||
|
// prune candidate.
|
||||||
|
let declared: HashSet<String> = existing_group_nodes
|
||||||
|
.iter()
|
||||||
|
.map(|(_, s)| s.to_lowercase())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let owned = crate::group_repo::list_owned_groups_tx(tx, project_id)
|
||||||
|
.await
|
||||||
|
.map_err(map_group_err)?;
|
||||||
|
let mut candidates: Vec<(GroupId, String, Option<GroupId>)> = owned
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, slug, _)| !declared.contains(&slug.to_lowercase()))
|
||||||
|
.collect();
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data-loss guard (§7 M3): the apps/child-group FKs are RESTRICT (a
|
||||||
|
// non-empty group aborts loudly via delete_group_tx), but the §11.6
|
||||||
|
// shared surfaces (group_collections + secrets) CASCADE. Refuse to
|
||||||
|
// silently vaporize a tenant's shared data/secrets just because a
|
||||||
|
// manifest node was dropped — mark such a group KEEP (with a warning),
|
||||||
|
// AND keep every candidate ancestor of it (RESTRICT would abort the
|
||||||
|
// apply if we tried to delete a parent whose protected child we're
|
||||||
|
// preserving). Also re-confirm ownership under the tx here — a
|
||||||
|
// concurrent `--takeover` could have flipped a candidate (undeclared, so
|
||||||
|
// the bound-plan token doesn't cover it) since the candidate SELECT.
|
||||||
|
let parent_of: HashMap<GroupId, Option<GroupId>> =
|
||||||
|
candidates.iter().map(|(g, _, p)| (*g, *p)).collect();
|
||||||
|
let mut keep: HashSet<GroupId> = HashSet::new();
|
||||||
|
for (gid, slug, _) in &candidates {
|
||||||
|
let still_owned = crate::group_repo::get_group_owner_tx(tx, *gid)
|
||||||
|
.await
|
||||||
|
.map_err(map_group_err)?
|
||||||
|
.map(|(oid, _)| oid)
|
||||||
|
== Some(project_id);
|
||||||
|
let protected = crate::group_repo::group_has_protected_data_tx(tx, *gid)
|
||||||
|
.await
|
||||||
|
.map_err(map_group_err)?;
|
||||||
|
if !still_owned || protected {
|
||||||
|
if protected {
|
||||||
|
report.warnings.push(format!(
|
||||||
|
"group `{slug}` owns shared collections or secrets; \
|
||||||
|
not pruned (delete it explicitly with `pic groups rm`)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Walk up the candidate chain, keeping this node + every
|
||||||
|
// candidate ancestor (a kept descendant RESTRICT-pins them).
|
||||||
|
let mut cur = Some(*gid);
|
||||||
|
while let Some(g) = cur {
|
||||||
|
if !keep.insert(g) {
|
||||||
|
break; // already walked from here
|
||||||
|
}
|
||||||
|
cur = parent_of.get(&g).copied().flatten();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.retain(|(g, _, _)| !keep.contains(g));
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete leaf-first: defer any candidate that is still the parent of
|
||||||
|
// another candidate until that child is gone.
|
||||||
|
while !candidates.is_empty() {
|
||||||
|
let parents: HashSet<GroupId> = candidates.iter().filter_map(|(_, _, p)| *p).collect();
|
||||||
|
let before = candidates.len();
|
||||||
|
let mut remaining: Vec<(GroupId, String, Option<GroupId>)> = Vec::new();
|
||||||
|
for (gid, slug, parent) in std::mem::take(&mut candidates) {
|
||||||
|
if parents.contains(&gid) {
|
||||||
|
remaining.push((gid, slug, parent));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
crate::group_repo::delete_group_tx(tx, gid)
|
||||||
|
.await
|
||||||
|
.map_err(map_group_err)?;
|
||||||
|
report.groups_pruned += 1;
|
||||||
|
}
|
||||||
|
if remaining.len() == before {
|
||||||
|
return Err(ApplyError::Invalid(
|
||||||
|
"structural prune could not order owned groups for deletion (cycle?)".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
candidates = remaining;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve nodes to owners, validate each (apps' route/trigger targets may
|
/// Resolve nodes to owners, validate each (apps' route/trigger targets may
|
||||||
/// bind to in-tree ancestor group scripts), compute each node's plan, and
|
/// bind to in-tree ancestor group scripts), compute each node's plan, and
|
||||||
/// fold a combined bound-plan token. Shared by `plan_tree` / `apply_tree`.
|
/// fold a combined bound-plan token. Shared by `plan_tree` / `apply_tree`.
|
||||||
@@ -1592,10 +1875,14 @@ impl ApplyService {
|
|||||||
async fn prepare_tree<'a>(
|
async fn prepare_tree<'a>(
|
||||||
&self,
|
&self,
|
||||||
bundle: &'a TreeBundle,
|
bundle: &'a TreeBundle,
|
||||||
) -> Result<(Vec<PreparedNode<'a>>, String), ApplyError> {
|
project_key: Option<&str>,
|
||||||
|
) -> Result<PreparedTree<'a>, ApplyError> {
|
||||||
if bundle.nodes.is_empty() {
|
if bundle.nodes.is_empty() {
|
||||||
return Err(ApplyError::Invalid("project tree has no nodes".into()));
|
return Err(ApplyError::Invalid("project tree has no nodes".into()));
|
||||||
}
|
}
|
||||||
|
if let Some(k) = project_key {
|
||||||
|
validate_project_key(k)?;
|
||||||
|
}
|
||||||
// Register in-tree group nodes first: their (resolved) ids and endpoint
|
// Register in-tree group nodes first: their (resolved) ids and endpoint
|
||||||
// script names, which an app node's binding validation may reference.
|
// script names, which an app node's binding validation may reference.
|
||||||
let mut group_id_by_slug: HashMap<String, GroupId> = HashMap::new();
|
let mut group_id_by_slug: HashMap<String, GroupId> = HashMap::new();
|
||||||
@@ -1719,8 +2006,88 @@ impl ApplyService {
|
|||||||
token_parts.push(format!("gs|{gid}|{}", g.structure_version));
|
token_parts.push(format!("gs|{gid}|{}", g.structure_version));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Fold the env approval policy (desired state) into the token, so a
|
||||||
|
// policy edit between plan and apply trips StateMoved (M5). Sorted for
|
||||||
|
// order-independence.
|
||||||
|
if let Some(policy) = &bundle.project {
|
||||||
|
let mut envs: Vec<String> = policy
|
||||||
|
.environments
|
||||||
|
.iter()
|
||||||
|
.map(|e| format!("{}={}", e.name, e.confirm))
|
||||||
|
.collect();
|
||||||
|
envs.sort_unstable();
|
||||||
|
token_parts.push(format!("proj|{}", envs.join(",")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership (§7, M3): classify each declared group node as ours /
|
||||||
|
// unclaimed / owned-by-another, and fold the owner key into the token so
|
||||||
|
// a claim or takeover by a different repo between plan and apply trips
|
||||||
|
// StateMoved. Gated on `project_key.is_some()` — a legacy caller with NO
|
||||||
|
// project key skips ALL ownership (no conflicts, no prune, no owner token
|
||||||
|
// fold), matching the "optional for legacy callers" contract. Note the
|
||||||
|
// discriminator is a *key present*, NOT a *persisted project*: a
|
||||||
|
// first-ever apply has a key but no `projects` row yet (our_project_id ==
|
||||||
|
// None), and MUST still see an already-owned group as a conflict. (Every
|
||||||
|
// group node pre-exists — `resolve_group` errors otherwise.)
|
||||||
|
let mut existing_group_nodes: Vec<(GroupId, String)> = Vec::new();
|
||||||
|
let mut conflicts: Vec<OwnershipConflict> = Vec::new();
|
||||||
|
let mut prune_candidates: Vec<(GroupId, String)> = Vec::new();
|
||||||
|
if project_key.is_some() {
|
||||||
|
let our_project_id = match project_key {
|
||||||
|
Some(k) => crate::group_repo::get_project_id_by_key(&self.pool, k)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let mut declared_group_slugs: HashSet<String> = HashSet::new();
|
||||||
|
for n in &bundle.nodes {
|
||||||
|
if n.kind != NodeKind::Group {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let lc = n.slug.to_lowercase();
|
||||||
|
declared_group_slugs.insert(lc.clone());
|
||||||
|
let gid = group_id_by_slug[&lc];
|
||||||
|
existing_group_nodes.push((gid, n.slug.clone()));
|
||||||
|
let owner = crate::group_repo::get_group_owner(&self.pool, gid)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
|
match &owner {
|
||||||
|
Some((_, key)) => token_parts.push(format!("own|{lc}|{key}")),
|
||||||
|
None => token_parts.push(format!("own|{lc}|none")),
|
||||||
|
}
|
||||||
|
if let Some((oid, okey)) = owner {
|
||||||
|
if Some(oid) != our_project_id {
|
||||||
|
conflicts.push(OwnershipConflict {
|
||||||
|
slug: n.slug.clone(),
|
||||||
|
owner_key: okey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structural-prune candidates: groups THIS project owns that the
|
||||||
|
// manifest omits. Computed read-only here (so `plan` can list them);
|
||||||
|
// `apply --prune` re-derives the set in-tx and deletes leaf-first.
|
||||||
|
if let Some(pid) = our_project_id {
|
||||||
|
for (gid, slug) in crate::group_repo::list_owned_groups(&self.pool, pid)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||||
|
{
|
||||||
|
if !declared_group_slugs.contains(&slug.to_lowercase()) {
|
||||||
|
prune_candidates.push((gid, slug));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
token_parts.sort_unstable();
|
token_parts.sort_unstable();
|
||||||
Ok((prepared, combine_tokens(&token_parts)))
|
Ok(PreparedTree {
|
||||||
|
prepared,
|
||||||
|
token: combine_tokens(&token_parts),
|
||||||
|
existing_group_nodes,
|
||||||
|
conflicts,
|
||||||
|
prune_candidates,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve an app node's inherited route/trigger targets to script ids,
|
/// Resolve an app node's inherited route/trigger targets to script ids,
|
||||||
@@ -3655,6 +4022,15 @@ pub struct ApplyReport {
|
|||||||
pub suppressions_created: u32,
|
pub suppressions_created: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub suppressions_deleted: u32,
|
pub suppressions_deleted: u32,
|
||||||
|
/// Group nodes this apply claimed (NULL owner → this project), §7 M3.
|
||||||
|
#[serde(default)]
|
||||||
|
pub groups_claimed: u32,
|
||||||
|
/// Group nodes this apply took over from another project (`--takeover`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub groups_taken_over: u32,
|
||||||
|
/// Owned-but-undeclared groups deleted by structural prune (`--prune`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub groups_pruned: u32,
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
pub warnings: Vec<String>,
|
pub warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -3677,6 +4053,21 @@ struct PreparedNode<'a> {
|
|||||||
app_chain: Vec<GroupId>,
|
app_chain: Vec<GroupId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The read-only result of `prepare_tree`: per-node plans + one combined token,
|
||||||
|
/// plus the ownership classification (§7, M3) the plan surfaces and the apply
|
||||||
|
/// acts on.
|
||||||
|
struct PreparedTree<'a> {
|
||||||
|
prepared: Vec<PreparedNode<'a>>,
|
||||||
|
token: String,
|
||||||
|
/// Declared group nodes in the bundle: `(id, slug)` — the apply path
|
||||||
|
/// re-reads each owner in-tx (authoritative) to claim / detect takeover.
|
||||||
|
existing_group_nodes: Vec<(GroupId, String)>,
|
||||||
|
/// Declared groups already owned by ANOTHER project (read-only diagnosis).
|
||||||
|
conflicts: Vec<OwnershipConflict>,
|
||||||
|
/// Owned-but-undeclared groups: `(id, slug)` — structural-prune candidates.
|
||||||
|
prune_candidates: Vec<(GroupId, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
/// FNV-1a over a set of already-sorted per-resource token parts — the same
|
/// FNV-1a over a set of already-sorted per-resource token parts — the same
|
||||||
/// hashing `state_token` uses, lifted to combine many nodes into one tree
|
/// hashing `state_token` uses, lifted to combine many nodes into one tree
|
||||||
/// token. Order-independent because the caller sorts `parts`.
|
/// token. Order-independent because the caller sorts `parts`.
|
||||||
@@ -3795,6 +4186,57 @@ fn map_trig(e: TriggerRepoError) -> ApplyError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Map a group-repo error to the apply surface (§7, M3). A `Conflict` (e.g. a
|
||||||
|
/// structural-prune candidate still holding descendants) is caller-actionable →
|
||||||
|
/// `Invalid`; a `NotFound` mid-apply is a lost race; `Db` is backend.
|
||||||
|
fn map_group_err(e: crate::group_repo::GroupRepositoryError) -> ApplyError {
|
||||||
|
use crate::group_repo::GroupRepositoryError as E;
|
||||||
|
match e {
|
||||||
|
E::Conflict(m) => ApplyError::Invalid(m),
|
||||||
|
E::NotFound(_) => ApplyError::Invalid("a referenced group was not found".into()),
|
||||||
|
E::Db(e) => ApplyError::Backend(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map an in-service authz denial to the apply error surface (403 vs 500).
|
||||||
|
fn map_authz_denied(e: AuthzDenied) -> ApplyError {
|
||||||
|
match e {
|
||||||
|
AuthzDenied::Denied => ApplyError::Forbidden,
|
||||||
|
AuthzDenied::Repo(r) => ApplyError::AuthzRepo(r.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate an attacker-supplied project key (§7, M3). The key is a bearer
|
||||||
|
/// handle for ownership, so it must be bounded and opaque before it's reflected
|
||||||
|
/// into error messages or upserted. The CLI mints a v4 UUID; we accept any
|
||||||
|
/// short, printable, delimiter-free token (ASCII alphanumeric plus `-`/`_`).
|
||||||
|
fn validate_project_key(key: &str) -> Result<(), ApplyError> {
|
||||||
|
if key.is_empty() || key.len() > 128 {
|
||||||
|
return Err(ApplyError::Invalid(
|
||||||
|
"project key must be 1–128 characters".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !key
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||||
|
{
|
||||||
|
return Err(ApplyError::Invalid(
|
||||||
|
"project key may contain only ASCII letters, digits, '-' and '_'".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render an ownership-conflict list as `slug (owned by project key), …` for
|
||||||
|
/// the `OwnershipConflict` error message.
|
||||||
|
fn format_conflicts(conflicts: &[OwnershipConflict]) -> String {
|
||||||
|
conflicts
|
||||||
|
.iter()
|
||||||
|
.map(|c| format!("{} (owned by project {})", c.slug, c.owner_key))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
/// A stable fingerprint of an app's live state, covering exactly what the
|
/// A stable fingerprint of an app's live state, covering exactly what the
|
||||||
/// reconcile diff keys on: script identity + write-counter (`version` bumps on
|
/// reconcile diff keys on: script identity + write-counter (`version` bumps on
|
||||||
/// any script edit), route identity + binding/attrs, trigger semantic identity
|
/// any script edit), route identity + binding/attrs, trigger semantic identity
|
||||||
@@ -4732,4 +5174,54 @@ mod tests {
|
|||||||
"queue identity is queue_name-only: {p:?}"
|
"queue identity is queue_name-only: {p:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_policy_gates_only_confirm_required_envs() {
|
||||||
|
let policy = ProjectPolicy {
|
||||||
|
environments: vec![
|
||||||
|
EnvPolicy {
|
||||||
|
name: "production".into(),
|
||||||
|
confirm: true,
|
||||||
|
},
|
||||||
|
EnvPolicy {
|
||||||
|
name: "staging".into(),
|
||||||
|
confirm: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert!(policy.confirm_required("production"));
|
||||||
|
assert!(!policy.confirm_required("staging"));
|
||||||
|
assert!(!policy.confirm_required("unknown"));
|
||||||
|
assert_eq!(policy.gated_envs(), vec!["production".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_conflicts_lists_each_node_and_owner() {
|
||||||
|
// The conflict message drives the CLI error a developer sees; pin it.
|
||||||
|
let c = vec![
|
||||||
|
OwnershipConflict {
|
||||||
|
slug: "team-a".into(),
|
||||||
|
owner_key: "key-x".into(),
|
||||||
|
},
|
||||||
|
OwnershipConflict {
|
||||||
|
slug: "team-b".into(),
|
||||||
|
owner_key: "key-y".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
format_conflicts(&c),
|
||||||
|
"team-a (owned by project key-x), team-b (owned by project key-y)"
|
||||||
|
);
|
||||||
|
assert_eq!(format_conflicts(&[]), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_project_key_bounds_and_charset() {
|
||||||
|
assert!(validate_project_key("a1b2-c3_d4").is_ok());
|
||||||
|
assert!(validate_project_key(&uuid::Uuid::nil().to_string()).is_ok());
|
||||||
|
assert!(validate_project_key("").is_err());
|
||||||
|
assert!(validate_project_key(&"x".repeat(129)).is_err());
|
||||||
|
assert!(validate_project_key("has space").is_err());
|
||||||
|
assert!(validate_project_key("semi;colon").is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -339,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)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct GroupRow {
|
struct GroupRow {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
|
|||||||
@@ -318,6 +318,11 @@ table: outbox
|
|||||||
claimed_by: text NULL
|
claimed_by: text NULL
|
||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: projects
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
key: text NOT NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
table: pubsub_trigger_details
|
table: pubsub_trigger_details
|
||||||
trigger_id: uuid NOT NULL
|
trigger_id: uuid NOT NULL
|
||||||
topic_pattern: text NOT NULL
|
topic_pattern: text NOT NULL
|
||||||
@@ -579,6 +584,7 @@ indexes on group_queue_messages:
|
|||||||
idx_group_queue_messages_group_collection: public.group_queue_messages USING btree (group_id, collection)
|
idx_group_queue_messages_group_collection: public.group_queue_messages USING btree (group_id, collection)
|
||||||
|
|
||||||
indexes on groups:
|
indexes on groups:
|
||||||
|
groups_owner_project_idx: public.groups USING btree (owner_project)
|
||||||
groups_parent_id_idx: public.groups USING btree (parent_id)
|
groups_parent_id_idx: public.groups USING btree (parent_id)
|
||||||
groups_pkey: public.groups USING btree (id)
|
groups_pkey: public.groups USING btree (id)
|
||||||
groups_slug_key: public.groups USING btree (slug)
|
groups_slug_key: public.groups USING btree (slug)
|
||||||
@@ -595,6 +601,10 @@ indexes on outbox:
|
|||||||
idx_outbox_due: public.outbox USING btree (next_attempt_at) WHERE (claimed_at IS NULL)
|
idx_outbox_due: public.outbox USING btree (next_attempt_at) WHERE (claimed_at IS NULL)
|
||||||
outbox_pkey: public.outbox USING btree (id)
|
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)
|
||||||
|
|
||||||
indexes on pubsub_trigger_details:
|
indexes on pubsub_trigger_details:
|
||||||
pubsub_trigger_details_pkey: public.pubsub_trigger_details USING btree (trigger_id)
|
pubsub_trigger_details_pkey: public.pubsub_trigger_details USING btree (trigger_id)
|
||||||
|
|
||||||
@@ -817,6 +827,7 @@ constraints on group_queue_messages:
|
|||||||
[PRIMARY KEY] group_queue_messages_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] group_queue_messages_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
constraints on groups:
|
constraints on groups:
|
||||||
|
[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
|
[FOREIGN KEY] groups_parent_id_fkey: FOREIGN KEY (parent_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||||
[PRIMARY KEY] groups_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] groups_pkey: PRIMARY KEY (id)
|
||||||
[UNIQUE] groups_slug_key: UNIQUE (slug)
|
[UNIQUE] groups_slug_key: UNIQUE (slug)
|
||||||
@@ -834,6 +845,10 @@ constraints on outbox:
|
|||||||
[FOREIGN KEY] outbox_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
[FOREIGN KEY] outbox_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] outbox_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] outbox_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
|
constraints on projects:
|
||||||
|
[PRIMARY KEY] projects_pkey: PRIMARY KEY (id)
|
||||||
|
[UNIQUE] projects_key_key: UNIQUE (key)
|
||||||
|
|
||||||
constraints on pubsub_trigger_details:
|
constraints on pubsub_trigger_details:
|
||||||
[FOREIGN KEY] pubsub_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
[FOREIGN KEY] pubsub_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] pubsub_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
[PRIMARY KEY] pubsub_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||||
@@ -973,3 +988,4 @@ constraints on vars:
|
|||||||
0063: materialized unique
|
0063: materialized unique
|
||||||
0064: shared topic queue kinds
|
0064: shared topic queue kinds
|
||||||
0065: group queues
|
0065: group queues
|
||||||
|
0066: owner project
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ toml = "0.8"
|
|||||||
directories = "5"
|
directories = "5"
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
# §7 M3: minting the stable project key in `.picloud/project.json`.
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_cmd = "2"
|
assert_cmd = "2"
|
||||||
|
|||||||
@@ -1262,27 +1262,47 @@ impl Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/v1/admin/tree/plan` — diff a whole project tree (Phase 5).
|
/// `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
|
let resp = self
|
||||||
.request(Method::POST, "/api/v1/admin/tree/plan")
|
.request(Method::POST, "/api/v1/admin/tree/plan")
|
||||||
.json(bundle)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
decode(resp).await
|
decode(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/v1/admin/tree/apply` — reconcile a whole project tree in one
|
/// `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(
|
pub async fn apply_tree(
|
||||||
&self,
|
&self,
|
||||||
bundle: &serde_json::Value,
|
bundle: &serde_json::Value,
|
||||||
prune: bool,
|
prune: bool,
|
||||||
expected_token: Option<&str>,
|
expected_token: Option<&str>,
|
||||||
|
env: Option<&str>,
|
||||||
|
approved_envs: &[String],
|
||||||
|
project_key: Option<&str>,
|
||||||
|
allow_takeover: bool,
|
||||||
) -> Result<ApplyReportDto> {
|
) -> Result<ApplyReportDto> {
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"bundle": bundle,
|
"bundle": bundle,
|
||||||
"prune": prune,
|
"prune": prune,
|
||||||
"expected_token": expected_token,
|
"expected_token": expected_token,
|
||||||
|
"env": env,
|
||||||
|
"approved_envs": approved_envs,
|
||||||
|
"project_key": project_key,
|
||||||
|
"allow_takeover": allow_takeover,
|
||||||
});
|
});
|
||||||
let resp = self
|
let resp = self
|
||||||
.request(Method::POST, "/api/v1/admin/tree/apply")
|
.request(Method::POST, "/api/v1/admin/tree/apply")
|
||||||
@@ -1292,6 +1312,12 @@ impl Client {
|
|||||||
if resp.status() == reqwest::StatusCode::CONFLICT {
|
if resp.status() == reqwest::StatusCode::CONFLICT {
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
let msg = parse_error_body(&body).unwrap_or(body);
|
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!(
|
return Err(anyhow!(
|
||||||
"{msg}\nThe project tree changed since your last `pic plan`. Re-run \
|
"{msg}\nThe project tree changed since your last `pic plan`. Re-run \
|
||||||
`pic plan --dir` to review, then `pic apply --dir` — or add `--force`."
|
`pic plan --dir` to review, then `pic apply --dir` — or add `--force`."
|
||||||
@@ -1531,6 +1557,22 @@ pub struct TreePlanDto {
|
|||||||
pub nodes: Vec<NodePlanDto>,
|
pub nodes: Vec<NodePlanDto>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub state_token: String,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -1592,6 +1634,15 @@ pub struct ApplyReportDto {
|
|||||||
pub suppressions_created: u32,
|
pub suppressions_created: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub suppressions_deleted: u32,
|
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)]
|
#[serde(default)]
|
||||||
pub warnings: Vec<String>,
|
pub warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,26 @@ pub async fn run(
|
|||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
|
|
||||||
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
||||||
|
|
||||||
|
// 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 base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
let bundle = build_bundle(&manifest, base_dir)?;
|
let bundle = build_bundle(&manifest, base_dir)?;
|
||||||
let kind = if manifest.is_group() {
|
let kind = if manifest.is_group() {
|
||||||
@@ -115,22 +135,36 @@ pub async fn run(
|
|||||||
|
|
||||||
/// `pic apply --dir <root>` (Phase 5): reconcile a whole project tree — every
|
/// `pic apply --dir <root>` (Phase 5): reconcile a whole project tree — every
|
||||||
/// `picloud.toml` under `root` — in ONE atomic server transaction.
|
/// `picloud.toml` under `root` — in ONE atomic server transaction.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn run_tree(
|
pub async fn run_tree(
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
prune: bool,
|
prune: bool,
|
||||||
yes: bool,
|
yes: bool,
|
||||||
force: bool,
|
force: bool,
|
||||||
env: Option<&str>,
|
env: Option<&str>,
|
||||||
|
approve: &[String],
|
||||||
|
takeover: bool,
|
||||||
mode: OutputMode,
|
mode: OutputMode,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let creds = config::resolve()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
let (bundle, node_count) = crate::discover::build_tree(dir, env)?;
|
let (bundle, node_count, envs) = crate::discover::build_tree(dir, env)?;
|
||||||
|
|
||||||
if prune && !yes {
|
if prune && !yes {
|
||||||
confirm_prune(&format!("{node_count} node(s) under {}", dir.display()))?;
|
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 token_key = crate::cmds::plan::TREE_TOKEN_KEY;
|
||||||
let expected_token = if force {
|
let expected_token = if force {
|
||||||
None
|
None
|
||||||
@@ -141,13 +175,28 @@ pub async fn run_tree(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let report = client
|
let report = client
|
||||||
.apply_tree(&bundle, prune, expected_token.as_deref())
|
.apply_tree(
|
||||||
|
&bundle,
|
||||||
|
prune,
|
||||||
|
expected_token.as_deref(),
|
||||||
|
env,
|
||||||
|
&approved,
|
||||||
|
Some(&project_key),
|
||||||
|
takeover,
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
crate::linkstate::clear_plan(dir, token_key);
|
crate::linkstate::clear_plan(dir, token_key);
|
||||||
|
|
||||||
let mut block = KvBlock::new();
|
let mut block = KvBlock::new();
|
||||||
block
|
block
|
||||||
.field("nodes", node_count.to_string())
|
.field("nodes", node_count.to_string())
|
||||||
|
.field(
|
||||||
|
"groups",
|
||||||
|
format!(
|
||||||
|
"claimed {} taken-over {} pruned {}",
|
||||||
|
report.groups_claimed, report.groups_taken_over, report.groups_pruned
|
||||||
|
),
|
||||||
|
)
|
||||||
.field(
|
.field(
|
||||||
"scripts",
|
"scripts",
|
||||||
format!(
|
format!(
|
||||||
@@ -205,6 +254,49 @@ pub async fn run_tree(
|
|||||||
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
|
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
|
||||||
/// for non-interactive/CI use. Refuse a non-interactive prune without `--yes`
|
/// for non-interactive/CI use. Refuse a non-interactive prune without `--yes`
|
||||||
/// rather than silently deleting (review the deletions first with `pic plan`).
|
/// rather than silently deleting (review the deletions first with `pic plan`).
|
||||||
|
/// 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<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 gated = policy.iter().any(|e| e.name == env && e.confirm);
|
||||||
|
if !gated || approved.iter().any(|e| e == env) {
|
||||||
|
return Ok(approved);
|
||||||
|
}
|
||||||
|
// 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<()> {
|
fn confirm_prune(slug: &str) -> Result<()> {
|
||||||
if !std::io::stdin().is_terminal() {
|
if !std::io::stdin().is_terminal() {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ pub fn run(
|
|||||||
}
|
}
|
||||||
fs::write(&manifest_path, body).with_context(|| format!("writing {MANIFEST_FILE}"))?;
|
fs::write(&manifest_path, body).with_context(|| format!("writing {MANIFEST_FILE}"))?;
|
||||||
ensure_gitignored(dir)?;
|
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();
|
let mut block = KvBlock::new();
|
||||||
block
|
block
|
||||||
@@ -116,6 +119,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
|||||||
extension_points: Vec::new(),
|
extension_points: Vec::new(),
|
||||||
}),
|
}),
|
||||||
group: None,
|
group: None,
|
||||||
|
project: None,
|
||||||
scripts: vec![ManifestScript {
|
scripts: vec![ManifestScript {
|
||||||
name: "hello".into(),
|
name: "hello".into(),
|
||||||
file: "scripts/hello.rhai".into(),
|
file: "scripts/hello.rhai".into(),
|
||||||
|
|||||||
@@ -49,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<()> {
|
pub async fn run_tree(dir: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||||
let creds = config::resolve()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
let (bundle, _count) = crate::discover::build_tree(dir, env)?;
|
let (bundle, _count, _envs) = crate::discover::build_tree(dir, env)?;
|
||||||
let plan = client.plan_tree(&bundle).await?;
|
// 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 !plan.state_token.is_empty() {
|
||||||
if let Err(e) = crate::linkstate::write_plan(dir, TREE_TOKEN_KEY, &plan.state_token) {
|
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}");
|
eprintln!("warning: could not record plan state for `pic apply`: {e}");
|
||||||
@@ -87,6 +95,33 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
table.print(mode);
|
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
|
/// Assemble the wire bundle: scripts carry inlined source (read from
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
|||||||
extension_points,
|
extension_points,
|
||||||
}),
|
}),
|
||||||
group: None,
|
group: None,
|
||||||
|
project: None,
|
||||||
scripts: manifest_scripts,
|
scripts: manifest_scripts,
|
||||||
routes,
|
routes,
|
||||||
triggers: manifest_triggers,
|
triggers: manifest_triggers,
|
||||||
|
|||||||
@@ -45,10 +45,15 @@ fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}] }`) from every
|
/// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}], project }`)
|
||||||
/// manifest under `root`. Returns the JSON plus the node count. Rejects a tree
|
/// from every manifest under `root`. Returns the JSON, the node count, and the
|
||||||
/// that names the same (kind, slug) twice.
|
/// root manifest's `[project]` environment policy (M5, empty if none). Rejects a
|
||||||
pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> {
|
/// 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, Vec<crate::manifest::ManifestEnvironment>)> {
|
||||||
let paths = find_manifests(root)?;
|
let paths = find_manifests(root)?;
|
||||||
if paths.is_empty() {
|
if paths.is_empty() {
|
||||||
bail!(
|
bail!(
|
||||||
@@ -56,11 +61,25 @@ pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> {
|
|||||||
root.display()
|
root.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
let root_manifest = root.join(MANIFEST_FILE);
|
||||||
let mut nodes = Vec::with_capacity(paths.len());
|
let mut nodes = Vec::with_capacity(paths.len());
|
||||||
let mut seen: HashSet<(String, String)> = HashSet::new();
|
let mut seen: HashSet<(String, String)> = HashSet::new();
|
||||||
|
let mut project: Option<crate::manifest::ManifestProject> = None;
|
||||||
for path in &paths {
|
for path in &paths {
|
||||||
let manifest = Manifest::load_with_env(path, env)
|
let manifest = Manifest::load_with_env(path, env)
|
||||||
.with_context(|| format!("loading {}", path.display()))?;
|
.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 == &root_manifest {
|
||||||
|
project = Some(p.clone());
|
||||||
|
} else {
|
||||||
|
bail!(
|
||||||
|
"{} declares [project], which is only valid on the root manifest ({})",
|
||||||
|
path.display(),
|
||||||
|
root_manifest.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
let bundle = build_bundle(&manifest, base_dir)?;
|
let bundle = build_bundle(&manifest, base_dir)?;
|
||||||
let kind = if manifest.is_group() { "group" } else { "app" };
|
let kind = if manifest.is_group() { "group" } else { "app" };
|
||||||
@@ -71,5 +90,20 @@ pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> {
|
|||||||
nodes.push(json!({ "kind": kind, "slug": slug, "bundle": bundle }));
|
nodes.push(json!({ "kind": kind, "slug": slug, "bundle": bundle }));
|
||||||
}
|
}
|
||||||
let count = nodes.len();
|
let count = nodes.len();
|
||||||
Ok((json!({ "nodes": nodes }), count))
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,71 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
const DIR: &str = ".picloud";
|
const DIR: &str = ".picloud";
|
||||||
const PLAN_FILE: &str = "plan.json";
|
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.
|
/// The recorded result of the last `pic plan`, scoped to the app it was for.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -250,6 +250,16 @@ struct ApplyArgs {
|
|||||||
/// top of the base manifest before applying.
|
/// top of the base manifest before applying.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
env: Option<String>,
|
env: Option<String>,
|
||||||
|
/// 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)]
|
#[derive(Args)]
|
||||||
@@ -1422,6 +1432,8 @@ async fn main() -> ExitCode {
|
|||||||
args.yes,
|
args.yes,
|
||||||
args.force,
|
args.force,
|
||||||
args.env.as_deref(),
|
args.env.as_deref(),
|
||||||
|
&args.approve,
|
||||||
|
args.takeover,
|
||||||
mode,
|
mode,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ pub struct Manifest {
|
|||||||
pub app: Option<ManifestApp>,
|
pub app: Option<ManifestApp>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub group: Option<ManifestGroup>,
|
pub group: Option<ManifestGroup>,
|
||||||
|
/// `[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")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub scripts: Vec<ManifestScript>,
|
pub scripts: Vec<ManifestScript>,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
@@ -263,6 +268,26 @@ pub struct ManifestGroup {
|
|||||||
pub collections: Vec<CollectionDecl>,
|
pub collections: Vec<CollectionDecl>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `[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 {
|
||||||
|
/// `[[project.environments]]` — per-env apply policy.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub environments: Vec<ManifestEnvironment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 ManifestEnvironment {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub confirm: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// The store kind of a shared collection (§11.6).
|
/// The store kind of a shared collection (§11.6).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -596,6 +621,7 @@ mod tests {
|
|||||||
extension_points: vec!["theme".into()],
|
extension_points: vec!["theme".into()],
|
||||||
}),
|
}),
|
||||||
group: None,
|
group: None,
|
||||||
|
project: None,
|
||||||
scripts: vec![
|
scripts: vec![
|
||||||
ManifestScript {
|
ManifestScript {
|
||||||
name: "create-post".into(),
|
name: "create-post".into(),
|
||||||
@@ -759,6 +785,7 @@ mod tests {
|
|||||||
extension_points: vec![],
|
extension_points: vec![],
|
||||||
}),
|
}),
|
||||||
group: None,
|
group: None,
|
||||||
|
project: None,
|
||||||
scripts: vec![],
|
scripts: vec![],
|
||||||
routes: vec![],
|
routes: vec![],
|
||||||
triggers: ManifestTriggers::default(),
|
triggers: ManifestTriggers::default(),
|
||||||
@@ -875,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]
|
#[test]
|
||||||
fn group_manifest_parses_and_rejects_app_only_blocks() {
|
fn group_manifest_parses_and_rejects_app_only_blocks() {
|
||||||
// A [group] node: scripts + vars, no [app].
|
// A [group] node: scripts + vars, no [app].
|
||||||
|
|||||||
240
crates/picloud-cli/tests/approval.rs
Normal file
240
crates/picloud-cli/tests/approval.rs
Normal 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ mod common;
|
|||||||
mod admins;
|
mod admins;
|
||||||
mod api_keys;
|
mod api_keys;
|
||||||
mod apply;
|
mod apply;
|
||||||
|
mod approval;
|
||||||
mod apps;
|
mod apps;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod collections;
|
mod collections;
|
||||||
@@ -35,6 +36,7 @@ mod init;
|
|||||||
mod invoke;
|
mod invoke;
|
||||||
mod logs;
|
mod logs;
|
||||||
mod output;
|
mod output;
|
||||||
|
mod ownership;
|
||||||
mod plan;
|
mod plan;
|
||||||
mod prune;
|
mod prune;
|
||||||
mod pull;
|
mod pull;
|
||||||
|
|||||||
332
crates/picloud-cli/tests/ownership.rs
Normal file
332
crates/picloud-cli/tests/ownership.rs
Normal 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1120,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
|
5. **Project tool maps onto groups.** Nested manifests, attach point, single-owner, server-computed
|
||||||
tree plan, per-env approval gating.
|
tree plan, per-env approval gating.
|
||||||
|
|
||||||
> **Status (Phase 5): ✅ shipped — single-repo nested tree apply, atomic.** A directory tree of
|
> **Status (Phase 5): ✅ shipped — nested tree apply, atomic, with multi-repo single-owner
|
||||||
> `picloud.toml` manifests (each declaring an `[app]` or `[group]` node) applies as ONE
|
> ownership.** A directory tree of `picloud.toml` manifests (each declaring an `[app]` or `[group]`
|
||||||
> server-computed plan in ONE Postgres transaction. Per the §11.1 review, the **multi-repo
|
> node) applies as ONE server-computed plan in ONE Postgres transaction. **Multi-repo single-owner
|
||||||
> single-owner / attach-point / takeover** layer (§7) is **deferred** for the solo-dev / single-repo
|
> ownership + takeover shipped (§7, M3, 2026-07-05)** — each repo mints a stable, gitignored project
|
||||||
> start, as is **per-env approval-policy gating** (`[project.environments]`); env overlays
|
> key in `.picloud/project.json`; the first `pic apply --dir` to touch a group **claims** it
|
||||||
> (`picloud.<env>.toml`) already exist. Groups must **pre-exist** (`pic groups create`) — a manifest
|
> (`groups.owner_project` FK → the new `projects` table, migration 0066 promoting the inert 0047
|
||||||
> owns each node's *content*, not the tree *shape* (declarative group create/reparent is a later
|
> column). A second repo (a distinct key) applying an owned group is **refused** (`owned by another
|
||||||
> add).
|
> 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:
|
> Shipped surface:
|
||||||
> - **Engine:** the reconcile engine generalized from app-only to an `ApplyOwner { App | Group }`
|
> - **Engine:** the reconcile engine generalized from app-only to an `ApplyOwner { App | Group }`
|
||||||
@@ -1243,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
|
- *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).
|
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.
|
Decide against **actual** target tenant counts before defaulting it late.
|
||||||
- *Multi-repo ownership (§7):* the single-owner / attach-point / takeover machinery is substantial
|
- *Multi-repo ownership (§7):* the single-owner / takeover machinery is substantial and only earns
|
||||||
and only earns its keep with a **second** managing repo. For a solo-dev / single-repo start, a
|
its keep with a **second** managing repo. For a solo-dev / single-repo start, a "one repo owns the
|
||||||
"one repo owns the whole subtree" model covers the near term; confirm the multi-repo need exists
|
whole subtree" model covers the near term. **Resolved: shipped as M3 (2026-07-05)** — single-owner
|
||||||
before building the ownership layer.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user