Initial public release v0.1.0
This commit is contained in:
commit
6c39e0bfbf
87 files changed
+10867
No files matched your search
@@ -0,0 +1,31 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::{
|
||||
db::{models::Application, repository::applications as repo},
|
||||
error::AppError,
|
||||
};
|
||||
|
||||
pub async fn create(
|
||||
pool: &SqlitePool,
|
||||
tenant_id: &str,
|
||||
name: &str,
|
||||
slug: &str,
|
||||
) -> Result<Application, AppError> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
repo::create(pool, &id, tenant_id, name, slug)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn list(pool: &SqlitePool, tenant_id: &str) -> Result<Vec<Application>, AppError> {
|
||||
repo::list(pool, tenant_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn find_by_slug(pool: &SqlitePool, slug: &str) -> Result<Application, AppError> {
|
||||
repo::find_by_slug(pool, slug)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod applications;
|
||||
pub mod permissions;
|
||||
pub mod roles;
|
||||
pub mod service_accounts;
|
||||
pub mod users;
|
||||
@@ -0,0 +1,37 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::{db::repository::permissions as repo, error::AppError};
|
||||
|
||||
/// Return all permission names held by a user.
|
||||
pub async fn list_user_permissions(
|
||||
pool: &SqlitePool,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<String>, AppError> {
|
||||
repo::list_for_user(pool, user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// Returns true if the user holds the given named permission.
|
||||
pub async fn has_permission(
|
||||
pool: &SqlitePool,
|
||||
user_id: &str,
|
||||
permission: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
repo::user_has_permission(pool, user_id, permission)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// Enforce that a user holds a permission, returning `Forbidden` otherwise.
|
||||
pub async fn require_permission(
|
||||
pool: &SqlitePool,
|
||||
user_id: &str,
|
||||
permission: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if has_permission(pool, user_id, permission).await? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Forbidden)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::{
|
||||
db::{models::Role, repository::roles as repo},
|
||||
error::AppError,
|
||||
};
|
||||
|
||||
/// Assign a named role to a user. No-ops if already assigned.
|
||||
pub async fn assign_role(
|
||||
pool: &SqlitePool,
|
||||
user_id: &str,
|
||||
role_name: &str,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
let role = repo::find_by_name(pool, role_name)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let mut tx = pool.begin().await.map_err(AppError::Database)?;
|
||||
|
||||
repo::assign_to_user(&mut tx, user_id, &role.id)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let metadata = serde_json::json!({ "role": role_name }).to_string();
|
||||
crate::audit::log(
|
||||
&mut tx,
|
||||
crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action: "role_assigned",
|
||||
resource_type: "role",
|
||||
resource_id: Some(&role.id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await.map_err(AppError::Database)?;
|
||||
|
||||
tracing::info!(user_id = %user_id, role = %role_name, "role assigned");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a named role from a user. No-ops if not assigned.
|
||||
pub async fn remove_role(
|
||||
pool: &SqlitePool,
|
||||
user_id: &str,
|
||||
role_name: &str,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
let role = repo::find_by_name(pool, role_name)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let mut tx = pool.begin().await.map_err(AppError::Database)?;
|
||||
|
||||
repo::remove_from_user(&mut tx, user_id, &role.id)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let metadata = serde_json::json!({ "role": role_name }).to_string();
|
||||
crate::audit::log(
|
||||
&mut tx,
|
||||
crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action: "role_removed",
|
||||
resource_type: "role",
|
||||
resource_id: Some(&role.id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await.map_err(AppError::Database)?;
|
||||
|
||||
tracing::info!(user_id = %user_id, role = %role_name, "role removed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all roles defined in the system.
|
||||
pub async fn list_roles(pool: &SqlitePool) -> Result<Vec<Role>, AppError> {
|
||||
repo::list_all(pool).await.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// List roles held by a specific user.
|
||||
pub async fn list_user_roles(pool: &SqlitePool, user_id: &str) -> Result<Vec<Role>, AppError> {
|
||||
repo::list_for_user(pool, user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::{
|
||||
db::{models::ServiceAccount, repository::service_accounts as repo},
|
||||
error::AppError,
|
||||
};
|
||||
|
||||
pub async fn create(
|
||||
pool: &SqlitePool,
|
||||
tenant_id: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<ServiceAccount, AppError> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let mut tx = pool.begin().await.map_err(AppError::Database)?;
|
||||
|
||||
let sa = repo::create(&mut tx, &id, tenant_id, name, description)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
crate::audit::log(
|
||||
&mut tx,
|
||||
crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: None,
|
||||
action: "service_account_created",
|
||||
resource_type: "service_account",
|
||||
resource_id: Some(&sa.id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await.map_err(AppError::Database)?;
|
||||
Ok(sa)
|
||||
}
|
||||
|
||||
pub async fn list(pool: &SqlitePool, tenant_id: &str) -> Result<Vec<ServiceAccount>, AppError> {
|
||||
repo::list(pool, tenant_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn set_enabled(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
let mut tx = pool.begin().await.map_err(AppError::Database)?;
|
||||
|
||||
repo::set_enabled(&mut tx, id, enabled)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let action = if enabled {
|
||||
"service_account_enabled"
|
||||
} else {
|
||||
"service_account_disabled"
|
||||
};
|
||||
|
||||
crate::audit::log(
|
||||
&mut tx,
|
||||
crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: None,
|
||||
action,
|
||||
resource_type: "service_account",
|
||||
resource_id: Some(id),
|
||||
severity: crate::db::models::AuditSeverity::Warning,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await.map_err(AppError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::{
|
||||
config::SecurityConfig,
|
||||
db::{models::User, repository::users as repo},
|
||||
error::AppError,
|
||||
security::passwords,
|
||||
};
|
||||
|
||||
/// Create a new user account in the given tenant.
|
||||
///
|
||||
/// Fails with `Conflict` if the username is already taken.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_user(
|
||||
pool: &SqlitePool,
|
||||
cfg: &SecurityConfig,
|
||||
tenant_id: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<User, AppError> {
|
||||
if username.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput("username cannot be empty".into()));
|
||||
}
|
||||
passwords::validate_password_strength(password, false)?;
|
||||
|
||||
if repo::username_exists(pool, tenant_id, username)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
{
|
||||
return Err(AppError::Conflict(format!(
|
||||
"username '{username}' is already taken"
|
||||
)));
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let hash = passwords::hash_password(password, cfg)?;
|
||||
|
||||
let mut tx = pool.begin().await.map_err(AppError::Database)?;
|
||||
|
||||
let user = repo::create(&mut tx, &id, tenant_id, username, &hash)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
crate::audit::log(
|
||||
&mut tx,
|
||||
crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(&user.id),
|
||||
action: "user_created",
|
||||
resource_type: "user",
|
||||
resource_id: Some(&user.id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await.map_err(AppError::Database)?;
|
||||
|
||||
tracing::info!(user_id = %user.id, username = %username, "user created");
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Retrieve a user by ID.
|
||||
pub async fn get_user(pool: &SqlitePool, id: &str) -> Result<User, AppError> {
|
||||
repo::find_by_id(pool, id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Retrieve a user by username.
|
||||
pub async fn get_user_by_username(pool: &SqlitePool, username: &str) -> Result<User, AppError> {
|
||||
repo::find_by_username(pool, username)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// List all users in a tenant.
|
||||
pub async fn list_users(pool: &SqlitePool, tenant_id: &str) -> Result<Vec<User>, AppError> {
|
||||
repo::list(pool, tenant_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// Set a user's status (Active=1, Disabled=2, Locked=3).
|
||||
pub async fn update_status(
|
||||
pool: &SqlitePool,
|
||||
user_id: &str,
|
||||
status: i32,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
// Verify user exists first
|
||||
let _user = get_user(pool, user_id).await?;
|
||||
|
||||
let mut tx = pool.begin().await.map_err(AppError::Database)?;
|
||||
|
||||
repo::update_status(&mut tx, user_id, status)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let action = match status {
|
||||
1 => "user_enabled",
|
||||
2 => "user_disabled",
|
||||
3 => "user_locked",
|
||||
_ => "user_updated",
|
||||
};
|
||||
|
||||
let severity = match status {
|
||||
1 => crate::db::models::AuditSeverity::Info,
|
||||
_ => crate::db::models::AuditSeverity::Warning,
|
||||
};
|
||||
|
||||
crate::audit::log(
|
||||
&mut tx,
|
||||
crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action,
|
||||
resource_type: "user",
|
||||
resource_id: Some(user_id),
|
||||
severity,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await.map_err(AppError::Database)?;
|
||||
tracing::info!(user_id = %user_id, status = %status, "user status updated");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset a user's password.
|
||||
pub async fn reset_password(
|
||||
pool: &SqlitePool,
|
||||
cfg: &SecurityConfig,
|
||||
user_id: &str,
|
||||
new_password: &str,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
let user = get_user(pool, user_id).await?;
|
||||
let user_roles = crate::db::repository::roles::list_for_user(pool, &user.id)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
let is_admin = user_roles.iter().any(|r| r.name == "admin");
|
||||
passwords::validate_password_strength(new_password, is_admin)?;
|
||||
let hash = passwords::hash_password(new_password, cfg)?;
|
||||
|
||||
let mut tx = pool.begin().await.map_err(AppError::Database)?;
|
||||
|
||||
repo::update_password_hash(&mut tx, user_id, &hash)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
crate::audit::log(
|
||||
&mut tx,
|
||||
crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action: "password_reset",
|
||||
resource_type: "user",
|
||||
resource_id: Some(user_id),
|
||||
severity: crate::db::models::AuditSeverity::Warning,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await.map_err(AppError::Database)?;
|
||||
|
||||
tracing::info!(user_id = %user_id, "password reset");
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user