feat(cli): pic groups (tree/create/rename/reparent/rm/members)

Adds the `pic groups` command family over /api/v1/admin/groups:
ls/tree, create (--parent), show (path + subgroups + apps), rename
(name/description; slug frozen), reparent (--to), rm (--recursive,
leaf-first; never auto-deletes apps), and members add/set/rm/ls. Also
`pic apps create --group <slug>` to place an app under a group.

End-to-end journey tests (tests/groups.rs, DB-gated) cover tree CRUD,
delete=RESTRICT (409 on a non-empty group), reparent cycle rejection, and
the headline invariant: a group_admin on an ancestor group can deploy to
an app it is NOT a direct member of (inherited membership), and loses that
access immediately on revoke — all green against a live server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-24 20:15:33 +02:00
parent a432091191
commit eea1d8984e
8 changed files with 796 additions and 2 deletions

View File

@@ -23,6 +23,7 @@ mod dead_letters;
mod email_queue;
mod enabled;
mod env_overlay;
mod groups;
mod init;
mod invoke;
mod logs;

View File

@@ -44,6 +44,35 @@ pub struct UserGuard {
user_id: String,
}
/// Deletes a group on drop (best-effort). The group must be empty by then
/// — register an `AppGuard`/child `GroupGuard` *after* this one so the
/// child drops (deletes) first, leaving an empty node here.
pub struct GroupGuard {
url: String,
token: String,
slug: String,
}
impl GroupGuard {
pub fn new(url: &str, token: &str, slug: &str) -> Self {
Self {
url: url.to_string(),
token: token.to_string(),
slug: slug.to_string(),
}
}
}
impl Drop for GroupGuard {
fn drop(&mut self) {
let client = reqwest::blocking::Client::new();
let _ = client
.delete(format!("{}/api/v1/admin/groups/{}", self.url, self.slug))
.bearer_auth(&self.token)
.send();
}
}
impl UserGuard {
pub fn new(url: &str, token: &str, user_id: &str) -> Self {
Self {

View File

@@ -0,0 +1,179 @@
//! Phase-2 groups, end to end via `pic`: tree CRUD, delete=RESTRICT,
//! reparent cycle rejection, and the headline invariant — a `group_admin`
//! on an ancestor group can act on an app it is NOT a direct member of
//! (inherited membership), and loses that access the instant the group
//! grant is revoked.
use predicates::prelude::*;
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard};
use crate::common::member;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn group_tree_create_show_and_delete_restrict() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let acme = common::unique_slug("g-acme");
let team = common::unique_slug("g-team");
let app = common::unique_slug("g-app");
// Root-level group, then a subgroup under it.
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
common::pic_as(&env)
.args(["groups", "create", &acme])
.assert()
.success();
let _g_team = GroupGuard::new(&env.url, &env.token, &team);
common::pic_as(&env)
.args(["groups", "create", &team, "--parent", &acme])
.assert()
.success();
// An app under the subgroup.
let _app = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &team])
.assert()
.success();
// `groups show team` lists the app.
let out = String::from_utf8(
common::pic_as(&env)
.args(["groups", "show", &team])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
out.contains(&app),
"group detail should list its app:\n{out}"
);
// delete=RESTRICT: acme has a subgroup → refused.
common::pic_as(&env)
.args(["groups", "rm", &acme])
.assert()
.failure()
.stderr(predicate::str::contains("409").or(predicate::str::contains("subgroup")));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn reparent_into_own_descendant_is_rejected() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let parent = common::unique_slug("g-cyc-p");
let child = common::unique_slug("g-cyc-c");
let _g_parent = GroupGuard::new(&env.url, &env.token, &parent);
common::pic_as(&env)
.args(["groups", "create", &parent])
.assert()
.success();
let _g_child = GroupGuard::new(&env.url, &env.token, &child);
common::pic_as(&env)
.args(["groups", "create", &child, "--parent", &parent])
.assert()
.success();
// Moving the parent under its own child would form a cycle → refused.
common::pic_as(&env)
.args(["groups", "reparent", &parent, "--to", &child])
.assert()
.failure()
.stderr(predicate::str::contains("409").or(predicate::str::contains("descendant")));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn inherited_group_admin_can_deploy_then_revoke() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let acme = common::unique_slug("g-inh");
let app = common::unique_slug("g-inh-app");
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
common::pic_as(&env)
.args(["groups", "create", &acme])
.assert()
.success();
let _app = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &acme])
.assert()
.success();
// A fresh Member with NO app membership.
let m = member::member_user(fx, &common::unique_username("inh"));
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
let fixture = common::fixture_path("hello.rhai");
// Baseline: without any grant, deploy is forbidden.
common::pic_as(&member_env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&app,
])
.assert()
.failure()
.stderr(predicate::str::contains("HTTP 403"));
// Grant group_admin on the ANCESTOR group (no app_members row).
common::pic_as(&env)
.args([
"groups",
"members",
"add",
&acme,
&m.id,
"--role",
"app_admin",
])
.assert()
.success();
// Inherited: the member can now deploy to the app it never joined.
// (Deploy prints a KvBlock — assert on the script name + create action,
// not a prose string.)
common::pic_as(&member_env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&app,
])
.assert()
.success()
.stdout(predicate::str::contains("hello").and(predicate::str::contains("created")));
// Revoke the group grant → access drops immediately (no cache lag).
common::pic_as(&env)
.args(["groups", "members", "rm", &acme, &m.id])
.assert()
.success();
common::pic_as(&member_env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&app,
])
.assert()
.failure()
.stderr(predicate::str::contains("HTTP 403"));
}