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

@@ -51,6 +51,12 @@ enum Cmd {
cmd: AppsCmd,
},
/// Group (org-tree) management.
Groups {
#[command(subcommand)]
cmd: GroupsCmd,
},
/// Script management.
Scripts {
#[command(subcommand)]
@@ -401,6 +407,9 @@ enum AppsCmd {
name: Option<String>,
#[arg(long)]
description: Option<String>,
/// Parent group (slug or id). Defaults to the instance root.
#[arg(long)]
group: Option<String>,
},
/// Show a single app, including the caller's role in it.
@@ -423,6 +432,75 @@ enum AppsCmd {
},
}
#[derive(Subcommand)]
enum GroupsCmd {
/// List all groups (flat).
Ls,
/// Render the group hierarchy as an indented tree.
Tree,
/// Create a new group. Omit `--parent` for a root-level group.
Create {
slug: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
/// Parent group (slug or id).
#[arg(long)]
parent: Option<String>,
},
/// Show a group with its path, subgroups, and apps.
Show { ident: String },
/// Rename a group (name/description only — the slug is frozen).
Rename {
ident: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
},
/// Move a group under a new parent (`--to` slug/id, or omit for root).
Reparent {
ident: String,
#[arg(long)]
to: Option<String>,
},
/// Delete a group. Refused (409) if non-empty unless `--recursive`,
/// which deletes child groups leaf-first (apps are never auto-deleted).
Rm {
ident: String,
#[arg(long)]
recursive: bool,
},
/// Manage a group's members (inherited down the tree).
Members {
#[command(subcommand)]
cmd: GroupMembersCmd,
},
}
#[derive(Subcommand)]
enum GroupMembersCmd {
/// List a group's members.
Ls { group: String },
/// Grant a member a role on the group.
Add {
group: String,
user_id: String,
#[arg(long, default_value = "viewer")]
role: String,
},
/// Change a member's role.
Set {
group: String,
user_id: String,
#[arg(long)]
role: String,
},
/// Remove a member from the group.
Rm { group: String, user_id: String },
}
#[derive(Subcommand)]
enum DomainsCmd {
/// List the app's domain claims.
@@ -1176,8 +1254,18 @@ async fn main() -> ExitCode {
slug,
name,
description,
group,
},
} => cmds::apps::create(&slug, name.as_deref(), description.as_deref(), mode).await,
} => {
cmds::apps::create(
&slug,
name.as_deref(),
description.as_deref(),
group.as_deref(),
mode,
)
.await
}
Cmd::Apps {
cmd: AppsCmd::Show { ident },
} => cmds::apps::show(&ident, mode).await,
@@ -1201,6 +1289,79 @@ async fn main() -> ExitCode {
cmd: DomainsCmd::Rm { app, domain_id },
},
} => cmds::apps_domains::rm(&app, &domain_id).await,
Cmd::Groups { cmd: GroupsCmd::Ls } => cmds::groups::ls(mode).await,
Cmd::Groups {
cmd: GroupsCmd::Tree,
} => cmds::groups::tree(mode).await,
Cmd::Groups {
cmd:
GroupsCmd::Create {
slug,
name,
description,
parent,
},
} => {
cmds::groups::create(
&slug,
name.as_deref(),
description.as_deref(),
parent.as_deref(),
mode,
)
.await
}
Cmd::Groups {
cmd: GroupsCmd::Show { ident },
} => cmds::groups::show(&ident, mode).await,
Cmd::Groups {
cmd:
GroupsCmd::Rename {
ident,
name,
description,
},
} => cmds::groups::rename(&ident, name.as_deref(), description.as_deref(), mode).await,
Cmd::Groups {
cmd: GroupsCmd::Reparent { ident, to },
} => cmds::groups::reparent(&ident, to.as_deref(), mode).await,
Cmd::Groups {
cmd: GroupsCmd::Rm { ident, recursive },
} => cmds::groups::rm(&ident, recursive).await,
Cmd::Groups {
cmd:
GroupsCmd::Members {
cmd: GroupMembersCmd::Ls { group },
},
} => cmds::groups::members_ls(&group, mode).await,
Cmd::Groups {
cmd:
GroupsCmd::Members {
cmd:
GroupMembersCmd::Add {
group,
user_id,
role,
},
},
} => cmds::groups::members_add(&group, &user_id, &role, mode).await,
Cmd::Groups {
cmd:
GroupsCmd::Members {
cmd:
GroupMembersCmd::Set {
group,
user_id,
role,
},
},
} => cmds::groups::members_set(&group, &user_id, &role, mode).await,
Cmd::Groups {
cmd:
GroupsCmd::Members {
cmd: GroupMembersCmd::Rm { group, user_id },
},
} => cmds::groups::members_rm(&group, &user_id).await,
Cmd::Scripts {
cmd: ScriptsCmd::Ls { app },
} => cmds::scripts::ls(app.as_deref(), mode).await,