§7 of the groups/project-tool design. A group node is now authoritatively managed by exactly one project-root; `pic apply --dir` claims, conflicts, takes over, and (with --prune) structurally reaps owned nodes. - Migration 0052: `projects(id, key UNIQUE)` + promote the inert `groups.owner_project` (0047) to a real FK (ON DELETE SET NULL) + index. - CLI mints a stable, gitignored project key in `.picloud/project.json` (`pic init`, or lazily on first tree plan/apply) and presents it on every tree request; `pic apply --dir --takeover` flag. - Server: prepare_tree resolves ownership read-only (plan surfaces conflicts + prune candidates; token folds each group's owner key). apply_tree upserts the project in-tx, claims created groups on insert, reconciles existing-group ownership under the per-node advisory lock (first-commit-wins), and prunes owned-but-undeclared groups leaf-first (delete=RESTRICT, never another repo's or a UI-owned node). - Authz (§7.4, ownership ⟂ RBAC): takeover requires GroupAdmin per contested node — enforced in authz_tree (pre-tx) AND re-verified in-tx at the ownership decision, so --force (which waives the staleness token) can't open a takeover-without-admin window. The attacker-supplied project key is length/charset-validated server-side. - Tests: tests/ownership.rs (claim → conflict → takeover → flip; non-admin takeover → 403; prune-owned-only); format_conflicts unit test; schema golden reblessed. tree_shape M2 journey updated to reparent in-place (a fresh dir is now a distinct project and would correctly conflict). Closes the M3 milestone; reviewed (4 findings: 1 authz-bypass via --force + key validation + 2 LOW, all fixed). M4 (trigger/route templates) remains. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
120 lines
3.8 KiB
Rust
120 lines
3.8 KiB
Rust
//! Helpers for non-admin (`instance_role: Member`) user lifecycle plus
|
|
//! direct API calls for granting / updating app memberships.
|
|
//!
|
|
//! These talk to the manager HTTP surface directly instead of going
|
|
//! through the CLI, so role-gated tests can stage state without
|
|
//! requiring `pic` to grow new commands.
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
use super::cleanup::UserGuard;
|
|
use super::Fixture;
|
|
|
|
pub const MEMBER_PASSWORD: &str = "pic-cli-test-pw-12345678";
|
|
|
|
pub struct MemberUser {
|
|
pub id: String,
|
|
pub username: String,
|
|
pub token: String,
|
|
pub _guard: UserGuard,
|
|
}
|
|
|
|
/// Mint a fresh `instance_role: Member` user, log them in for a bearer
|
|
/// token, and register a `UserGuard` for teardown.
|
|
pub fn member_user(fx: &Fixture, username: &str) -> MemberUser {
|
|
let client = reqwest::blocking::Client::new();
|
|
|
|
let create = client
|
|
.post(format!("{}/api/v1/admin/admins", fx.url))
|
|
.bearer_auth(&fx.admin_token)
|
|
.json(&json!({
|
|
"username": username,
|
|
"password": MEMBER_PASSWORD,
|
|
// InstanceRole / AppRole serialize via `rename_all =
|
|
// "snake_case"` — wire forms are always lowercase.
|
|
"instance_role": "member",
|
|
}))
|
|
.send()
|
|
.expect("create member user");
|
|
assert!(
|
|
create.status().is_success(),
|
|
"create member user failed: {} {}",
|
|
create.status(),
|
|
create.text().unwrap_or_default(),
|
|
);
|
|
let body: Value = create.json().expect("admin create json");
|
|
let id = body["id"]
|
|
.as_str()
|
|
.expect("admin create returns id")
|
|
.to_string();
|
|
|
|
// Register cleanup before we attempt anything else that could fail.
|
|
let guard = UserGuard::new(&fx.url, &fx.admin_token, &id);
|
|
|
|
let token = super::server::login_for_bearer_token(&fx.url, username, MEMBER_PASSWORD);
|
|
|
|
MemberUser {
|
|
id,
|
|
username: username.to_string(),
|
|
token,
|
|
_guard: guard,
|
|
}
|
|
}
|
|
|
|
/// `POST /api/v1/admin/apps/{slug}/members` — grant `role` to `user_id`.
|
|
pub fn grant_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) {
|
|
let client = reqwest::blocking::Client::new();
|
|
let resp = client
|
|
.post(format!("{}/api/v1/admin/apps/{}/members", fx.url, app_slug))
|
|
.bearer_auth(&fx.admin_token)
|
|
.json(&json!({ "user_id": user_id, "role": role }))
|
|
.send()
|
|
.expect("grant membership");
|
|
assert!(
|
|
resp.status().is_success(),
|
|
"grant membership failed: {} {}",
|
|
resp.status(),
|
|
resp.text().unwrap_or_default(),
|
|
);
|
|
}
|
|
|
|
/// `POST /api/v1/admin/groups/{slug}/members` — grant `role` on a group.
|
|
pub fn grant_group_membership(fx: &Fixture, group_slug: &str, user_id: &str, role: &str) {
|
|
let client = reqwest::blocking::Client::new();
|
|
let resp = client
|
|
.post(format!(
|
|
"{}/api/v1/admin/groups/{}/members",
|
|
fx.url, group_slug
|
|
))
|
|
.bearer_auth(&fx.admin_token)
|
|
.json(&json!({ "user_id": user_id, "role": role }))
|
|
.send()
|
|
.expect("grant group membership");
|
|
assert!(
|
|
resp.status().is_success(),
|
|
"grant group membership failed: {} {}",
|
|
resp.status(),
|
|
resp.text().unwrap_or_default(),
|
|
);
|
|
}
|
|
|
|
/// `PATCH /api/v1/admin/apps/{slug}/members/{user_id}` — promote/demote.
|
|
pub fn update_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) {
|
|
let client = reqwest::blocking::Client::new();
|
|
let resp = client
|
|
.patch(format!(
|
|
"{}/api/v1/admin/apps/{}/members/{}",
|
|
fx.url, app_slug, user_id
|
|
))
|
|
.bearer_auth(&fx.admin_token)
|
|
.json(&json!({ "role": role }))
|
|
.send()
|
|
.expect("update membership");
|
|
assert!(
|
|
resp.status().is_success(),
|
|
"update membership failed: {} {}",
|
|
resp.status(),
|
|
resp.text().unwrap_or_default(),
|
|
);
|
|
}
|