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:
@@ -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<Vec<Group>> {
|
||||
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<GroupDetailDto> {
|
||||
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<Group> {
|
||||
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<Group> {
|
||||
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<Group> {
|
||||
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<Vec<AppMemberDto>> {
|
||||
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<AppMemberDto> {
|
||||
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<AppMemberDto> {
|
||||
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<Group>,
|
||||
pub subgroups: Vec<Group>,
|
||||
pub apps: Vec<App>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
||||
Reference in New Issue
Block a user