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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user