feat(admin): create and list users commands

This commit is contained in:
Matthieu Bessat 2024-12-03 13:20:33 +01:00
parent 8d20cab18f
commit a0de3b287b
19 changed files with 314 additions and 30 deletions

8
Cargo.lock generated
View file

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

View file

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

3
admin.sh Executable file
View file

@ -0,0 +1,3 @@
#!/usr/bin/sh
cargo run -q --bin minauthator-admin -- --config ./config.toml --database ./tmp/dbs/minauthator.db --static-assets ./assets $@

View file

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

View file

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

View file

@ -0,0 +1 @@
pub mod users;

View file

@ -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<String>,
/// 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<usize>,
}
#[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
}
}

View file

@ -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<String>,
/// path to the Sqlite3 DB file to use
#[argh(option)]
database: Option<String>,
/// path to the static assets dir
#[argh(option)]
static_assets: Option<String>,
}
/// 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
}
}
}

View file

@ -14,6 +14,7 @@ strum = { workspace = true }
strum_macros = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
fully_pub = { workspace = true }
tokio = { workspace = true }

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
pub mod users;

View file

@ -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(())
}
}

View file

@ -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<KernelContext> {
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
})
}

View file

@ -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<DateTime<Utc>>,
created_at: DateTime<Utc>
}
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;
}
}

View file

@ -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<User> {
pub async fn get_user_by_id(storage: &Storage, user_id: &str) -> Result<User> {
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<Vec<User>> {
sqlx::query_as::<_, User>("SELECT * FROM users")
.fetch_all(&storage.0)
.await
.context("To get users.")
}

View file

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