Add role management commands

This commit is contained in:
Olivier 'reivilibre 2025-06-16 22:02:45 +01:00
parent 301302f1d0
commit 63c2a7fb1d
14 changed files with 454 additions and 11 deletions

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM roles WHERE role_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "8e4a982ac705a58413512a1064eb2d9d3e961fc1d7b707da92737380902fd2b8"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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

33
docs/admin/cli/role.md Normal file
View File

@ -0,0 +1,33 @@
# `idcoop role` — role management commands
## `idcoop role add` — add a new role
```
idcoop role add <ROLE> [--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 <ROLE>
```
aliases: `del`, `delete`, `remove`
Removes a role.
## `idcoop role list` — list all roles
```
idcoop role list
```
aliases: `ls`

View File

@ -53,15 +53,31 @@ idcoop user <lock|unlock> <USERNAME>
- `<USERNAME>`: 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 <ROLE> <USERNAME...>
```
aliases: `grant`
## `idcoop user role-rm` — remove users from a role
```
idcoop user role-rm <ROLE> <USERNAME...>
```
aliases: `revoke`, `role-remove`

View File

@ -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<C: Config>(files: &[PathBuf]) -> eyre::Result<C> {
@ -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(())

View File

@ -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<String>,
},
/// 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<String>,
},
}
/// 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<Result<Vec<Uuid>, BTreeSet<String>>> {
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<String>,
},
/// 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(())
}

View File

@ -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.
///

View File

@ -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<String>,
}
/// 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<Vec<UserInfo>> {
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<Vec<RoleInfo>> {
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(())
}
}

View File

@ -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.