feat(auth): add role/permission management (backend + frontend)

- RoleService: CRUD, assign_permissions, get_role_permissions
- PermissionService: list all tenant permissions
- Role handlers: 8 endpoints with RBAC permission checks
- Frontend Roles page: table, create/edit modal, permission assignment
- Frontend Roles API: full CRUD + permission operations
- Routes registered in AuthModule protected_routes
This commit is contained in:
iven
2026-04-11 03:46:54 +08:00
parent 4a03a639a6
commit 6fd0288e7c
9 changed files with 946 additions and 2 deletions

View File

@@ -1,5 +1,7 @@
pub mod auth_service;
pub mod password;
pub mod permission_service;
pub mod role_service;
pub mod seed;
pub mod token_service;
pub mod user_service;

View File

@@ -0,0 +1,38 @@
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use uuid::Uuid;
use crate::dto::PermissionResp;
use crate::entity::permission;
use crate::error::AuthResult;
/// Permission read-only service — list permissions within a tenant.
///
/// Permissions are seeded by the system and not typically created via API.
pub struct PermissionService;
impl PermissionService {
/// List all active permissions within a tenant.
pub async fn list(
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<Vec<PermissionResp>> {
let perms = permission::Entity::find()
.filter(permission::Column::TenantId.eq(tenant_id))
.filter(permission::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| crate::error::AuthError::Validation(e.to_string()))?;
Ok(perms
.iter()
.map(|p| PermissionResp {
id: p.id,
code: p.code.clone(),
name: p.name.clone(),
resource: p.resource.clone(),
action: p.action.clone(),
description: p.description.clone(),
})
.collect())
}
}

View File

@@ -0,0 +1,321 @@
use chrono::Utc;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
};
use uuid::Uuid;
use crate::dto::{PermissionResp, RoleResp};
use crate::entity::{permission, role, role_permission};
use crate::error::AuthError;
use crate::error::AuthResult;
use erp_core::events::EventBus;
use erp_core::types::Pagination;
/// Role CRUD service — create, read, update, soft-delete roles within a tenant,
/// and manage role-permission assignments.
pub struct RoleService;
impl RoleService {
/// List roles within a tenant with pagination.
///
/// Returns `(roles, total_count)`.
pub async fn list(
tenant_id: Uuid,
pagination: &Pagination,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<(Vec<RoleResp>, u64)> {
let paginator = role::Entity::find()
.filter(role::Column::TenantId.eq(tenant_id))
.filter(role::Column::DeletedAt.is_null())
.paginate(db, pagination.limit());
let total = paginator
.num_items()
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let page_index = pagination.page.unwrap_or(1).saturating_sub(1) as u64;
let models = paginator
.fetch_page(page_index)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let resps: Vec<RoleResp> = models
.iter()
.map(|m| RoleResp {
id: m.id,
name: m.name.clone(),
code: m.code.clone(),
description: m.description.clone(),
is_system: m.is_system,
})
.collect();
Ok((resps, total))
}
/// Fetch a single role by ID, scoped to the given tenant.
pub async fn get_by_id(
id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<RoleResp> {
let model = role::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
Ok(RoleResp {
id: model.id,
name: model.name.clone(),
code: model.code.clone(),
description: model.description.clone(),
is_system: model.is_system,
})
}
/// Create a new role within the current tenant.
///
/// Validates code uniqueness, then inserts the record and publishes
/// a `role.created` domain event.
pub async fn create(
tenant_id: Uuid,
operator_id: Uuid,
name: &str,
code: &str,
description: &Option<String>,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> AuthResult<RoleResp> {
// Check code uniqueness within tenant
let existing = role::Entity::find()
.filter(role::Column::TenantId.eq(tenant_id))
.filter(role::Column::Code.eq(code))
.filter(role::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(AuthError::Validation("角色编码已存在".to_string()));
}
let now = Utc::now();
let id = Uuid::now_v7();
let model = role::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
name: Set(name.to_string()),
code: Set(code.to_string()),
description: Set(description.clone()),
is_system: Set(false),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
model
.insert(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
event_bus.publish(erp_core::events::DomainEvent::new(
"role.created",
tenant_id,
serde_json::json!({ "role_id": id, "code": code }),
));
Ok(RoleResp {
id,
name: name.to_string(),
code: code.to_string(),
description: description.clone(),
is_system: false,
})
}
/// Update editable role fields (name and description).
///
/// Code and is_system cannot be changed after creation.
pub async fn update(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
name: &Option<String>,
description: &Option<String>,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<RoleResp> {
let model = role::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
let mut active: role::ActiveModel = model.into();
if let Some(name) = name {
active.name = Set(name.clone());
}
if let Some(desc) = description {
active.description = Set(Some(desc.clone()));
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
let updated = active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok(RoleResp {
id: updated.id,
name: updated.name.clone(),
code: updated.code.clone(),
description: updated.description.clone(),
is_system: updated.is_system,
})
}
/// Soft-delete a role by setting the `deleted_at` timestamp.
///
/// System roles cannot be deleted.
pub async fn delete(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> AuthResult<()> {
let model = role::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
if model.is_system {
return Err(AuthError::Validation("系统角色不可删除".to_string()));
}
let mut active: role::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
event_bus.publish(erp_core::events::DomainEvent::new(
"role.deleted",
tenant_id,
serde_json::json!({ "role_id": id }),
));
Ok(())
}
/// Replace all permission assignments for a role.
///
/// Soft-deletes existing assignments and creates new ones.
pub async fn assign_permissions(
role_id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
permission_ids: &[Uuid],
db: &sea_orm::DatabaseConnection,
) -> AuthResult<()> {
// Verify the role exists and belongs to this tenant
let _role = role::Entity::find_by_id(role_id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?;
// Soft-delete existing role_permission rows
let existing = role_permission::Entity::find()
.filter(role_permission::Column::RoleId.eq(role_id))
.filter(role_permission::Column::TenantId.eq(tenant_id))
.filter(role_permission::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let now = Utc::now();
for rp in existing {
let mut active: role_permission::ActiveModel = rp.into();
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
}
// Insert new role_permission rows
for perm_id in permission_ids {
let rp = role_permission::ActiveModel {
role_id: Set(role_id),
permission_id: Set(*perm_id),
tenant_id: Set(tenant_id),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
rp.insert(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
}
Ok(())
}
/// Fetch all permissions assigned to a role.
///
/// Resolves through the role_permission join table.
pub async fn get_role_permissions(
role_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<Vec<PermissionResp>> {
let rp_rows = role_permission::Entity::find()
.filter(role_permission::Column::RoleId.eq(role_id))
.filter(role_permission::Column::TenantId.eq(tenant_id))
.filter(role_permission::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let perm_ids: Vec<Uuid> = rp_rows.iter().map(|rp| rp.permission_id).collect();
if perm_ids.is_empty() {
return Ok(vec![]);
}
let perms = permission::Entity::find()
.filter(permission::Column::Id.is_in(perm_ids))
.filter(permission::Column::TenantId.eq(tenant_id))
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok(perms
.iter()
.map(|p| PermissionResp {
id: p.id,
code: p.code.clone(),
name: p.name.clone(),
resource: p.resource.clone(),
action: p.action.clone(),
description: p.description.clone(),
})
.collect())
}
}