Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ memorylake key create --name ci [--member <principal-id>] [--expires-at UNIX_SEC
memorylake key rotate <id>
memorylake key revoke <id>

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 <principal-id> --role tenant_admin
Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions crates/cli/src/commands/role.rs
Original file line number Diff line number Diff line change
@@ -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<String>, base_url: Option<String>) -> Result<()> {
let client = api_client(profile, base_url)?;

match command {
RoleCommand::List => {
let data = list_roles(&client).context("list roles")?;
print_json(&data)
}
}
}
7 changes: 7 additions & 0 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 => {
Expand Down
41 changes: 41 additions & 0 deletions crates/cli/tests/admin/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions crates/cli/tests/admin/offline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
] {
Expand Down
13 changes: 13 additions & 0 deletions crates/cli/tests/admin/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
2 changes: 2 additions & 0 deletions crates/core/src/api/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ mod api_keys;
mod invitations;
mod members;
mod path;
mod roles;
mod team;
mod types;
mod usage;
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/core/src/api/admin/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
46 changes: 46 additions & 0 deletions crates/core/src/api/admin/roles.rs
Original file line number Diff line number Diff line change
@@ -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<Role>,
}

/// 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<String>,
/// 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<String>,
}

/// List the team's roles. Any member may read this.
pub fn list_roles(client: &Client) -> Result<RoleList> {
client.get_data(path::ROLES, &[])
}
Loading