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,2 +1,3 @@
pub mod auth_handler;
pub mod role_handler;
pub mod user_handler;

View File

@@ -0,0 +1,217 @@
use axum::Extension;
use axum::extract::{FromRef, Path, Query, State};
use axum::response::Json;
use validator::Validate;
use erp_core::error::AppError;
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
use uuid::Uuid;
use crate::auth_state::AuthState;
use crate::dto::{AssignPermissionsReq, CreateRoleReq, PermissionResp, RoleResp, UpdateRoleReq};
use crate::middleware::rbac::require_permission;
use crate::service::permission_service::PermissionService;
use crate::service::role_service::RoleService;
/// GET /api/v1/roles
///
/// List roles within the current tenant with pagination.
/// Requires the `role.list` permission.
pub async fn list_roles<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Query(pagination): Query<Pagination>,
) -> Result<Json<ApiResponse<PaginatedResponse<RoleResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.list")?;
let (roles, total) = RoleService::list(ctx.tenant_id, &pagination, &state.db).await?;
let page = pagination.page.unwrap_or(1);
let page_size = pagination.limit();
let total_pages = (total + page_size - 1) / page_size;
Ok(Json(ApiResponse::ok(PaginatedResponse {
data: roles,
total,
page,
page_size,
total_pages,
})))
}
/// POST /api/v1/roles
///
/// Create a new role within the current tenant.
/// Requires the `role.create` permission.
pub async fn create_role<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateRoleReq>,
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.create")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let role = RoleService::create(
ctx.tenant_id,
ctx.user_id,
&req.name,
&req.code,
&req.description,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(role)))
}
/// GET /api/v1/roles/:id
///
/// Fetch a single role by ID within the current tenant.
/// Requires the `role.read` permission.
pub async fn get_role<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.read")?;
let role = RoleService::get_by_id(id, ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(role)))
}
/// PUT /api/v1/roles/:id
///
/// Update editable role fields (name, description).
/// Requires the `role.update` permission.
pub async fn update_role<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateRoleReq>,
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.update")?;
let role = RoleService::update(
id,
ctx.tenant_id,
ctx.user_id,
&req.name,
&req.description,
&state.db,
)
.await?;
Ok(Json(ApiResponse::ok(role)))
}
/// DELETE /api/v1/roles/:id
///
/// Soft-delete a role by ID within the current tenant.
/// System roles cannot be deleted.
/// Requires the `role.delete` permission.
pub async fn delete_role<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.delete")?;
RoleService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("角色已删除".to_string()),
}))
}
/// POST /api/v1/roles/:id/permissions
///
/// Replace all permission assignments for a role.
/// Requires the `role.update` permission.
pub async fn assign_permissions<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<AssignPermissionsReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.update")?;
RoleService::assign_permissions(
id,
ctx.tenant_id,
ctx.user_id,
&req.permission_ids,
&state.db,
)
.await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("权限分配成功".to_string()),
}))
}
/// GET /api/v1/roles/:id/permissions
///
/// Fetch all permissions assigned to a role.
/// Requires the `role.read` permission.
pub async fn get_role_permissions<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<Vec<PermissionResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.read")?;
let perms = RoleService::get_role_permissions(id, ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(perms)))
}
/// GET /api/v1/permissions
///
/// List all permissions within the current tenant.
/// Requires the `permission.list` permission.
pub async fn list_permissions<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<PermissionResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "permission.list")?;
let perms = PermissionService::list(ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(perms)))
}

View File

@@ -5,7 +5,7 @@ use erp_core::error::AppResult;
use erp_core::events::EventBus;
use erp_core::module::ErpModule;
use crate::handler::{auth_handler, user_handler};
use crate::handler::{auth_handler, role_handler, user_handler};
/// Auth module implementing the `ErpModule` trait.
///
@@ -53,6 +53,25 @@ impl AuthModule {
.put(user_handler::update_user)
.delete(user_handler::delete_user),
)
.route(
"/roles",
axum::routing::get(role_handler::list_roles).post(role_handler::create_role),
)
.route(
"/roles/{id}",
axum::routing::get(role_handler::get_role)
.put(role_handler::update_role)
.delete(role_handler::delete_role),
)
.route(
"/roles/{id}/permissions",
axum::routing::get(role_handler::get_role_permissions)
.post(role_handler::assign_permissions),
)
.route(
"/permissions",
axum::routing::get(role_handler::list_permissions),
)
}
}

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