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:
@@ -31,6 +31,7 @@ pub async fn create(
|
||||
slug: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
group: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
@@ -39,6 +40,7 @@ pub async fn create(
|
||||
slug,
|
||||
name: name.unwrap_or(slug),
|
||||
description,
|
||||
group,
|
||||
};
|
||||
let app = client.apps_create(&body).await?;
|
||||
// Emit the created object so `--output json` callers can capture the
|
||||
|
||||
257
crates/picloud-cli/src/cmds/groups.rs
Normal file
257
crates/picloud-cli/src/cmds/groups.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
//! `pic groups` — manage the org-tree groups (Phase 2).
|
||||
//!
|
||||
//! Wraps `/api/v1/admin/groups*`. Structural mutations are gated
|
||||
//! server-side: create/reparent/delete need group-admin (reparent at both
|
||||
//! source and destination parent); the slug is frozen at creation.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use picloud_shared::{AppRole, Group};
|
||||
|
||||
use crate::client::{Client, CreateGroupBody};
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
pub async fn ls(mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let groups = client.groups_list().await?;
|
||||
let mut table = Table::new(["slug", "name", "parent", "created_at"]);
|
||||
let by_id: BTreeMap<_, _> = groups.iter().map(|g| (g.id, g.slug.clone())).collect();
|
||||
for g in &groups {
|
||||
let parent = g
|
||||
.parent_id
|
||||
.and_then(|p| by_id.get(&p).cloned())
|
||||
.unwrap_or_else(|| "-".into());
|
||||
table.row([
|
||||
g.slug.clone(),
|
||||
g.name.clone(),
|
||||
parent,
|
||||
g.created_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic groups tree` — render the hierarchy as an indented tree (text
|
||||
/// mode); falls back to the flat list for `--output json`.
|
||||
pub async fn tree(mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let groups = client.groups_list().await?;
|
||||
if matches!(mode, OutputMode::Json) {
|
||||
// Machine consumers get the flat list; the shape carries parent_id.
|
||||
println!("{}", serde_json::to_string_pretty(&groups)?);
|
||||
return Ok(());
|
||||
}
|
||||
// children-by-parent, then DFS from the roots.
|
||||
let mut children: BTreeMap<Option<_>, Vec<&Group>> = BTreeMap::new();
|
||||
for g in &groups {
|
||||
children.entry(g.parent_id).or_default().push(g);
|
||||
}
|
||||
for kids in children.values_mut() {
|
||||
kids.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
}
|
||||
print_subtree(&children, None, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_subtree(
|
||||
children: &BTreeMap<Option<picloud_shared::GroupId>, Vec<&Group>>,
|
||||
parent: Option<picloud_shared::GroupId>,
|
||||
depth: usize,
|
||||
) {
|
||||
let Some(kids) = children.get(&parent) else {
|
||||
return;
|
||||
};
|
||||
for g in kids {
|
||||
println!("{}{} ({})", " ".repeat(depth), g.name, g.slug);
|
||||
print_subtree(children, Some(g.id), depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
slug: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
parent: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let body = CreateGroupBody {
|
||||
slug,
|
||||
name: name.unwrap_or(slug),
|
||||
description,
|
||||
parent,
|
||||
};
|
||||
let group = client.groups_create(&body).await?;
|
||||
print_group(&group, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn show(ident: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let detail = client.groups_get(ident).await?;
|
||||
let path = detail
|
||||
.path
|
||||
.iter()
|
||||
.map(|g| g.slug.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("id", detail.group.id.to_string())
|
||||
.field("slug", detail.group.slug.clone())
|
||||
.field("name", detail.group.name.clone())
|
||||
.field("path", if path.is_empty() { "-".into() } else { path })
|
||||
.field(
|
||||
"subgroups",
|
||||
detail
|
||||
.subgroups
|
||||
.iter()
|
||||
.map(|g| g.slug.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)
|
||||
.field(
|
||||
"apps",
|
||||
detail
|
||||
.apps
|
||||
.iter()
|
||||
.map(|a| a.slug.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
);
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rename(
|
||||
ident: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let group = client.groups_rename(ident, name, description).await?;
|
||||
print_group(&group, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reparent(ident: &str, to: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let group = client.groups_reparent(ident, to).await?;
|
||||
print_group(&group, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic groups rm <slug>`. The server enforces delete=RESTRICT (409 on a
|
||||
/// non-empty group); `--recursive` expands the delete into ordered,
|
||||
/// leaf-first child deletions (groups + apps) the operator opted into.
|
||||
pub async fn rm(ident: &str, recursive: bool) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
if !recursive {
|
||||
client.groups_delete(ident).await?;
|
||||
println!("Deleted group {ident}");
|
||||
return Ok(());
|
||||
}
|
||||
// Recursive: delete the subtree leaf-first so each DB delete sees an
|
||||
// empty node (the FK stays RESTRICT — we never cascade implicitly).
|
||||
let detail = client.groups_get(ident).await?;
|
||||
if let Some(app) = detail.apps.first() {
|
||||
anyhow::bail!(
|
||||
"group {ident} contains app {:?}; move or delete apps before a recursive group delete \
|
||||
(apps are never auto-deleted)",
|
||||
app.slug
|
||||
);
|
||||
}
|
||||
for sub in &detail.subgroups {
|
||||
Box::pin(rm(&sub.slug, true)).await?;
|
||||
}
|
||||
client.groups_delete(ident).await?;
|
||||
println!("Deleted group {ident}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- members ---------------------------------------------------------------
|
||||
|
||||
pub async fn members_ls(group: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let members = client.group_members_list(group).await?;
|
||||
let mut table = Table::new(["user_id", "username", "role", "instance_role", "active"]);
|
||||
for m in members {
|
||||
table.row([
|
||||
m.user_id.to_string(),
|
||||
m.username,
|
||||
m.role.as_str().to_string(),
|
||||
format!("{:?}", m.instance_role).to_lowercase(),
|
||||
m.is_active.to_string(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn members_add(group: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let m = client
|
||||
.group_members_grant(group, user_id, parse_role(role)?)
|
||||
.await?;
|
||||
print_member(&m, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn members_set(group: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let m = client
|
||||
.group_members_set_role(group, user_id, parse_role(role)?)
|
||||
.await?;
|
||||
print_member(&m, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn members_rm(group: &str, user_id: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.group_members_remove(group, user_id).await?;
|
||||
println!("Removed {user_id} from {group}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_group(g: &Group, mode: OutputMode) {
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("id", g.id.to_string())
|
||||
.field("slug", g.slug.clone())
|
||||
.field("name", g.name.clone())
|
||||
.field(
|
||||
"parent_id",
|
||||
g.parent_id.map_or_else(|| "-".into(), |p| p.to_string()),
|
||||
)
|
||||
.field("created_at", g.created_at.to_rfc3339());
|
||||
block.print(mode);
|
||||
}
|
||||
|
||||
fn print_member(m: &crate::client::AppMemberDto, mode: OutputMode) {
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("user_id", m.user_id.to_string())
|
||||
.field("username", m.username.clone())
|
||||
.field("role", m.role.as_str().to_string());
|
||||
block.print(mode);
|
||||
}
|
||||
|
||||
fn parse_role(role: &str) -> Result<AppRole> {
|
||||
AppRole::from_db_str(role)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid role {role:?}; want app_admin | editor | viewer"))
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod apps_domains;
|
||||
pub mod config;
|
||||
pub mod dead_letters;
|
||||
pub mod files;
|
||||
pub mod groups;
|
||||
pub mod init;
|
||||
pub mod kv;
|
||||
pub mod login;
|
||||
|
||||
Reference in New Issue
Block a user