From 63c2a7fb1d690c65477853d95d3b85eee7171560 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Mon, 16 Jun 2025 22:02:45 +0100 Subject: [PATCH] Add role management commands --- ...18e9766dbed4aa10e1a57246fccd8bcedfe1.json} | 12 +- ...b64956fa02903597ac65c0ed6b0a527ca02aa.json | 32 +++ ...d6616f6642cdf93e55b85c6e0f7dae58cf229.json | 15 ++ ...b2d9d3e961fc1d7b707da92737380902fd2b8.json | 14 ++ ...283f41d9b9e84bbd895e1d53369524f611301.json | 15 ++ ...d7e0317d6f0a41368456032b5f0b5ecb58d7c.json | 15 ++ docs/SUMMARY.md | 1 + docs/admin/cli/role.md | 33 +++ docs/admin/cli/user.md | 20 +- src/bin/idcoop.rs | 14 +- src/cli.rs | 190 +++++++++++++++++- src/config.rs | 1 + src/store.rs | 101 +++++++++- src/web/oauth_openid/authorisation.rs | 2 + 14 files changed, 454 insertions(+), 11 deletions(-) rename .sqlx/{query-bbaa41c568bee22eae314186e0d7402f235f8eba9a4ed6adb7bfb6824ec86396.json => query-21c71fb90265218b7422c756264618e9766dbed4aa10e1a57246fccd8bcedfe1.json} (50%) create mode 100644 .sqlx/query-5e059b78f4ebbbd340021e41f2ab64956fa02903597ac65c0ed6b0a527ca02aa.json create mode 100644 .sqlx/query-81ca59dcf136d31e129e2d14fbbd6616f6642cdf93e55b85c6e0f7dae58cf229.json create mode 100644 .sqlx/query-8e4a982ac705a58413512a1064eb2d9d3e961fc1d7b707da92737380902fd2b8.json create mode 100644 .sqlx/query-9650a20bd3bb3eee0453d4a8cb2283f41d9b9e84bbd895e1d53369524f611301.json create mode 100644 .sqlx/query-d822ddde0ad92c6480e6e10f7bbd7e0317d6f0a41368456032b5f0b5ecb58d7c.json create mode 100644 docs/admin/cli/role.md diff --git a/.sqlx/query-bbaa41c568bee22eae314186e0d7402f235f8eba9a4ed6adb7bfb6824ec86396.json b/.sqlx/query-21c71fb90265218b7422c756264618e9766dbed4aa10e1a57246fccd8bcedfe1.json similarity index 50% rename from .sqlx/query-bbaa41c568bee22eae314186e0d7402f235f8eba9a4ed6adb7bfb6824ec86396.json rename to .sqlx/query-21c71fb90265218b7422c756264618e9766dbed4aa10e1a57246fccd8bcedfe1.json index 8ee4c1c..c70d5b7 100644 --- a/.sqlx/query-bbaa41c568bee22eae314186e0d7402f235f8eba9a4ed6adb7bfb6824ec86396.json +++ b/.sqlx/query-21c71fb90265218b7422c756264618e9766dbed4aa10e1a57246fccd8bcedfe1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT user_name, user_id, locked FROM users ORDER BY user_name", + "query": "SELECT user_name, user_id, locked, COALESCE((\n SELECT array_agg(role_id ORDER BY role_id) FROM users_roles ur WHERE ur.user_id = u.user_id\n ), ARRAY[]::text[]) AS \"roles!\" FROM users u ORDER BY user_name", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "ordinal": 2, "name": "locked", "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "roles!", + "type_info": "TextArray" } ], "parameters": { @@ -25,8 +30,9 @@ "nullable": [ false, false, - false + false, + null ] }, - "hash": "bbaa41c568bee22eae314186e0d7402f235f8eba9a4ed6adb7bfb6824ec86396" + "hash": "21c71fb90265218b7422c756264618e9766dbed4aa10e1a57246fccd8bcedfe1" } diff --git a/.sqlx/query-5e059b78f4ebbbd340021e41f2ab64956fa02903597ac65c0ed6b0a527ca02aa.json b/.sqlx/query-5e059b78f4ebbbd340021e41f2ab64956fa02903597ac65c0ed6b0a527ca02aa.json new file mode 100644 index 0000000..f8ca296 --- /dev/null +++ b/.sqlx/query-5e059b78f4ebbbd340021e41f2ab64956fa02903597ac65c0ed6b0a527ca02aa.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT role_id, role_name, (\n SELECT COUNT(1) FROM users_roles ur WHERE ur.role_id = r.role_id\n ) AS \"num_users!\" FROM roles r ORDER BY role_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "role_id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "role_name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "num_users!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + null + ] + }, + "hash": "5e059b78f4ebbbd340021e41f2ab64956fa02903597ac65c0ed6b0a527ca02aa" +} diff --git a/.sqlx/query-81ca59dcf136d31e129e2d14fbbd6616f6642cdf93e55b85c6e0f7dae58cf229.json b/.sqlx/query-81ca59dcf136d31e129e2d14fbbd6616f6642cdf93e55b85c6e0f7dae58cf229.json new file mode 100644 index 0000000..ea7594c --- /dev/null +++ b/.sqlx/query-81ca59dcf136d31e129e2d14fbbd6616f6642cdf93e55b85c6e0f7dae58cf229.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO roles (role_id, role_name) VALUES ($1, $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "81ca59dcf136d31e129e2d14fbbd6616f6642cdf93e55b85c6e0f7dae58cf229" +} diff --git a/.sqlx/query-8e4a982ac705a58413512a1064eb2d9d3e961fc1d7b707da92737380902fd2b8.json b/.sqlx/query-8e4a982ac705a58413512a1064eb2d9d3e961fc1d7b707da92737380902fd2b8.json new file mode 100644 index 0000000..8faed73 --- /dev/null +++ b/.sqlx/query-8e4a982ac705a58413512a1064eb2d9d3e961fc1d7b707da92737380902fd2b8.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM roles WHERE role_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "8e4a982ac705a58413512a1064eb2d9d3e961fc1d7b707da92737380902fd2b8" +} diff --git a/.sqlx/query-9650a20bd3bb3eee0453d4a8cb2283f41d9b9e84bbd895e1d53369524f611301.json b/.sqlx/query-9650a20bd3bb3eee0453d4a8cb2283f41d9b9e84bbd895e1d53369524f611301.json new file mode 100644 index 0000000..a767c73 --- /dev/null +++ b/.sqlx/query-9650a20bd3bb3eee0453d4a8cb2283f41d9b9e84bbd895e1d53369524f611301.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users_roles (user_id, role_id, granted_at_utc) VALUES ($1, $2, NOW())", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9650a20bd3bb3eee0453d4a8cb2283f41d9b9e84bbd895e1d53369524f611301" +} diff --git a/.sqlx/query-d822ddde0ad92c6480e6e10f7bbd7e0317d6f0a41368456032b5f0b5ecb58d7c.json b/.sqlx/query-d822ddde0ad92c6480e6e10f7bbd7e0317d6f0a41368456032b5f0b5ecb58d7c.json new file mode 100644 index 0000000..2eea7b5 --- /dev/null +++ b/.sqlx/query-d822ddde0ad92c6480e6e10f7bbd7e0317d6f0a41368456032b5f0b5ecb58d7c.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM users_roles WHERE user_id = $1 AND role_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "d822ddde0ad92c6480e6e10f7bbd7e0317d6f0a41368456032b5f0b5ecb58d7c" +} diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 671305a..a2724bd 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -6,6 +6,7 @@ - [Command Line Tool](admin/cli/index.md) - [serve](admin/cli/serve.md) - [user](admin/cli/user.md) + - [role](admin/cli/role.md) # Development diff --git a/docs/admin/cli/role.md b/docs/admin/cli/role.md new file mode 100644 index 0000000..886c784 --- /dev/null +++ b/docs/admin/cli/role.md @@ -0,0 +1,33 @@ +# `idcoop role` — role management commands + +## `idcoop role add` — add a new role + +``` +idcoop role add [--name ROLE_NAME] +``` +aliases: `new`, `create` + +Adds a role. +The ROLE identifier must consist of only alphanumeric characters +(this may be expanded in the future). + +The optional `--name` flag can be used to give a human-readable name +to the role. + + +## `idcoop role rm` — remove a role + +``` +idcoop role rm +``` +aliases: `del`, `delete`, `remove` + +Removes a role. + + +## `idcoop role list` — list all roles + +``` +idcoop role list +``` +aliases: `ls` diff --git a/docs/admin/cli/user.md b/docs/admin/cli/user.md index b3ba59e..dd73726 100644 --- a/docs/admin/cli/user.md +++ b/docs/admin/cli/user.md @@ -53,15 +53,31 @@ idcoop user - ``: name of the user to be locked or unlocked -## `idcoop user list-all` — list all users +## `idcoop user list` — list all users Displays a list of users in tabular form. ``` -idcoop user list-all [--usernames] +idcoop user list [--usernames] ``` aliases: `idcoop user ls` - `--usernames`: if specified, only the usernames of users will be shown, one per line. The output of this command is not considered stable, and should not be used in scripts, unless the `--usernames` option is used. + +## `idcoop user role-add` — add users to a role + +The role must exist prior to adding any users to it. + +``` +idcoop user role-add +``` +aliases: `grant` + +## `idcoop user role-rm` — remove users from a role + +``` +idcoop user role-rm +``` +aliases: `revoke`, `role-remove` diff --git a/src/bin/idcoop.rs b/src/bin/idcoop.rs index d02a75d..4207de6 100644 --- a/src/bin/idcoop.rs +++ b/src/bin/idcoop.rs @@ -4,7 +4,7 @@ use std::{net::SocketAddr, path::PathBuf}; use clap::Parser; use confique::{Config, Partial}; use eyre::{bail, Context}; -use idcoop::cli::{handle_user_command, UserCommand}; +use idcoop::cli::{handle_role_command, handle_user_command, RoleCommand, UserCommand}; use idcoop::config::{SecretConfig, SeparateSecretConfiguration}; use idcoop::store::IdCoopStore; use idcoop::{config::Configuration, web}; @@ -40,6 +40,12 @@ enum Subcommand { #[clap(subcommand)] cmd: UserCommand, }, + + /// Manage roles. + Role { + #[clap(subcommand)] + cmd: RoleCommand, + }, } fn load_config_files(files: &[PathBuf]) -> eyre::Result { @@ -111,6 +117,12 @@ async fn main() -> eyre::Result<()> { .context("Failed to connect to Postgres")?; handle_user_command(cmd, &config, &store).await?; } + Subcommand::Role { cmd } => { + let store = IdCoopStore::connect(&config.postgres.connect) + .await + .context("Failed to connect to Postgres")?; + handle_role_command(cmd, &config, &store).await?; + } } Ok(()) diff --git a/src/cli.rs b/src/cli.rs index 9d460c3..5fadefa 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,14 +1,16 @@ //! idCoop Command Line Interface +use std::collections::BTreeSet; use std::io::stdin; use crate::config::Configuration; use crate::passwords::create_password_hash; -use crate::store::{CreateUser, IdCoopStore}; +use crate::store::{CreateRole, CreateUser, IdCoopStore, IdCoopStoreTxn}; use clap::Parser; use comfy_table::presets::UTF8_FULL; use comfy_table::{Attribute, Cell, Color, ContentArrangement, Row, Table}; use eyre::{bail, Context, ContextCompat}; +use uuid::Uuid; /// Commands for user management. #[derive(Clone, Parser)] @@ -49,11 +51,27 @@ pub enum UserCommand { }, /// Lists all users that are registered. #[clap(alias = "ls")] - ListAll { + List { /// Only show a list of usernames, without table formatting characters and one per line. May be useful in scripts. #[clap(long = "usernames")] usernames: bool, }, + /// Adds users to a role. + #[clap(alias = "grant")] + RoleAdd { + /// The ID of the role to add users to. + role: String, + /// The names of users to add to the role. + usernames: Vec, + }, + /// Adds users to a role. + #[clap(alias = "role-rm", alias = "revoke")] + RoleRemove { + /// The ID of the role to remove users from. + role: String, + /// The names of users to remove from the role. + usernames: Vec, + }, } /// Handles a user command from the command-line interface. @@ -143,7 +161,7 @@ pub async fn handle_user_command( }) .await?; } - UserCommand::ListAll { usernames } => { + UserCommand::List { usernames } => { let user_infos = store .txn(|mut txn| Box::pin(async move { txn.list_user_info().await })) .await?; @@ -157,11 +175,12 @@ pub async fn handle_user_command( table .load_preset(UTF8_FULL) .set_content_arrangement(ContentArrangement::Dynamic) - .set_width(80) + // .set_width(80) .set_header(vec![ Cell::new("Name").add_attribute(Attribute::Bold), Cell::new("UUID").add_attribute(Attribute::Bold), Cell::new("Locked").add_attribute(Attribute::Bold), + Cell::new("Roles").add_attribute(Attribute::Bold), ]); for user_info in user_infos { @@ -174,12 +193,175 @@ pub async fn handle_user_command( ("no", Color::White) }; row.add_cell(Cell::new(lock_str).fg(lock_colour)); + row.add_cell(Cell::new(user_info.roles.join(", "))); table.add_row(row); } println!("{}", table); } } + UserCommand::RoleAdd { role, usernames } => { + let missing_opt = store + .txn(|mut txn| { + Box::pin(async move { + let user_ids = match resolve_usernames(&mut txn, &usernames).await? { + Ok(found) => found, + Err(missing) => { + return Ok(Some(missing)); + } + }; + + for user_id in user_ids { + txn.add_user_to_role(user_id, &role).await?; + } + + Ok(None) + }) + }) + .await?; + if let Some(missing) = missing_opt { + bail!("Unknown users: {missing:?}"); + } + } + UserCommand::RoleRemove { role, usernames } => { + let missing_opt = store + .txn(|mut txn| { + Box::pin(async move { + let user_ids = match resolve_usernames(&mut txn, &usernames).await? { + Ok(found) => found, + Err(missing) => { + return Ok(Some(missing)); + } + }; + + for user_id in user_ids { + txn.remove_user_from_role(user_id, &role).await?; + } + + Ok(None) + }) + }) + .await?; + if let Some(missing) = missing_opt { + bail!("Unknown users: {missing:?}"); + } + } + } + Ok(()) +} + +/// Resolves usernames to UUIDs, returning them (in the same order). +/// +/// If any of the usernames don't exist, returns a set of the usernames that don't exist instead. +async fn resolve_usernames( + txn: &mut IdCoopStoreTxn<'_, '_>, + usernames: &[String], +) -> eyre::Result, BTreeSet>> { + let mut missing = BTreeSet::new(); + let mut found = Vec::new(); + + // Find user IDs + for user_name in usernames { + match txn.lookup_user_by_name(user_name.clone()).await? { + Some(user) => { + found.push(user.user_id); + } + None => { + missing.insert(user_name.clone()); + } + } + } + + if !missing.is_empty() { + return Ok(Err(missing)); + } + + Ok(Ok(found)) +} + +/// Commands for user management. +#[derive(Clone, Parser)] +pub enum RoleCommand { + /// Add a role. + #[clap(alias = "new", alias = "create")] + Add { + /// The role ID. Must only consist of alphanumeric characters. + role: String, + + /// Human-readable name for the role/ + #[clap(long = "name")] + name: Option, + }, + /// Deletes a role. + #[clap(alias = "remove", alias = "rm", alias = "del")] + Delete { + /// The role ID. + role: String, + }, + /// List all roles. + #[clap(alias = "ls")] + List {}, +} + +/// Handles a role command from the command-line interface. +pub async fn handle_role_command( + command: RoleCommand, + _config: &Configuration, + store: &IdCoopStore, +) -> eyre::Result<()> { + match command { + RoleCommand::Add { role, name } => { + let name = name.unwrap_or_else(|| role.clone()); + + store + .txn(|mut txn| { + Box::pin(async move { + txn.create_role(CreateRole { + role_id: role, + role_name: name, + }) + .await + }) + }) + .await?; + } + RoleCommand::Delete { role } => { + store + .txn(|mut txn| Box::pin(async move { txn.delete_role(&role).await })) + .await?; + } + RoleCommand::List {} => { + let role_infos = store + .txn(|mut txn| Box::pin(async move { txn.list_role_info().await })) + .await?; + + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + // .set_width(80) + .set_header(vec![ + Cell::new("Role ID").add_attribute(Attribute::Bold), + Cell::new("Name").add_attribute(Attribute::Bold), + Cell::new("Users").add_attribute(Attribute::Bold), + ]); + + for role_info in role_infos { + let mut row = Row::new(); + row.add_cell(Cell::new(role_info.role_id)); + row.add_cell(Cell::new(role_info.role_name).fg(Color::Grey)); + row.add_cell( + Cell::new(role_info.num_users).fg(if role_info.num_users == 0 { + Color::Red + } else { + Color::White + }), + ); + table.add_row(row); + } + + println!("{}", table); + } } Ok(()) } diff --git a/src/config.rs b/src/config.rs index 5dd1b17..d728b9b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -110,6 +110,7 @@ pub struct OidcClientConfiguration { pub name: String, /// User roles to allow to access this application. + /// Users must satisfy ONE OF the specified roles in order to proceed. /// /// The `*` 'role' can be used to allow all users to access this application. /// diff --git a/src/store.rs b/src/store.rs index 7af9818..94be615 100644 --- a/src/store.rs +++ b/src/store.rs @@ -7,6 +7,7 @@ use std::collections::BTreeSet; use chrono::DateTime; use chrono::NaiveDateTime; use chrono::Utc; +use eyre::ensure; use eyre::eyre; use eyre::Context; use futures::future::BoxFuture; @@ -98,6 +99,14 @@ pub struct CreateUser { pub locked: bool, } +/// Representation of the action of creating a role. +pub struct CreateRole { + /// Is alphanumeric. + pub role_id: String, + /// Human-readable name of the role. + pub role_name: String, +} + /// Basic information about a user pub struct UserInfo { /// The unique system name for the user. @@ -108,6 +117,21 @@ pub struct UserInfo { pub user_id: Uuid, /// Whether the user is locked and is therefore not allowed to log in. pub locked: bool, + /// List of role IDs the user is in + pub roles: Vec, +} + +/// Basic information about a role +pub struct RoleInfo { + /// The ID of the role. + /// Is alphanumeric. + pub role_id: String, + + /// The human-readable name of the role. + pub role_name: String, + + /// How many users are in the role. + pub num_users: i64, } /// A wrapper around a database transaction with some database methods on it. @@ -298,7 +322,9 @@ impl IdCoopStoreTxn<'_, '_> { pub async fn list_user_info(&mut self) -> eyre::Result> { sqlx::query_as!( UserInfo, - "SELECT user_name, user_id, locked FROM users ORDER BY user_name" + r#"SELECT user_name, user_id, locked, COALESCE(( + SELECT array_agg(role_id ORDER BY role_id) FROM users_roles ur WHERE ur.user_id = u.user_id + ), ARRAY[]::text[]) AS "roles!" FROM users u ORDER BY user_name"# ) .fetch_all(&mut **self.txn) .await @@ -430,4 +456,77 @@ impl IdCoopStoreTxn<'_, '_> { .context("failed to fetch roleset of user") .map(|v| v.into_iter().collect()) } + + /// Creates a role. + pub async fn create_role(&mut self, cr: CreateRole) -> eyre::Result<()> { + ensure!( + cr.role_id.chars().all(|c| c.is_ascii_alphanumeric()), + "attempted to create role {} with non-alphanum ID", + cr.role_id + ); + + // TODO(prettiness): handle the case where the role already exists + // in a nicer way + sqlx::query!( + "INSERT INTO roles (role_id, role_name) VALUES ($1, $2)", + &cr.role_id, + &cr.role_name + ) + .execute(&mut **self.txn) + .await + .context("failed to create role in DB")?; + + Ok(()) + } + + /// Deletes a role. Silently no-ops if the role doesn't exist. + pub async fn delete_role(&mut self, role_id: &str) -> eyre::Result<()> { + sqlx::query!("DELETE FROM roles WHERE role_id = $1", role_id) + .execute(&mut **self.txn) + .await + .context("failed to delete role in DB")?; + Ok(()) + } + + /// Lists all roles and provides some information about them. + pub async fn list_role_info(&mut self) -> eyre::Result> { + sqlx::query_as!( + RoleInfo, + r#"SELECT role_id, role_name, ( + SELECT COUNT(1) FROM users_roles ur WHERE ur.role_id = r.role_id + ) AS "num_users!" FROM roles r ORDER BY role_id"# + ) + .fetch_all(&mut **self.txn) + .await + .context("failed to list roles") + } + + /// Adds a user to a role. + pub async fn add_user_to_role(&mut self, user_id: Uuid, role_id: &str) -> eyre::Result<()> { + sqlx::query!( + "INSERT INTO users_roles (user_id, role_id, granted_at_utc) VALUES ($1, $2, NOW())", + user_id, + role_id + ) + .execute(&mut **self.txn) + .await + .context("failed to add user to role")?; + Ok(()) + } + /// Removes a user to a role. + pub async fn remove_user_from_role( + &mut self, + user_id: Uuid, + role_id: &str, + ) -> eyre::Result<()> { + sqlx::query!( + "DELETE FROM users_roles WHERE user_id = $1 AND role_id = $2", + user_id, + role_id + ) + .execute(&mut **self.txn) + .await + .context("failed to remove user from role")?; + Ok(()) + } } diff --git a/src/web/oauth_openid/authorisation.rs b/src/web/oauth_openid/authorisation.rs index 068b758..e53095c 100644 --- a/src/web/oauth_openid/authorisation.rs +++ b/src/web/oauth_openid/authorisation.rs @@ -30,6 +30,8 @@ use crate::{ use super::ext_codes::VolatileCodeStore; +/// Role string that always identifies anyone. +/// Not a real role. pub const EVERYONE_ROLE: &str = "*"; /// Query string parameters for the OIDC authorisation request.