feat(role): role bind

This commit is contained in:
2026-01-31 17:23:56 +08:00
parent 4dc46659c9
commit 41cdbb5b29
30 changed files with 1773 additions and 52 deletions

2
Cargo.lock generated
View File

@@ -989,6 +989,7 @@ dependencies = [
"dotenvy",
"governor",
"hex",
"hmac",
"http",
"ipnet",
"jsonwebtoken",
@@ -996,6 +997,7 @@ dependencies = [
"rsa",
"serde",
"serde_json",
"sha2",
"sqlx",
"thiserror 2.0.18",
"tokio",

View File

@@ -52,6 +52,8 @@ jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] }
rand = "0.9.2"
rsa = "0.9.10"
uuid = { version = "1", features = ["v4", "serde"] }
hmac = "0.12.1"
sha2 = "0.10.9"
# API 文档 (OpenAPI/Scalar)
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }

View File

@@ -149,6 +149,17 @@
下一步依赖:`access_token`
(可选)使用 refresh_token 自动续期:
- **POST** `/auth/refresh`
- Tag`Auth`
- Header
- Body
```json
{ "refresh_token": "<opaque>" }
```
### Step 3获取当前租户信息Tenant
**GET** `/tenants/me`
@@ -319,6 +330,133 @@ enabled_apps 维护建议:
{ "code": 0, "message": "Success", "data": [{ "id": "<role_id>", "name": "Admin", "description": "..." }], "trace_id": null }
```
## Role & Permission Management
本节补齐“角色Role与权限Permission管理”的完整指引适用于横向扩展到多个应用`cms` / `tms` / `iam` 等)。
### 权限模型(可扩展到十几个应用)
权限编码规范:
- `permission.code` 统一采用:`${app_code}:${resource}:${action}`
- 示例:`cms:article:publish``tms:task:assign``iam:tenant:enabled_apps:write`
- `app_code` 必须与应用注册表app_registry中的应用标识一致本项目对应表为 `apps.id`)。
- 支持通配符(需要在 `permissions.code` 中显式存储通配符权限并分配到角色):
- `cms:*:*`CMS 全权限
- `cms:article:*`:文章相关全权限
> 说明:鉴权时支持通配符匹配(例如持有 `cms:article:*` 可满足 `cms:article:publish`),但只有当该通配符权限被写入 `permissions` 并绑定到角色时才会生效。
数据库关系ER 图):
```mermaid
erDiagram
apps {
VARCHAR id PK
VARCHAR name
VARCHAR status
}
permissions {
UUID id PK
VARCHAR code
TEXT description
VARCHAR resource
VARCHAR action
}
users {
UUID id PK
UUID tenant_id FK
VARCHAR email
}
roles {
UUID id PK
UUID tenant_id FK
VARCHAR name
BOOLEAN is_system
}
role_permissions {
UUID role_id FK
UUID permission_id FK
}
user_roles {
UUID user_id FK
UUID role_id FK
}
apps ||--o{ permissions : namespaces
roles ||--o{ role_permissions : grants
permissions ||--o{ role_permissions : assigned
users ||--o{ user_roles : has
roles ||--o{ user_roles : assigned
```
应用区分方式:
- 通过 `apps`app_registry维护“允许的应用标识”并要求权限码的 `app_code``apps.id` 一致。
- 在租户侧通过 `enabled_apps` 控制某租户开通哪些应用;登录/权限查询会按 `enabled_apps` 过滤应用级权限(例如 `cms:*:*` 会在租户未开通 `cms` 时被过滤)。
### 1) 为 CMS 添加最小必要权限SQL
脚本位置:`scripts/db/migrations/0006_cms_permissions.sql`
包含权限项:
- 文章:创建、编辑、发布
- 栏目:管理
- 媒体库:管理
- 系统配置:管理
### 2) 查询权限列表(分页/搜索)
**GET** `/permissions?page=1&page_size=20&app_code=cms&search=article`
- Tag`Permission`
- Header`Authorization: Bearer <access_token>`
- 需要权限:`role:read`
### 3) 角色 CRUDRole
创建角色:
- **POST** `/roles`
查询角色详情:
- **GET** `/roles/{id}`
更新角色:
- **PATCH** `/roles/{id}`
删除角色:
- **DELETE** `/roles/{id}`
说明:
- 系统内置角色(`is_system=true`,如 `Admin`)不允许通过 API 修改/删除。
### 4) 为角色批量绑定/解绑权限
绑定权限(批量):
- **POST** `/roles/{id}/permissions/grant`
- Body
```json
{ "permission_codes": ["cms:article:create", "cms:article:publish"] }
```
解绑权限(批量):
- **POST** `/roles/{id}/permissions/revoke`
### 5) 批量给用户授予/回收角色
批量授予:
- **POST** `/roles/{id}/users/grant`
- Body
```json
{ "user_ids": ["<user_id_1>", "<user_id_2>"] }
```
批量回收:
- **POST** `/roles/{id}/users/revoke`
审计说明:
- 角色创建/更新/删除、角色-权限绑定/解绑、角色-用户授予/回收都会写入 `audit_logs`
### Step 7用户-角色绑定User
用户注册后默认无角色;通常由具备 `user:write` 的管理员进行角色分配。

View File

@@ -1,23 +1,55 @@
`00000000-0000-0000-0000-000000000001`
`superadmin@example.com`
`superadmin1234`
```json
{
"code": 0,
"message": "Success",
"data": {
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI3YTgzYmJjMC0yZjA1LTQwMDItOGFmZC0yYWI1M2RkZDMxNWIiLCJ0ZW5hbnRfaWQiOiI0ZDc3OTQxNC1kYTA0LTQ5YzMtYjM0Mi1kYWJmOTNiNmExMTkiLCJleHAiOjE3Njk3NTc3MTAsImlhdCI6MTc2OTc1NjgxMCwiaXNzIjoiaWFtLXNlcnZpY2UiLCJyb2xlcyI6WyJBZG1pbiJdLCJwZXJtaXNzaW9ucyI6WyJyb2xlOnJlYWQiLCJyb2xlOndyaXRlIiwidGVuYW50OnJlYWQiLCJ0ZW5hbnQ6d3JpdGUiLCJ1c2VyOnJlYWQiLCJ1c2VyOndyaXRlIl19.VGsoZdMwodRWKW4NQuQwezh3xZivFbRUzSw_-RnD-EJIv7qPHmcNbdIcxNSKXCHGKdK_b1B3404m7ji2wdEOweKz0GEcwPWswc9fannP5_6l9k83jn0ZKQ1pS3l27V5mr9feym_83ZIqEtFfKcCKGIM684Ze7CMM6i-gfYisn0poG1XW3K4ptsVnuNZux0TWNFl5TO6kgiw0_399tZnSH5qc4CckHOuoF3Jz1Q2aIgnvyfxbxEFTNZm-ykjhlbK5zWBpYfJdYOALQg-FQ3eGuVnSF4U_If1MNQKQ0p6DqDKMCO0IfdCr2WMBvfCYA1SxmPbETr2Tm7RguhJBEiVQ4Q",
"refresh_token": "e1649e730ef3583cd80087f7fa63774330deb88e81aec2edae41322764e441eb",
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI4YWRlYzEzOC0wMzU5LTQ5NjgtYjAzNy03ZDI3Nzg2NTZhNjIiLCJ0ZW5hbnRfaWQiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDEiLCJleHAiOjE3Njk4NTA1ODIsImlhdCI6MTc2OTg0OTY4MiwiaXNzIjoiaWFtLXNlcnZpY2UiLCJyb2xlcyI6WyJBZG1pbiJdLCJwZXJtaXNzaW9ucyI6WyJpYW06dGVuYW50OmVuYWJsZWRfYXBwczpyZWFkIiwiaWFtOnRlbmFudDplbmFibGVkX2FwcHM6d3JpdGUiLCJyb2xlOnJlYWQiLCJyb2xlOndyaXRlIiwidGVuYW50OnJlYWQiLCJ0ZW5hbnQ6d3JpdGUiLCJ1c2VyOnBhc3N3b3JkOnJlc2V0OmFueSIsInVzZXI6cmVhZCIsInVzZXI6d3JpdGUiXSwiYXBwcyI6W10sImFwcHNfdmVyc2lvbiI6MH0.JouDbnuLxCIXl4CpZ3G3zcwILohS1Dv6_k-2pV_5Iezrnk9IGlnmv-TqhP1BB0_A88etOIV9EPx8LVD0dJQaci_jABVr9ciQU126yu8GeTkoJTKDXuroornAxv6C7K2qp7jl9lfxgmm0h0jYla79YdEW8RdDu8q9VojjVNnyiT-0thOKRJddflKjovOrlOsVpqjtZvf_F86e9vEFBFImUs8_xuWo3gUUysbINbZc-I-DGRqIC95pVOOts3LzashkIVlPl4akXpbukSJjwNd9WnTSr9zf-7bN_YBFJGSutLor74cMlklZ6k828GmHrsuAIxyZMqoeTLrrsG3VcUjYKQ",
"refresh_token": "46d067317f9a6181255d151b099311b609683baa0c25ff62a81a3e6e7d8a9942",
"token_type": "Bearer",
"expires_in": 900
}
}
```
`4d779414-da04-49c3-b342-dabf93b6a119`
`shay7sev@gmail.com`
`tenantdev1`
```json
{
"code": 0,
"message": "Success",
"data": {
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmOTg5MWI2Yy0xNTQ1LTQ0NGItODIxOC0yNTQ5MDA1NDczMGYiLCJ0ZW5hbnRfaWQiOiI0ZDc3OTQxNC1kYTA0LTQ5YzMtYjM0Mi1kYWJmOTNiNmExMTkiLCJleHAiOjE3Njk3NTg4NDMsImlhdCI6MTc2OTc1Nzk0MywiaXNzIjoiaWFtLXNlcnZpY2UiLCJyb2xlcyI6W10sInBlcm1pc3Npb25zIjpbXX0.Is-JjQ9l1BuZoYVr6QAt-4ZSfRQro3COPSVYHVl1NI0CAz7T2x9Hz2QiJPFamjsX7cFrCIJxNMn9ioqK2FzEnSTu2oVATqlhE5OMCcK1M7Mq_FvZ7WqGgPl8CE06s7yvneC97mknk5y1-nm5claYYGeHAjLrRPbjO2t3zUQO5boNPdjzEGx4kTFvgmJbwWMrsBtkeaW1nacxhFiSj-RFCSzHOOaSRoKLDsx9nUsuDJL1NCaHDuKDacphkwpjP5AWLd41hlrs6PC8XLUPey2EXHqJ5SmOaDdQ60LfItvohgHBTY6CO8IUIJgtZobrFsKUlnHqA9eZwm2dvAW560g4VA",
"refresh_token": "71e3ba6285b503891294ca7dad81cdc6bd5b3f72b09b1e2b796979a433d687f3",
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI3YTgzYmJjMC0yZjA1LTQwMDItOGFmZC0yYWI1M2RkZDMxNWIiLCJ0ZW5hbnRfaWQiOiI0ZDc3OTQxNC1kYTA0LTQ5YzMtYjM0Mi1kYWJmOTNiNmExMTkiLCJleHAiOjE3Njk4NTEyOTIsImlhdCI6MTc2OTg1MDM5MiwiaXNzIjoiaWFtLXNlcnZpY2UiLCJyb2xlcyI6WyJBZG1pbiJdLCJwZXJtaXNzaW9ucyI6WyJyb2xlOnJlYWQiLCJyb2xlOndyaXRlIiwidGVuYW50OnJlYWQiLCJ0ZW5hbnQ6d3JpdGUiLCJ1c2VyOnBhc3N3b3JkOnJlc2V0OmFueSIsInVzZXI6cmVhZCIsInVzZXI6d3JpdGUiXSwiYXBwcyI6WyJjbXMiXSwiYXBwc192ZXJzaW9uIjoxfQ.jt1os6re3yhxBk4wfmBjy1_Qh8n5nkfwe8ptn-yi7Vws_MOepOAmdxqSY_sabOnvGZ74Rq2EFSDpOaan4HuPln35Vlt6-CPlEu5eikLu3AIBl7sZfGoOquHwnybuOwo8b5oFwgAWF0bqSmn1v--LdGvv7vX4zWiKxK6GeeCTZ8279GqO70tl4o6ug2swSMqPbspL-ZwnWrnvFRhfZkyrRmM6jn3TVUMFWX3FfTlm68lNl_UPj9OcUPvbIXFL3X-h8qk-W1Dq2hV_Z1WxjkwVV0XEa0iwz12Mb_-QFys2xLSXSxL4ubUJhV2RVQ2WmW-I0njLEJAQ5oR56nZi7XMZHA",
"refresh_token": "982236b2f680366a895768df1ffc29bf4bbc09eb82d6a88a4413a07f66b3badb",
"token_type": "Bearer",
"expires_in": 900
}
}
```
`user1@example.com`
`user1secret`
```json
{
"code": 0,
"message": "Success",
"data": {
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmOTg5MWI2Yy0xNTQ1LTQ0NGItODIxOC0yNTQ5MDA1NDczMGYiLCJ0ZW5hbnRfaWQiOiI0ZDc3OTQxNC1kYTA0LTQ5YzMtYjM0Mi1kYWJmOTNiNmExMTkiLCJleHAiOjE3Njk4NTE1NDYsImlhdCI6MTc2OTg1MDY0NiwiaXNzIjoiaWFtLXNlcnZpY2UiLCJyb2xlcyI6WyJlZGl0b3IiXSwicGVybWlzc2lvbnMiOlsiY21zOmFydGljbGU6Y3JlYXRlIiwiY21zOmFydGljbGU6ZWRpdCIsImNtczphcnRpY2xlOnB1Ymxpc2giXSwiYXBwcyI6WyJjbXMiXSwiYXBwc192ZXJzaW9uIjoxfQ.r3G5AVkJuvR9vqZhv2Gnj-kZT04Vp_FWNLaZxU7mCvq6uo0unv0d4n3kxDnVYnTYi8cAMtYd4OXvAcpyJ6cN1c9UvyjHEZbhYscZXT6bsc794Jv1kC2rj0upra3zBOUPz-rSeVaQiOS4vA7th2GlvOCQhpkVqHyvZXuoG8AhS17ZyMWl9ZPtzhrE5Ql14w0Au3pmnj6p8zhZslDHXbLeejV0PC314yexqdpbXS6lB72ovzNGUgu30t3Va5sQFbb18PyRzEtI2JOzvGC0TlN6w4n8o3aXQ7rwurkqx00fuzf5nQpOssfO3EFtxIIOT-ndzsM0pDZgh4l6QBXqz4O20Q",
"refresh_token": "f876c11eb2cd221151096406ccd8878879b5bc3ba32822845674e117964c54b4",
"token_type": "Bearer",
"expires_in": 900
}
}
```
```text
permission项你是推荐数据库迁移的方式新增吗permission项的管理的最佳方式是什么
```

View File

@@ -18,6 +18,8 @@
| 0002 | `migrations/0002_enabled_apps.sql` | enabled_apps租户应用开通、平台租户与平台权限SuperAdmin |
| 0003 | `migrations/0003_app_lifecycle.sql` | apps 生命周期管理(扩展字段、变更记录、上下线审批) |
| 0004 | `migrations/0004_password_reset.sql` | 密码重置(权限码与 Admin/SuperAdmin 授权) |
| 0005 | `migrations/0005_refresh_token_fingerprint.sql` | refresh token 指纹索引(支持刷新时安全查找) |
| 0006 | `migrations/0006_cms_permissions.sql` | CMS 最小必要权限permissions 种子) |
校验脚本映射(与 migrations 一一对应):
@@ -27,6 +29,8 @@
| 0002 | `scripts/db/verify/0002_enabled_apps.sql` | 校验 enabled_apps 相关表与平台种子 |
| 0003 | `scripts/db/verify/0003_app_lifecycle.sql` | 校验 apps 生命周期管理相关表与权限种子 |
| 0004 | `scripts/db/verify/0004_password_reset.sql` | 校验密码重置权限码种子 |
| 0005 | `scripts/db/verify/0005_refresh_token_fingerprint.sql` | 校验 refresh_tokens 指纹字段 |
| 0006 | `scripts/db/verify/0006_cms_permissions.sql` | 校验 CMS 权限种子 |
## 执行方式

View File

@@ -0,0 +1,11 @@
BEGIN;
ALTER TABLE refresh_tokens
ADD COLUMN IF NOT EXISTS token_fingerprint VARCHAR(64);
CREATE UNIQUE INDEX IF NOT EXISTS idx_refresh_tokens_token_fingerprint
ON refresh_tokens(token_fingerprint)
WHERE token_fingerprint IS NOT NULL;
COMMIT;

View File

@@ -0,0 +1,13 @@
BEGIN;
INSERT INTO permissions (code, description, resource, action) VALUES
('cms:article:create', 'Create article', 'article', 'create'),
('cms:article:edit', 'Edit article', 'article', 'edit'),
('cms:article:publish', 'Publish article', 'article', 'publish'),
('cms:category:manage', 'Manage categories', 'category', 'manage'),
('cms:media:manage', 'Manage media library', 'media', 'manage'),
('cms:settings:manage', 'Manage system settings', 'settings', 'manage')
ON CONFLICT (code) DO NOTHING;
COMMIT;

View File

@@ -0,0 +1,9 @@
BEGIN;
DROP INDEX IF EXISTS idx_refresh_tokens_token_fingerprint;
ALTER TABLE refresh_tokens
DROP COLUMN IF EXISTS token_fingerprint;
COMMIT;

View File

@@ -0,0 +1,14 @@
BEGIN;
DELETE FROM permissions
WHERE code IN (
'cms:article:create',
'cms:article:edit',
'cms:article:publish',
'cms:category:manage',
'cms:media:manage',
'cms:settings:manage'
);
COMMIT;

View File

@@ -0,0 +1,12 @@
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'refresh_tokens'
AND column_name = 'token_fingerprint'
) THEN
RAISE EXCEPTION 'refresh_tokens.token_fingerprint missing';
END IF;
END $$;

View File

@@ -0,0 +1,7 @@
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM permissions WHERE code = 'cms:article:create') THEN
RAISE EXCEPTION 'missing cms permissions seed';
END IF;
END $$;

View File

@@ -3,8 +3,9 @@ use crate::models::{
AdminResetUserPasswordRequest, AdminResetUserPasswordResponse, App, AppStatusChangeRequest,
ApproveAppStatusChangeRequest, CreateAppRequest, CreateRoleRequest, CreateTenantRequest,
CreateUserRequest, ListAppsQuery, LoginRequest, LoginResponse,
RequestAppStatusChangeRequest, ResetMyPasswordRequest, Role, RoleResponse, Tenant,
TenantEnabledAppsResponse, TenantResponse, UpdateAppRequest,
RefreshTokenRequest, RequestAppStatusChangeRequest, ResetMyPasswordRequest, Role, RoleResponse, Tenant,
TenantEnabledAppsResponse, TenantResponse, UpdateAppRequest, Permission, ListPermissionsQuery,
UpdateRoleRequest, RolePermissionsRequest, RoleUsersRequest,
UpdateTenantEnabledAppsRequest, UpdateTenantRequest, UpdateTenantStatusRequest,
UpdateUserRequest, UpdateUserRolesRequest, User, UserResponse,
};
@@ -137,7 +138,9 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
paths(
handlers::auth::register_handler,
handlers::auth::login_handler,
handlers::auth::refresh_handler,
handlers::authorization::my_permissions_handler,
handlers::permission::list_permissions_handler,
handlers::platform::get_tenant_enabled_apps_handler,
handlers::platform::set_tenant_enabled_apps_handler,
handlers::app::create_app_handler,
@@ -156,6 +159,13 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
handlers::tenant::delete_tenant_handler,
handlers::role::create_role_handler,
handlers::role::list_roles_handler,
handlers::role::get_role_handler,
handlers::role::update_role_handler,
handlers::role::delete_role_handler,
handlers::role::grant_role_permissions_handler,
handlers::role::revoke_role_permissions_handler,
handlers::role::grant_role_users_handler,
handlers::role::revoke_role_users_handler,
handlers::user::list_users_handler,
handlers::user::get_user_handler,
handlers::user::update_user_handler,
@@ -174,9 +184,15 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
UpdateUserRequest,
LoginRequest,
LoginResponse,
RefreshTokenRequest,
Permission,
ListPermissionsQuery,
Role,
CreateRoleRequest,
RoleResponse,
UpdateRoleRequest,
RolePermissionsRequest,
RoleUsersRequest,
Tenant,
TenantResponse,
CreateTenantRequest,
@@ -202,6 +218,7 @@ fn apply_header_parameter_examples(openapi: &mut utoipa::openapi::OpenApi, cfg:
(name = "Tenant", description = "租户:创建/查询/更新/状态/删除"),
(name = "User", description = "用户:查询/列表/更新/删除(需权限)"),
(name = "Role", description = "角色:创建/列表(需权限)"),
(name = "Permission", description = "权限:列表与检索(需权限)"),
(name = "Me", description = "当前用户:权限自查等"),
(name = "App", description = "应用:应用注册表与生命周期管理(平台级)"),
(name = "Policy", description = "策略预留ABAC/策略引擎后续扩展)")

View File

@@ -1,6 +1,8 @@
use crate::handlers::AppState;
use crate::middleware::TenantId;
use crate::models::{CreateUserRequest, LoginRequest, LoginResponse, UserResponse};
use crate::models::{
CreateUserRequest, LoginRequest, LoginResponse, RefreshTokenRequest, UserResponse,
};
use axum::{Json, extract::State};
use common_telemetry::{AppError, AppResponse};
use tracing::instrument;
@@ -67,3 +69,27 @@ pub async fn login_handler(
Ok(AppResponse::ok(response))
}
/// Refresh access token (rotate refresh token).
/// 刷新访问令牌(同时轮换 refresh_token一次性使用
#[utoipa::path(
post,
path = "/auth/refresh",
tag = "Auth",
request_body = RefreshTokenRequest,
responses(
(status = 200, description = "Token refreshed", body = LoginResponse),
(status = 401, description = "Unauthorized")
)
)]
#[instrument(skip(state, payload))]
pub async fn refresh_handler(
State(state): State<AppState>,
Json(payload): Json<RefreshTokenRequest>,
) -> Result<AppResponse<LoginResponse>, AppError> {
let response = state
.auth_service
.refresh_access_token(payload.refresh_token)
.await?;
Ok(AppResponse::ok(response))
}

View File

@@ -1,13 +1,15 @@
pub mod app;
pub mod auth;
pub mod authorization;
pub mod permission;
pub mod platform;
pub mod role;
pub mod tenant;
pub mod user;
use crate::services::{
AppService, AuthService, AuthorizationService, RoleService, TenantService, UserService,
AppService, AuthService, AuthorizationService, PermissionService, RoleService, TenantService,
UserService,
};
pub use app::{
@@ -15,10 +17,15 @@ pub use app::{
list_app_status_change_requests_handler, list_apps_handler, reject_app_status_change_handler,
request_app_status_change_handler, update_app_handler,
};
pub use auth::{login_handler, register_handler};
pub use auth::{login_handler, refresh_handler, register_handler};
pub use authorization::my_permissions_handler;
pub use permission::list_permissions_handler;
pub use platform::{get_tenant_enabled_apps_handler, set_tenant_enabled_apps_handler};
pub use role::{create_role_handler, list_roles_handler};
pub use role::{
create_role_handler, delete_role_handler, get_role_handler, grant_role_permissions_handler,
grant_role_users_handler, list_roles_handler, revoke_role_permissions_handler,
revoke_role_users_handler, update_role_handler,
};
pub use tenant::{
create_tenant_handler, delete_tenant_handler, get_tenant_handler, update_tenant_handler,
update_tenant_status_handler,
@@ -38,4 +45,5 @@ pub struct AppState {
pub tenant_service: TenantService,
pub authorization_service: AuthorizationService,
pub app_service: AppService,
pub permission_service: PermissionService,
}

View File

@@ -0,0 +1,48 @@
use crate::handlers::AppState;
use crate::middleware::TenantId;
use crate::middleware::auth::AuthContext;
use crate::models::{ListPermissionsQuery, Permission};
use axum::extract::{Query, State};
use common_telemetry::{AppError, AppResponse};
use tracing::instrument;
#[utoipa::path(
get,
path = "/permissions",
tag = "Permission",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "Permission list", body = [Permission]),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
ListPermissionsQuery
)
)]
#[instrument(skip(state))]
pub async fn list_permissions_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Query(query): Query<ListPermissionsQuery>,
) -> Result<AppResponse<Vec<Permission>>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "role:read")
.await?;
let rows = state.permission_service.list_permissions(query).await?;
Ok(AppResponse::ok(rows))
}

View File

@@ -1,10 +1,16 @@
use crate::handlers::AppState;
use crate::middleware::TenantId;
use crate::middleware::auth::AuthContext;
use crate::models::{CreateRoleRequest, RoleResponse};
use axum::{Json, extract::State};
use crate::models::{
CreateRoleRequest, RolePermissionsRequest, RoleResponse, RoleUsersRequest, UpdateRoleRequest,
};
use axum::{
Json,
extract::{Path, State},
};
use common_telemetry::{AppError, AppResponse};
use tracing::instrument;
use uuid::Uuid;
#[utoipa::path(
post,
@@ -62,7 +68,10 @@ pub async fn create_role_handler(
.require_permission(tenant_id, user_id, "role:write")
.await?;
let role = state.role_service.create_role(tenant_id, payload).await?;
let role = state
.role_service
.create_role(tenant_id, payload, user_id)
.await?;
Ok(AppResponse::created(RoleResponse {
id: role.id,
name: role.name,
@@ -132,3 +141,331 @@ pub async fn list_roles_handler(
.collect();
Ok(AppResponse::ok(response))
}
#[utoipa::path(
get,
path = "/roles/{id}",
tag = "Role",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "角色详情", body = RoleResponse),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "未找到")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
("id" = String, Path, description = "角色 UUID")
)
)]
#[instrument(skip(state))]
pub async fn get_role_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Path(role_id): Path<Uuid>,
) -> Result<AppResponse<RoleResponse>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "role:read")
.await?;
let role = state.role_service.get_role(tenant_id, role_id).await?;
Ok(AppResponse::ok(RoleResponse {
id: role.id,
name: role.name,
description: role.description,
}))
}
#[utoipa::path(
patch,
path = "/roles/{id}",
tag = "Role",
security(
("bearer_auth" = [])
),
request_body = UpdateRoleRequest,
responses(
(status = 200, description = "角色更新成功", body = RoleResponse),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "未找到")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
("id" = String, Path, description = "角色 UUID")
)
)]
#[instrument(skip(state, payload))]
pub async fn update_role_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Path(role_id): Path<Uuid>,
Json(payload): Json<UpdateRoleRequest>,
) -> Result<AppResponse<RoleResponse>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "role:write")
.await?;
let role = state
.role_service
.update_role(tenant_id, role_id, payload, user_id)
.await?;
Ok(AppResponse::ok(RoleResponse {
id: role.id,
name: role.name,
description: role.description,
}))
}
#[utoipa::path(
delete,
path = "/roles/{id}",
tag = "Role",
security(
("bearer_auth" = [])
),
responses(
(status = 200, description = "角色删除成功"),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "未找到")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
("id" = String, Path, description = "角色 UUID")
)
)]
#[instrument(skip(state))]
pub async fn delete_role_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Path(role_id): Path<Uuid>,
) -> Result<AppResponse<serde_json::Value>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "role:write")
.await?;
state
.role_service
.delete_role(tenant_id, role_id, user_id)
.await?;
Ok(AppResponse::ok(serde_json::json!({})))
}
#[utoipa::path(
post,
path = "/roles/{id}/permissions/grant",
tag = "Role",
security(
("bearer_auth" = [])
),
request_body = RolePermissionsRequest,
responses(
(status = 200, description = "绑定权限成功"),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "未找到")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
("id" = String, Path, description = "角色 UUID")
)
)]
#[instrument(skip(state, payload))]
pub async fn grant_role_permissions_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Path(role_id): Path<Uuid>,
Json(payload): Json<RolePermissionsRequest>,
) -> Result<AppResponse<serde_json::Value>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "role:write")
.await?;
state
.role_service
.grant_permissions_to_role(tenant_id, role_id, payload.permission_codes, user_id)
.await?;
Ok(AppResponse::ok(serde_json::json!({})))
}
#[utoipa::path(
post,
path = "/roles/{id}/permissions/revoke",
tag = "Role",
security(
("bearer_auth" = [])
),
request_body = RolePermissionsRequest,
responses(
(status = 200, description = "解绑权限成功"),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "未找到")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
("id" = String, Path, description = "角色 UUID")
)
)]
#[instrument(skip(state, payload))]
pub async fn revoke_role_permissions_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Path(role_id): Path<Uuid>,
Json(payload): Json<RolePermissionsRequest>,
) -> Result<AppResponse<serde_json::Value>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "role:write")
.await?;
state
.role_service
.revoke_permissions_from_role(tenant_id, role_id, payload.permission_codes, user_id)
.await?;
Ok(AppResponse::ok(serde_json::json!({})))
}
#[utoipa::path(
post,
path = "/roles/{id}/users/grant",
tag = "Role",
security(
("bearer_auth" = [])
),
request_body = RoleUsersRequest,
responses(
(status = 200, description = "批量授予角色成功"),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "未找到")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
("id" = String, Path, description = "角色 UUID")
)
)]
#[instrument(skip(state, payload))]
pub async fn grant_role_users_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Path(role_id): Path<Uuid>,
Json(payload): Json<RoleUsersRequest>,
) -> Result<AppResponse<serde_json::Value>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "user:write")
.await?;
state
.role_service
.grant_role_to_users(tenant_id, role_id, payload.user_ids, user_id)
.await?;
Ok(AppResponse::ok(serde_json::json!({})))
}
#[utoipa::path(
post,
path = "/roles/{id}/users/revoke",
tag = "Role",
security(
("bearer_auth" = [])
),
request_body = RoleUsersRequest,
responses(
(status = 200, description = "批量回收角色成功"),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未认证"),
(status = 403, description = "无权限"),
(status = 404, description = "未找到")
),
params(
("Authorization" = String, Header, description = "Bearer <access_token>(访问令牌)"),
("X-Tenant-ID" = String, Header, description = "租户 UUID可选若提供需与 Token 中 tenant_id 一致)"),
("id" = String, Path, description = "角色 UUID")
)
)]
#[instrument(skip(state, payload))]
pub async fn revoke_role_users_handler(
TenantId(tenant_id): TenantId,
State(state): State<AppState>,
AuthContext {
tenant_id: auth_tenant_id,
user_id,
..
}: AuthContext,
Path(role_id): Path<Uuid>,
Json(payload): Json<RoleUsersRequest>,
) -> Result<AppResponse<serde_json::Value>, AppError> {
if auth_tenant_id != tenant_id {
return Err(AppError::PermissionDenied("tenant:mismatch".into()));
}
state
.authorization_service
.require_permission(tenant_id, user_id, "user:write")
.await?;
state
.role_service
.revoke_role_from_users(tenant_id, role_id, payload.user_ids, user_id)
.await?;
Ok(AppResponse::ok(serde_json::json!({})))
}

View File

@@ -16,17 +16,20 @@ use axum::{
use config::AppConfig;
use handlers::{
AppState, approve_app_status_change_handler, create_app_handler, create_role_handler,
create_tenant_handler, delete_app_handler, delete_tenant_handler, delete_user_handler,
get_app_handler, get_tenant_enabled_apps_handler, get_tenant_handler, get_user_handler,
list_app_status_change_requests_handler, list_apps_handler, list_roles_handler,
list_user_roles_handler, list_users_handler, login_handler, my_permissions_handler,
register_handler, reject_app_status_change_handler, request_app_status_change_handler,
reset_my_password_handler, reset_user_password_handler, set_tenant_enabled_apps_handler,
set_user_roles_handler, update_app_handler, update_tenant_handler,
create_tenant_handler, delete_app_handler, delete_role_handler, delete_tenant_handler,
delete_user_handler, get_app_handler, get_role_handler, get_tenant_enabled_apps_handler,
get_tenant_handler, get_user_handler, grant_role_permissions_handler, grant_role_users_handler,
list_app_status_change_requests_handler, list_apps_handler, list_permissions_handler,
list_roles_handler, list_user_roles_handler, list_users_handler, login_handler,
my_permissions_handler, refresh_handler, register_handler, reject_app_status_change_handler,
request_app_status_change_handler, reset_my_password_handler, reset_user_password_handler,
revoke_role_permissions_handler, revoke_role_users_handler, set_tenant_enabled_apps_handler,
set_user_roles_handler, update_app_handler, update_role_handler, update_tenant_handler,
update_tenant_status_handler, update_user_handler,
};
use services::{
AppService, AuthService, AuthorizationService, RoleService, TenantService, UserService,
AppService, AuthService, AuthorizationService, PermissionService, RoleService, TenantService,
UserService,
};
use std::net::SocketAddr;
use utoipa::OpenApi;
@@ -79,6 +82,7 @@ async fn main() {
let tenant_service = TenantService::new(pool.clone());
let authorization_service = AuthorizationService::new(pool.clone());
let app_service = AppService::new(pool.clone());
let permission_service = PermissionService::new(pool.clone());
let state = AppState {
auth_service,
@@ -87,6 +91,7 @@ async fn main() {
tenant_service,
authorization_service,
app_service,
permission_service,
};
// 5. 构建路由
@@ -111,9 +116,16 @@ async fn main() {
.layer(middleware::rate_limit::login_rate_limiter())
.layer(from_fn(middleware::rate_limit::log_rate_limit_login)),
)
.route(
"/auth/refresh",
post(refresh_handler)
.layer(middleware::rate_limit::login_rate_limiter())
.layer(from_fn(middleware::rate_limit::log_rate_limit_login)),
)
.route("/me/permissions", get(my_permissions_handler))
.route("/users", get(list_users_handler))
.route("/users/me/password/reset", post(reset_my_password_handler))
.route("/permissions", get(list_permissions_handler))
.route(
"/users/{id}",
get(get_user_handler)
@@ -129,6 +141,22 @@ async fn main() {
get(list_user_roles_handler).put(set_user_roles_handler),
)
.route("/roles", get(list_roles_handler).post(create_role_handler))
.route(
"/roles/{id}",
get(get_role_handler)
.patch(update_role_handler)
.delete(delete_role_handler),
)
.route(
"/roles/{id}/permissions/grant",
post(grant_role_permissions_handler),
)
.route(
"/roles/{id}/permissions/revoke",
post(revoke_role_permissions_handler),
)
.route("/roles/{id}/users/grant", post(grant_role_users_handler))
.route("/roles/{id}/users/revoke", post(revoke_role_users_handler))
.layer(from_fn(middleware::resolve_tenant))
.layer(from_fn(middleware::auth::authenticate))
.layer(from_fn(

View File

@@ -21,6 +21,7 @@ pub async fn authenticate(mut req: Request, next: Next) -> Result<Response, AppE
|| path == "/tenants/register"
|| path == "/auth/register"
|| path == "/auth/login"
|| path == "/auth/refresh"
{
return Ok(next.run(req).await);
}

View File

@@ -14,7 +14,7 @@ pub struct TenantId(pub Uuid);
pub async fn resolve_tenant(mut req: Request, next: Next) -> Result<Response, AppError> {
let path = req.uri().path();
if path.starts_with("/scalar") || path == "/tenants/register" {
if path.starts_with("/scalar") || path == "/tenants/register" || path == "/auth/refresh" {
return Ok(next.run(req).await);
}

View File

@@ -352,3 +352,58 @@ pub struct AdminResetUserPasswordResponse {
#[serde(default)]
pub temporary_password: String,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct RefreshTokenRequest {
#[schema(default = "", example = "opaque_refresh_token")]
#[serde(default)]
pub refresh_token: String,
}
#[derive(Debug, Serialize, FromRow, ToSchema, IntoParams)]
pub struct Permission {
#[serde(default = "default_uuid")]
pub id: Uuid,
#[serde(default)]
pub code: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub resource: Option<String>,
#[serde(default)]
pub action: Option<String>,
#[serde(default)]
pub created_at: String,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct ListPermissionsQuery {
pub page: Option<u32>,
pub page_size: Option<u32>,
pub search: Option<String>,
pub app_code: Option<String>,
pub resource: Option<String>,
pub action: Option<String>,
pub sort_by: Option<String>,
pub sort_order: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct UpdateRoleRequest {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct RolePermissionsRequest {
#[serde(default)]
pub permission_codes: Vec<String>,
}
#[derive(Debug, Deserialize, ToSchema, IntoParams)]
pub struct RoleUsersRequest {
#[serde(default)]
pub user_ids: Vec<Uuid>,
}

View File

@@ -2,7 +2,9 @@ use crate::models::{CreateUserRequest, LoginRequest, LoginResponse, User};
use crate::utils::authz::filter_permissions_by_enabled_apps;
use crate::utils::{hash_password, sign, verify_password};
use common_telemetry::AppError;
use hmac::{Hmac, Mac};
use rand::RngCore;
use sha2::Sha256;
use sqlx::PgPool;
use tracing::instrument;
use uuid::Uuid;
@@ -11,6 +13,7 @@ use uuid::Uuid;
pub struct AuthService {
pool: PgPool,
// jwt_secret removed, using RS256 keys
refresh_token_pepper: String,
}
impl AuthService {
@@ -19,7 +22,17 @@ impl AuthService {
/// 说明:
/// - 当前实现使用 RS256 密钥对进行 JWT 签发与校验,因此 `_jwt_secret` 参数仅为兼容保留。
pub fn new(pool: PgPool, _jwt_secret: String) -> Self {
Self { pool }
Self {
pool,
refresh_token_pepper: _jwt_secret,
}
}
fn refresh_token_fingerprint(&self, refresh_token: &str) -> Result<String, AppError> {
let mut mac = Hmac::<Sha256>::new_from_slice(self.refresh_token_pepper.as_bytes())
.map_err(|_| AppError::ConfigError("Invalid JWT_SECRET".into()))?;
mac.update(refresh_token.as_bytes());
Ok(hex::encode(mac.finalize().into_bytes()))
}
// 注册业务
@@ -176,15 +189,17 @@ impl AuthService {
// Hash refresh token for storage
let refresh_token_hash =
hash_password(&refresh_token).map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?;
let refresh_token_fingerprint = self.refresh_token_fingerprint(&refresh_token)?;
// 5. 存储 Refresh Token (30天过期)
let expires_at = chrono::Utc::now() + chrono::Duration::days(30);
sqlx::query(
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
"INSERT INTO refresh_tokens (user_id, token_hash, token_fingerprint, expires_at) VALUES ($1, $2, $3, $4)",
)
.bind(user.id)
.bind(refresh_token_hash)
.bind(refresh_token_fingerprint)
.bind(expires_at)
.execute(&self.pool)
.await?;
@@ -244,4 +259,179 @@ impl AuthService {
Ok(())
}
#[instrument(skip(self, refresh_token))]
pub async fn refresh_access_token(
&self,
refresh_token: String,
) -> Result<LoginResponse, AppError> {
if refresh_token.trim().is_empty() {
return Err(AppError::BadRequest("refresh_token is required".into()));
}
let fingerprint = self.refresh_token_fingerprint(refresh_token.trim())?;
let mut tx = self.pool.begin().await?;
let row = sqlx::query_as::<
_,
(
Uuid,
String,
chrono::DateTime<chrono::Utc>,
bool,
Option<String>,
),
>(
r#"
SELECT user_id, token_hash, expires_at, is_revoked, replaced_by_token_hash
FROM refresh_tokens
WHERE token_fingerprint = $1
FOR UPDATE
"#,
)
.bind(&fingerprint)
.fetch_optional(&mut *tx)
.await?;
let Some((user_id, token_hash, expires_at, is_revoked, replaced_by)) = row else {
return Err(AppError::AuthError("Invalid refresh token".into()));
};
if is_revoked {
let msg = if replaced_by.is_some() {
"Refresh token already used"
} else {
"Refresh token revoked"
};
return Err(AppError::AuthError(msg.into()));
}
if chrono::Utc::now() >= expires_at {
sqlx::query("UPDATE refresh_tokens SET is_revoked = TRUE WHERE token_fingerprint = $1")
.bind(&fingerprint)
.execute(&mut *tx)
.await?;
tx.commit().await?;
return Err(AppError::RefreshTokenExpired);
}
if !verify_password(refresh_token.trim(), &token_hash) {
return Err(AppError::AuthError("Invalid refresh token".into()));
}
let tenant_id: Uuid = sqlx::query_scalar("SELECT tenant_id FROM users WHERE id = $1")
.bind(user_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
if tenant_id == Uuid::nil() {
return Err(AppError::NotFound("User not found".into()));
}
let mut refresh_bytes = [0u8; 32];
rand::rng().fill_bytes(&mut refresh_bytes);
let new_refresh_token = hex::encode(refresh_bytes);
let new_refresh_token_hash = hash_password(&new_refresh_token)
.map_err(|e| AppError::AnyhowError(anyhow::anyhow!(e)))?;
let new_fingerprint = self.refresh_token_fingerprint(&new_refresh_token)?;
sqlx::query(
r#"
UPDATE refresh_tokens
SET is_revoked = TRUE,
replaced_by_token_hash = $1
WHERE token_fingerprint = $2
"#,
)
.bind(&new_fingerprint)
.bind(&fingerprint)
.execute(&mut *tx)
.await?;
let expires_at = chrono::Utc::now() + chrono::Duration::days(30);
sqlx::query(
r#"
INSERT INTO refresh_tokens (user_id, token_hash, token_fingerprint, expires_at)
VALUES ($1, $2, $3, $4)
"#,
)
.bind(user_id)
.bind(new_refresh_token_hash)
.bind(&new_fingerprint)
.bind(expires_at)
.execute(&mut *tx)
.await?;
let roles = sqlx::query_scalar::<_, String>(
r#"
SELECT r.name
FROM roles r
JOIN user_roles ur ON ur.role_id = r.id
WHERE r.tenant_id = $1 AND ur.user_id = $2
"#,
)
.bind(tenant_id)
.bind(user_id)
.fetch_all(&mut *tx)
.await?;
let permissions = sqlx::query_scalar::<_, String>(
r#"
SELECT DISTINCT p.code
FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
JOIN user_roles ur ON ur.role_id = rp.role_id
JOIN roles r ON r.id = ur.role_id
WHERE r.tenant_id = $1 AND ur.user_id = $2
"#,
)
.bind(tenant_id)
.bind(user_id)
.fetch_all(&mut *tx)
.await?;
let (enabled_apps, apps_version) = sqlx::query_as::<_, (Vec<String>, i32)>(
r#"
SELECT enabled_apps, version
FROM tenant_entitlements
WHERE tenant_id = $1
"#,
)
.bind(tenant_id)
.fetch_optional(&mut *tx)
.await?
.unwrap_or_else(|| (vec![], 0));
let permissions = filter_permissions_by_enabled_apps(permissions, &enabled_apps);
let access_token = sign(
user_id,
tenant_id,
roles,
permissions,
enabled_apps,
apps_version,
)?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ($1, $2, 'auth.token.refresh', 'auth', 'allow', $3)
"#,
)
.bind(tenant_id)
.bind(user_id)
.bind(serde_json::json!({}))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(LoginResponse {
access_token,
refresh_token: new_refresh_token,
token_type: "Bearer".to_string(),
expires_in: 15 * 60,
})
}
}

View File

@@ -1,4 +1,4 @@
use crate::utils::authz::filter_permissions_by_enabled_apps;
use crate::utils::authz::{filter_permissions_by_enabled_apps, matches_permission};
use common_telemetry::AppError;
use sqlx::PgPool;
use tracing::instrument;
@@ -78,7 +78,10 @@ impl AuthorizationService {
permission_code: &str,
) -> Result<(), AppError> {
let permissions = self.list_permissions_for_user(tenant_id, user_id).await?;
if permissions.iter().any(|p| p == permission_code) {
if permissions
.iter()
.any(|p| matches_permission(p.as_str(), permission_code))
{
Ok(())
} else {
Err(AppError::PermissionDenied(permission_code.to_string()))

View File

@@ -1,13 +1,15 @@
pub mod auth;
pub mod app;
pub mod auth;
pub mod authorization;
pub mod permission;
pub mod role;
pub mod tenant;
pub mod user;
pub use auth::AuthService;
pub use app::AppService;
pub use auth::AuthService;
pub use authorization::AuthorizationService;
pub use permission::PermissionService;
pub use role::RoleService;
pub use tenant::TenantService;
pub use user::UserService;

View File

@@ -0,0 +1,86 @@
use crate::models::{ListPermissionsQuery, Permission};
use common_telemetry::AppError;
use sqlx::PgPool;
use tracing::instrument;
#[derive(Clone)]
pub struct PermissionService {
pool: PgPool,
}
impl PermissionService {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
#[instrument(skip(self))]
pub async fn list_permissions(&self, query: ListPermissionsQuery) -> Result<Vec<Permission>, AppError> {
let page = query.page.unwrap_or(1);
let page_size = query.page_size.unwrap_or(20);
if page == 0 || page_size == 0 || page_size > 200 {
return Err(AppError::BadRequest("Invalid pagination parameters".into()));
}
let offset = (page - 1) as i64 * page_size as i64;
let search = query
.search
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let app_code = query
.app_code
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let resource = query
.resource
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let action = query
.action
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty());
let sort_by = query.sort_by.unwrap_or_else(|| "code".to_string());
let sort_order = query.sort_order.unwrap_or_else(|| "asc".to_string());
let sort_by = match sort_by.as_str() {
"code" => "code",
"created_at" => "created_at",
_ => "code",
};
let sort_order = match sort_order.to_ascii_lowercase().as_str() {
"desc" => "DESC",
_ => "ASC",
};
let sql = format!(
r#"
SELECT
id,
code,
description,
resource,
action,
created_at::text as created_at
FROM permissions
WHERE ($1::text IS NULL OR code ILIKE '%' || $1 || '%' OR COALESCE(description, '') ILIKE '%' || $1 || '%')
AND ($2::text IS NULL OR split_part(code, ':', 1) = $2)
AND ($3::text IS NULL OR COALESCE(resource, '') = $3)
AND ($4::text IS NULL OR COALESCE(action, '') = $4)
ORDER BY {sort_by} {sort_order}
LIMIT $5 OFFSET $6
"#
);
let rows = sqlx::query_as::<_, Permission>(&sql)
.bind(search)
.bind(app_code)
.bind(resource)
.bind(action)
.bind(page_size as i64)
.bind(offset)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
}

View File

@@ -1,4 +1,4 @@
use crate::models::{CreateRoleRequest, Role};
use crate::models::{CreateRoleRequest, Role, UpdateRoleRequest};
use common_telemetry::AppError;
use sqlx::PgPool;
use tracing::instrument;
@@ -27,20 +27,44 @@ impl RoleService {
&self,
tenant_id: Uuid,
req: CreateRoleRequest,
actor_user_id: Uuid,
) -> Result<Role, AppError> {
let mut tx = self.pool.begin().await?;
let query = r#"
INSERT INTO roles (tenant_id, name, description)
VALUES ($1, $2, $3)
RETURNING id, tenant_id, name, description
INSERT INTO roles (tenant_id, name, description)
VALUES ($1, $2, $3)
RETURNING id, tenant_id, name, description
"#;
// Note: 'roles' table needs to be created in DB
sqlx::query_as::<_, Role>(query)
let role = sqlx::query_as::<_, Role>(query)
.bind(tenant_id)
.bind(req.name)
.bind(req.description)
.fetch_one(&self.pool)
.fetch_one(&mut *tx)
.await
.map_err(|e| AppError::DbError(e))
.map_err(|e| {
if let sqlx::Error::Database(db) = &e {
if db.is_unique_violation() {
return AppError::AlreadyExists("Role name already exists".into());
}
}
AppError::DbError(e)
})?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ($1, $2, 'role.create', 'role', 'allow', $3)
"#,
)
.bind(tenant_id)
.bind(actor_user_id)
.bind(serde_json::json!({ "role_id": role.id }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(role)
}
#[instrument(skip(self))]
@@ -49,7 +73,7 @@ impl RoleService {
/// 异常:
/// - 数据库查询失败
pub async fn list_roles(&self, tenant_id: Uuid) -> Result<Vec<Role>, AppError> {
let query = "SELECT * FROM roles WHERE tenant_id = $1";
let query = "SELECT id, tenant_id, name, description FROM roles WHERE tenant_id = $1";
sqlx::query_as::<_, Role>(query)
.bind(tenant_id)
.fetch_all(&self.pool)
@@ -57,6 +81,427 @@ impl RoleService {
.map_err(|e| AppError::DbError(e))
}
#[instrument(skip(self))]
pub async fn get_role(&self, tenant_id: Uuid, role_id: Uuid) -> Result<Role, AppError> {
let query =
"SELECT id, tenant_id, name, description FROM roles WHERE tenant_id = $1 AND id = $2";
sqlx::query_as::<_, Role>(query)
.bind(tenant_id)
.bind(role_id)
.fetch_optional(&self.pool)
.await
.map_err(AppError::DbError)?
.ok_or_else(|| AppError::NotFound("Role not found".into()))
}
#[instrument(skip(self, req))]
pub async fn update_role(
&self,
tenant_id: Uuid,
role_id: Uuid,
req: UpdateRoleRequest,
actor_user_id: Uuid,
) -> Result<Role, AppError> {
let is_system: Option<bool> =
sqlx::query_scalar("SELECT is_system FROM roles WHERE tenant_id = $1 AND id = $2")
.bind(tenant_id)
.bind(role_id)
.fetch_optional(&self.pool)
.await
.map_err(AppError::DbError)?;
let Some(is_system) = is_system else {
return Err(AppError::NotFound("Role not found".into()));
};
if is_system {
return Err(AppError::PermissionDenied("role:system_immutable".into()));
}
let mut tx = self.pool.begin().await?;
let role = sqlx::query_as::<_, Role>(
r#"
UPDATE roles
SET name = COALESCE($1, name),
description = COALESCE($2, description),
updated_at = NOW()
WHERE tenant_id = $3 AND id = $4
RETURNING id, tenant_id, name, description
"#,
)
.bind(req.name)
.bind(req.description)
.bind(tenant_id)
.bind(role_id)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
if let sqlx::Error::Database(db) = &e {
if db.is_unique_violation() {
return AppError::AlreadyExists("Role name already exists".into());
}
}
AppError::DbError(e)
})?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ($1, $2, 'role.update', 'role', 'allow', $3)
"#,
)
.bind(tenant_id)
.bind(actor_user_id)
.bind(serde_json::json!({ "role_id": role_id }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(role)
}
#[instrument(skip(self))]
pub async fn delete_role(
&self,
tenant_id: Uuid,
role_id: Uuid,
actor_user_id: Uuid,
) -> Result<(), AppError> {
let is_system: Option<bool> =
sqlx::query_scalar("SELECT is_system FROM roles WHERE tenant_id = $1 AND id = $2")
.bind(tenant_id)
.bind(role_id)
.fetch_optional(&self.pool)
.await
.map_err(AppError::DbError)?;
let Some(is_system) = is_system else {
return Err(AppError::NotFound("Role not found".into()));
};
if is_system {
return Err(AppError::PermissionDenied("role:system_immutable".into()));
}
let mut tx = self.pool.begin().await?;
let result = sqlx::query("DELETE FROM roles WHERE tenant_id = $1 AND id = $2")
.bind(tenant_id)
.bind(role_id)
.execute(&mut *tx)
.await
.map_err(AppError::DbError)?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Role not found".into()));
}
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ($1, $2, 'role.delete', 'role', 'allow', $3)
"#,
)
.bind(tenant_id)
.bind(actor_user_id)
.bind(serde_json::json!({ "role_id": role_id }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
#[instrument(skip(self, permission_codes))]
pub async fn grant_permissions_to_role(
&self,
tenant_id: Uuid,
role_id: Uuid,
permission_codes: Vec<String>,
actor_user_id: Uuid,
) -> Result<(), AppError> {
if permission_codes.is_empty() {
return Ok(());
}
let unique: Vec<String> = {
let mut s = std::collections::HashSet::new();
let mut out = Vec::new();
for c in permission_codes {
let code = c.trim().to_string();
if code.is_empty() {
continue;
}
if s.insert(code.clone()) {
out.push(code);
}
}
out
};
if unique.is_empty() {
return Ok(());
}
let mut tx = self.pool.begin().await?;
let is_system: Option<bool> = sqlx::query_scalar(
"SELECT is_system FROM roles WHERE tenant_id = $1 AND id = $2 FOR UPDATE",
)
.bind(tenant_id)
.bind(role_id)
.fetch_optional(&mut *tx)
.await?;
let Some(is_system) = is_system else {
return Err(AppError::NotFound("Role not found".into()));
};
if is_system {
return Err(AppError::PermissionDenied("role:system_immutable".into()));
}
let permission_ids: Vec<Uuid> =
sqlx::query_scalar("SELECT id FROM permissions WHERE code = ANY($1)")
.bind(&unique)
.fetch_all(&mut *tx)
.await?;
if permission_ids.len() != unique.len() {
return Err(AppError::BadRequest("Invalid permission_codes".into()));
}
sqlx::query(
r#"
INSERT INTO role_permissions (role_id, permission_id)
SELECT $1, UNNEST($2::uuid[])
ON CONFLICT DO NOTHING
"#,
)
.bind(role_id)
.bind(&permission_ids)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ($1, $2, 'role.permissions.grant', 'role_permission', 'allow', $3)
"#,
)
.bind(tenant_id)
.bind(actor_user_id)
.bind(serde_json::json!({ "role_id": role_id, "permission_codes": unique }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
#[instrument(skip(self, permission_codes))]
pub async fn revoke_permissions_from_role(
&self,
tenant_id: Uuid,
role_id: Uuid,
permission_codes: Vec<String>,
actor_user_id: Uuid,
) -> Result<(), AppError> {
if permission_codes.is_empty() {
return Ok(());
}
let unique: Vec<String> = {
let mut s = std::collections::HashSet::new();
let mut out = Vec::new();
for c in permission_codes {
let code = c.trim().to_string();
if code.is_empty() {
continue;
}
if s.insert(code.clone()) {
out.push(code);
}
}
out
};
if unique.is_empty() {
return Ok(());
}
let mut tx = self.pool.begin().await?;
let is_system: Option<bool> = sqlx::query_scalar(
"SELECT is_system FROM roles WHERE tenant_id = $1 AND id = $2 FOR UPDATE",
)
.bind(tenant_id)
.bind(role_id)
.fetch_optional(&mut *tx)
.await?;
let Some(is_system) = is_system else {
return Err(AppError::NotFound("Role not found".into()));
};
if is_system {
return Err(AppError::PermissionDenied("role:system_immutable".into()));
}
sqlx::query(
r#"
DELETE FROM role_permissions rp
USING permissions p
WHERE rp.permission_id = p.id
AND rp.role_id = $1
AND p.code = ANY($2)
"#,
)
.bind(role_id)
.bind(&unique)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ($1, $2, 'role.permissions.revoke', 'role_permission', 'allow', $3)
"#,
)
.bind(tenant_id)
.bind(actor_user_id)
.bind(serde_json::json!({ "role_id": role_id, "permission_codes": unique }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
#[instrument(skip(self, user_ids))]
pub async fn grant_role_to_users(
&self,
tenant_id: Uuid,
role_id: Uuid,
user_ids: Vec<Uuid>,
actor_user_id: Uuid,
) -> Result<(), AppError> {
if user_ids.is_empty() {
return Ok(());
}
let unique: Vec<Uuid> = {
let mut s = std::collections::HashSet::new();
let mut out = Vec::new();
for id in user_ids {
if s.insert(id) {
out.push(id);
}
}
out
};
let mut tx = self.pool.begin().await?;
let exists: Option<Uuid> =
sqlx::query_scalar("SELECT id FROM roles WHERE tenant_id = $1 AND id = $2")
.bind(tenant_id)
.bind(role_id)
.fetch_optional(&mut *tx)
.await?;
if exists.is_none() {
return Err(AppError::NotFound("Role not found".into()));
}
let count: i64 =
sqlx::query_scalar("SELECT COUNT(1) FROM users WHERE tenant_id = $1 AND id = ANY($2)")
.bind(tenant_id)
.bind(&unique)
.fetch_one(&mut *tx)
.await?;
if count != unique.len() as i64 {
return Err(AppError::BadRequest("Invalid user_ids".into()));
}
sqlx::query(
r#"
INSERT INTO user_roles (user_id, role_id)
SELECT UNNEST($1::uuid[]), $2
ON CONFLICT DO NOTHING
"#,
)
.bind(&unique)
.bind(role_id)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ($1, $2, 'role.users.grant', 'user_role', 'allow', $3)
"#,
)
.bind(tenant_id)
.bind(actor_user_id)
.bind(serde_json::json!({ "role_id": role_id, "user_ids": unique }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
#[instrument(skip(self, user_ids))]
pub async fn revoke_role_from_users(
&self,
tenant_id: Uuid,
role_id: Uuid,
user_ids: Vec<Uuid>,
actor_user_id: Uuid,
) -> Result<(), AppError> {
if user_ids.is_empty() {
return Ok(());
}
let unique: Vec<Uuid> = {
let mut s = std::collections::HashSet::new();
let mut out = Vec::new();
for id in user_ids {
if s.insert(id) {
out.push(id);
}
}
out
};
let mut tx = self.pool.begin().await?;
let exists: Option<Uuid> =
sqlx::query_scalar("SELECT id FROM roles WHERE tenant_id = $1 AND id = $2")
.bind(tenant_id)
.bind(role_id)
.fetch_optional(&mut *tx)
.await?;
if exists.is_none() {
return Err(AppError::NotFound("Role not found".into()));
}
sqlx::query(
r#"
DELETE FROM user_roles ur
USING users u
WHERE ur.user_id = u.id
AND u.tenant_id = $1
AND ur.role_id = $2
AND ur.user_id = ANY($3)
"#,
)
.bind(tenant_id)
.bind(role_id)
.bind(&unique)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO audit_logs (tenant_id, user_id, action, resource, status, details)
VALUES ($1, $2, 'role.users.revoke', 'user_role', 'allow', $3)
"#,
)
.bind(tenant_id)
.bind(actor_user_id)
.bind(serde_json::json!({ "role_id": role_id, "user_ids": unique }))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
#[instrument(skip(self, role_ids))]
pub async fn list_roles_by_ids(
&self,
@@ -115,13 +560,12 @@ impl RoleService {
let mut tx = self.pool.begin().await?;
let exists: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM users WHERE tenant_id = $1 AND id = $2",
)
.bind(tenant_id)
.bind(target_user_id)
.fetch_optional(&mut *tx)
.await?;
let exists: Option<Uuid> =
sqlx::query_scalar("SELECT id FROM users WHERE tenant_id = $1 AND id = $2")
.bind(tenant_id)
.bind(target_user_id)
.fetch_optional(&mut *tx)
.await?;
if exists.is_none() {
return Err(AppError::NotFound("User not found".into()));
}

View File

@@ -204,23 +204,21 @@ impl UserService {
let mut tx = self.pool.begin().await?;
let updated: i64 = sqlx::query_scalar(
let result = sqlx::query(
r#"
UPDATE users
SET password_hash = $1, updated_at = NOW()
WHERE tenant_id = $2 AND id = $3
RETURNING 1
"#,
)
.bind(&new_hash)
.bind(tenant_id)
.bind(target_user_id)
.fetch_optional(&mut *tx)
.execute(&mut *tx)
.await
.map_err(|e| AppError::DbError(e))?
.unwrap_or(0);
.map_err(|e| AppError::DbError(e))?;
if updated == 0 {
if result.rows_affected() == 0 {
return Err(AppError::NotFound("User not found".into()));
}

View File

@@ -20,3 +20,35 @@ pub fn filter_permissions_by_enabled_apps(
.collect()
}
pub fn matches_permission(granted: &str, required: &str) -> bool {
if granted == required {
return true;
}
let g: Vec<&str> = granted.split(':').collect();
let r: Vec<&str> = required.split(':').collect();
if g.len() != r.len() {
return false;
}
for (gs, rs) in g.iter().zip(r.iter()) {
if *gs == "*" {
continue;
}
if gs != rs {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::matches_permission;
#[test]
fn wildcard_matches_three_segments() {
assert!(matches_permission("cms:*:*", "cms:article:create"));
assert!(matches_permission("cms:article:*", "cms:article:publish"));
assert!(!matches_permission("cms:article:*", "cms:media:manage"));
assert!(!matches_permission("cms:*:*", "user:read"));
}
}

View File

@@ -0,0 +1,107 @@
use hmac::{Hmac, Mac};
use iam_service::models::{CreateTenantRequest, CreateUserRequest, LoginRequest};
use iam_service::services::{AuthService, TenantService};
use sha2::Sha256;
use sqlx::PgPool;
use uuid::Uuid;
fn fingerprint(pepper: &str, token: &str) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(pepper.as_bytes()).unwrap();
mac.update(token.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
#[tokio::test]
async fn refresh_token_rotate_and_expire_cases()
-> Result<(), Box<dyn std::error::Error>> {
let database_url = match std::env::var("DATABASE_URL") {
Ok(v) if !v.trim().is_empty() => v,
_ => return Ok(()),
};
let pool = PgPool::connect(&database_url).await?;
let has_fingerprint: bool = sqlx::query_scalar(
r#"
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema='public' AND table_name='refresh_tokens' AND column_name='token_fingerprint'
)
"#,
)
.fetch_one(&pool)
.await?;
if !has_fingerprint {
return Ok(());
}
let pepper = "test-pepper";
let tenant_service = TenantService::new(pool.clone());
let auth_service = AuthService::new(pool.clone(), pepper.to_string());
let tenant = tenant_service
.create_tenant(CreateTenantRequest {
name: format!("refresh-{}", Uuid::new_v4()),
config: None,
})
.await?;
let user = auth_service
.register(
tenant.id,
CreateUserRequest {
email: format!("u-{}@example.com", Uuid::new_v4()),
password: "Password12345".to_string(),
},
)
.await?;
let login = auth_service
.login(
tenant.id,
LoginRequest {
email: user.email.clone(),
password: "Password12345".to_string(),
},
)
.await?;
let old_refresh = login.refresh_token.clone();
let refreshed = auth_service
.refresh_access_token(old_refresh.clone())
.await?;
assert_ne!(refreshed.refresh_token, old_refresh);
assert!(!refreshed.access_token.is_empty());
let second = auth_service.refresh_access_token(old_refresh.clone()).await;
assert!(second.is_err());
let invalid = auth_service
.refresh_access_token("not-a-real-token".to_string())
.await;
assert!(invalid.is_err());
let login2 = auth_service
.login(
tenant.id,
LoginRequest {
email: user.email.clone(),
password: "Password12345".to_string(),
},
)
.await?;
let fp = fingerprint(pepper, &login2.refresh_token);
sqlx::query("UPDATE refresh_tokens SET expires_at = NOW() - INTERVAL '1 second' WHERE token_fingerprint = $1")
.bind(fp)
.execute(&pool)
.await?;
let expired = auth_service
.refresh_access_token(login2.refresh_token.clone())
.await;
assert!(expired.is_err());
Ok(())
}

View File

@@ -0,0 +1,92 @@
use iam_service::models::{CreateRoleRequest, CreateTenantRequest, CreateUserRequest};
use iam_service::services::{AuthService, AuthorizationService, RoleService, TenantService};
use sqlx::PgPool;
use uuid::Uuid;
#[tokio::test]
async fn role_permission_grant_and_wildcard_match()
-> Result<(), Box<dyn std::error::Error>> {
let database_url = match std::env::var("DATABASE_URL") {
Ok(v) if !v.trim().is_empty() => v,
_ => return Ok(()),
};
let pool = PgPool::connect(&database_url).await?;
let tenant_service = TenantService::new(pool.clone());
let role_service = RoleService::new(pool.clone());
let authz_service = AuthorizationService::new(pool.clone());
let auth_service = AuthService::new(pool.clone(), "unused".to_string());
let tenant = tenant_service
.create_tenant(CreateTenantRequest {
name: format!("rp-{}", Uuid::new_v4()),
config: None,
})
.await?;
let admin = auth_service
.register(
tenant.id,
CreateUserRequest {
email: format!("admin-{}@example.com", Uuid::new_v4()),
password: "Password12345".to_string(),
},
)
.await?;
let user = auth_service
.register(
tenant.id,
CreateUserRequest {
email: format!("user-{}@example.com", Uuid::new_v4()),
password: "Password12345".to_string(),
},
)
.await?;
let _ = sqlx::query(
r#"
INSERT INTO permissions (code, description, resource, action)
VALUES
('cms:article:create', 'Create article', 'article', 'create'),
('cms:*:*', 'CMS wildcard', '*', '*')
ON CONFLICT (code) DO NOTHING
"#,
)
.execute(&pool)
.await?;
tenant_service
.set_enabled_apps(tenant.id, vec!["cms".to_string()], None, admin.id)
.await?;
let role = role_service
.create_role(
tenant.id,
CreateRoleRequest {
name: "ContentAdmin".into(),
description: None,
},
admin.id,
)
.await?;
role_service
.grant_permissions_to_role(
tenant.id,
role.id,
vec!["cms:*:*".to_string()],
admin.id,
)
.await?;
role_service
.grant_role_to_users(tenant.id, role.id, vec![user.id], admin.id)
.await?;
authz_service
.require_permission(tenant.id, user.id, "cms:article:create")
.await?;
Ok(())
}

View File

@@ -4,8 +4,8 @@ use sqlx::PgPool;
use uuid::Uuid;
#[tokio::test]
async fn set_user_roles_is_idempotent_and_validates_tenant_roles(
) -> Result<(), Box<dyn std::error::Error>> {
async fn set_user_roles_is_idempotent_and_validates_tenant_roles()
-> Result<(), Box<dyn std::error::Error>> {
let database_url = match std::env::var("DATABASE_URL") {
Ok(v) if !v.trim().is_empty() => v,
_ => return Ok(()),
@@ -42,6 +42,7 @@ async fn set_user_roles_is_idempotent_and_validates_tenant_roles(
name: "R1".into(),
description: Some("role1".into()),
},
user_id,
)
.await?;
let role2 = role_service
@@ -51,6 +52,7 @@ async fn set_user_roles_is_idempotent_and_validates_tenant_roles(
name: "R2".into(),
description: Some("role2".into()),
},
user_id,
)
.await?;
@@ -80,6 +82,7 @@ async fn set_user_roles_is_idempotent_and_validates_tenant_roles(
name: "Other".into(),
description: None,
},
user_id,
)
.await?;