diff --git a/Cargo.lock b/Cargo.lock index 2b64f3a..8fb77b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index e67ba2f..a05a286 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/docs/SCALAR_GUIDE.md b/docs/SCALAR_GUIDE.md index 905c21f..6fb5373 100644 --- a/docs/SCALAR_GUIDE.md +++ b/docs/SCALAR_GUIDE.md @@ -149,6 +149,17 @@ 下一步依赖:`access_token`。 +(可选)使用 refresh_token 自动续期: + +- **POST** `/auth/refresh` +- Tag:`Auth` +- Header:无 +- Body: + +```json +{ "refresh_token": "" } +``` + ### Step 3:获取当前租户信息(Tenant) **GET** `/tenants/me` @@ -319,6 +330,133 @@ enabled_apps 维护建议: { "code": 0, "message": "Success", "data": [{ "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 ` +- 需要权限:`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": ["", ""] } +``` + +批量回收: +- **POST** `/roles/{id}/users/revoke` + +审计说明: +- 角色创建/更新/删除、角色-权限绑定/解绑、角色-用户授予/回收都会写入 `audit_logs`。 + ### Step 7:用户-角色绑定(User) 用户注册后默认无角色;通常由具备 `user:write` 的管理员进行角色分配。 diff --git a/docs/TEMP.md b/docs/TEMP.md index bdd132f..66a8ccf 100644 --- a/docs/TEMP.md +++ b/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项的管理的最佳方式是什么? ``` \ No newline at end of file diff --git a/scripts/db/README.md b/scripts/db/README.md index 450d8b3..ef3a730 100644 --- a/scripts/db/README.md +++ b/scripts/db/README.md @@ -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 权限种子 | ## 执行方式 diff --git a/scripts/db/migrations/0005_refresh_token_fingerprint.sql b/scripts/db/migrations/0005_refresh_token_fingerprint.sql new file mode 100644 index 0000000..e040958 --- /dev/null +++ b/scripts/db/migrations/0005_refresh_token_fingerprint.sql @@ -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; + diff --git a/scripts/db/migrations/0006_cms_permissions.sql b/scripts/db/migrations/0006_cms_permissions.sql new file mode 100644 index 0000000..7b4ffb7 --- /dev/null +++ b/scripts/db/migrations/0006_cms_permissions.sql @@ -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; + diff --git a/scripts/db/rollback/0005.down.sql b/scripts/db/rollback/0005.down.sql new file mode 100644 index 0000000..ddda688 --- /dev/null +++ b/scripts/db/rollback/0005.down.sql @@ -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; + diff --git a/scripts/db/rollback/0006.down.sql b/scripts/db/rollback/0006.down.sql new file mode 100644 index 0000000..d5971cc --- /dev/null +++ b/scripts/db/rollback/0006.down.sql @@ -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; + diff --git a/scripts/db/verify/0005_refresh_token_fingerprint.sql b/scripts/db/verify/0005_refresh_token_fingerprint.sql new file mode 100644 index 0000000..54628c0 --- /dev/null +++ b/scripts/db/verify/0005_refresh_token_fingerprint.sql @@ -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 $$; + diff --git a/scripts/db/verify/0006_cms_permissions.sql b/scripts/db/verify/0006_cms_permissions.sql new file mode 100644 index 0000000..1ca96ab --- /dev/null +++ b/scripts/db/verify/0006_cms_permissions.sql @@ -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 $$; + diff --git a/src/docs.rs b/src/docs.rs index 8540783..7ccd728 100644 --- a/src/docs.rs +++ b/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/策略引擎后续扩展)") diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index ea975a4..25ad50c 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -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, + Json(payload): Json, +) -> Result, AppError> { + let response = state + .auth_service + .refresh_access_token(payload.refresh_token) + .await?; + Ok(AppResponse::ok(response)) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index e9f4ff3..5b97b53 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -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, } diff --git a/src/handlers/permission.rs b/src/handlers/permission.rs new file mode 100644 index 0000000..277039f --- /dev/null +++ b/src/handlers/permission.rs @@ -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 (访问令牌)"), + ("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, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, + Query(query): Query, +) -> Result>, 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)) +} diff --git a/src/handlers/role.rs b/src/handlers/role.rs index 8be4ecb..cbbeeea 100644 --- a/src/handlers/role.rs +++ b/src/handlers/role.rs @@ -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 (访问令牌)"), + ("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, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, + Path(role_id): Path, +) -> Result, 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 (访问令牌)"), + ("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, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, + Path(role_id): Path, + Json(payload): Json, +) -> Result, 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 (访问令牌)"), + ("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, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, + Path(role_id): Path, +) -> Result, 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 (访问令牌)"), + ("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, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, + Path(role_id): Path, + Json(payload): Json, +) -> Result, 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 (访问令牌)"), + ("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, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, + Path(role_id): Path, + Json(payload): Json, +) -> Result, 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 (访问令牌)"), + ("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, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, + Path(role_id): Path, + Json(payload): Json, +) -> Result, 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 (访问令牌)"), + ("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, + AuthContext { + tenant_id: auth_tenant_id, + user_id, + .. + }: AuthContext, + Path(role_id): Path, + Json(payload): Json, +) -> Result, 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!({}))) +} diff --git a/src/main.rs b/src/main.rs index 64afe1b..8243cbd 100644 --- a/src/main.rs +++ b/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( diff --git a/src/middleware/auth.rs b/src/middleware/auth.rs index e40c57a..fd7293a 100644 --- a/src/middleware/auth.rs +++ b/src/middleware/auth.rs @@ -21,6 +21,7 @@ pub async fn authenticate(mut req: Request, next: Next) -> Result Result { 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); } diff --git a/src/models.rs b/src/models.rs index f7d29fa..940b487 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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, + #[serde(default)] + pub resource: Option, + #[serde(default)] + pub action: Option, + #[serde(default)] + pub created_at: String, +} + +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +pub struct ListPermissionsQuery { + pub page: Option, + pub page_size: Option, + pub search: Option, + pub app_code: Option, + pub resource: Option, + pub action: Option, + pub sort_by: Option, + pub sort_order: Option, +} + +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +pub struct UpdateRoleRequest { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub description: Option, +} + +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +pub struct RolePermissionsRequest { + #[serde(default)] + pub permission_codes: Vec, +} + +#[derive(Debug, Deserialize, ToSchema, IntoParams)] +pub struct RoleUsersRequest { + #[serde(default)] + pub user_ids: Vec, +} diff --git a/src/services/auth.rs b/src/services/auth.rs index 82746d3..0b42e4f 100644 --- a/src/services/auth.rs +++ b/src/services/auth.rs @@ -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 { + let mut mac = Hmac::::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 { + 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, + bool, + Option, + ), + >( + 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, 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, + }) + } } diff --git a/src/services/authorization.rs b/src/services/authorization.rs index ab257c3..3fe6200 100644 --- a/src/services/authorization.rs +++ b/src/services/authorization.rs @@ -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())) diff --git a/src/services/mod.rs b/src/services/mod.rs index daac0c6..ef4419b 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -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; diff --git a/src/services/permission.rs b/src/services/permission.rs new file mode 100644 index 0000000..862c17c --- /dev/null +++ b/src/services/permission.rs @@ -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, 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) + } +} + diff --git a/src/services/role.rs b/src/services/role.rs index 0e13471..dbc6475 100644 --- a/src/services/role.rs +++ b/src/services/role.rs @@ -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 { + 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, 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 { + 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 { + let is_system: Option = + 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 = + 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, + actor_user_id: Uuid, + ) -> Result<(), AppError> { + if permission_codes.is_empty() { + return Ok(()); + } + let unique: Vec = { + 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 = 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 = + 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, + actor_user_id: Uuid, + ) -> Result<(), AppError> { + if permission_codes.is_empty() { + return Ok(()); + } + let unique: Vec = { + 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 = 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, + actor_user_id: Uuid, + ) -> Result<(), AppError> { + if user_ids.is_empty() { + return Ok(()); + } + let unique: Vec = { + 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 = + 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, + actor_user_id: Uuid, + ) -> Result<(), AppError> { + if user_ids.is_empty() { + return Ok(()); + } + let unique: Vec = { + 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 = + 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 = 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 = + 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())); } diff --git a/src/services/user.rs b/src/services/user.rs index 1665651..fff7f4d 100644 --- a/src/services/user.rs +++ b/src/services/user.rs @@ -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())); } diff --git a/src/utils/authz.rs b/src/utils/authz.rs index ce60b9a..37119a3 100644 --- a/src/utils/authz.rs +++ b/src/utils/authz.rs @@ -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")); + } +} diff --git a/tests/refresh_token_smoke.rs b/tests/refresh_token_smoke.rs new file mode 100644 index 0000000..0b15f7e --- /dev/null +++ b/tests/refresh_token_smoke.rs @@ -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::::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> { + 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(()) +} + diff --git a/tests/role_permission_smoke.rs b/tests/role_permission_smoke.rs new file mode 100644 index 0000000..acd82de --- /dev/null +++ b/tests/role_permission_smoke.rs @@ -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> { + 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(()) +} + diff --git a/tests/user_roles_smoke.rs b/tests/user_roles_smoke.rs index 0a291ff..16e395a 100644 --- a/tests/user_roles_smoke.rs +++ b/tests/user_roles_smoke.rs @@ -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> { +async fn set_user_roles_is_idempotent_and_validates_tenant_roles() +-> Result<(), Box> { 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?;