From 7340658305de30b234bc87e0b5490e4c34a49b9a Mon Sep 17 00:00:00 2001 From: liuyun Date: Sat, 10 Oct 2026 23:19:41 +0800 Subject: [PATCH] feat: enforce RBAC, audit/login logging, and seed script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收口 Phase 0 三处「声明未生效」: - 三级鉴权落地:权限集存 Redis(key 前缀隔离多项目),@RequirePermissions 挂到全部受控接口,super_admin 角色绕过权限码与 data_scope - 审计/登录日志自动落库:@AuditLog 装饰器 + 拦截器异步写入(写操作+登录登出),登录成功/失败均记 login_log(脱敏) - seed 脚本:一键初始化租户 touyun/用户 touyun/超管 super_admin/全量资源树(48 资源全量授权) Co-Authored-By: Claude Opus 4.8 --- .env.example | 25 +++ package.json | 8 +- pnpm-lock.yaml | 84 ++++++-- prisma/seed.ts | 181 ++++++++++++++++++ src/app.module.ts | 4 + src/audit-logs/audit-logs.controller.ts | 3 + src/audit-logs/audit-logs.module.ts | 1 + src/audit-logs/audit-logs.service.ts | 32 +++- src/auth/auth.controller.ts | 11 +- src/auth/auth.module.ts | 4 + src/auth/auth.service.ts | 154 +++++++++++++-- src/auth/decorators.ts | 8 + src/auth/permissions.guard.ts | 20 +- .../interceptors/audit-log.interceptor.ts | 58 ++++++ src/common/mask.ts | 25 +++ src/common/permissions.ts | 57 ++++++ src/common/redis/redis.module.ts | 9 + src/common/redis/redis.service.ts | 56 ++++++ src/configs/configs.controller.ts | 10 + src/dicts/dict-items.controller.ts | 10 + src/dicts/dict-types.controller.ts | 10 + src/logging/body-logging.interceptor.ts | 29 +-- src/login-logs/login-logs.controller.ts | 3 + src/login-logs/login-logs.module.ts | 1 + src/login-logs/login-logs.service.ts | 24 ++- src/orgs/orgs.controller.ts | 10 + src/resources/resources.controller.ts | 10 + src/roles/roles.controller.ts | 13 ++ src/roles/roles.service.ts | 8 +- src/tenants/tenants.controller.ts | 10 + src/users/users.controller.ts | 12 ++ 31 files changed, 814 insertions(+), 76 deletions(-) create mode 100644 .env.example create mode 100644 prisma/seed.ts create mode 100644 src/common/interceptors/audit-log.interceptor.ts create mode 100644 src/common/mask.ts create mode 100644 src/common/permissions.ts create mode 100644 src/common/redis/redis.module.ts create mode 100644 src/common/redis/redis.service.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..57ee239 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# 复制为 .env 并按需修改 + +# 数据库(MySQL 8) +DATABASE_URL="mysql://root:Mysql.123@127.0.0.1:3306/sysmgr" + +# 服务端口 +PORT=3000 + +# JWT(生产务必替换 secret) +JWT_SECRET="dev-secret-change-me" +JWT_ACCESS_TTL="7200" +JWT_REFRESH_TTL="604800" + +# 日志(详见 src/logging/logger.config.ts) +LOG_LEVEL="info" +LOG_PRETTY="true" +LOG_FILE_ENABLED="false" +LOG_FILE_PATH="logs/app.log" + +# Redis(权限缓存;多 fork 项目共用一个实例时用 REDIS_KEY_PREFIX 隔离) +REDIS_HOST="127.0.0.1" +REDIS_PORT="6379" +REDIS_DB="0" +REDIS_PASSWORD="" +REDIS_KEY_PREFIX="sysmgr:" diff --git a/package.json b/package.json index f907afb..b40e077 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "private": true, "license": "UNLICENSED", "type": "module", + "prisma": { + "seed": "tsx prisma/seed.ts" + }, "engines": { "node": "^22.23.1" }, @@ -23,7 +26,8 @@ "test:watch": "vitest", "test:cov": "vitest run --coverage", "test:debug": "vitest --inspect-brk --no-file-parallelism", - "test:e2e": "vitest run --config ./vitest.config.e2e.ts" + "test:e2e": "vitest run --config ./vitest.config.e2e.ts", + "seed": "prisma db seed" }, "dependencies": { "@nestjs/common": "^12.0.1", @@ -35,6 +39,7 @@ "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "ioredis": "^6.0.0", "nestjs-pino": "^5.3.1", "pino": "^10.4.0", "pino-pretty": "^13.2.0", @@ -59,6 +64,7 @@ "prisma": "^6.19.3", "source-map-support": "^0.5.21", "supertest": "^7.0.0", + "tsx": "^4.23.15", "typescript": "^6.0.2", "vite-tsconfig-paths": "^5.1.4", "vitest": "^4.1.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d63e3f..efba00e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: class-validator: specifier: ^0.15.1 version: 0.15.1 + ioredis: + specifier: ^6.0.0 + version: 6.0.0(supports-color@7.2.0) nestjs-pino: specifier: ^5.3.1 version: 5.3.1(@nestjs/common@12.1.2(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@12.1.2)(pino-http@11.0.0)(pino@10.4.0)(rxjs@7.8.2) @@ -260,15 +263,18 @@ importers: supertest: specifier: ^7.0.0 version: 7.3.1(supports-color@7.2.0) + tsx: + specifier: ^4.23.15 + version: 4.23.15 typescript: specifier: ^6.0.2 version: 6.0.3 vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) + version: 5.1.4(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) vitest: specifier: ^4.1.2 - version: 4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) + version: 4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) packages: @@ -608,6 +614,9 @@ packages: '@types/node': optional: true + '@ioredis/commands@2.0.0': + resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -1363,6 +1372,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1461,6 +1474,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -1716,6 +1733,10 @@ packages: resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==} engines: {node: '>=12.0.0'} + ioredis@6.0.0: + resolution: {integrity: sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==} + engines: {node: '>=20.0.0'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -2201,6 +2222,10 @@ packages: real-require@1.0.0: resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -2319,6 +2344,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -2431,6 +2459,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + type-fest@0.21.3: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} @@ -2847,6 +2880,8 @@ snapshots: optionalDependencies: '@types/node': 24.19.1 + '@ioredis/commands@2.0.0': {} + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.6.0': {} @@ -3257,7 +3292,7 @@ snapshots: obug: 2.2.1 std-env: 4.3.0 tinyrainbow: 3.2.0 - vitest: 4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) + vitest: 4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@vitest/expect@4.1.11': dependencies: @@ -3268,13 +3303,13 @@ snapshots: chai: 6.3.0 tinyrainbow: 3.2.0 - '@vitest/mocker@4.1.11(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))': + '@vitest/mocker@4.1.11(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) '@vitest/pretty-format@4.1.11': dependencies: @@ -3485,6 +3520,8 @@ snapshots: clone@1.0.4: {} + cluster-key-slot@1.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3553,6 +3590,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + depd@2.0.0: {} destr@2.0.5: {} @@ -3644,7 +3683,6 @@ snapshots: '@esbuild/win32-arm64': 0.28.2 '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 - optional: true escape-html@1.0.3: {} @@ -3863,6 +3901,17 @@ snapshots: through: 2.3.8 wrap-ansi: 6.2.0 + ioredis@6.0.0(supports-color@7.2.0): + dependencies: + '@ioredis/commands': 2.0.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3(supports-color@7.2.0) + denque: 2.1.0 + redis-errors: 1.2.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ipaddr.js@1.9.1: {} is-fullwidth-code-point@3.0.0: {} @@ -4326,6 +4375,8 @@ snapshots: real-require@1.0.0: {} + redis-errors@1.2.0: {} + reflect-metadata@0.2.2: {} require-from-string@2.0.2: {} @@ -4475,6 +4526,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + statuses@2.0.2: {} std-env@4.3.0: {} @@ -4585,6 +4638,12 @@ snapshots: tslib@2.8.1: {} + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + type-fest@0.21.3: {} type-is@1.6.18: @@ -4620,18 +4679,18 @@ snapshots: vary@1.1.2: {} - vite-tsconfig-paths@5.1.4(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)): + vite-tsconfig-paths@5.1.4(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): dependencies: debug: 4.4.3(supports-color@7.2.0) globrex: 0.1.2 tsconfck: 3.1.6(typescript@6.0.3) optionalDependencies: - vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) transitivePeerDependencies: - supports-color - typescript - vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1): + vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -4643,12 +4702,13 @@ snapshots: esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 + tsx: 4.23.15 yaml: 2.9.1 - vitest@4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)): + vitest@4.1.11(@types/node@24.19.1)(@vitest/coverage-v8@4.1.11)(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) + '@vitest/mocker': 4.1.11(vite@8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -4665,7 +4725,7 @@ snapshots: tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.2.0 - vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.19.1 diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..7e9955a --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,181 @@ +import { PrismaClient } from '@prisma/client'; +import bcrypt from 'bcryptjs'; +import { Permissions } from '../src/common/permissions.js'; + +const prisma = new PrismaClient(); + +const TENANT = 'touyun'; +const SUPER_ROLE = 'super_admin'; +const ADMIN_USERNAME = 'touyun'; +const ADMIN_PASSWORD = 'Touyun@123'; + +/** 菜单资源(导航)。 */ +const MENUS = [ + { code: 'dashboard', name: '工作台', parent: null, path: '/dashboard', component: 'dashboard/index', sort: 1 }, + { code: 'system', name: '系统管理', parent: null, path: '/system', component: 'Layout', sort: 2 }, + { code: 'tenant', name: '租户管理', parent: 'system', path: '/system/tenant', component: 'system/tenant/index', sort: 1 }, + { code: 'user', name: '用户管理', parent: 'system', path: '/system/user', component: 'system/user/index', sort: 2 }, + { code: 'role', name: '角色管理', parent: 'system', path: '/system/role', component: 'system/role/index', sort: 3 }, + { code: 'resource', name: '资源管理', parent: 'system', path: '/system/resource', component: 'system/resource/index', sort: 4 }, + { code: 'org', name: '组织管理', parent: 'system', path: '/system/org', component: 'system/org/index', sort: 5 }, + { code: 'dict', name: '字典管理', parent: 'system', path: '/system/dict', component: 'system/dict/index', sort: 6 }, + { code: 'config', name: '参数配置', parent: 'system', path: '/system/config', component: 'system/config/index', sort: 7 }, + { code: 'monitor', name: '系统监控', parent: null, path: '/monitor', component: 'Layout', sort: 3 }, + { code: 'audit', name: '操作日志', parent: 'monitor', path: '/monitor/audit', component: 'monitor/audit/index', sort: 1 }, + { code: 'login-log', name: '登录日志', parent: 'monitor', path: '/monitor/login-log', component: 'monitor/login-log/index', sort: 2 }, +]; + +/** 接口资源(每个受控接口一个,code = 权限码)。 */ +const APIS: { code: string; name: string; method: string; path: string; parent: string }[] = [ + { code: Permissions.tenant.list, name: '租户列表', method: 'GET', path: '/api/v1/tenants', parent: 'tenant' }, + { code: Permissions.tenant.create, name: '创建租户', method: 'POST', path: '/api/v1/tenants', parent: 'tenant' }, + { code: Permissions.tenant.update, name: '更新租户', method: 'PATCH', path: '/api/v1/tenants/:id', parent: 'tenant' }, + { code: Permissions.tenant.delete, name: '删除租户', method: 'DELETE', path: '/api/v1/tenants/:id', parent: 'tenant' }, + { code: Permissions.user.list, name: '用户列表', method: 'GET', path: '/api/v1/users', parent: 'user' }, + { code: Permissions.user.create, name: '创建用户', method: 'POST', path: '/api/v1/users', parent: 'user' }, + { code: Permissions.user.update, name: '更新用户', method: 'PATCH', path: '/api/v1/users/:id', parent: 'user' }, + { code: Permissions.user.delete, name: '删除用户', method: 'DELETE', path: '/api/v1/users/:id', parent: 'user' }, + { code: Permissions.user.resetPwd, name: '重置密码', method: 'PATCH', path: '/api/v1/users/:id/password', parent: 'user' }, + { code: Permissions.role.list, name: '角色列表', method: 'GET', path: '/api/v1/roles', parent: 'role' }, + { code: Permissions.role.create, name: '创建角色', method: 'POST', path: '/api/v1/roles', parent: 'role' }, + { code: Permissions.role.update, name: '更新角色', method: 'PATCH', path: '/api/v1/roles/:id', parent: 'role' }, + { code: Permissions.role.delete, name: '删除角色', method: 'DELETE', path: '/api/v1/roles/:id', parent: 'role' }, + { code: Permissions.role.assignResource, name: '分配资源', method: 'PUT', path: '/api/v1/roles/:id/resources', parent: 'role' }, + { code: Permissions.resource.list, name: '资源列表', method: 'GET', path: '/api/v1/resources', parent: 'resource' }, + { code: Permissions.resource.create, name: '创建资源', method: 'POST', path: '/api/v1/resources', parent: 'resource' }, + { code: Permissions.resource.update, name: '更新资源', method: 'PATCH', path: '/api/v1/resources/:id', parent: 'resource' }, + { code: Permissions.resource.delete, name: '删除资源', method: 'DELETE', path: '/api/v1/resources/:id', parent: 'resource' }, + { code: Permissions.org.list, name: '组织列表', method: 'GET', path: '/api/v1/orgs', parent: 'org' }, + { code: Permissions.org.create, name: '创建组织', method: 'POST', path: '/api/v1/orgs', parent: 'org' }, + { code: Permissions.org.update, name: '更新组织', method: 'PATCH', path: '/api/v1/orgs/:id', parent: 'org' }, + { code: Permissions.org.delete, name: '删除组织', method: 'DELETE', path: '/api/v1/orgs/:id', parent: 'org' }, + { code: Permissions.dict.list, name: '字典列表', method: 'GET', path: '/api/v1/dict-types', parent: 'dict' }, + { code: Permissions.dict.create, name: '创建字典', method: 'POST', path: '/api/v1/dict-types', parent: 'dict' }, + { code: Permissions.dict.update, name: '更新字典', method: 'PATCH', path: '/api/v1/dict-types/:id', parent: 'dict' }, + { code: Permissions.dict.delete, name: '删除字典', method: 'DELETE', path: '/api/v1/dict-types/:id', parent: 'dict' }, + { code: Permissions.config.list, name: '配置列表', method: 'GET', path: '/api/v1/configs', parent: 'config' }, + { code: Permissions.config.create, name: '创建配置', method: 'POST', path: '/api/v1/configs', parent: 'config' }, + { code: Permissions.config.update, name: '更新配置', method: 'PATCH', path: '/api/v1/configs/:id', parent: 'config' }, + { code: Permissions.config.delete, name: '删除配置', method: 'DELETE', path: '/api/v1/configs/:id', parent: 'config' }, + { code: Permissions.audit.list, name: '操作日志列表', method: 'GET', path: '/api/v1/audit-logs', parent: 'audit' }, + { code: Permissions.loginLog.list, name: '登录日志列表', method: 'GET', path: '/api/v1/login-logs', parent: 'login-log' }, +]; + +/** 按钮资源(纯前端按钮,无独立受控接口)。 */ +const BUTTONS = [ + { code: 'user:export', name: '用户导出', parent: 'user' }, + { code: 'role:export', name: '角色导出', parent: 'role' }, + { code: 'audit:export', name: '操作日志导出', parent: 'audit' }, + { code: 'login-log:export', name: '登录日志导出', parent: 'login-log' }, +]; + +async function main(): Promise { + // 1. 租户 + await prisma.tenant.upsert({ + where: { code: TENANT }, + update: { name: '投云', status: 'enabled' }, + create: { code: TENANT, name: '投云', status: 'enabled' }, + }); + + // 2. 根组织 + await prisma.org.upsert({ + where: { tenantCode_code: { tenantCode: TENANT, code: TENANT } }, + update: { name: '投云总部' }, + create: { tenantCode: TENANT, code: TENANT, name: '投云总部', parentCode: null, sort: 0, status: 'enabled' }, + }); + + // 3. 超管角色 + await prisma.role.upsert({ + where: { tenantCode_code: { tenantCode: TENANT, code: SUPER_ROLE } }, + update: { name: '超级管理员', dataScope: 'all', status: 'enabled' }, + create: { tenantCode: TENANT, code: SUPER_ROLE, name: '超级管理员', dataScope: 'all', status: 'enabled' }, + }); + + // 4. 管理员用户 + const passwordHash = await bcrypt.hash(ADMIN_PASSWORD, 10); + const user = await prisma.user.upsert({ + where: { tenantCode_username: { tenantCode: TENANT, username: ADMIN_USERNAME } }, + update: { passwordHash, nickname: '管理员', status: 'enabled' }, + create: { + tenantCode: TENANT, + username: ADMIN_USERNAME, + passwordHash, + nickname: '管理员', + status: 'enabled', + orgCode: TENANT, + }, + }); + + // 5. 绑定超管角色 + await prisma.userRole.upsert({ + where: { tenantCode_userId_roleCode: { tenantCode: TENANT, userId: user.id, roleCode: SUPER_ROLE } }, + update: {}, + create: { tenantCode: TENANT, userId: user.id, roleCode: SUPER_ROLE }, + }); + + // 6. 资源树(菜单 + 接口 + 按钮) + const allResourceCodes: string[] = []; + for (const m of MENUS) { + allResourceCodes.push(m.code); + await prisma.resource.upsert({ + where: { tenantCode_code: { tenantCode: TENANT, code: m.code } }, + update: { name: m.name, parentCode: m.parent, path: m.path, component: m.component, sort: m.sort, type: 'menu' }, + create: { + tenantCode: TENANT, + code: m.code, + name: m.name, + parentCode: m.parent, + type: 'menu', + path: m.path, + component: m.component, + sort: m.sort, + status: 'enabled', + }, + }); + } + for (const a of APIS) { + allResourceCodes.push(a.code); + await prisma.resource.upsert({ + where: { tenantCode_code: { tenantCode: TENANT, code: a.code } }, + update: { name: a.name, parentCode: a.parent, method: a.method, apiPath: a.path, type: 'api' }, + create: { + tenantCode: TENANT, + code: a.code, + name: a.name, + parentCode: a.parent, + type: 'api', + method: a.method, + apiPath: a.path, + status: 'enabled', + }, + }); + } + for (const b of BUTTONS) { + allResourceCodes.push(b.code); + await prisma.resource.upsert({ + where: { tenantCode_code: { tenantCode: TENANT, code: b.code } }, + update: { name: b.name, parentCode: b.parent, type: 'button' }, + create: { tenantCode: TENANT, code: b.code, name: b.name, parentCode: b.parent, type: 'button', status: 'enabled' }, + }); + } + + // 7. 超管全量授权(菜单 + 接口 + 按钮) + for (const resourceCode of allResourceCodes) { + await prisma.roleResource.upsert({ + where: { tenantCode_roleCode_resourceCode: { tenantCode: TENANT, roleCode: SUPER_ROLE, resourceCode } }, + update: {}, + create: { tenantCode: TENANT, roleCode: SUPER_ROLE, resourceCode }, + }); + } + + console.log(`seed 完成:租户 ${TENANT}、用户 ${ADMIN_USERNAME}、角色 ${SUPER_ROLE}、资源 ${allResourceCodes.length} 个(已全量授权)。`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/app.module.ts b/src/app.module.ts index 6cc41ed..99a8ded 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { PrismaModule } from './prisma/prisma.module.js'; +import { RedisModule } from './common/redis/redis.module.js'; import { createLoggerModule } from './logging/logger.config.js'; import { AuthModule } from './auth/auth.module.js'; import { JwtAuthGuard } from './auth/jwt-auth.guard.js'; @@ -8,6 +9,7 @@ import { PermissionsGuard } from './auth/permissions.guard.js'; import { RequestContextInterceptor } from './auth/request-context.interceptor.js'; import { BodyLoggingInterceptor } from './logging/body-logging.interceptor.js'; import { TransformInterceptor } from './common/interceptors/transform.interceptor.js'; +import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor.js'; import { AllExceptionsFilter } from './common/filters/http-exception.filter.js'; import { TenantsModule } from './tenants/tenants.module.js'; import { UsersModule } from './users/users.module.js'; @@ -25,6 +27,7 @@ import { AppService } from './app.service.js'; imports: [ createLoggerModule(), PrismaModule, + RedisModule, AuthModule, TenantsModule, UsersModule, @@ -44,6 +47,7 @@ import { AppService } from './app.service.js'; { provide: APP_INTERCEPTOR, useClass: RequestContextInterceptor }, { provide: APP_INTERCEPTOR, useClass: BodyLoggingInterceptor }, { provide: APP_INTERCEPTOR, useClass: TransformInterceptor }, + { provide: APP_INTERCEPTOR, useClass: AuditLogInterceptor }, { provide: APP_FILTER, useClass: AllExceptionsFilter }, ], }) diff --git a/src/audit-logs/audit-logs.controller.ts b/src/audit-logs/audit-logs.controller.ts index 1605992..4408443 100644 --- a/src/audit-logs/audit-logs.controller.ts +++ b/src/audit-logs/audit-logs.controller.ts @@ -1,5 +1,7 @@ import { Controller, Get, Query } from '@nestjs/common'; import { PageQuery } from '../common/page.dto.js'; +import { Permissions } from '../common/permissions.js'; +import { RequirePermissions } from '../auth/decorators.js'; import { AuditLogsService } from './audit-logs.service.js'; @Controller('audit-logs') @@ -7,6 +9,7 @@ export class AuditLogsController { constructor(private readonly service: AuditLogsService) {} @Get() + @RequirePermissions(Permissions.audit.list) list(@Query() q: PageQuery) { return this.service.paginate(q.page, q.pageSize); } diff --git a/src/audit-logs/audit-logs.module.ts b/src/audit-logs/audit-logs.module.ts index eecd3b6..c1cc080 100644 --- a/src/audit-logs/audit-logs.module.ts +++ b/src/audit-logs/audit-logs.module.ts @@ -5,5 +5,6 @@ import { AuditLogsService } from './audit-logs.service.js'; @Module({ controllers: [AuditLogsController], providers: [AuditLogsService], + exports: [AuditLogsService], }) export class AuditLogsModule {} diff --git a/src/audit-logs/audit-logs.service.ts b/src/audit-logs/audit-logs.service.ts index 0451403..6e43d86 100644 --- a/src/audit-logs/audit-logs.service.ts +++ b/src/audit-logs/audit-logs.service.ts @@ -1,10 +1,40 @@ import { Injectable } from '@nestjs/common'; +import { InjectPinoLogger, PinoLogger } from 'nestjs-pino'; import { BaseCrudService } from '../common/base-crud.service.js'; import { PrismaService } from '../prisma/prisma.service.js'; +export interface AuditLogEntry { + tenantCode: string; + userId?: string | null; + username?: string | null; + module?: string | null; + action?: string | null; + method?: string | null; + path?: string | null; + params?: string | null; + result?: string | null; + status?: string | null; + errorMessage?: string | null; + ip?: string | null; + userAgent?: string | null; + durationMs?: number | null; +} + @Injectable() export class AuditLogsService extends BaseCrudService { - constructor(prisma: PrismaService) { + constructor( + prisma: PrismaService, + @InjectPinoLogger(AuditLogsService.name) private readonly logger: PinoLogger, + ) { super(prisma, 'auditLog'); } + + /** 写入一条审计日志;失败仅告警,不抛出(异步审计不阻塞业务)。 */ + async record(data: AuditLogEntry): Promise { + try { + await this.db().create({ data }); + } catch (err) { + this.logger.warn({ err }, '审计日志写入失败'); + } + } } diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index efe5745..c293582 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,4 +1,5 @@ -import { Body, Controller, Post } from '@nestjs/common'; +import { Body, Controller, Post, Req } from '@nestjs/common'; +import type { Request } from 'express'; import { AuthService } from './auth.service.js'; import { LoginDto, RefreshDto } from './dto.js'; import { Public } from './decorators.js'; @@ -9,8 +10,8 @@ export class AuthController { @Public() @Post('login') - login(@Body() dto: LoginDto) { - return this.auth.login(dto); + login(@Body() dto: LoginDto, @Req() req: Request) { + return this.auth.login(dto, req.ip, req.headers['user-agent']); } @Public() @@ -21,7 +22,7 @@ export class AuthController { @Public() @Post('logout') - logout(@Body() dto: RefreshDto) { - return this.auth.logout(dto); + logout(@Body() dto: RefreshDto, @Req() req: Request) { + return this.auth.logout(dto, req.ip, req.headers['user-agent']); } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index b5fb134..9032ab2 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -2,6 +2,8 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { AuthController } from './auth.controller.js'; import { AuthService } from './auth.service.js'; +import { AuditLogsModule } from '../audit-logs/audit-logs.module.js'; +import { LoginLogsModule } from '../login-logs/login-logs.module.js'; @Module({ imports: [ @@ -10,6 +12,8 @@ import { AuthService } from './auth.service.js'; secret: process.env.JWT_SECRET ?? 'dev-secret-change-me', signOptions: { expiresIn: Number(process.env.JWT_ACCESS_TTL ?? 7200) }, }), + AuditLogsModule, + LoginLogsModule, ], controllers: [AuthController], providers: [AuthService], diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 786b202..bac3911 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -3,34 +3,72 @@ import { JwtService } from '@nestjs/jwt'; import bcrypt from 'bcryptjs'; import { createHash, randomUUID } from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service.js'; +import { RedisService } from '../common/redis/redis.service.js'; import { LoginDto, RefreshDto } from './dto.js'; import { resolveDataScope, resolveVisibleOrgs } from '../common/data-scope.js'; import type { DataScope } from '../common/enums.js'; import type { RequestContext } from '../common/context/request-context.js'; +import { AuditLogsService } from '../audit-logs/audit-logs.service.js'; +import { LoginLogsService } from '../login-logs/login-logs.service.js'; const REFRESH_TTL = Number(process.env.JWT_REFRESH_TTL ?? 604800); const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200); +const SUPER_ROLE = 'super_admin'; + +export interface PermissionSet { + super: boolean; + permissions: string[]; +} @Injectable() export class AuthService { constructor( private readonly prisma: PrismaService, private readonly jwt: JwtService, + private readonly redis: RedisService, + private readonly auditLogs: AuditLogsService, + private readonly loginLogs: LoginLogsService, ) {} - async login(dto: LoginDto): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { + async login( + dto: LoginDto, + ip?: string, + userAgent?: string, + ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { const tenant = await this.prisma.client.tenant.findUnique({ where: { code: dto.tenantCode } }); if (!tenant || tenant.status !== 'enabled') { + await this.recordLoginLog(dto.tenantCode, dto.username, 'fail', '租户不存在或已停用', ip, userAgent); throw new UnauthorizedException('租户不存在或已停用'); } const user = await this.prisma.client.user.findFirst({ where: { tenantCode: dto.tenantCode, username: dto.username, deletedAt: null }, }); - if (!user) throw new UnauthorizedException('账号或密码错误'); - if (user.status !== 'enabled') throw new UnauthorizedException('账号已停用或锁定'); + if (!user) { + await this.recordLoginLog(dto.tenantCode, dto.username, 'fail', '账号或密码错误', ip, userAgent); + throw new UnauthorizedException('账号或密码错误'); + } + if (user.status !== 'enabled') { + await this.recordLoginLog(dto.tenantCode, dto.username, 'fail', '账号已停用或锁定', ip, userAgent); + throw new UnauthorizedException('账号已停用或锁定'); + } const ok = await bcrypt.compare(dto.password, user.passwordHash); - if (!ok) throw new UnauthorizedException('账号或密码错误'); + if (!ok) { + await this.recordLoginLog(dto.tenantCode, dto.username, 'fail', '账号或密码错误', ip, userAgent); + throw new UnauthorizedException('账号或密码错误'); + } + await this.writePermissionCache(dto.tenantCode, user.id); + await this.recordLoginLog(dto.tenantCode, dto.username, 'success', null, ip, userAgent); + await this.recordAudit({ + tenantCode: dto.tenantCode, + userId: user.id, + username: user.username, + module: 'auth', + action: 'login', + status: 'success', + ip: ip ?? null, + userAgent: userAgent ?? null, + }); return this.issue(dto.tenantCode, user.id, user.username); } @@ -44,25 +82,44 @@ export class AuthService { }); if (!user || user.status !== 'enabled') throw new UnauthorizedException('账号已失效'); - // 旋转:作废旧 refresh + // 旋转:作废旧 refresh,并重聚合权限集(权限变更在刷新时生效) await this.prisma.client.refreshToken.update({ where: { jti: dto.refreshToken }, data: { revokedAt: new Date() }, }); + await this.writePermissionCache(record.tenantCode, user.id); return this.issue(record.tenantCode, user.id, user.username); } - async logout(dto: RefreshDto): Promise { + async logout(dto: RefreshDto, ip?: string, userAgent?: string): Promise { const record = await this.prisma.client.refreshToken.findUnique({ where: { jti: dto.refreshToken } }); if (record) { await this.prisma.client.refreshToken.update({ where: { jti: dto.refreshToken }, data: { revokedAt: new Date() }, }); + await this.redis.del(this.permKey(record.tenantCode, record.userId)); + await this.recordAudit({ + tenantCode: record.tenantCode, + userId: record.userId, + username: null, + module: 'auth', + action: 'logout', + status: 'success', + ip: ip ?? null, + userAgent: userAgent ?? null, + }); } return null; } + /** 读取用户权限集;缓存缺失时重新聚合并回填(Redis 重启自愈)。 */ + async getPermissionSet(tenantCode: string, userId: string): Promise { + const cached = await this.redis.getJson(this.permKey(tenantCode, userId)); + if (cached) return cached; + return this.writePermissionCache(tenantCode, userId); + } + /** 由 RequestContextInterceptor 调用,构建当前请求的租户/用户/数据权限上下文。 */ async buildContext(user: { userId: string; tenantCode: string; username: string }): Promise { const u = await this.prisma.client.user.findFirst({ @@ -76,24 +133,93 @@ export class AuthService { select: { roleCode: true }, }); const roleCodes = roleRows.map((r) => r.roleCode); + const isSuper = roleCodes.includes(SUPER_ROLE); const roles = roleCodes.length ? await this.prisma.client.role.findMany({ where: { tenantCode: user.tenantCode, code: { in: roleCodes }, deletedAt: null }, select: { dataScope: true }, }) : []; - const dataScope = resolveDataScope(roles.map((r) => r.dataScope as DataScope)); - const orgCodes = await resolveVisibleOrgs({ - prisma: this.prisma.client, - tenantCode: user.tenantCode, - dataScope, - orgCode, - roleCodes, - }); + const dataScope: DataScope = isSuper ? 'all' : resolveDataScope(roles.map((r) => r.dataScope as DataScope)); + const orgCodes = isSuper + ? [] + : await resolveVisibleOrgs({ + prisma: this.prisma.client, + tenantCode: user.tenantCode, + dataScope, + orgCode, + roleCodes, + }); return { tenantCode: user.tenantCode, userId: user.userId, username: user.username, dataScope, orgCode, orgCodes }; } + private permKey(tenantCode: string, userId: string): string { + return `perm:${tenantCode}:${userId}`; + } + + /** 聚合权限码与超管标志。 */ + private async resolveAuth(tenantCode: string, userId: string): Promise { + const roleRows = await this.prisma.client.userRole.findMany({ + where: { tenantCode, userId }, + select: { roleCode: true }, + }); + const roleCodes = roleRows.map((r) => r.roleCode); + const isSuper = roleCodes.includes(SUPER_ROLE); + const resourceRows = await this.prisma.client.roleResource.findMany({ + where: { tenantCode, roleCode: { in: roleCodes } }, + select: { resourceCode: true }, + }); + const resourceCodes = [...new Set(resourceRows.map((r) => r.resourceCode))]; + const permissions: string[] = resourceCodes.length + ? ( + await this.prisma.client.resource.findMany({ + where: { tenantCode, code: { in: resourceCodes }, type: { in: ['button', 'api'] }, deletedAt: null }, + select: { code: true }, + }) + ).map((r) => r.code) + : []; + return { super: isSuper, permissions }; + } + + /** 聚合并写入权限缓存(登录/刷新时调用)。 */ + private async writePermissionCache(tenantCode: string, userId: string): Promise { + const set = await this.resolveAuth(tenantCode, userId); + await this.redis.setJson(this.permKey(tenantCode, userId), set, REFRESH_TTL); + return set; + } + + private async recordLoginLog( + tenantCode: string, + username: string, + status: string, + message: string | null, + ip?: string, + userAgent?: string, + ): Promise { + await this.loginLogs.record({ + tenantCode, + username, + status, + message, + ip: ip ?? null, + userAgent: userAgent ?? null, + }); + } + + private async recordAudit(entry: { + tenantCode: string; + userId: string | null; + username: string | null; + module: string; + action: string; + status: string; + ip?: string | null; + userAgent?: string | null; + }): Promise { + await this.auditLogs.record(entry); + } + private async issue( tenantCode: string, userId: string, diff --git a/src/auth/decorators.ts b/src/auth/decorators.ts index 967b638..7cb438a 100644 --- a/src/auth/decorators.ts +++ b/src/auth/decorators.ts @@ -8,6 +8,14 @@ export const PERMISSIONS_KEY = 'permissions'; /** 要求当前用户具备给定权限标识(resource code)。 */ export const RequirePermissions = (...permissions: string[]) => SetMetadata(PERMISSIONS_KEY, permissions); +export const AUDIT_KEY = 'audit'; +export interface AuditMeta { + module: string; + action: string; +} +/** 标记写接口需自动落审计日志(module/action 记录到 sys_audit_log)。 */ +export const AuditLog = (module: string, action: string) => SetMetadata(AUDIT_KEY, { module, action } satisfies AuditMeta); + export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => { return ctx.switchToHttp().getRequest().user; }); diff --git a/src/auth/permissions.guard.ts b/src/auth/permissions.guard.ts index 1b6f5e1..6166eb0 100644 --- a/src/auth/permissions.guard.ts +++ b/src/auth/permissions.guard.ts @@ -1,12 +1,12 @@ import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { PrismaService } from '../prisma/prisma.service.js'; +import { AuthService } from './auth.service.js'; import { PERMISSIONS_KEY } from './decorators.js'; @Injectable() export class PermissionsGuard implements CanActivate { constructor( - private readonly prisma: PrismaService, + private readonly auth: AuthService, private readonly reflector: Reflector, ) {} @@ -21,17 +21,9 @@ export class PermissionsGuard implements CanActivate { const user = request.user as { userId: string; tenantCode: string } | undefined; if (!user) throw new ForbiddenException('无权限'); - const roleRows = await this.prisma.client.userRole.findMany({ - where: { tenantCode: user.tenantCode, userId: user.userId }, - select: { roleCode: true }, - }); - const roleCodes = roleRows.map((r) => r.roleCode); - if (roleCodes.length === 0) throw new ForbiddenException('无权限'); - - const count = await this.prisma.client.roleResource.count({ - where: { tenantCode: user.tenantCode, roleCode: { in: roleCodes }, resourceCode: { in: required } }, - }); - if (count === 0) throw new ForbiddenException('无权限'); - return true; + const { super: isSuper, permissions } = await this.auth.getPermissionSet(user.tenantCode, user.userId); + if (isSuper) return true; + if (required.every((code) => permissions.includes(code))) return true; + throw new ForbiddenException('无权限'); } } diff --git a/src/common/interceptors/audit-log.interceptor.ts b/src/common/interceptors/audit-log.interceptor.ts new file mode 100644 index 0000000..b342310 --- /dev/null +++ b/src/common/interceptors/audit-log.interceptor.ts @@ -0,0 +1,58 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { catchError, Observable, tap, throwError } from 'rxjs'; +import { AUDIT_KEY, AuditMeta } from '../../auth/decorators.js'; +import { AuditLogsService } from '../../audit-logs/audit-logs.service.js'; +import { maskSensitive } from '../mask.js'; + +/** + * 审计日志拦截器:对标注 @AuditLog(module, action) 的接口,在请求完成后异步写入 + * sys_audit_log(成功 status=success,异常 status=fail + error_message)。 + * 入参脱敏;写入失败由 AuditLogsService 内部告警,不阻塞响应。 + */ +@Injectable() +export class AuditLogInterceptor implements NestInterceptor { + constructor( + private readonly reflector: Reflector, + private readonly auditLogs: AuditLogsService, + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const meta = this.reflector.getAllAndOverride(AUDIT_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!meta) return next.handle(); + + const request = context.switchToHttp().getRequest(); + const user = request.user as { userId?: string; tenantCode?: string; username?: string } | undefined; + const start = Date.now(); + const base = { + tenantCode: user?.tenantCode ?? '', + userId: user?.userId ?? null, + username: user?.username ?? null, + module: meta.module, + action: meta.action, + method: request.method, + path: request.originalUrl ?? request.url, + params: request.body ? JSON.stringify(maskSensitive(request.body)) : null, + ip: request.ip ?? request.remoteAddress ?? null, + userAgent: request.headers?.['user-agent'] ?? null, + }; + + return next.handle().pipe( + tap(() => { + void this.auditLogs.record({ ...base, status: 'success', durationMs: Date.now() - start }); + }), + catchError((err) => { + void this.auditLogs.record({ + ...base, + status: 'fail', + errorMessage: err instanceof Error ? err.message : String(err), + durationMs: Date.now() - start, + }); + return throwError(() => err); + }), + ); + } +} diff --git a/src/common/mask.ts b/src/common/mask.ts new file mode 100644 index 0000000..8e737fd --- /dev/null +++ b/src/common/mask.ts @@ -0,0 +1,25 @@ +/** 敏感字段递归脱敏:命中的字段值替换为 ***(供请求体日志与审计参数复用)。 */ +const SENSITIVE_KEYS = new Set([ + 'password', + 'passwordhash', + 'oldpassword', + 'newpassword', + 'confirmpassword', + 'refreshtoken', + 'token', + 'secret', + 'authorization', +]); + +export function maskSensitive(value: unknown): unknown { + if (value === null || value === undefined) return value; + if (Array.isArray(value)) return value.map(maskSensitive); + if (typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? '***' : maskSensitive(v); + } + return out; + } + return value; +} diff --git a/src/common/permissions.ts b/src/common/permissions.ts new file mode 100644 index 0000000..1540cd1 --- /dev/null +++ b/src/common/permissions.ts @@ -0,0 +1,57 @@ +/** + * 权限码单一事实源(细粒度 domain:action)。 + * 控制器 @RequirePermissions 与 seed 资源树共用,避免漂移。 + */ +export const Permissions = { + tenant: { + list: 'tenant:list', + create: 'tenant:create', + update: 'tenant:update', + delete: 'tenant:delete', + }, + user: { + list: 'user:list', + create: 'user:create', + update: 'user:update', + delete: 'user:delete', + resetPwd: 'user:reset-pwd', + }, + role: { + list: 'role:list', + create: 'role:create', + update: 'role:update', + delete: 'role:delete', + assignResource: 'role:assign-resource', + }, + resource: { + list: 'resource:list', + create: 'resource:create', + update: 'resource:update', + delete: 'resource:delete', + }, + org: { + list: 'org:list', + create: 'org:create', + update: 'org:update', + delete: 'org:delete', + }, + dict: { + list: 'dict:list', + create: 'dict:create', + update: 'dict:update', + delete: 'dict:delete', + }, + config: { + list: 'config:list', + create: 'config:create', + update: 'config:update', + delete: 'config:delete', + }, + audit: { list: 'audit:list' }, + loginLog: { list: 'login-log:list' }, +} as const; + +/** 全部权限码扁平列表(seed 授权与资源生成用)。 */ +export const ALL_PERMISSIONS: string[] = Object.values(Permissions).flatMap((m) => + Object.values(m), +); diff --git a/src/common/redis/redis.module.ts b/src/common/redis/redis.module.ts new file mode 100644 index 0000000..a898d5a --- /dev/null +++ b/src/common/redis/redis.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { RedisService } from './redis.service.js'; + +@Global() +@Module({ + providers: [RedisService], + exports: [RedisService], +}) +export class RedisModule {} diff --git a/src/common/redis/redis.service.ts b/src/common/redis/redis.service.ts new file mode 100644 index 0000000..65ec1c2 --- /dev/null +++ b/src/common/redis/redis.service.ts @@ -0,0 +1,56 @@ +import { Injectable, OnModuleDestroy } from '@nestjs/common'; +import { Redis } from 'ioredis'; + +/** + * Redis 封装:统一 key 前缀(REDIS_KEY_PREFIX),支持 JSON 读写与按前缀删除。 + * 多个 fork 项目共用一个 Redis 实例时,通过不同前缀隔离。 + */ +@Injectable() +export class RedisService implements OnModuleDestroy { + readonly prefix: string; + private readonly client: Redis; + + constructor() { + this.prefix = process.env.REDIS_KEY_PREFIX ?? 'sysmgr:'; + this.client = new Redis({ + host: process.env.REDIS_HOST ?? '127.0.0.1', + port: Number(process.env.REDIS_PORT ?? 6379), + db: Number(process.env.REDIS_DB ?? 0), + password: process.env.REDIS_PASSWORD || undefined, + }); + } + + private key(k: string): string { + return this.prefix + k; + } + + async getJson(key: string): Promise { + const raw = await this.client.get(this.key(key)); + return raw ? (JSON.parse(raw) as T) : null; + } + + async setJson(key: string, value: unknown, ttlSeconds?: number): Promise { + const raw = JSON.stringify(value); + if (ttlSeconds && ttlSeconds > 0) await this.client.set(this.key(key), raw, 'EX', ttlSeconds); + else await this.client.set(this.key(key), raw); + } + + async del(key: string): Promise { + await this.client.del(this.key(key)); + } + + /** 按前缀删除(SCAN 而非 KEYS,避免阻塞)。 */ + async delByPrefix(prefix: string): Promise { + const fullPrefix = this.prefix + prefix; + let cursor = '0'; + do { + const [next, keys] = await this.client.scan(cursor, 'MATCH', `${fullPrefix}*`, 'COUNT', 200); + cursor = next; + if (keys.length > 0) await this.client.del(...keys); + } while (cursor !== '0'); + } + + async onModuleDestroy(): Promise { + await this.client.quit(); + } +} diff --git a/src/configs/configs.controller.ts b/src/configs/configs.controller.ts index 17faac4..50cdb04 100644 --- a/src/configs/configs.controller.ts +++ b/src/configs/configs.controller.ts @@ -1,5 +1,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { PageQuery } from '../common/page.dto.js'; +import { Permissions } from '../common/permissions.js'; +import { AuditLog, RequirePermissions } from '../auth/decorators.js'; import { CreateConfigDto, UpdateConfigDto } from './dto.js'; import { ConfigsService } from './configs.service.js'; @@ -8,26 +10,34 @@ export class ConfigsController { constructor(private readonly service: ConfigsService) {} @Get() + @RequirePermissions(Permissions.config.list) list(@Query() q: PageQuery) { return this.service.paginate(q.page, q.pageSize); } @Get(':id') + @RequirePermissions(Permissions.config.list) get(@Param('id') id: string) { return this.service.findById(id); } @Post() + @RequirePermissions(Permissions.config.create) + @AuditLog('config', 'create') create(@Body() dto: CreateConfigDto) { return this.service.createConfig(dto); } @Patch(':id') + @RequirePermissions(Permissions.config.update) + @AuditLog('config', 'update') update(@Param('id') id: string, @Body() dto: UpdateConfigDto) { return this.service.updateConfig(id, dto); } @Delete(':id') + @RequirePermissions(Permissions.config.delete) + @AuditLog('config', 'delete') remove(@Param('id') id: string) { return this.service.softRemove(id); } diff --git a/src/dicts/dict-items.controller.ts b/src/dicts/dict-items.controller.ts index 517f2c3..59b5083 100644 --- a/src/dicts/dict-items.controller.ts +++ b/src/dicts/dict-items.controller.ts @@ -1,4 +1,6 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { Permissions } from '../common/permissions.js'; +import { AuditLog, RequirePermissions } from '../auth/decorators.js'; import { CreateDictItemDto, UpdateDictItemDto } from './dto.js'; import { DictItemsService } from './dict-items.service.js'; @@ -7,26 +9,34 @@ export class DictItemsController { constructor(private readonly service: DictItemsService) {} @Get() + @RequirePermissions(Permissions.dict.list) list(@Query('dictTypeCode') dictTypeCode?: string) { return this.service.listByType(dictTypeCode); } @Get(':id') + @RequirePermissions(Permissions.dict.list) get(@Param('id') id: string) { return this.service.findById(id); } @Post() + @RequirePermissions(Permissions.dict.create) + @AuditLog('dict', 'create') create(@Body() dto: CreateDictItemDto) { return this.service.createItem(dto); } @Patch(':id') + @RequirePermissions(Permissions.dict.update) + @AuditLog('dict', 'update') update(@Param('id') id: string, @Body() dto: UpdateDictItemDto) { return this.service.updateItem(id, dto); } @Delete(':id') + @RequirePermissions(Permissions.dict.delete) + @AuditLog('dict', 'delete') remove(@Param('id') id: string) { return this.service.softRemove(id); } diff --git a/src/dicts/dict-types.controller.ts b/src/dicts/dict-types.controller.ts index d81045c..8fff1d2 100644 --- a/src/dicts/dict-types.controller.ts +++ b/src/dicts/dict-types.controller.ts @@ -1,5 +1,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { PageQuery } from '../common/page.dto.js'; +import { Permissions } from '../common/permissions.js'; +import { AuditLog, RequirePermissions } from '../auth/decorators.js'; import { CreateDictTypeDto, UpdateDictTypeDto } from './dto.js'; import { DictTypesService } from './dict-types.service.js'; @@ -8,26 +10,34 @@ export class DictTypesController { constructor(private readonly service: DictTypesService) {} @Get() + @RequirePermissions(Permissions.dict.list) list(@Query() q: PageQuery) { return this.service.paginate(q.page, q.pageSize); } @Get(':id') + @RequirePermissions(Permissions.dict.list) get(@Param('id') id: string) { return this.service.findById(id); } @Post() + @RequirePermissions(Permissions.dict.create) + @AuditLog('dict', 'create') create(@Body() dto: CreateDictTypeDto) { return this.service.createType(dto); } @Patch(':id') + @RequirePermissions(Permissions.dict.update) + @AuditLog('dict', 'update') update(@Param('id') id: string, @Body() dto: UpdateDictTypeDto) { return this.service.updateType(id, dto); } @Delete(':id') + @RequirePermissions(Permissions.dict.delete) + @AuditLog('dict', 'delete') remove(@Param('id') id: string) { return this.service.softRemove(id); } diff --git a/src/logging/body-logging.interceptor.ts b/src/logging/body-logging.interceptor.ts index 469a7c4..eaadb9b 100644 --- a/src/logging/body-logging.interceptor.ts +++ b/src/logging/body-logging.interceptor.ts @@ -1,32 +1,7 @@ import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { Observable } from 'rxjs'; import { InjectPinoLogger, PinoLogger } from 'nestjs-pino'; - -const SENSITIVE_KEYS = new Set([ - 'password', - 'passwordhash', - 'oldpassword', - 'newpassword', - 'confirmpassword', - 'refreshtoken', - 'token', - 'secret', - 'authorization', -]); - -/** 递归脱敏:命中的字段值替换为 ***。 */ -function mask(value: unknown): unknown { - if (value === null || value === undefined) return value; - if (Array.isArray(value)) return value.map(mask); - if (typeof value === 'object') { - const out: Record = {}; - for (const [k, v] of Object.entries(value as Record)) { - out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? '***' : mask(v); - } - return out; - } - return value; -} +import { maskSensitive } from '../common/mask.js'; /** 记录请求 body(POST/PUT/PATCH/DELETE),敏感字段脱敏,级别 debug。 */ @Injectable() @@ -37,7 +12,7 @@ export class BodyLoggingInterceptor implements NestInterceptor { const request = context.switchToHttp().getRequest(); const { method, url, body } = request; if (body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) && Object.keys(body).length > 0) { - this.logger.debug({ method, url, body: mask(body) }, 'request body'); + this.logger.debug({ method, url, body: maskSensitive(body) }, 'request body'); } return next.handle(); } diff --git a/src/login-logs/login-logs.controller.ts b/src/login-logs/login-logs.controller.ts index 758ce8c..3be6459 100644 --- a/src/login-logs/login-logs.controller.ts +++ b/src/login-logs/login-logs.controller.ts @@ -1,5 +1,7 @@ import { Controller, Get, Query } from '@nestjs/common'; import { PageQuery } from '../common/page.dto.js'; +import { Permissions } from '../common/permissions.js'; +import { RequirePermissions } from '../auth/decorators.js'; import { LoginLogsService } from './login-logs.service.js'; @Controller('login-logs') @@ -7,6 +9,7 @@ export class LoginLogsController { constructor(private readonly service: LoginLogsService) {} @Get() + @RequirePermissions(Permissions.loginLog.list) list(@Query() q: PageQuery) { return this.service.paginate(q.page, q.pageSize); } diff --git a/src/login-logs/login-logs.module.ts b/src/login-logs/login-logs.module.ts index e5894bf..b6fadca 100644 --- a/src/login-logs/login-logs.module.ts +++ b/src/login-logs/login-logs.module.ts @@ -5,5 +5,6 @@ import { LoginLogsService } from './login-logs.service.js'; @Module({ controllers: [LoginLogsController], providers: [LoginLogsService], + exports: [LoginLogsService], }) export class LoginLogsModule {} diff --git a/src/login-logs/login-logs.service.ts b/src/login-logs/login-logs.service.ts index 7417db5..21388ec 100644 --- a/src/login-logs/login-logs.service.ts +++ b/src/login-logs/login-logs.service.ts @@ -1,10 +1,32 @@ import { Injectable } from '@nestjs/common'; +import { InjectPinoLogger, PinoLogger } from 'nestjs-pino'; import { BaseCrudService } from '../common/base-crud.service.js'; import { PrismaService } from '../prisma/prisma.service.js'; +export interface LoginLogEntry { + tenantCode: string; + username?: string | null; + ip?: string | null; + userAgent?: string | null; + status: string; + message?: string | null; +} + @Injectable() export class LoginLogsService extends BaseCrudService { - constructor(prisma: PrismaService) { + constructor( + prisma: PrismaService, + @InjectPinoLogger(LoginLogsService.name) private readonly logger: PinoLogger, + ) { super(prisma, 'loginLog'); } + + /** 写入一条登录日志;失败仅告警,不抛出。 */ + async record(data: LoginLogEntry): Promise { + try { + await this.db().create({ data }); + } catch (err) { + this.logger.warn({ err }, '登录日志写入失败'); + } + } } diff --git a/src/orgs/orgs.controller.ts b/src/orgs/orgs.controller.ts index 4abdbba..1e734ae 100644 --- a/src/orgs/orgs.controller.ts +++ b/src/orgs/orgs.controller.ts @@ -1,4 +1,6 @@ import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { Permissions } from '../common/permissions.js'; +import { AuditLog, RequirePermissions } from '../auth/decorators.js'; import { CreateOrgDto, UpdateOrgDto } from './dto.js'; import { OrgsService } from './orgs.service.js'; @@ -7,26 +9,34 @@ export class OrgsController { constructor(private readonly service: OrgsService) {} @Get() + @RequirePermissions(Permissions.org.list) list() { return this.service.list({}, { sort: 'asc' }); } @Get(':id') + @RequirePermissions(Permissions.org.list) get(@Param('id') id: string) { return this.service.findById(id); } @Post() + @RequirePermissions(Permissions.org.create) + @AuditLog('org', 'create') create(@Body() dto: CreateOrgDto) { return this.service.createOrg(dto); } @Patch(':id') + @RequirePermissions(Permissions.org.update) + @AuditLog('org', 'update') update(@Param('id') id: string, @Body() dto: UpdateOrgDto) { return this.service.updateOrg(id, dto); } @Delete(':id') + @RequirePermissions(Permissions.org.delete) + @AuditLog('org', 'delete') remove(@Param('id') id: string) { return this.service.softRemove(id); } diff --git a/src/resources/resources.controller.ts b/src/resources/resources.controller.ts index 2244557..7a937c0 100644 --- a/src/resources/resources.controller.ts +++ b/src/resources/resources.controller.ts @@ -1,4 +1,6 @@ import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { Permissions } from '../common/permissions.js'; +import { AuditLog, RequirePermissions } from '../auth/decorators.js'; import { CreateResourceDto, UpdateResourceDto } from './dto.js'; import { ResourcesService } from './resources.service.js'; @@ -7,26 +9,34 @@ export class ResourcesController { constructor(private readonly service: ResourcesService) {} @Get() + @RequirePermissions(Permissions.resource.list) list() { return this.service.list({}, { sort: 'asc' }); } @Get(':id') + @RequirePermissions(Permissions.resource.list) get(@Param('id') id: string) { return this.service.findById(id); } @Post() + @RequirePermissions(Permissions.resource.create) + @AuditLog('resource', 'create') create(@Body() dto: CreateResourceDto) { return this.service.createResource(dto); } @Patch(':id') + @RequirePermissions(Permissions.resource.update) + @AuditLog('resource', 'update') update(@Param('id') id: string, @Body() dto: UpdateResourceDto) { return this.service.updateResource(id, dto); } @Delete(':id') + @RequirePermissions(Permissions.resource.delete) + @AuditLog('resource', 'delete') remove(@Param('id') id: string) { return this.service.softRemove(id); } diff --git a/src/roles/roles.controller.ts b/src/roles/roles.controller.ts index d28cd63..db7c6af 100644 --- a/src/roles/roles.controller.ts +++ b/src/roles/roles.controller.ts @@ -1,5 +1,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; import { PageQuery } from '../common/page.dto.js'; +import { Permissions } from '../common/permissions.js'; +import { AuditLog, RequirePermissions } from '../auth/decorators.js'; import { AssignResourcesDto, CreateRoleDto, UpdateRoleDto } from './dto.js'; import { RolesService } from './roles.service.js'; @@ -8,36 +10,47 @@ export class RolesController { constructor(private readonly service: RolesService) {} @Get() + @RequirePermissions(Permissions.role.list) list(@Query() q: PageQuery) { return this.service.paginate(q.page, q.pageSize); } @Get(':id') + @RequirePermissions(Permissions.role.list) get(@Param('id') id: string) { return this.service.findById(id); } @Post() + @RequirePermissions(Permissions.role.create) + @AuditLog('role', 'create') create(@Body() dto: CreateRoleDto) { return this.service.createRole(dto); } @Patch(':id') + @RequirePermissions(Permissions.role.update) + @AuditLog('role', 'update') update(@Param('id') id: string, @Body() dto: UpdateRoleDto) { return this.service.updateRole(id, dto); } @Delete(':id') + @RequirePermissions(Permissions.role.delete) + @AuditLog('role', 'delete') remove(@Param('id') id: string) { return this.service.softRemove(id); } @Get(':id/resources') + @RequirePermissions(Permissions.role.list) resources(@Param('id') id: string) { return this.service.assignedResources(id); } @Put(':id/resources') + @RequirePermissions(Permissions.role.assignResource) + @AuditLog('role', 'assign-resource') assign(@Param('id') id: string, @Body() dto: AssignResourcesDto) { return this.service.assignResources(id, dto); } diff --git a/src/roles/roles.service.ts b/src/roles/roles.service.ts index 07a6db1..e289bfb 100644 --- a/src/roles/roles.service.ts +++ b/src/roles/roles.service.ts @@ -1,12 +1,16 @@ import { Injectable } from '@nestjs/common'; import { BaseCrudService } from '../common/base-crud.service.js'; import { PrismaService } from '../prisma/prisma.service.js'; +import { RedisService } from '../common/redis/redis.service.js'; import { getRequestContext } from '../common/context/request-context.js'; import { AssignResourcesDto, CreateRoleDto, UpdateRoleDto } from './dto.js'; @Injectable() export class RolesService extends BaseCrudService { - constructor(prisma: PrismaService) { + constructor( + prisma: PrismaService, + private readonly redis: RedisService, + ) { super(prisma, 'role'); } @@ -35,6 +39,8 @@ export class RolesService extends BaseCrudService { data: dto.resourceCodes.map((resourceCode) => ({ tenantCode: ctx.tenantCode, roleCode, resourceCode })), }); } + // 授权变更:删除该租户全部用户权限缓存,下一次请求守卫重新聚合并回填 + await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`); return null; } } diff --git a/src/tenants/tenants.controller.ts b/src/tenants/tenants.controller.ts index eb78461..a1c7125 100644 --- a/src/tenants/tenants.controller.ts +++ b/src/tenants/tenants.controller.ts @@ -1,5 +1,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { PageQuery } from '../common/page.dto.js'; +import { Permissions } from '../common/permissions.js'; +import { AuditLog, RequirePermissions } from '../auth/decorators.js'; import { CreateTenantDto, UpdateTenantDto } from './dto.js'; import { TenantsService } from './tenants.service.js'; @@ -8,26 +10,34 @@ export class TenantsController { constructor(private readonly service: TenantsService) {} @Get() + @RequirePermissions(Permissions.tenant.list) list(@Query() q: PageQuery) { return this.service.paginate(q.page, q.pageSize); } @Get(':id') + @RequirePermissions(Permissions.tenant.list) get(@Param('id') id: string) { return this.service.findById(id); } @Post() + @RequirePermissions(Permissions.tenant.create) + @AuditLog('tenant', 'create') create(@Body() dto: CreateTenantDto) { return this.service.createTenant(dto); } @Patch(':id') + @RequirePermissions(Permissions.tenant.update) + @AuditLog('tenant', 'update') update(@Param('id') id: string, @Body() dto: UpdateTenantDto) { return this.service.updateTenant(id, dto); } @Delete(':id') + @RequirePermissions(Permissions.tenant.delete) + @AuditLog('tenant', 'delete') remove(@Param('id') id: string) { return this.service.removeTenant(id); } diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 6753cda..a5eadb6 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,5 +1,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { PageQuery } from '../common/page.dto.js'; +import { Permissions } from '../common/permissions.js'; +import { AuditLog, RequirePermissions } from '../auth/decorators.js'; import { CreateUserDto, ResetPasswordDto, UpdateUserDto } from './dto.js'; import { UsersService } from './users.service.js'; @@ -8,31 +10,41 @@ export class UsersController { constructor(private readonly service: UsersService) {} @Get() + @RequirePermissions(Permissions.user.list) list(@Query() q: PageQuery) { return this.service.listUsers(q); } @Get(':id') + @RequirePermissions(Permissions.user.list) get(@Param('id') id: string) { return this.service.findById(id); } @Post() + @RequirePermissions(Permissions.user.create) + @AuditLog('user', 'create') create(@Body() dto: CreateUserDto) { return this.service.createUser(dto); } @Patch(':id') + @RequirePermissions(Permissions.user.update) + @AuditLog('user', 'update') update(@Param('id') id: string, @Body() dto: UpdateUserDto) { return this.service.updateUser(id, dto); } @Patch(':id/password') + @RequirePermissions(Permissions.user.resetPwd) + @AuditLog('user', 'reset-pwd') resetPassword(@Param('id') id: string, @Body() dto: ResetPasswordDto) { return this.service.resetPassword(id, dto); } @Delete(':id') + @RequirePermissions(Permissions.user.delete) + @AuditLog('user', 'delete') remove(@Param('id') id: string) { return this.service.softRemove(id); }