From a0de3b287b71a9d604226a4a281536fec345d734 Mon Sep 17 00:00:00 2001 From: Matthieu Bessat Date: Tue, 3 Dec 2024 13:20:33 +0100 Subject: [PATCH] feat(admin): create and list users commands --- Cargo.lock | 8 ++ Cargo.toml | 1 + admin.sh | 3 + justfile | 13 +-- lib/admin_cli/Cargo.toml | 15 +++- lib/admin_cli/src/commands/mod.rs | 1 + lib/admin_cli/src/commands/users.rs | 115 +++++++++++++++++++++++++++ lib/admin_cli/src/main.rs | 51 +++++++++++- lib/http_server/Cargo.toml | 1 + lib/http_server/src/lib.rs | 14 ++-- lib/http_server/src/main.rs | 6 +- lib/kernel/Cargo.toml | 3 +- lib/kernel/src/actions/mod.rs | 1 + lib/kernel/src/actions/user.rs | 0 lib/kernel/src/actions/users.rs | 44 ++++++++++ lib/kernel/src/context.rs | 16 +++- lib/kernel/src/models/user.rs | 29 +++++++ lib/kernel/src/repositories/users.rs | 11 ++- lib/utils/src/lib.rs | 12 +++ 19 files changed, 314 insertions(+), 30 deletions(-) create mode 100755 admin.sh create mode 100644 lib/admin_cli/src/commands/mod.rs create mode 100644 lib/admin_cli/src/commands/users.rs delete mode 100644 lib/kernel/src/actions/user.rs create mode 100644 lib/kernel/src/actions/users.rs diff --git a/Cargo.lock b/Cargo.lock index b4f9f1a..2874db8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,7 +22,13 @@ name = "admin_cli" version = "0.0.0" dependencies = [ "anyhow", + "argh", + "env_logger", "fully_pub", + "kernel", + "log", + "thiserror 2.0.3", + "tokio", ] [[package]] @@ -965,6 +971,7 @@ dependencies = [ "sqlx", "strum", "strum_macros", + "thiserror 2.0.3", "time", "tokio", "tower-http", @@ -1255,6 +1262,7 @@ dependencies = [ "sqlx", "strum", "strum_macros", + "thiserror 2.0.3", "toml", "url", "utils", diff --git a/Cargo.toml b/Cargo.toml index c6d0713..edef17d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ [workspace.dependencies] # commons utils anyhow = "1.0" +thiserror = "2" fully_pub = "0.1" strum = "0.26.3" strum_macros = "0.26" diff --git a/admin.sh b/admin.sh new file mode 100755 index 0000000..67faba1 --- /dev/null +++ b/admin.sh @@ -0,0 +1,3 @@ +#!/usr/bin/sh + +cargo run -q --bin minauthator-admin -- --config ./config.toml --database ./tmp/dbs/minauthator.db --static-assets ./assets $@ diff --git a/justfile b/justfile index 8bc1c40..fc10ea4 100644 --- a/justfile +++ b/justfile @@ -1,12 +1,15 @@ export RUST_BACKTRACE := "1" export RUST_LOG := "trace" -export RUN_ARGS := "run --bin minauthator-server -- --config ./config.toml --database ./tmp/dbs/minauthator.db --static-assets ./assets" +export CONTEXT_ARGS := "--config ./config.toml --database ./tmp/dbs/minauthator.db --static-assets ./assets" -watch-run: - cargo-watch -x "$RUN_ARGS" +watch-server: + cargo-watch -x "run --bin minauthator-server -- $CONTEXT_ARGS" -run: - cargo $RUN_ARGS +server: + cargo run --bin minauthator-server -- $CONTEXT_ARGS + +admin: + cargo run --bin minauthator-admin -- $CONTEXT_ARGS docker-run: docker run -p 3085:8080 -v ./tmp/docker/config:/etc/minauthator -v ./tmp/docker/db:/var/lib/minauthator minauthator diff --git a/lib/admin_cli/Cargo.toml b/lib/admin_cli/Cargo.toml index 97e0107..4268cef 100644 --- a/lib/admin_cli/Cargo.toml +++ b/lib/admin_cli/Cargo.toml @@ -2,10 +2,17 @@ name = "admin_cli" edition = "2021" -[dependencies] -anyhow = { workspace = true } -fully_pub = { workspace = true } - [[bin]] name = "minauthator-admin" path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +thiserror = { workspace = true } +fully_pub = { workspace = true } +argh = { workspace = true } +tokio = { workspace = true } +log = { workspace = true } +env_logger = { workspace = true } + +kernel = { path = "../kernel" } diff --git a/lib/admin_cli/src/commands/mod.rs b/lib/admin_cli/src/commands/mod.rs new file mode 100644 index 0000000..913bd46 --- /dev/null +++ b/lib/admin_cli/src/commands/mod.rs @@ -0,0 +1 @@ +pub mod users; diff --git a/lib/admin_cli/src/commands/users.rs b/lib/admin_cli/src/commands/users.rs new file mode 100644 index 0000000..c3268ba --- /dev/null +++ b/lib/admin_cli/src/commands/users.rs @@ -0,0 +1,115 @@ +use anyhow::{Context, Result}; +use argh::FromArgs; +use fully_pub::fully_pub; +use kernel::{context::KernelContext, models::user::User, repositories::users::get_users}; +use log::info; + +#[fully_pub] +#[derive(FromArgs, PartialEq, Debug)] +#[argh( + subcommand, name = "create", + description = "Create user in DB." +)] +struct CreateUserCommand { + /// aka login, username + #[argh(option)] + handle: String, + + /// displayed name (eg. first name and last name) + #[argh(option)] + full_name: Option, + + /// use to identify and prove user identity + /// formated as specified in RFC 2821, RFC 3696 + #[argh(option)] + email: String, + + /// if true, create an invitation token + #[argh(switch)] + invite: bool +} + +#[fully_pub] +#[derive(FromArgs, PartialEq, Debug)] +#[argh( + subcommand, name = "delete", + description = "Delete user in DB." +)] +struct DeleteUserCommand { + /// delete by user Uuid + #[argh(option)] + id: String +} + + +#[fully_pub] +#[derive(FromArgs, PartialEq, Debug)] +#[argh( + subcommand, name = "list", + description = "List users in DB." +)] +struct ListUsersCommand { + /// how many users to return + #[argh(option)] + limit: Option, +} + +#[fully_pub] +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand)] +enum UsersSubCommands { + Create(CreateUserCommand), + List(ListUsersCommand), + Delete(DeleteUserCommand), +} + +#[fully_pub] +#[derive(FromArgs, PartialEq, Debug)] +#[argh( + subcommand, name = "users", + description = "Manage instance users." +)] +struct UsersCommand { + #[argh(subcommand)] + nested: UsersSubCommands, +} + + +pub async fn list_users(cmd: ListUsersCommand, ctx: KernelContext) -> Result<()> { + for user in get_users(&ctx.storage).await? { + println!( + "{0: <36} | [{1:<8}] | {2: <15} | {3: <25}", + user.id, user.status, user.handle, user.email.unwrap_or("()".to_string()) + ); + } + Ok(()) +} + +pub async fn create_user(cmd: CreateUserCommand, ctx: KernelContext) -> Result<()> { + let mut user = User::new(cmd.handle); + user.email = Some(cmd.email); + user.full_name = cmd.full_name; + if cmd.invite { + user.invite(); + println!("Generated invite code: {}", user.reset_password_token.as_ref().unwrap()); + } + let _res = kernel::actions::users::create_user(ctx, user).await?; + info!("Created user."); + if cmd.invite { + // TODO: Send invitation email + info!("Not sending invitation email."); + } + Ok(()) +} + +pub async fn delete_user(cmd: DeleteUserCommand, ctx: KernelContext) -> Result<()> { + todo!() +} + +pub async fn handle_command_tree(cmd: UsersCommand, ctx: KernelContext) -> Result<()> { + match cmd.nested { + UsersSubCommands::List(sc) => list_users(sc, ctx).await, + UsersSubCommands::Create(sc) => create_user(sc, ctx).await, + UsersSubCommands::Delete(sc) => delete_user(sc, ctx).await + } +} diff --git a/lib/admin_cli/src/main.rs b/lib/admin_cli/src/main.rs index 9cf2d88..19f4849 100644 --- a/lib/admin_cli/src/main.rs +++ b/lib/admin_cli/src/main.rs @@ -1,6 +1,49 @@ -use anyhow::Result; +use argh::FromArgs; +use anyhow::{Context, Result}; +use commands::users; +use kernel::context::{get_kernel_context, StartKernelConfig}; +use log::info; -fn main() -> Result<()> { - println!("Starting minauthator admin CLI"); - Ok(()) +pub mod commands; + +#[derive(FromArgs, PartialEq, Debug)] +#[argh(subcommand)] +enum SubCommands { + Users(users::UsersCommand), +} + +#[derive(FromArgs, PartialEq, Debug)] +/// Minauthator admin top level +struct AdminCliTopLevelCommand { + #[argh(subcommand)] + nested: SubCommands, + + /// path to YAML config file to use to configure this instance + #[argh(option)] + config: Option, + + /// path to the Sqlite3 DB file to use + #[argh(option)] + database: Option, + + /// path to the static assets dir + #[argh(option)] + static_assets: Option, +} + +/// handle CLI arguments to run admin CLI +#[tokio::main] +pub async fn main() -> Result<()> { + info!("Starting minauth"); + let command_input: AdminCliTopLevelCommand = argh::from_env(); + let ctx = get_kernel_context(StartKernelConfig { + config_path: command_input.config.clone(), + database_path: command_input.database.clone() + }).await.context("Getting kernel context")?; + + match command_input.nested { + SubCommands::Users(sc) => { + users::handle_command_tree(sc, ctx).await + } + } } diff --git a/lib/http_server/Cargo.toml b/lib/http_server/Cargo.toml index 8695cf6..260d58c 100644 --- a/lib/http_server/Cargo.toml +++ b/lib/http_server/Cargo.toml @@ -14,6 +14,7 @@ strum = { workspace = true } strum_macros = { workspace = true } anyhow = { workspace = true } +thiserror = { workspace = true } fully_pub = { workspace = true } tokio = { workspace = true } diff --git a/lib/http_server/src/lib.rs b/lib/http_server/src/lib.rs index cc32177..7962a8c 100644 --- a/lib/http_server/src/lib.rs +++ b/lib/http_server/src/lib.rs @@ -7,7 +7,7 @@ pub mod token_claims; use fully_pub::fully_pub; use anyhow::{Result, Context}; -use kernel::{context::AppSecrets, models::config::Config, repositories::storage::Storage}; +use kernel::{context::{AppSecrets, KernelContext}, models::config::Config, repositories::storage::Storage}; use log::info; use minijinja::Environment; @@ -38,16 +38,14 @@ pub struct AppState { pub async fn start_http_server( server_config: ServerConfig, - config: Config, - secrets: AppSecrets, - db_pool: Storage + ctx: KernelContext ) -> Result<()> { // build state let state = AppState { - templating_env: build_templating_env(&config), - config, - secrets, - db: db_pool + templating_env: build_templating_env(&ctx.config), + config: ctx.config, + secrets: ctx.secrets, + db: ctx.storage }; // build routes diff --git a/lib/http_server/src/main.rs b/lib/http_server/src/main.rs index 6b93ae9..d9680e9 100644 --- a/lib/http_server/src/main.rs +++ b/lib/http_server/src/main.rs @@ -32,7 +32,7 @@ struct ServerCliFlags { pub async fn main() -> Result<()> { info!("Starting minauth"); let flags: ServerCliFlags = argh::from_env(); - let (config, secrets, db_pool) = get_kernel_context(StartKernelConfig { + let kernel_context = get_kernel_context(StartKernelConfig { config_path: flags.config, database_path: flags.database }).await.context("Getting kernel context")?; @@ -42,8 +42,6 @@ pub async fn main() -> Result<()> { listen_host: flags.listen_host, listen_port: flags.listen_port }, - config, - secrets, - db_pool + kernel_context ).await } diff --git a/lib/kernel/Cargo.toml b/lib/kernel/Cargo.toml index a345f83..57eaea8 100644 --- a/lib/kernel/Cargo.toml +++ b/lib/kernel/Cargo.toml @@ -5,9 +5,10 @@ edition = "2021" [dependencies] utils = { path = "../utils" } +anyhow = { workspace = true } +thiserror = { workspace = true } log = { workspace = true } env_logger = { workspace = true } -anyhow = { workspace = true } fully_pub = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } diff --git a/lib/kernel/src/actions/mod.rs b/lib/kernel/src/actions/mod.rs index e69de29..913bd46 100644 --- a/lib/kernel/src/actions/mod.rs +++ b/lib/kernel/src/actions/mod.rs @@ -0,0 +1 @@ +pub mod users; diff --git a/lib/kernel/src/actions/user.rs b/lib/kernel/src/actions/user.rs deleted file mode 100644 index e69de29..0000000 diff --git a/lib/kernel/src/actions/users.rs b/lib/kernel/src/actions/users.rs new file mode 100644 index 0000000..39d448a --- /dev/null +++ b/lib/kernel/src/actions/users.rs @@ -0,0 +1,44 @@ +use crate::{context::KernelContext, models::user::User}; + +use anyhow::{Context, Result}; +use chrono::SecondsFormat; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum CreateUserErr { + #[error("Handle or email for user is already used.")] + HandleOrEmailNotUnique, + + #[error("Database error.")] + DatabaseErr(String) +} + +pub async fn create_user(ctx: KernelContext, user: User) -> Result<(), CreateUserErr> { + let res = sqlx::query(" + INSERT INTO users + (id, handle, email, status, roles, password_hash, reset_password_token, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ") + .bind(user.id) + .bind(user.handle) + .bind(user.email) + .bind(user.status.to_string()) + .bind(user.roles) + .bind(user.password_hash) + .bind(user.reset_password_token) + .bind(user.created_at.to_rfc3339_opts(SecondsFormat::Millis, true)) + .execute(&ctx.storage.0) + .await; + match res { + Err(err) => { + let db_err = &err.as_database_error().unwrap(); + if db_err.code().unwrap() == "2067" { + Err(CreateUserErr::HandleOrEmailNotUnique) + } else { + dbg!(&err); + Err(CreateUserErr::DatabaseErr(db_err.to_string())) + } + } + Ok(_) => Ok(()) + } +} diff --git a/lib/kernel/src/context.rs b/lib/kernel/src/context.rs index af5fab3..bbbf0df 100644 --- a/lib/kernel/src/context.rs +++ b/lib/kernel/src/context.rs @@ -29,7 +29,15 @@ struct AppSecrets { jwt_secret: String } -pub async fn get_kernel_context(start_config: StartKernelConfig) -> Result<(Config, AppSecrets, Storage)> { +#[derive(Debug, Clone)] +#[fully_pub] +struct KernelContext { + config: Config, + secrets: AppSecrets, + storage: Storage +} + +pub async fn get_kernel_context(start_config: StartKernelConfig) -> Result { env_logger::init(); let _ = dotenvy::dotenv(); @@ -47,5 +55,9 @@ pub async fn get_kernel_context(start_config: StartKernelConfig) -> Result<(Conf jwt_secret: env::var("APP_JWT_SECRET").context("Expecting APP_JWT_SECRET env var.")? }; - Ok((config, secrets, storage)) + Ok(KernelContext { + config, + secrets, + storage + }) } diff --git a/lib/kernel/src/models/user.rs b/lib/kernel/src/models/user.rs index 3984181..266ef5b 100644 --- a/lib/kernel/src/models/user.rs +++ b/lib/kernel/src/models/user.rs @@ -2,6 +2,8 @@ use fully_pub::fully_pub; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::types::Json; +use utils::get_random_human_token; +use uuid::Uuid; #[derive(sqlx::Type, Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(strum_macros::Display)] @@ -30,3 +32,30 @@ struct User { last_login_at: Option>, created_at: DateTime } + +impl User { + pub fn new( + handle: String + ) -> User { + User { + id: Uuid::new_v4().to_string(), + handle, + full_name: None, + email: None, + website: None, + picture: None, + password_hash: None, + status: UserStatus::Disabled, + roles: Json(Vec::new()), + reset_password_token: None, + last_login_at: None, + created_at: Utc::now() + } + } + + pub fn invite(self: &mut Self) { + self.reset_password_token = Some(get_random_human_token()); + self.status = UserStatus::Invited; + } + +} diff --git a/lib/kernel/src/repositories/users.rs b/lib/kernel/src/repositories/users.rs index 6ef31f4..cf3095e 100644 --- a/lib/kernel/src/repositories/users.rs +++ b/lib/kernel/src/repositories/users.rs @@ -5,10 +5,17 @@ use crate::models::user::User; use super::storage::Storage; use anyhow::{Result, Context}; -async fn get_user_by_id(storage: &Storage, user_id: &str) -> Result { +pub async fn get_user_by_id(storage: &Storage, user_id: &str) -> Result { sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") .bind(user_id) .fetch_one(&storage.0) .await - .context("To get user from claim") + .context("To get user by id.") +} + +pub async fn get_users(storage: &Storage) -> Result> { + sqlx::query_as::<_, User>("SELECT * FROM users") + .fetch_all(&storage.0) + .await + .context("To get users.") } diff --git a/lib/utils/src/lib.rs b/lib/utils/src/lib.rs index bb524dc..140960d 100644 --- a/lib/utils/src/lib.rs +++ b/lib/utils/src/lib.rs @@ -43,6 +43,18 @@ pub fn get_random_alphanumerical(length: usize) -> String { .collect() } +/// Generate easy to type token +pub fn get_random_human_token() -> String { + return format!( + "{}-{}-{}-{}-{}", + get_random_alphanumerical(4), + get_random_alphanumerical(4), + get_random_alphanumerical(4), + get_random_alphanumerical(4), + get_random_alphanumerical(4) + ).to_uppercase(); +} + pub fn parse_basic_auth(header_value: &str) -> Result<(String, String)> { let header_val_components: Vec<&str> = header_value.split(" ").collect(); let encoded_header_value: &str = header_val_components