feat(role): role bind
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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) 角色 CRUD(Role)
|
||||
|
||||
创建角色:
|
||||
- **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` 的管理员进行角色分配。
|
||||
|
||||
40
docs/TEMP.md
40
docs/TEMP.md
@@ -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项的管理的最佳方式是什么?
|
||||
```
|
||||
@@ -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 权限种子 |
|
||||
|
||||
## 执行方式
|
||||
|
||||
|
||||
11
scripts/db/migrations/0005_refresh_token_fingerprint.sql
Normal file
11
scripts/db/migrations/0005_refresh_token_fingerprint.sql
Normal 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;
|
||||
|
||||
13
scripts/db/migrations/0006_cms_permissions.sql
Normal file
13
scripts/db/migrations/0006_cms_permissions.sql
Normal 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;
|
||||
|
||||
9
scripts/db/rollback/0005.down.sql
Normal file
9
scripts/db/rollback/0005.down.sql
Normal 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;
|
||||
|
||||
14
scripts/db/rollback/0006.down.sql
Normal file
14
scripts/db/rollback/0006.down.sql
Normal 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;
|
||||
|
||||
12
scripts/db/verify/0005_refresh_token_fingerprint.sql
Normal file
12
scripts/db/verify/0005_refresh_token_fingerprint.sql
Normal 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 $$;
|
||||
|
||||
7
scripts/db/verify/0006_cms_permissions.sql
Normal file
7
scripts/db/verify/0006_cms_permissions.sql
Normal 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 $$;
|
||||
|
||||
21
src/docs.rs
21
src/docs.rs
@@ -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/策略引擎后续扩展)")
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
48
src/handlers/permission.rs
Normal file
48
src/handlers/permission.rs
Normal 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))
|
||||
}
|
||||
@@ -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!({})))
|
||||
}
|
||||
|
||||
44
src/main.rs
44
src/main.rs
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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;
|
||||
|
||||
86
src/services/permission.rs
Normal file
86
src/services/permission.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
107
tests/refresh_token_smoke.rs
Normal file
107
tests/refresh_token_smoke.rs
Normal 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(())
|
||||
}
|
||||
|
||||
92
tests/role_permission_smoke.rs
Normal file
92
tests/role_permission_smoke.rs
Normal 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(())
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user