From f666f18637930054992b2e7007c4b382bb6f132e Mon Sep 17 00:00:00 2001 From: xiuliang Date: Thu, 27 Aug 2026 18:43:50 +0800 Subject: [PATCH] feat: List the team's roles `role list` reads GET /admin/v1/roles: built-ins first with assignable / admin_grant_only flags, then the team's custom roles. Live assertions self-arm once apiservice#73 deploys; until then a 404 skips them loudly. Co-Authored-By: Claude Fable 5 --- README.md | 1 + crates/cli/src/commands/mod.rs | 1 + crates/cli/src/commands/role.rs | 27 ++++++++++++++++++ crates/cli/src/main.rs | 7 +++++ crates/cli/tests/admin/live.rs | 41 ++++++++++++++++++++++++++ crates/cli/tests/admin/offline.rs | 1 + crates/cli/tests/admin/wire.rs | 13 +++++++++ crates/core/src/api/admin/mod.rs | 2 ++ crates/core/src/api/admin/path.rs | 1 + crates/core/src/api/admin/roles.rs | 46 ++++++++++++++++++++++++++++++ 10 files changed, 140 insertions(+) create mode 100644 crates/cli/src/commands/role.rs create mode 100644 crates/core/src/api/admin/roles.rs diff --git a/README.md b/README.md index 1610d9d..edb58fd 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,7 @@ memorylake key create --name ci [--member ] [--expires-at UNIX_SEC memorylake key rotate memorylake key revoke +memorylake role list # what --role below accepts memorylake member list [--name FUZZY] [--page-size N] memorylake member create --name "CI Bot" --role tenant_member # virtual member memorylake member set-role --role tenant_admin diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index a89b6c4..9fe07dc 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -11,6 +11,7 @@ pub mod library; pub mod member; pub mod project; +pub mod role; pub mod search; pub mod team; pub mod usage; diff --git a/crates/cli/src/commands/role.rs b/crates/cli/src/commands/role.rs new file mode 100644 index 0000000..c601ec3 --- /dev/null +++ b/crates/cli/src/commands/role.rs @@ -0,0 +1,27 @@ +//! `memorylake role` commands. + +use anyhow::{Context, Result}; +use clap::Subcommand; +use memorylake_core::api::admin::list_roles; + +use super::{api_client, print_json}; + +/// Role subcommands. +#[derive(Debug, Subcommand)] +pub enum RoleCommand { + /// List the team's roles: built-ins first, then custom roles. The `key` + /// is what `member` and `invitation` commands accept as `--role`. + List, +} + +/// Execute a `role` subcommand. +pub fn run(command: RoleCommand, profile: Option, base_url: Option) -> Result<()> { + let client = api_client(profile, base_url)?; + + match command { + RoleCommand::List => { + let data = list_roles(&client).context("list roles")?; + print_json(&data) + } + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 7c97aa9..3f93456 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -18,6 +18,7 @@ use commands::invitation::{InvitationCommand, run as run_invitation}; use commands::library::{LibraryCommand, run as run_library}; use commands::member::{MemberCommand, run as run_member}; use commands::project::{ProjectCommand, run as run_project}; +use commands::role::{RoleCommand, run as run_role}; use commands::search::{SearchArgs, run as run_search}; use commands::team::{TeamCommand, run as run_team}; use commands::usage::{UsageArgs, run as run_usage}; @@ -122,6 +123,11 @@ enum Commands { #[command(subcommand)] command: MemberCommand, }, + /// List the roles members and invitees can hold. + Role { + #[command(subcommand)] + command: RoleCommand, + }, /// Invite people to the team and manage pending invitations. #[command(visible_alias = "invite")] Invitation { @@ -151,6 +157,7 @@ fn main() -> Result<()> { Commands::Team { command } => run_team(command, cli.profile, cli.base_url)?, Commands::ApiKey { command } => run_api_key(command, cli.profile, cli.base_url)?, Commands::Member { command } => run_member(command, cli.profile, cli.base_url)?, + Commands::Role { command } => run_role(command, cli.profile, cli.base_url)?, Commands::Invitation { command } => run_invitation(command, cli.profile, cli.base_url)?, Commands::Usage(args) => run_usage(args, cli.profile, cli.base_url)?, Commands::Version => { diff --git a/crates/cli/tests/admin/live.rs b/crates/cli/tests/admin/live.rs index 8973d25..d7db59b 100644 --- a/crates/cli/tests/admin/live.rs +++ b/crates/cli/tests/admin/live.rs @@ -155,6 +155,47 @@ fn every_read_endpoint_answers_and_the_team_joins_its_roster() { let _ = fs::remove_dir_all(&home); } +#[test] +fn the_role_catalog_matches_what_the_writes_enforce() { + let api_key = require_api_key(); + let home = temp_home(); + login_default(&home, &api_key); + + let args = ["role", "list"]; + let output = run(&home, &args); + // GET /admin/v1/roles ships with apiservice#73. Until that deploys, the + // path answers the envelope 404 — report it and stop rather than fail a + // suite that cannot see the endpoint yet. Once deployed, this branch goes + // dead and the assertions below take over for good. + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr); + assert!( + err.contains("404"), + "role list failed for a reason other than not-yet-deployed: {err}" + ); + eprintln!("role list: endpoint not deployed yet (404); skipping the catalog assertions"); + let _ = fs::remove_dir_all(&home); + return; + } + let stdout = assert_success(&output, &args); + let catalog = parse(&stdout, "role list"); + let roles = catalog.get("roles").and_then(|v| v.as_array()).unwrap(); + assert!(roles.len() >= 3, "built-ins missing: {stdout}"); + + // The catalog's promises must match the write endpoints' behaviour: the + // owner row says unassignable, and the built-ins lead in fixed order. + assert_eq!(str_field(&roles[0], "key"), "tenant_owner"); + assert_eq!(roles[0].get("assignable"), Some(&serde_json::json!(false))); + assert_eq!(str_field(&roles[1], "key"), "tenant_admin"); + assert_eq!( + roles[1].get("admin_grant_only"), + Some(&serde_json::json!(true)) + ); + assert_eq!(str_field(&roles[2], "key"), "tenant_member"); + + let _ = fs::remove_dir_all(&home); +} + #[test] fn usage_honours_the_period_and_its_cap() { let api_key = require_api_key(); diff --git a/crates/cli/tests/admin/offline.rs b/crates/cli/tests/admin/offline.rs index de104a8..ab69207 100644 --- a/crates/cli/tests/admin/offline.rs +++ b/crates/cli/tests/admin/offline.rs @@ -13,6 +13,7 @@ fn every_management_command_family_requires_login() { ["team", "get"].as_slice(), ["api-key", "list"].as_slice(), ["member", "list"].as_slice(), + ["role", "list"].as_slice(), ["invitation", "list"].as_slice(), ["usage"].as_slice(), ] { diff --git a/crates/cli/tests/admin/wire.rs b/crates/cli/tests/admin/wire.rs index 7391f7d..2b4e791 100644 --- a/crates/cli/tests/admin/wire.rs +++ b/crates/cli/tests/admin/wire.rs @@ -141,6 +141,19 @@ fn the_key_alias_reaches_the_same_endpoint() { assert_eq!(request_line(&request), "GET /admin/v1/api-keys HTTP/1.1"); } +#[test] +fn role_list_reads_the_catalog() { + let roles = r#"{"success":true,"data":{"roles":[{"key":"tenant_owner","label":"Owner","built_in":true,"assignable":false,"admin_grant_only":false},{"key":"tenant_custom_a1","label":"Ops","description":"billing","built_in":false,"assignable":true,"admin_grant_only":false,"parent_role_key":"tenant_member"}]}}"#; + let (request, output) = exchange(roles, &["role", "list"]); + let stdout = assert_success(&output, &["role", "list"]); + + assert_eq!(request_line(&request), "GET /admin/v1/roles HTTP/1.1"); + assert!( + stdout.contains("tenant_custom_a1") && stdout.contains("admin_grant_only"), + "catalog not printed: {stdout}" + ); +} + #[test] fn member_list_reads_the_roster() { let (request, output) = exchange(EMPTY_PAGE, &["member", "list"]); diff --git a/crates/core/src/api/admin/mod.rs b/crates/core/src/api/admin/mod.rs index 54ef8f6..d379eb7 100644 --- a/crates/core/src/api/admin/mod.rs +++ b/crates/core/src/api/admin/mod.rs @@ -16,6 +16,7 @@ mod api_keys; mod invitations; mod members; mod path; +mod roles; mod team; mod types; mod usage; @@ -30,6 +31,7 @@ pub use invitations::{ pub use members::{ CreateMemberRequest, create_member, list_members, remove_member, set_member_role, }; +pub use roles::{Role, RoleList, list_roles}; pub use team::{get_team, rename_team}; pub use types::{ ApiKey, ApiKeyCreated, Invitation, ListParams, Member, Page, Team, Usage, UsageByModel, diff --git a/crates/core/src/api/admin/path.rs b/crates/core/src/api/admin/path.rs index 2b68b0e..fe5fc40 100644 --- a/crates/core/src/api/admin/path.rs +++ b/crates/core/src/api/admin/path.rs @@ -10,6 +10,7 @@ pub(super) const TEAM: &str = "/admin/v1/team"; pub(super) const API_KEYS: &str = "/admin/v1/api-keys"; pub(super) const MEMBERS: &str = "/admin/v1/members"; pub(super) const INVITATIONS: &str = "/admin/v1/invitations"; +pub(super) const ROLES: &str = "/admin/v1/roles"; pub(super) const USAGE: &str = "/admin/v1/usage"; pub(super) fn api_key(id: &str) -> String { diff --git a/crates/core/src/api/admin/roles.rs b/crates/core/src/api/admin/roles.rs new file mode 100644 index 0000000..13bedac --- /dev/null +++ b/crates/core/src/api/admin/roles.rs @@ -0,0 +1,46 @@ +//! Roles of the team (`/admin/v1/roles`). + +use serde::{Deserialize, Serialize}; + +use crate::client::Client; +use crate::error::Result; + +use super::path; + +/// The team's role catalog: built-in roles first (owner, admin, member), +/// then this team's custom roles oldest-first. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoleList { + /// Every role of the team. + #[serde(default = "Vec::new")] + pub roles: Vec, +} + +/// One role. Its `key` is what the `--role` flags of `member` and +/// `invitation` commands accept. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Role { + /// The value the role fields of member and invitation writes accept. + pub key: String, + /// Display name: fixed English for built-ins, operator-authored for + /// custom roles. + pub label: String, + /// Operator-authored description. Only custom roles carry one. + #[serde(default)] + pub description: Option, + /// true for the three roles every team has. + pub built_in: bool, + /// Whether member and invitation writes accept this role. The owner role + /// never is — ownership transfer stays in the console. + pub assignable: bool, + /// When true, only a caller whose own role is owner or admin may grant it. + pub admin_grant_only: bool, + /// For custom roles: the role its policies were copied from at creation. + #[serde(default)] + pub parent_role_key: Option, +} + +/// List the team's roles. Any member may read this. +pub fn list_roles(client: &Client) -> Result { + client.get_data(path::ROLES, &[]) +}