diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index eeb8a72..e53f9a4 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -12,7 +12,8 @@ use chrono::{DateTime, Utc}; use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; use picloud_shared::{ AdminUserId, ApiKeyId, App, AppDomain, AppId, AppRole, AppUser, DispatchMode, ExecutionLog, - HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind, ScriptSandbox, + Group, HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind, + ScriptSandbox, }; use reqwest::{header, Method, RequestBuilder, StatusCode}; use serde::{Deserialize, Serialize}; @@ -149,6 +150,144 @@ impl Client { decode_status(resp).await } + // --- Groups (Phase 2) ------------------------------------------------- + + /// `GET /api/v1/admin/groups` — the full flat list (assemble the tree + /// client-side from `parent_id`). + pub async fn groups_list(&self) -> Result> { + let resp = self + .request(Method::GET, "/api/v1/admin/groups") + .send() + .await?; + decode(resp).await + } + + /// `GET /api/v1/admin/groups/{id_or_slug}` — group + path + children. + pub async fn groups_get(&self, ident: &str) -> Result { + let ident = seg(ident); + let resp = self + .request(Method::GET, &format!("/api/v1/admin/groups/{ident}")) + .send() + .await?; + decode(resp).await + } + + /// `POST /api/v1/admin/groups` + pub async fn groups_create(&self, body: &CreateGroupBody<'_>) -> Result { + let resp = self + .request(Method::POST, "/api/v1/admin/groups") + .json(body) + .send() + .await?; + decode(resp).await + } + + /// `PATCH /api/v1/admin/groups/{id_or_slug}` — name/description only + /// (the slug is frozen). + pub async fn groups_rename( + &self, + ident: &str, + name: Option<&str>, + description: Option<&str>, + ) -> Result { + let ident = seg(ident); + let body = serde_json::json!({ "name": name, "description": description }); + let resp = self + .request(Method::PATCH, &format!("/api/v1/admin/groups/{ident}")) + .json(&body) + .send() + .await?; + decode(resp).await + } + + /// `POST /api/v1/admin/groups/{id_or_slug}/reparent` — `parent` is a + /// slug/id, or `None` to move to root. + pub async fn groups_reparent(&self, ident: &str, parent: Option<&str>) -> Result { + let ident = seg(ident); + let body = serde_json::json!({ "parent": parent }); + let resp = self + .request( + Method::POST, + &format!("/api/v1/admin/groups/{ident}/reparent"), + ) + .json(&body) + .send() + .await?; + decode(resp).await + } + + /// `DELETE /api/v1/admin/groups/{id_or_slug}` — 409 if non-empty. + pub async fn groups_delete(&self, ident: &str) -> Result<()> { + let ident = seg(ident); + let resp = self + .request(Method::DELETE, &format!("/api/v1/admin/groups/{ident}")) + .send() + .await?; + decode_status(resp).await + } + + pub async fn group_members_list(&self, group: &str) -> Result> { + let group = seg(group); + let resp = self + .request( + Method::GET, + &format!("/api/v1/admin/groups/{group}/members"), + ) + .send() + .await?; + decode(resp).await + } + + pub async fn group_members_grant( + &self, + group: &str, + user_id: &str, + role: AppRole, + ) -> Result { + let group = seg(group); + let body = serde_json::json!({ "user_id": user_id, "role": role }); + let resp = self + .request( + Method::POST, + &format!("/api/v1/admin/groups/{group}/members"), + ) + .json(&body) + .send() + .await?; + decode(resp).await + } + + pub async fn group_members_set_role( + &self, + group: &str, + user_id: &str, + role: AppRole, + ) -> Result { + let (group, user_id) = (seg(group), seg(user_id)); + let body = serde_json::json!({ "role": role }); + let resp = self + .request( + Method::PATCH, + &format!("/api/v1/admin/groups/{group}/members/{user_id}"), + ) + .json(&body) + .send() + .await?; + decode(resp).await + } + + pub async fn group_members_remove(&self, group: &str, user_id: &str) -> Result<()> { + let (group, user_id) = (seg(group), seg(user_id)); + let resp = self + .request( + Method::DELETE, + &format!("/api/v1/admin/groups/{group}/members/{user_id}"), + ) + .send() + .await?; + decode_status(resp).await + } + /// `DELETE /api/v1/admin/scripts/{id}` — requires `AppAdmin` on the /// owning app (stricter than the edit endpoints, by design). pub async fn scripts_delete(&self, id: &str) -> Result<()> { @@ -1049,6 +1188,31 @@ pub struct CreateAppBody<'a> { pub name: &'a str, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option<&'a str>, + /// Parent group (slug or id); omit for the instance root. + #[serde(skip_serializing_if = "Option::is_none")] + pub group: Option<&'a str>, +} + +#[derive(Debug, Serialize)] +pub struct CreateGroupBody<'a> { + pub slug: &'a str, + pub name: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option<&'a str>, + /// Parent group (slug or id); omit for a root-level group. + #[serde(skip_serializing_if = "Option::is_none")] + pub parent: Option<&'a str>, +} + +/// `GET /groups/{id}` response — the group plus its breadcrumb path and +/// direct children. +#[derive(Debug, Deserialize)] +pub struct GroupDetailDto { + #[serde(flatten)] + pub group: Group, + pub path: Vec, + pub subgroups: Vec, + pub apps: Vec, } #[derive(Debug, Serialize)] diff --git a/crates/picloud-cli/src/cmds/apps.rs b/crates/picloud-cli/src/cmds/apps.rs index d19894a..d4c7a94 100644 --- a/crates/picloud-cli/src/cmds/apps.rs +++ b/crates/picloud-cli/src/cmds/apps.rs @@ -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 diff --git a/crates/picloud-cli/src/cmds/groups.rs b/crates/picloud-cli/src/cmds/groups.rs new file mode 100644 index 0000000..a498a3a --- /dev/null +++ b/crates/picloud-cli/src/cmds/groups.rs @@ -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, 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, Vec<&Group>>, + parent: Option, + 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::>() + .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::>() + .join(", "), + ) + .field( + "apps", + detail + .apps + .iter() + .map(|a| a.slug.as_str()) + .collect::>() + .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 `. 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::from_db_str(role) + .ok_or_else(|| anyhow::anyhow!("invalid role {role:?}; want app_admin | editor | viewer")) +} diff --git a/crates/picloud-cli/src/cmds/mod.rs b/crates/picloud-cli/src/cmds/mod.rs index a0070ed..8725727 100644 --- a/crates/picloud-cli/src/cmds/mod.rs +++ b/crates/picloud-cli/src/cmds/mod.rs @@ -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; diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index 12bc481..6ac8a90 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -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, #[arg(long)] description: Option, + /// Parent group (slug or id). Defaults to the instance root. + #[arg(long)] + group: Option, }, /// 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, + #[arg(long)] + description: Option, + /// Parent group (slug or id). + #[arg(long)] + parent: Option, + }, + /// 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, + #[arg(long)] + description: Option, + }, + /// Move a group under a new parent (`--to` slug/id, or omit for root). + Reparent { + ident: String, + #[arg(long)] + to: Option, + }, + /// 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, diff --git a/crates/picloud-cli/tests/cli.rs b/crates/picloud-cli/tests/cli.rs index f1c8420..9ace257 100644 --- a/crates/picloud-cli/tests/cli.rs +++ b/crates/picloud-cli/tests/cli.rs @@ -23,6 +23,7 @@ mod dead_letters; mod email_queue; mod enabled; mod env_overlay; +mod groups; mod init; mod invoke; mod logs; diff --git a/crates/picloud-cli/tests/common/cleanup.rs b/crates/picloud-cli/tests/common/cleanup.rs index 5e4992d..a6c8822 100644 --- a/crates/picloud-cli/tests/common/cleanup.rs +++ b/crates/picloud-cli/tests/common/cleanup.rs @@ -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 { diff --git a/crates/picloud-cli/tests/groups.rs b/crates/picloud-cli/tests/groups.rs new file mode 100644 index 0000000..398ba9b --- /dev/null +++ b/crates/picloud-cli/tests/groups.rs @@ -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")); +}