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

@@ -5,6 +5,7 @@ import zhCN from 'antd/locale/zh_CN';
import MainLayout from './layouts/MainLayout'; import MainLayout from './layouts/MainLayout';
import Login from './pages/Login'; import Login from './pages/Login';
import Home from './pages/Home'; import Home from './pages/Home';
import Roles from './pages/Roles';
import { useAuthStore } from './stores/auth'; import { useAuthStore } from './stores/auth';
import { useAppStore } from './stores/app'; import { useAppStore } from './stores/app';
@@ -40,7 +41,7 @@ export default function App() {
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/users" element={<div></div>} /> <Route path="/users" element={<div></div>} />
<Route path="/roles" element={<div></div>} /> <Route path="/roles" element={<Roles />} />
<Route path="/settings" element={<div></div>} /> <Route path="/settings" element={<div></div>} />
</Routes> </Routes>
</MainLayout> </MainLayout>

73
apps/web/src/api/roles.ts Normal file
View File

@@ -0,0 +1,73 @@
import client from './client';
import type { PaginatedResponse } from './users';
export interface RoleInfo {
id: string;
name: string;
code: string;
description?: string;
is_system: boolean;
}
export interface PermissionInfo {
id: string;
code: string;
name: string;
resource: string;
action: string;
description?: string;
}
export interface CreateRoleRequest {
name: string;
code: string;
description?: string;
}
export interface UpdateRoleRequest {
name?: string;
description?: string;
}
export async function listRoles(page = 1, pageSize = 20) {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<RoleInfo> }>(
'/roles',
{ params: { page, page_size: pageSize } },
);
return data.data;
}
export async function getRole(id: string) {
const { data } = await client.get<{ success: boolean; data: RoleInfo }>(`/roles/${id}`);
return data.data;
}
export async function createRole(req: CreateRoleRequest) {
const { data } = await client.post<{ success: boolean; data: RoleInfo }>('/roles', req);
return data.data;
}
export async function updateRole(id: string, req: UpdateRoleRequest) {
const { data } = await client.put<{ success: boolean; data: RoleInfo }>(`/roles/${id}`, req);
return data.data;
}
export async function deleteRole(id: string) {
await client.delete(`/roles/${id}`);
}
export async function assignPermissions(roleId: string, permissionIds: string[]) {
await client.post(`/roles/${roleId}/permissions`, { permission_ids: permissionIds });
}
export async function getRolePermissions(roleId: string) {
const { data } = await client.get<{ success: boolean; data: PermissionInfo[] }>(
`/roles/${roleId}/permissions`,
);
return data.data;
}
export async function listPermissions() {
const { data } = await client.get<{ success: boolean; data: PermissionInfo[] }>('/permissions');
return data.data;
}

View File

@@ -0,0 +1,272 @@
import { useState, useEffect, useCallback } from 'react';
import {
Table,
Button,
Space,
Modal,
Form,
Input,
Tag,
Popconfirm,
Checkbox,
message,
Typography,
} from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import {
listRoles,
createRole,
updateRole,
deleteRole,
assignPermissions,
getRolePermissions,
listPermissions,
type RoleInfo,
type PermissionInfo,
} from '../api/roles';
export default function Roles() {
const [roles, setRoles] = useState<RoleInfo[]>([]);
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
const [loading, setLoading] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editRole, setEditRole] = useState<RoleInfo | null>(null);
const [permModalOpen, setPermModalOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState<RoleInfo | null>(null);
const [selectedPermIds, setSelectedPermIds] = useState<string[]>([]);
const [form] = Form.useForm();
const fetchRoles = useCallback(async () => {
setLoading(true);
try {
const result = await listRoles();
setRoles(result.data);
} catch {
message.error('加载角色失败');
}
setLoading(false);
}, []);
const fetchPermissions = useCallback(async () => {
try {
setPermissions(await listPermissions());
} catch {
// Permissions may not be seeded yet; silently ignore
}
}, []);
useEffect(() => {
fetchRoles();
fetchPermissions();
}, [fetchRoles, fetchPermissions]);
const handleCreate = async (values: {
name: string;
code: string;
description?: string;
}) => {
try {
if (editRole) {
await updateRole(editRole.id, values);
message.success('角色更新成功');
} else {
await createRole(values);
message.success('角色创建成功');
}
setCreateModalOpen(false);
setEditRole(null);
form.resetFields();
fetchRoles();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '操作失败';
message.error(errorMsg);
}
};
const handleDelete = async (id: string) => {
try {
await deleteRole(id);
message.success('角色已删除');
fetchRoles();
} catch {
message.error('删除失败');
}
};
const openPermModal = async (role: RoleInfo) => {
setSelectedRole(role);
try {
const rolePerms = await getRolePermissions(role.id);
setSelectedPermIds(rolePerms.map((p) => p.id));
} catch {
setSelectedPermIds([]);
}
setPermModalOpen(true);
};
const savePermissions = async () => {
if (!selectedRole) return;
try {
await assignPermissions(selectedRole.id, selectedPermIds);
message.success('权限分配成功');
setPermModalOpen(false);
} catch {
message.error('权限分配失败');
}
};
const openEditModal = (role: RoleInfo) => {
setEditRole(role);
form.setFieldsValue({
name: role.name,
code: role.code,
description: role.description,
});
setCreateModalOpen(true);
};
const openCreateModal = () => {
setEditRole(null);
form.resetFields();
setCreateModalOpen(true);
};
const closeCreateModal = () => {
setCreateModalOpen(false);
setEditRole(null);
form.resetFields();
};
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '编码', dataIndex: 'code', key: 'code' },
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '类型',
dataIndex: 'is_system',
key: 'is_system',
render: (v: boolean) =>
v ? <Tag color="blue"></Tag> : <Tag></Tag>,
},
{
title: '操作',
key: 'actions',
render: (_: unknown, record: RoleInfo) => (
<Space>
<Button size="small" onClick={() => openPermModal(record)}>
</Button>
{!record.is_system && (
<>
<Button size="small" onClick={() => openEditModal(record)}>
</Button>
<Popconfirm
title="确定删除此角色?"
onConfirm={() => handleDelete(record.id)}
>
<Button size="small" danger>
</Button>
</Popconfirm>
</>
)}
</Space>
),
},
];
// Group permissions by resource for better UX
const groupedPermissions = permissions.reduce<Record<string, PermissionInfo[]>>(
(acc, p) => {
if (!acc[p.resource]) acc[p.resource] = [];
acc[p.resource].push(p);
return acc;
},
{},
);
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
</Button>
</div>
<Table
columns={columns}
dataSource={roles}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20 }}
/>
<Modal
title={editRole ? '编辑角色' : '新建角色'}
open={createModalOpen}
onCancel={closeCreateModal}
onOk={() => form.submit()}
>
<Form form={form} onFinish={handleCreate} layout="vertical">
<Form.Item
name="name"
label="名称"
rules={[{ required: true, message: '请输入角色名称' }]}
>
<Input />
</Form.Item>
<Form.Item
name="code"
label="编码"
rules={[{ required: true, message: '请输入角色编码' }]}
>
<Input disabled={!!editRole} />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
<Modal
title={`权限分配 - ${selectedRole?.name || ''}`}
open={permModalOpen}
onCancel={() => setPermModalOpen(false)}
onOk={savePermissions}
width={600}
>
{Object.entries(groupedPermissions).map(([resource, perms]) => (
<div key={resource} style={{ marginBottom: 16 }}>
<Typography.Text strong style={{ textTransform: 'capitalize' }}>
{resource}
</Typography.Text>
<div style={{ marginTop: 8 }}>
<Checkbox.Group
value={selectedPermIds}
onChange={(values) => setSelectedPermIds(values as string[])}
options={perms.map((p) => ({ label: p.name, value: p.id }))}
/>
</div>
</div>
))}
</Modal>
</div>
);
}

View File

@@ -1,2 +1,3 @@
pub mod auth_handler; pub mod auth_handler;
pub mod role_handler;
pub mod user_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::events::EventBus;
use erp_core::module::ErpModule; 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. /// Auth module implementing the `ErpModule` trait.
/// ///
@@ -53,6 +53,25 @@ impl AuthModule {
.put(user_handler::update_user) .put(user_handler::update_user)
.delete(user_handler::delete_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 auth_service;
pub mod password; pub mod password;
pub mod permission_service;
pub mod role_service;
pub mod seed; pub mod seed;
pub mod token_service; pub mod token_service;
pub mod user_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())
}
}