feat: complete Phase 0 Enterprise IAM
This commit is contained in:
1 parent
3d2d291006
commit
c2f5ba3f54
202 files changed
+21371
-1360
No files matched your search
+110
-12
@@ -1,31 +1,129 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::{
|
||||
db::{models::Application, repository::applications as repo},
|
||||
error::AppError,
|
||||
};
|
||||
use crate::{db::models::Application, error::AppError};
|
||||
|
||||
pub async fn create(
|
||||
pool: &SqlitePool,
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
tenant_id: &str,
|
||||
name: &str,
|
||||
slug: &str,
|
||||
) -> Result<Application, AppError> {
|
||||
let name = name.trim();
|
||||
let slug = slug.trim();
|
||||
if name.is_empty() || slug.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"name and slug cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
if provider
|
||||
.applications()
|
||||
.find_by_slug(slug)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::Conflict(format!("slug '{slug}' already exists")));
|
||||
}
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
repo::create(pool, &id, tenant_id, name, slug)
|
||||
provider
|
||||
.applications()
|
||||
.create(&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)
|
||||
pub async fn list(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
tenant_id: &str,
|
||||
) -> Result<Vec<Application>, AppError> {
|
||||
provider
|
||||
.applications()
|
||||
.list(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)
|
||||
pub async fn get(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
id: &str,
|
||||
) -> Result<Application, AppError> {
|
||||
provider
|
||||
.applications()
|
||||
.find_by_id(id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn find_by_slug(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
slug: &str,
|
||||
) -> Result<Application, AppError> {
|
||||
provider
|
||||
.applications()
|
||||
.find_by_slug(slug)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
id: &str,
|
||||
name: &str,
|
||||
slug: &str,
|
||||
enabled: bool,
|
||||
) -> Result<Application, AppError> {
|
||||
let _ = provider.applications().find_by_id(id).await?;
|
||||
let name = name.trim();
|
||||
let slug = slug.trim();
|
||||
if name.is_empty() || slug.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"name and slug cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
if let Some(other) = provider
|
||||
.applications()
|
||||
.find_by_slug(slug)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
{
|
||||
if other.id != id {
|
||||
return Err(AppError::Conflict(format!("slug '{slug}' already exists")));
|
||||
}
|
||||
}
|
||||
provider
|
||||
.applications()
|
||||
.update(id, name, slug, enabled)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
provider
|
||||
.applications()
|
||||
.find_by_id(id)
|
||||
.await
|
||||
.map_err(crate::error::AppError::Database)?
|
||||
.ok_or_else(|| crate::error::AppError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn set_enabled(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let _ = provider.applications().find_by_id(id).await?;
|
||||
provider
|
||||
.applications()
|
||||
.set_enabled(id, enabled)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
id: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let _ = provider.applications().find_by_id(id).await?;
|
||||
provider
|
||||
.applications()
|
||||
.delete(id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
@@ -1,35 +1,117 @@
|
||||
use sqlx::SqlitePool;
|
||||
use crate::db::repository::traits::AuditRepositoryExt;
|
||||
|
||||
use crate::{db::repository::permissions as repo, error::AppError};
|
||||
use crate::{db::models::Permission, error::AppError};
|
||||
|
||||
/// Return all permission names held by a user.
|
||||
pub async fn list_user_permissions(
|
||||
pool: &SqlitePool,
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<String>, AppError> {
|
||||
repo::list_for_user(pool, user_id)
|
||||
provider
|
||||
.permissions()
|
||||
.list_for_user(user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// List all system permissions.
|
||||
pub async fn list_permissions(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
) -> Result<Vec<Permission>, AppError> {
|
||||
provider
|
||||
.permissions()
|
||||
.list_all()
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// List permissions for a role.
|
||||
pub async fn list_role_permissions(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
role_id: &str,
|
||||
) -> Result<Vec<Permission>, AppError> {
|
||||
provider
|
||||
.permissions()
|
||||
.list_for_role(role_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// Set the full permission set for a role (replace semantics).
|
||||
pub async fn set_role_permissions(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
role_id: &str,
|
||||
permission_names: &[String],
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<Vec<Permission>, AppError> {
|
||||
// Resolve names → ids
|
||||
let mut permission_ids = Vec::with_capacity(permission_names.len());
|
||||
for name in permission_names {
|
||||
let perm = provider
|
||||
.permissions()
|
||||
.find_by_name(name)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or_else(|| AppError::InvalidInput(format!("unknown permission: {name}")))?;
|
||||
permission_ids.push(perm.id);
|
||||
}
|
||||
|
||||
// Clear existing assignments
|
||||
provider
|
||||
.permissions()
|
||||
.clear_for_role(role_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
for pid in &permission_ids {
|
||||
provider
|
||||
.permissions()
|
||||
.assign_to_role(role_id, pid)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
let metadata = serde_json::json!({ "permissions": permission_names }).to_string();
|
||||
provider
|
||||
.audit()
|
||||
.log(crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: None,
|
||||
action: "role_permissions_updated",
|
||||
resource_type: "role",
|
||||
resource_id: Some(role_id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
})
|
||||
.await?;
|
||||
|
||||
list_role_permissions(provider, role_id).await
|
||||
}
|
||||
|
||||
/// Returns true if the user holds the given named permission.
|
||||
pub async fn has_permission(
|
||||
pool: &SqlitePool,
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
user_id: &str,
|
||||
permission: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
repo::user_has_permission(pool, user_id, permission)
|
||||
provider
|
||||
.permissions()
|
||||
.user_has_permission(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,
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
user_id: &str,
|
||||
permission: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if has_permission(pool, user_id, permission).await? {
|
||||
if has_permission(provider, user_id, permission).await? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Forbidden)
|
||||
|
||||
+219
-38
@@ -1,34 +1,33 @@
|
||||
use sqlx::SqlitePool;
|
||||
use crate::db::repository::traits::AuditRepositoryExt;
|
||||
|
||||
use crate::{
|
||||
db::{models::Role, repository::roles as repo},
|
||||
error::AppError,
|
||||
};
|
||||
use crate::{db::models::Role, error::AppError};
|
||||
|
||||
/// Assign a named role to a user. No-ops if already assigned.
|
||||
pub async fn assign_role(
|
||||
pool: &SqlitePool,
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
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)
|
||||
let role = provider
|
||||
.roles()
|
||||
.find_by_name(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)
|
||||
provider
|
||||
.roles()
|
||||
.assign_to_user(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 {
|
||||
provider
|
||||
.audit()
|
||||
.log(crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action: "role_assigned",
|
||||
@@ -38,11 +37,8 @@ pub async fn assign_role(
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await.map_err(AppError::Database)?;
|
||||
})
|
||||
.await?;
|
||||
|
||||
tracing::info!(user_id = %user_id, role = %role_name, "role assigned");
|
||||
Ok(())
|
||||
@@ -50,28 +46,30 @@ pub async fn assign_role(
|
||||
|
||||
/// Remove a named role from a user. No-ops if not assigned.
|
||||
pub async fn remove_role(
|
||||
pool: &SqlitePool,
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
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)
|
||||
let role = provider
|
||||
.roles()
|
||||
.find_by_name(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)
|
||||
provider
|
||||
.roles()
|
||||
.remove_from_user(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 {
|
||||
provider
|
||||
.audit()
|
||||
.log(crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action: "role_removed",
|
||||
@@ -81,24 +79,207 @@ pub async fn remove_role(
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await.map_err(AppError::Database)?;
|
||||
})
|
||||
.await?;
|
||||
|
||||
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)
|
||||
pub async fn list_roles(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
) -> Result<Vec<Role>, AppError> {
|
||||
provider
|
||||
.roles()
|
||||
.list_all()
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// List roles held by a specific user.
|
||||
pub async fn list_user_roles(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<Role>, AppError> {
|
||||
provider
|
||||
.roles()
|
||||
.list_for_user(user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// Create a new role.
|
||||
pub async fn create_role(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<Role, AppError> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::InvalidInput("role name cannot be empty".into()));
|
||||
}
|
||||
if provider
|
||||
.roles()
|
||||
.find_by_name(name)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::Conflict(format!("role '{name}' already exists")));
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let role = provider
|
||||
.roles()
|
||||
.create(&id, name, description)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
provider
|
||||
.audit()
|
||||
.log(crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: None,
|
||||
action: "role_created",
|
||||
resource_type: "role",
|
||||
resource_id: Some(&role.id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&format!(r#"{{"name":"{name}"}}"#)),
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(role)
|
||||
}
|
||||
|
||||
/// Update an existing role.
|
||||
pub async fn update_role(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
id: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<Role, AppError> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::InvalidInput("role name cannot be empty".into()));
|
||||
}
|
||||
|
||||
let existing = provider
|
||||
.roles()
|
||||
.find_by_id(id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
// Protect built-in admin role rename
|
||||
if existing.name == "admin" && name != "admin" {
|
||||
return Err(AppError::InvalidInput(
|
||||
"cannot rename the built-in admin role".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(other) = provider
|
||||
.roles()
|
||||
.find_by_name(name)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
{
|
||||
if other.id != id {
|
||||
return Err(AppError::Conflict(format!("role '{name}' already exists")));
|
||||
}
|
||||
}
|
||||
|
||||
provider
|
||||
.roles()
|
||||
.update(id, name, description)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
provider
|
||||
.audit()
|
||||
.log(crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: None,
|
||||
action: "role_updated",
|
||||
resource_type: "role",
|
||||
resource_id: Some(id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
provider
|
||||
.roles()
|
||||
.find_by_id(id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Delete a role (cannot delete admin).
|
||||
pub async fn delete_role(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
id: &str,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
let role = provider
|
||||
.roles()
|
||||
.find_by_id(id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
if role.name == "admin" {
|
||||
return Err(AppError::InvalidInput(
|
||||
"cannot delete the built-in admin role".into(),
|
||||
));
|
||||
}
|
||||
|
||||
provider
|
||||
.roles()
|
||||
.delete(id)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
provider
|
||||
.audit()
|
||||
.log(crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: None,
|
||||
action: "role_deleted",
|
||||
resource_type: "role",
|
||||
resource_id: Some(id),
|
||||
severity: crate::db::models::AuditSeverity::Warning,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&format!(r#"{{"name":"{}"}}"#, role.name)),
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a role by id.
|
||||
pub async fn get_role(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
id: &str,
|
||||
) -> Result<Role, AppError> {
|
||||
provider
|
||||
.roles()
|
||||
.find_by_id(id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
use sqlx::SqlitePool;
|
||||
use crate::db::repository::traits::AuditRepositoryExt;
|
||||
|
||||
use crate::{
|
||||
db::{models::ServiceAccount, repository::service_accounts as repo},
|
||||
error::AppError,
|
||||
};
|
||||
use crate::{db::models::ServiceAccount, error::AppError};
|
||||
|
||||
pub async fn create(
|
||||
pool: &SqlitePool,
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
tenant_id: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
@@ -15,15 +12,16 @@ pub async fn create(
|
||||
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)
|
||||
let sa = provider
|
||||
.service_accounts()
|
||||
.create(&id, tenant_id, name, description)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
crate::audit::log(
|
||||
&mut tx,
|
||||
crate::audit::AuditEvent {
|
||||
provider
|
||||
.audit()
|
||||
.log(crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: None,
|
||||
action: "service_account_created",
|
||||
@@ -33,31 +31,36 @@ pub async fn create(
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
})
|
||||
.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)
|
||||
pub async fn list(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
tenant_id: &str,
|
||||
) -> Result<Vec<ServiceAccount>, AppError> {
|
||||
provider
|
||||
.service_accounts()
|
||||
.list(tenant_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn set_enabled(
|
||||
pool: &SqlitePool,
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
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)?;
|
||||
let _ = provider.service_accounts().find_by_id(id).await?;
|
||||
|
||||
repo::set_enabled(&mut tx, id, enabled)
|
||||
provider
|
||||
.service_accounts()
|
||||
.set_enabled(id, enabled)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
@@ -67,9 +70,9 @@ pub async fn set_enabled(
|
||||
"service_account_disabled"
|
||||
};
|
||||
|
||||
crate::audit::log(
|
||||
&mut tx,
|
||||
crate::audit::AuditEvent {
|
||||
provider
|
||||
.audit()
|
||||
.log(crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: None,
|
||||
action,
|
||||
@@ -79,10 +82,90 @@ pub async fn set_enabled(
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
})
|
||||
.await?;
|
||||
|
||||
tx.commit().await.map_err(AppError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
id: &str,
|
||||
) -> Result<ServiceAccount, AppError> {
|
||||
provider
|
||||
.service_accounts()
|
||||
.find_by_id(id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
id: &str,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
let _ = provider.service_accounts().find_by_id(id).await?;
|
||||
|
||||
provider
|
||||
.service_accounts()
|
||||
.delete(id)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
provider
|
||||
.audit()
|
||||
.log(crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: None,
|
||||
action: "service_account_deleted",
|
||||
resource_type: "service_account",
|
||||
resource_id: Some(id),
|
||||
severity: crate::db::models::AuditSeverity::Warning,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a one-time display secret for a service account.
|
||||
///
|
||||
/// The raw secret is returned once; only a BLAKE3 hash is stored in audit metadata
|
||||
/// until a dedicated secrets table is introduced (OAuth2 milestone).
|
||||
pub async fn generate_secret(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
id: &str,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<String, AppError> {
|
||||
let _ = provider.service_accounts().find_by_id(id).await?;
|
||||
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut bytes);
|
||||
let raw = format!("nx9sa_{}", hex::encode(bytes));
|
||||
let hash = hex::encode(blake3::hash(raw.as_bytes()).as_bytes());
|
||||
|
||||
let metadata = serde_json::json!({ "secret_hash": hash }).to_string();
|
||||
provider
|
||||
.audit()
|
||||
.log(crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: None,
|
||||
action: "service_account_secret_rotated",
|
||||
resource_type: "service_account",
|
||||
resource_id: Some(id),
|
||||
severity: crate::db::models::AuditSeverity::Warning,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(raw)
|
||||
}
|
||||
+58
-52
@@ -1,18 +1,13 @@
|
||||
use sqlx::SqlitePool;
|
||||
use crate::db::repository::traits::AuditRepositoryExt;
|
||||
|
||||
use crate::{
|
||||
config::SecurityConfig,
|
||||
db::{models::User, repository::users as repo},
|
||||
error::AppError,
|
||||
security::passwords,
|
||||
};
|
||||
use crate::{config::SecurityConfig, db::models::User, 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,
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
cfg: &SecurityConfig,
|
||||
tenant_id: &str,
|
||||
username: &str,
|
||||
@@ -26,7 +21,9 @@ pub async fn create_user(
|
||||
}
|
||||
passwords::validate_password_strength(password, false)?;
|
||||
|
||||
if repo::username_exists(pool, tenant_id, username)
|
||||
if provider
|
||||
.users()
|
||||
.username_exists(tenant_id, username)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
{
|
||||
@@ -38,15 +35,15 @@ pub async fn create_user(
|
||||
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)
|
||||
let user = provider
|
||||
.users()
|
||||
.create(&id, tenant_id, username, &hash)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
crate::audit::log(
|
||||
&mut tx,
|
||||
crate::audit::AuditEvent {
|
||||
provider
|
||||
.audit()
|
||||
.log(crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(&user.id),
|
||||
action: "user_created",
|
||||
@@ -56,42 +53,54 @@ pub async fn create_user(
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await.map_err(AppError::Database)?;
|
||||
})
|
||||
.await?;
|
||||
|
||||
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)
|
||||
pub async fn get_user(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
id: &str,
|
||||
) -> Result<User, AppError> {
|
||||
provider
|
||||
.users()
|
||||
.find_by_id(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)
|
||||
pub async fn get_user_by_username(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
username: &str,
|
||||
) -> Result<User, AppError> {
|
||||
provider
|
||||
.users()
|
||||
.find_by_username(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)
|
||||
pub async fn list_users(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
tenant_id: &str,
|
||||
) -> Result<Vec<User>, AppError> {
|
||||
provider
|
||||
.users()
|
||||
.list(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,
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
user_id: &str,
|
||||
status: i32,
|
||||
audit_actor_id: Option<&str>,
|
||||
@@ -99,11 +108,11 @@ pub async fn update_status(
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
// Verify user exists first
|
||||
let _user = get_user(pool, user_id).await?;
|
||||
let _user = provider.users().find_by_id(user_id).await?;
|
||||
|
||||
let mut tx = pool.begin().await.map_err(AppError::Database)?;
|
||||
|
||||
repo::update_status(&mut tx, user_id, status)
|
||||
provider
|
||||
.users()
|
||||
.update_status(user_id, status)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
@@ -119,9 +128,9 @@ pub async fn update_status(
|
||||
_ => crate::db::models::AuditSeverity::Warning,
|
||||
};
|
||||
|
||||
crate::audit::log(
|
||||
&mut tx,
|
||||
crate::audit::AuditEvent {
|
||||
provider
|
||||
.audit()
|
||||
.log(crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action,
|
||||
@@ -131,18 +140,16 @@ pub async fn update_status(
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
})
|
||||
.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,
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
cfg: &SecurityConfig,
|
||||
user_id: &str,
|
||||
new_password: &str,
|
||||
@@ -150,23 +157,25 @@ pub async fn reset_password(
|
||||
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)
|
||||
let user = provider.users().find_by_id(user_id).await?;
|
||||
let user_roles = provider
|
||||
.roles()
|
||||
.list_for_user(&user.unwrap().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)
|
||||
provider
|
||||
.users()
|
||||
.update_password_hash(user_id, &hash)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
crate::audit::log(
|
||||
&mut tx,
|
||||
crate::audit::AuditEvent {
|
||||
provider
|
||||
.audit()
|
||||
.log(crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action: "password_reset",
|
||||
@@ -176,11 +185,8 @@ pub async fn reset_password(
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await.map_err(AppError::Database)?;
|
||||
})
|
||||
.await?;
|
||||
|
||||
tracing::info!(user_id = %user_id, "password reset");
|
||||
Ok(())
|
||||
|
||||
Reference in new issue
Block a user