Add privilege management system with related migrations and guards
- Introduced a new `PrivilegesModule` to manage user privileges and access control. - Added `RequirePrivilege` decorator to enforce privilege checks on controller handlers. - Implemented `PrivilegesGuard` to handle authorization based on user privileges. - Created database migrations for `privileges`, `privilege_keys`, and `privilege_details` tables. - Updated user model to include `is_superadmin` field for enhanced access control. - Added unit tests for the new privileges functionality and guards to ensure correct behavior.
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
description: Primary modules must use @RequirePrivilege on every non-public handler; privilege_keys seeded per module
|
||||||
|
globs: "src/modules/**/*.ts,src/common/decorators/**/*.ts,src/common/guards/**/*.ts"
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Privileges Authorization
|
||||||
|
|
||||||
|
## Mandatory
|
||||||
|
|
||||||
|
Every **non-public** controller handler on a primary (CRUD) resource MUST use:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@RequirePrivilege('MODULE.RESOURCE', 'view' | 'create' | 'update' | 'delete' | 'import')
|
||||||
|
```
|
||||||
|
|
||||||
|
Map HTTP verbs to actions:
|
||||||
|
|
||||||
|
| Handler | Action |
|
||||||
|
| ------- | ------ |
|
||||||
|
| `GET` list / detail | `view` |
|
||||||
|
| `POST /` create | `create` |
|
||||||
|
| `PATCH /:id`, status, bulk-status | `update` |
|
||||||
|
| `DELETE /:id`, bulk-delete | `delete` |
|
||||||
|
| `POST /import` | `import` |
|
||||||
|
|
||||||
|
Key codes use dotted uppercase module levels (`PRIVILEGES`, `SALES.INVOICE`). New modules add a `privilege_keys` seed row via migration — do not invent a parallel permission helper.
|
||||||
|
|
||||||
|
Seed an Administrator privilege only via SQL/ops after the first user exists (`created_by` requires a user). Documented bootstrap: insert privilege + details, then `UPDATE users SET privilege_id = …`. Do not auto-grant on register.
|
||||||
|
|
||||||
|
## Guard behavior
|
||||||
|
|
||||||
|
`PrivilegesGuard` (global) allows when there is no metadata. When metadata is present, `users.is_superadmin === true` skips the matrix check. Otherwise the user’s assigned privilege must be **status `active`** and the matrix cell must be `value === true`, or the request is `403 Forbidden`. Missing privilege / draft / archived / missing cell / `false` → deny.
|
||||||
|
|
||||||
|
Do not set `is_superadmin` via register/login. Default is `false`; promote via SQL/ops (`UPDATE users SET is_superadmin = true`). The flag is loaded from the database on each JWT validation (not from JWT claims).
|
||||||
|
|
||||||
|
Primary tables still use `primaryEntityColumns(users)` (`status`, audit timestamps, `created_by` / `updated_by`).
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
CREATE TABLE "privilege_details" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"privilege_id" uuid NOT NULL,
|
||||||
|
"privilege_key_id" uuid NOT NULL,
|
||||||
|
"action" text NOT NULL,
|
||||||
|
"value" boolean NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "privilege_keys" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"code" text NOT NULL,
|
||||||
|
"label" text NOT NULL,
|
||||||
|
"sort_order" integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "privileges" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"code" text NOT NULL,
|
||||||
|
"status" text DEFAULT 'draft' NOT NULL,
|
||||||
|
"created_at" bigint NOT NULL,
|
||||||
|
"updated_at" bigint NOT NULL,
|
||||||
|
"created_by" uuid NOT NULL,
|
||||||
|
"updated_by" uuid NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "privilege_id" uuid;--> statement-breakpoint
|
||||||
|
ALTER TABLE "privilege_details" ADD CONSTRAINT "privilege_details_privilege_id_privileges_id_fk" FOREIGN KEY ("privilege_id") REFERENCES "public"."privileges"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "privilege_details" ADD CONSTRAINT "privilege_details_privilege_key_id_privilege_keys_id_fk" FOREIGN KEY ("privilege_key_id") REFERENCES "public"."privilege_keys"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "privileges" ADD CONSTRAINT "privileges_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "privileges" ADD CONSTRAINT "privileges_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "privilege_details_privilege_key_action_unique" ON "privilege_details" USING btree ("privilege_id","privilege_key_id","action");--> statement-breakpoint
|
||||||
|
CREATE INDEX "privilege_details_privilege_id_idx" ON "privilege_details" USING btree ("privilege_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "privilege_keys_code_unique" ON "privilege_keys" USING btree ("code");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "privileges_code_unique" ON "privileges" USING btree ("code");--> statement-breakpoint
|
||||||
|
CREATE INDEX "users_privilege_id_idx" ON "users" USING btree ("privilege_id");--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD CONSTRAINT "users_privilege_id_privileges_id_fk" FOREIGN KEY ("privilege_id") REFERENCES "public"."privileges"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||||
|
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
|
||||||
|
('PRIVILEGES', 'Privileges', 1),
|
||||||
|
('USERS', 'Users', 2);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "is_superadmin" boolean DEFAULT false NOT NULL;
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
{
|
||||||
|
"id": "687eb28d-2560-40f6-8390-06555bd39836",
|
||||||
|
"prevId": "55ce9d09-18dc-4cf8-8fa0-6b96b229820f",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.privilege_details": {
|
||||||
|
"name": "privilege_details",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"privilege_id": {
|
||||||
|
"name": "privilege_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"privilege_key_id": {
|
||||||
|
"name": "privilege_key_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"name": "action",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"privilege_details_privilege_key_action_unique": {
|
||||||
|
"name": "privilege_details_privilege_key_action_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "privilege_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "privilege_key_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "action",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"privilege_details_privilege_id_idx": {
|
||||||
|
"name": "privilege_details_privilege_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "privilege_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"privilege_details_privilege_id_privileges_id_fk": {
|
||||||
|
"name": "privilege_details_privilege_id_privileges_id_fk",
|
||||||
|
"tableFrom": "privilege_details",
|
||||||
|
"tableTo": "privileges",
|
||||||
|
"columnsFrom": [
|
||||||
|
"privilege_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"privilege_details_privilege_key_id_privilege_keys_id_fk": {
|
||||||
|
"name": "privilege_details_privilege_key_id_privilege_keys_id_fk",
|
||||||
|
"tableFrom": "privilege_details",
|
||||||
|
"tableTo": "privilege_keys",
|
||||||
|
"columnsFrom": [
|
||||||
|
"privilege_key_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "restrict",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.privilege_keys": {
|
||||||
|
"name": "privilege_keys",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"code": {
|
||||||
|
"name": "code",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"name": "label",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"sort_order": {
|
||||||
|
"name": "sort_order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"privilege_keys_code_unique": {
|
||||||
|
"name": "privilege_keys_code_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "code",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.privileges": {
|
||||||
|
"name": "privileges",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"code": {
|
||||||
|
"name": "code",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'draft'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"name": "created_by",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_by": {
|
||||||
|
"name": "updated_by",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"privileges_code_unique": {
|
||||||
|
"name": "privileges_code_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "code",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"privileges_created_by_users_id_fk": {
|
||||||
|
"name": "privileges_created_by_users_id_fk",
|
||||||
|
"tableFrom": "privileges",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"created_by"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"privileges_updated_by_users_id_fk": {
|
||||||
|
"name": "privileges_updated_by_users_id_fk",
|
||||||
|
"tableFrom": "privileges",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"updated_by"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.refresh_tokens": {
|
||||||
|
"name": "refresh_tokens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token_hash": {
|
||||||
|
"name": "token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"access_jti": {
|
||||||
|
"name": "access_jti",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"revoked_at": {
|
||||||
|
"name": "revoked_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"replaced_by": {
|
||||||
|
"name": "replaced_by",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"refresh_tokens_token_hash_unique": {
|
||||||
|
"name": "refresh_tokens_token_hash_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "token_hash",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"refresh_tokens_user_id_idx": {
|
||||||
|
"name": "refresh_tokens_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"refresh_tokens_user_id_users_id_fk": {
|
||||||
|
"name": "refresh_tokens_user_id_users_id_fk",
|
||||||
|
"tableFrom": "refresh_tokens",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.revoked_access_tokens": {
|
||||||
|
"name": "revoked_access_tokens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"jti": {
|
||||||
|
"name": "jti",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.users": {
|
||||||
|
"name": "users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"privilege_id": {
|
||||||
|
"name": "privilege_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "username",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"users_privilege_id_idx": {
|
||||||
|
"name": "users_privilege_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "privilege_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
{
|
||||||
|
"id": "892b17dc-a719-40eb-a6e1-ac43663d4f3c",
|
||||||
|
"prevId": "687eb28d-2560-40f6-8390-06555bd39836",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.privilege_details": {
|
||||||
|
"name": "privilege_details",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"privilege_id": {
|
||||||
|
"name": "privilege_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"privilege_key_id": {
|
||||||
|
"name": "privilege_key_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"name": "action",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"privilege_details_privilege_key_action_unique": {
|
||||||
|
"name": "privilege_details_privilege_key_action_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "privilege_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "privilege_key_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "action",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"privilege_details_privilege_id_idx": {
|
||||||
|
"name": "privilege_details_privilege_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "privilege_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"privilege_details_privilege_id_privileges_id_fk": {
|
||||||
|
"name": "privilege_details_privilege_id_privileges_id_fk",
|
||||||
|
"tableFrom": "privilege_details",
|
||||||
|
"tableTo": "privileges",
|
||||||
|
"columnsFrom": [
|
||||||
|
"privilege_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"privilege_details_privilege_key_id_privilege_keys_id_fk": {
|
||||||
|
"name": "privilege_details_privilege_key_id_privilege_keys_id_fk",
|
||||||
|
"tableFrom": "privilege_details",
|
||||||
|
"tableTo": "privilege_keys",
|
||||||
|
"columnsFrom": [
|
||||||
|
"privilege_key_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "restrict",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.privilege_keys": {
|
||||||
|
"name": "privilege_keys",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"code": {
|
||||||
|
"name": "code",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"name": "label",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"sort_order": {
|
||||||
|
"name": "sort_order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"privilege_keys_code_unique": {
|
||||||
|
"name": "privilege_keys_code_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "code",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.privileges": {
|
||||||
|
"name": "privileges",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"code": {
|
||||||
|
"name": "code",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'draft'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"name": "created_by",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_by": {
|
||||||
|
"name": "updated_by",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"privileges_code_unique": {
|
||||||
|
"name": "privileges_code_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "code",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"privileges_created_by_users_id_fk": {
|
||||||
|
"name": "privileges_created_by_users_id_fk",
|
||||||
|
"tableFrom": "privileges",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"created_by"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"privileges_updated_by_users_id_fk": {
|
||||||
|
"name": "privileges_updated_by_users_id_fk",
|
||||||
|
"tableFrom": "privileges",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"updated_by"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.refresh_tokens": {
|
||||||
|
"name": "refresh_tokens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token_hash": {
|
||||||
|
"name": "token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"access_jti": {
|
||||||
|
"name": "access_jti",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"revoked_at": {
|
||||||
|
"name": "revoked_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"replaced_by": {
|
||||||
|
"name": "replaced_by",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"refresh_tokens_token_hash_unique": {
|
||||||
|
"name": "refresh_tokens_token_hash_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "token_hash",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"refresh_tokens_user_id_idx": {
|
||||||
|
"name": "refresh_tokens_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"refresh_tokens_user_id_users_id_fk": {
|
||||||
|
"name": "refresh_tokens_user_id_users_id_fk",
|
||||||
|
"tableFrom": "refresh_tokens",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.revoked_access_tokens": {
|
||||||
|
"name": "revoked_access_tokens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"jti": {
|
||||||
|
"name": "jti",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.users": {
|
||||||
|
"name": "users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"privilege_id": {
|
||||||
|
"name": "privilege_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"is_superadmin": {
|
||||||
|
"name": "is_superadmin",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "username",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"users_privilege_id_idx": {
|
||||||
|
"name": "users_privilege_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "privilege_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,20 @@
|
|||||||
"when": 1787285129382,
|
"when": 1787285129382,
|
||||||
"tag": "0001_amusing_maximus",
|
"tag": "0001_amusing_maximus",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787305078482,
|
||||||
|
"tag": "0002_wooden_king_cobra",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787544143116,
|
||||||
|
"tag": "0003_pretty_darkhawk",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/multer": "^2.2.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
|
|||||||
Generated
+10
@@ -87,6 +87,9 @@ importers:
|
|||||||
'@types/jest':
|
'@types/jest':
|
||||||
specifier: ^30.0.0
|
specifier: ^30.0.0
|
||||||
version: 30.0.0
|
version: 30.0.0
|
||||||
|
'@types/multer':
|
||||||
|
specifier: ^2.2.0
|
||||||
|
version: 2.2.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.0.0
|
specifier: ^24.0.0
|
||||||
version: 24.13.3
|
version: 24.13.3
|
||||||
@@ -1387,6 +1390,9 @@ packages:
|
|||||||
'@types/ms@2.1.0':
|
'@types/ms@2.1.0':
|
||||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||||
|
|
||||||
|
'@types/multer@2.2.0':
|
||||||
|
resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==}
|
||||||
|
|
||||||
'@types/node@24.13.3':
|
'@types/node@24.13.3':
|
||||||
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
||||||
|
|
||||||
@@ -5035,6 +5041,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/ms@2.1.0': {}
|
'@types/ms@2.1.0': {}
|
||||||
|
|
||||||
|
'@types/multer@2.2.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/express': 5.0.6
|
||||||
|
|
||||||
'@types/node@24.13.3':
|
'@types/node@24.13.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
undici-types: 7.18.2
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AppService } from './app.service';
|
|||||||
import loadEnv from './config/env';
|
import loadEnv from './config/env';
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
import { PrivilegesModule } from './modules/privileges/privileges.module';
|
||||||
import { UsersModule } from './modules/users/users.module';
|
import { UsersModule } from './modules/users/users.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -16,6 +17,7 @@ import { UsersModule } from './modules/users/users.module';
|
|||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
PrivilegesModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type AuthUser = {
|
|||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly username: string;
|
readonly username: string;
|
||||||
readonly jti: string;
|
readonly jti: string;
|
||||||
|
readonly isSuperadmin: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type JwtAccessPayload = {
|
export type JwtAccessPayload = {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ describe('CurrentUser decorator', () => {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
jti: 'jti-1',
|
jti: 'jti-1',
|
||||||
|
isSuperadmin: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const createCtx = (u?: AuthUser): ExecutionContext =>
|
const createCtx = (u?: AuthUser): ExecutionContext =>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import type { PrivilegeAction } from '../../modules/privileges/privilege-action';
|
||||||
|
|
||||||
|
export const REQUIRE_PRIVILEGE_KEY = 'requirePrivilege';
|
||||||
|
|
||||||
|
export type RequirePrivilegeMeta = {
|
||||||
|
readonly key: string;
|
||||||
|
readonly action: PrivilegeAction;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Marks a handler as requiring a privilege matrix cell to be true. */
|
||||||
|
export const RequirePrivilege = (key: string, action: PrivilegeAction) =>
|
||||||
|
SetMetadata(REQUIRE_PRIVILEGE_KEY, {
|
||||||
|
key,
|
||||||
|
action,
|
||||||
|
} satisfies RequirePrivilegeMeta);
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import {
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import type { AuthUser } from '../auth/auth-user';
|
||||||
|
import {
|
||||||
|
REQUIRE_PRIVILEGE_KEY,
|
||||||
|
type RequirePrivilegeMeta,
|
||||||
|
} from '../decorators/require-privilege.decorator';
|
||||||
|
import { PrivilegesGuard } from './privileges.guard';
|
||||||
|
|
||||||
|
describe('PrivilegesGuard', () => {
|
||||||
|
const checkPermission = jest.fn();
|
||||||
|
const getAllAndOverride = jest.fn();
|
||||||
|
const reflector = {
|
||||||
|
getAllAndOverride,
|
||||||
|
} as unknown as Reflector;
|
||||||
|
|
||||||
|
const guard = new PrivilegesGuard(reflector, {
|
||||||
|
checkPermission,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const user: AuthUser = {
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
jti: 'jti-1',
|
||||||
|
isSuperadmin: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createContext(currentUser?: AuthUser): ExecutionContext {
|
||||||
|
return {
|
||||||
|
getHandler: () => jest.fn(),
|
||||||
|
getClass: () => jest.fn(),
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({ user: currentUser }),
|
||||||
|
}),
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows when no RequirePrivilege metadata', async () => {
|
||||||
|
getAllAndOverride.mockReturnValue(undefined);
|
||||||
|
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
|
||||||
|
expect(checkPermission).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows when permission value is true', async () => {
|
||||||
|
const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'view' };
|
||||||
|
getAllAndOverride.mockReturnValue(meta);
|
||||||
|
checkPermission.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
|
||||||
|
expect(checkPermission).toHaveBeenCalledWith(
|
||||||
|
'user-1',
|
||||||
|
'PRIVILEGES',
|
||||||
|
'view',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids when permission is false or missing', async () => {
|
||||||
|
const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'delete' };
|
||||||
|
getAllAndOverride.mockReturnValue(meta);
|
||||||
|
checkPermission.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(createContext(user))).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips privilege lookup when user is superadmin', async () => {
|
||||||
|
const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'delete' };
|
||||||
|
getAllAndOverride.mockReturnValue(meta);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
guard.canActivate(createContext({ ...user, isSuperadmin: true })),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
expect(checkPermission).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unauthorized when metadata present but no user', async () => {
|
||||||
|
const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'view' };
|
||||||
|
getAllAndOverride.mockReturnValue(meta);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(createContext())).rejects.toBeInstanceOf(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads metadata from handler and class', async () => {
|
||||||
|
getAllAndOverride.mockReturnValue(undefined);
|
||||||
|
await guard.canActivate(createContext(user));
|
||||||
|
expect(getAllAndOverride).toHaveBeenCalledWith(
|
||||||
|
REQUIRE_PRIVILEGE_KEY,
|
||||||
|
expect.any(Array),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import type { AuthUser } from '../auth/auth-user';
|
||||||
|
import {
|
||||||
|
REQUIRE_PRIVILEGE_KEY,
|
||||||
|
type RequirePrivilegeMeta,
|
||||||
|
} from '../decorators/require-privilege.decorator';
|
||||||
|
import { PrivilegesService } from '../../modules/privileges/privileges.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrivilegesGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly privilegesService: PrivilegesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const required = this.reflector.getAllAndOverride<
|
||||||
|
RequirePrivilegeMeta | undefined
|
||||||
|
>(REQUIRE_PRIVILEGE_KEY, [context.getHandler(), context.getClass()]);
|
||||||
|
|
||||||
|
if (!required) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<{ user?: AuthUser }>();
|
||||||
|
const user = request.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.isSuperadmin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = await this.privilegesService.checkPermission(
|
||||||
|
user.id,
|
||||||
|
required.key,
|
||||||
|
required.action,
|
||||||
|
);
|
||||||
|
if (!allowed) {
|
||||||
|
throw new ForbiddenException('Insufficient privilege');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { AppController } from '../../app.controller';
|
|||||||
import { AppService } from '../../app.service';
|
import { AppService } from '../../app.service';
|
||||||
import { AuthController } from '../../modules/auth/auth.controller';
|
import { AuthController } from '../../modules/auth/auth.controller';
|
||||||
import { AuthService } from '../../modules/auth/auth.service';
|
import { AuthService } from '../../modules/auth/auth.service';
|
||||||
|
import { PrivilegesService } from '../../modules/privileges/privileges.service';
|
||||||
|
import { UsersService } from '../../modules/users/users.service';
|
||||||
import {
|
import {
|
||||||
createOpenApiDocument,
|
createOpenApiDocument,
|
||||||
isSwaggerEnabled,
|
isSwaggerEnabled,
|
||||||
@@ -56,6 +58,17 @@ describe('createOpenApiDocument', () => {
|
|||||||
revoke: jest.fn(),
|
revoke: jest.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: UsersService,
|
||||||
|
useValue: { findById: jest.fn() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PrivilegesService,
|
||||||
|
useValue: {
|
||||||
|
findPrivilegeSummary: jest.fn(),
|
||||||
|
getPermissionsMap: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@@ -52,14 +52,14 @@ describe('primaryEntityColumns', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function getConfig(column: { config: Record<string, unknown> }): {
|
function getConfig(column: unknown): {
|
||||||
name: string;
|
name: string;
|
||||||
dataType: string;
|
dataType: string;
|
||||||
notNull: boolean;
|
notNull: boolean;
|
||||||
hasDefault?: boolean;
|
hasDefault?: boolean;
|
||||||
default?: unknown;
|
default?: unknown;
|
||||||
} {
|
} {
|
||||||
return column.config as {
|
return (column as { config: Record<string, unknown> }).config as {
|
||||||
name: string;
|
name: string;
|
||||||
dataType: string;
|
dataType: string;
|
||||||
notNull: boolean;
|
notNull: boolean;
|
||||||
|
|||||||
+68
-1
@@ -1,14 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
bigint,
|
bigint,
|
||||||
|
boolean,
|
||||||
index,
|
index,
|
||||||
|
integer,
|
||||||
pgTable,
|
pgTable,
|
||||||
text,
|
text,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
uuid,
|
uuid,
|
||||||
} from 'drizzle-orm/pg-core';
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { primaryEntityColumns } from './primary-entity-columns';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application users. Timestamps are UTC unix milliseconds.
|
* Application users. Timestamps are UTC unix milliseconds.
|
||||||
|
* privilege_id is nullable until a role is assigned (deny-by-default).
|
||||||
|
* FK to privileges.id is enforced in the migration (circular table dependency).
|
||||||
*/
|
*/
|
||||||
export const users = pgTable(
|
export const users = pgTable(
|
||||||
'users',
|
'users',
|
||||||
@@ -16,10 +21,15 @@ export const users = pgTable(
|
|||||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
username: text('username').notNull(),
|
username: text('username').notNull(),
|
||||||
passwordHash: text('password_hash').notNull(),
|
passwordHash: text('password_hash').notNull(),
|
||||||
|
privilegeId: uuid('privilege_id'),
|
||||||
|
isSuperadmin: boolean('is_superadmin').notNull().default(false),
|
||||||
createdAt: bigint('created_at', { mode: 'number' }).notNull(),
|
createdAt: bigint('created_at', { mode: 'number' }).notNull(),
|
||||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
|
updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
|
||||||
},
|
},
|
||||||
(t) => [uniqueIndex('users_username_unique').on(t.username)],
|
(t) => [
|
||||||
|
uniqueIndex('users_username_unique').on(t.username),
|
||||||
|
index('users_privilege_id_idx').on(t.privilegeId),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,7 +63,64 @@ export const revokedAccessTokens = pgTable('revoked_access_tokens', {
|
|||||||
expiresAt: bigint('expires_at', { mode: 'number' }).notNull(),
|
expiresAt: bigint('expires_at', { mode: 'number' }).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalog of privilege keys (MODULE / MODULE.RESOURCE). Seeded via migration.
|
||||||
|
*/
|
||||||
|
export const privilegeKeys = pgTable(
|
||||||
|
'privilege_keys',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
code: text('code').notNull(),
|
||||||
|
label: text('label').notNull(),
|
||||||
|
sortOrder: integer('sort_order').notNull(),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex('privilege_keys_code_unique').on(t.code)],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role templates (primary aggregate).
|
||||||
|
*/
|
||||||
|
export const privileges = pgTable(
|
||||||
|
'privileges',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
code: text('code').notNull(),
|
||||||
|
...primaryEntityColumns(users),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex('privileges_code_unique').on(t.code)],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boolean permission cells for a privilege × key × action.
|
||||||
|
*/
|
||||||
|
export const privilegeDetails = pgTable(
|
||||||
|
'privilege_details',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
privilegeId: uuid('privilege_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => privileges.id, { onDelete: 'cascade' }),
|
||||||
|
privilegeKeyId: uuid('privilege_key_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => privilegeKeys.id, { onDelete: 'restrict' }),
|
||||||
|
action: text('action').notNull(),
|
||||||
|
value: boolean('value').notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('privilege_details_privilege_key_action_unique').on(
|
||||||
|
t.privilegeId,
|
||||||
|
t.privilegeKeyId,
|
||||||
|
t.action,
|
||||||
|
),
|
||||||
|
index('privilege_details_privilege_id_idx').on(t.privilegeId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export type UserRow = typeof users.$inferSelect;
|
export type UserRow = typeof users.$inferSelect;
|
||||||
export type NewUserRow = typeof users.$inferInsert;
|
export type NewUserRow = typeof users.$inferInsert;
|
||||||
export type RefreshTokenRow = typeof refreshTokens.$inferSelect;
|
export type RefreshTokenRow = typeof refreshTokens.$inferSelect;
|
||||||
export type NewRefreshTokenRow = typeof refreshTokens.$inferInsert;
|
export type NewRefreshTokenRow = typeof refreshTokens.$inferInsert;
|
||||||
|
export type PrivilegeKeyRow = typeof privilegeKeys.$inferSelect;
|
||||||
|
export type PrivilegeRow = typeof privileges.$inferSelect;
|
||||||
|
export type PrivilegeDetailRow = typeof privilegeDetails.$inferSelect;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { PrivilegesService } from '../privileges/privileges.service';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
@@ -7,6 +9,10 @@ describe('AuthController', () => {
|
|||||||
let authService: jest.Mocked<
|
let authService: jest.Mocked<
|
||||||
Pick<AuthService, 'register' | 'login' | 'refresh' | 'revoke'>
|
Pick<AuthService, 'register' | 'login' | 'refresh' | 'revoke'>
|
||||||
>;
|
>;
|
||||||
|
let usersService: jest.Mocked<Pick<UsersService, 'findById'>>;
|
||||||
|
let privilegesService: jest.Mocked<
|
||||||
|
Pick<PrivilegesService, 'findPrivilegeSummary' | 'getPermissionsMap'>
|
||||||
|
>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
authService = {
|
authService = {
|
||||||
@@ -24,10 +30,26 @@ describe('AuthController', () => {
|
|||||||
}),
|
}),
|
||||||
revoke: jest.fn().mockResolvedValue(undefined),
|
revoke: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
usersService = {
|
||||||
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
privilegeId: null,
|
||||||
|
isSuperadmin: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
privilegesService = {
|
||||||
|
findPrivilegeSummary: jest.fn(),
|
||||||
|
getPermissionsMap: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [{ provide: AuthService, useValue: authService }],
|
providers: [
|
||||||
|
{ provide: AuthService, useValue: authService },
|
||||||
|
{ provide: UsersService, useValue: usersService },
|
||||||
|
{ provide: PrivilegesService, useValue: privilegesService },
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = moduleRef.get(AuthController);
|
controller = moduleRef.get(AuthController);
|
||||||
@@ -55,9 +77,81 @@ describe('AuthController', () => {
|
|||||||
expect(authService.revoke).toHaveBeenCalledWith(token);
|
expect(authService.revoke).toHaveBeenCalledWith(token);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('me returns id and username', () => {
|
it('me returns privilege null when unassigned', async () => {
|
||||||
expect(
|
await expect(
|
||||||
controller.me({ id: 'user-1', username: 'alice', jti: 'jti-1' }),
|
controller.me({
|
||||||
).toEqual({ id: 'user-1', username: 'alice' });
|
id: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
jti: 'jti-1',
|
||||||
|
isSuperadmin: false,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
isSuperadmin: false,
|
||||||
|
privilege: null,
|
||||||
|
permissions: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('me returns privilege and permissions when assigned', async () => {
|
||||||
|
usersService.findById.mockResolvedValue({
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
privilegeId: 'priv-1',
|
||||||
|
isSuperadmin: false,
|
||||||
|
} as never);
|
||||||
|
privilegesService.findPrivilegeSummary.mockResolvedValue({
|
||||||
|
id: 'priv-1',
|
||||||
|
name: 'Admin',
|
||||||
|
code: 'ADMIN',
|
||||||
|
});
|
||||||
|
privilegesService.getPermissionsMap.mockResolvedValue({
|
||||||
|
PRIVILEGES: {
|
||||||
|
view: true,
|
||||||
|
create: true,
|
||||||
|
update: true,
|
||||||
|
delete: true,
|
||||||
|
import: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.me({
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
jti: 'jti-1',
|
||||||
|
isSuperadmin: false,
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
privilege: { id: 'priv-1', code: 'ADMIN' },
|
||||||
|
permissions: {
|
||||||
|
PRIVILEGES: expect.objectContaining({ view: true }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('me returns isSuperadmin when the user is promoted', async () => {
|
||||||
|
usersService.findById.mockResolvedValue({
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
privilegeId: null,
|
||||||
|
isSuperadmin: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.me({
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
jti: 'jti-1',
|
||||||
|
isSuperadmin: true,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
isSuperadmin: true,
|
||||||
|
privilege: null,
|
||||||
|
permissions: {},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import type { AuthUser } from '../../common/auth/auth-user';
|
|||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { Public } from '../../common/decorators/public.decorator';
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
|
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
|
||||||
|
import { PrivilegesService } from '../privileges/privileges.service';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import {
|
import {
|
||||||
LoginDto,
|
LoginDto,
|
||||||
@@ -28,7 +30,11 @@ import {
|
|||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
private readonly usersService: UsersService,
|
||||||
|
private readonly privilegesService: PrivilegesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||||
@@ -87,7 +93,32 @@ export class AuthController {
|
|||||||
@ApiOperation({ summary: 'Get the current authenticated user' })
|
@ApiOperation({ summary: 'Get the current authenticated user' })
|
||||||
@ApiOkResponse({ type: MeResponseDto })
|
@ApiOkResponse({ type: MeResponseDto })
|
||||||
@ApiUnauthorizedResponse({ description: 'Missing or invalid access token' })
|
@ApiUnauthorizedResponse({ description: 'Missing or invalid access token' })
|
||||||
me(@CurrentUser() user: AuthUser): MeResponseDto {
|
async me(@CurrentUser() user: AuthUser): Promise<MeResponseDto> {
|
||||||
return { id: user.id, username: user.username };
|
const full = await this.usersService.findById(user.id);
|
||||||
|
const isSuperadmin = full?.isSuperadmin ?? user.isSuperadmin;
|
||||||
|
if (!full?.privilegeId) {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
isSuperadmin,
|
||||||
|
privilege: null,
|
||||||
|
permissions: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const privilege = await this.privilegesService.findPrivilegeSummary(
|
||||||
|
full.privilegeId,
|
||||||
|
);
|
||||||
|
const permissions = privilege
|
||||||
|
? await this.privilegesService.getPermissionsMap(privilege.id)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
isSuperadmin,
|
||||||
|
privilege,
|
||||||
|
permissions,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { JwtModule } from '@nestjs/jwt';
|
|||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { PrivilegesGuard } from '../../common/guards/privileges.guard';
|
||||||
|
import { PrivilegesModule } from '../privileges/privileges.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
@@ -15,6 +17,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
UsersModule,
|
UsersModule,
|
||||||
|
PrivilegesModule,
|
||||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
@@ -36,7 +39,9 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
|||||||
RefreshTokensRepository,
|
RefreshTokensRepository,
|
||||||
RevokedAccessTokensRepository,
|
RevokedAccessTokensRepository,
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
|
// Registration order = execution order: JWT before privileges.
|
||||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
|
{ provide: APP_GUARD, useClass: PrivilegesGuard },
|
||||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||||
],
|
],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ describe('AuthService', () => {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
passwordHash: await bcrypt.hash('password123', 4),
|
passwordHash: await bcrypt.hash('password123', 4),
|
||||||
|
privilegeId: null,
|
||||||
|
isSuperadmin: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,10 +68,50 @@ export class TokenPairDto implements TokenPair {
|
|||||||
refreshToken!: string;
|
refreshToken!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MePrivilegeDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class MeResponseDto {
|
export class MeResponseDto {
|
||||||
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'alice' })
|
@ApiProperty({ example: 'alice' })
|
||||||
username!: string;
|
username!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: false })
|
||||||
|
isSuperadmin!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ type: MePrivilegeDto, nullable: true })
|
||||||
|
privilege!: MePrivilegeDto | null;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Permission matrix keyed by privilege key code',
|
||||||
|
example: {
|
||||||
|
PRIVILEGES: {
|
||||||
|
view: true,
|
||||||
|
create: false,
|
||||||
|
update: false,
|
||||||
|
delete: false,
|
||||||
|
import: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
permissions!: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
view: boolean;
|
||||||
|
create: boolean;
|
||||||
|
update: boolean;
|
||||||
|
delete: boolean;
|
||||||
|
import: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ describe('JwtStrategy', () => {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
passwordHash: 'hash',
|
passwordHash: 'hash',
|
||||||
|
privilegeId: null,
|
||||||
|
isSuperadmin: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -53,7 +55,31 @@ describe('JwtStrategy', () => {
|
|||||||
jti: 'jti-1',
|
jti: 'jti-1',
|
||||||
typ: 'access',
|
typ: 'access',
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual({ id: 'user-1', username: 'alice', jti: 'jti-1' });
|
).resolves.toEqual({
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
jti: 'jti-1',
|
||||||
|
isSuperadmin: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps isSuperadmin from the persisted user', async () => {
|
||||||
|
revoked.exists.mockResolvedValue(false);
|
||||||
|
usersService.findById.mockResolvedValue({ ...user, isSuperadmin: true });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
strategy.validate({
|
||||||
|
sub: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
jti: 'jti-1',
|
||||||
|
typ: 'access',
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
jti: 'jti-1',
|
||||||
|
isSuperadmin: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects revoked access tokens', async () => {
|
it('rejects revoked access tokens', async () => {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
jti: payload.jti,
|
jti: payload.jti,
|
||||||
|
isSuperadmin: user.isSuperadmin,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayNotEmpty,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { PaginationQueryDto } from '../../../common/http/response';
|
||||||
|
import { CORE_STATUSES } from '../../../common/value-objects/status/status';
|
||||||
|
import { PRIVILEGE_ACTIONS } from '../privilege-action';
|
||||||
|
|
||||||
|
export class PrivilegeDetailInputDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
@IsUUID()
|
||||||
|
privilegeKeyId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: PRIVILEGE_ACTIONS })
|
||||||
|
@IsIn([...PRIVILEGE_ACTIONS])
|
||||||
|
action!: (typeof PRIVILEGE_ACTIONS)[number];
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsBoolean()
|
||||||
|
value!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreatePrivilegeDto {
|
||||||
|
@ApiProperty({ example: 'Sales Staff' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(120)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'SALES_STAFF' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(64)
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...CORE_STATUSES])
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [PrivilegeDetailInputDto] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => PrivilegeDetailInputDto)
|
||||||
|
details?: PrivilegeDetailInputDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdatePrivilegeDto {
|
||||||
|
@ApiPropertyOptional({ example: 'Sales Staff' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(120)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'SALES_STAFF' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(64)
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [PrivilegeDetailInputDto] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => PrivilegeDetailInputDto)
|
||||||
|
details?: PrivilegeDetailInputDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdatePrivilegeStatusDto {
|
||||||
|
@ApiProperty({ enum: CORE_STATUSES })
|
||||||
|
@IsIn([...CORE_STATUSES])
|
||||||
|
status!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BulkIdsDto {
|
||||||
|
@ApiProperty({ type: [String], format: 'uuid' })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
ids!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BulkStatusDto {
|
||||||
|
@ApiProperty({ type: [String], format: 'uuid' })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
ids!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ enum: CORE_STATUSES })
|
||||||
|
@IsIn([...CORE_STATUSES])
|
||||||
|
status!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListPrivilegesQueryDto extends PaginationQueryDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...CORE_STATUSES])
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Case-insensitive match on name or code',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListPrivilegeKeysQueryDto extends PaginationQueryDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PrivilegeDetailDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
privilegeKeyId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'SALES.INVOICE' })
|
||||||
|
keyCode!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Sales Invoice' })
|
||||||
|
keyLabel!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 1 })
|
||||||
|
sortOrder!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: PRIVILEGE_ACTIONS })
|
||||||
|
action!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
value!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PrivilegeDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: CORE_STATUSES })
|
||||||
|
status!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Unix ms' })
|
||||||
|
createdAt!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Unix ms' })
|
||||||
|
updatedAt!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
createdBy!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
updatedBy!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PrivilegeDetailResponseDto extends PrivilegeDto {
|
||||||
|
@ApiProperty({ type: [PrivilegeDetailDto] })
|
||||||
|
details!: PrivilegeDetailDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PrivilegeKeyDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'PRIVILEGES' })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Privileges' })
|
||||||
|
label!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 1 })
|
||||||
|
sortOrder!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { assertPrivilegeAction, isPrivilegeAction } from './privilege-action';
|
||||||
|
import {
|
||||||
|
assertPrivilegeKeyCode,
|
||||||
|
isValidPrivilegeKeyCode,
|
||||||
|
} from './privilege-key-code';
|
||||||
|
|
||||||
|
describe('privilege-action', () => {
|
||||||
|
it('accepts known actions', () => {
|
||||||
|
expect(assertPrivilegeAction('view')).toBe('view');
|
||||||
|
expect(isPrivilegeAction('import')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown actions', () => {
|
||||||
|
expect(() => assertPrivilegeAction('execute')).toThrow(TypeError);
|
||||||
|
expect(isPrivilegeAction('execute')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('privilege-key-code', () => {
|
||||||
|
it('accepts dotted uppercase module levels', () => {
|
||||||
|
expect(isValidPrivilegeKeyCode('PRIVILEGES')).toBe(true);
|
||||||
|
expect(isValidPrivilegeKeyCode('SALES.INVOICE')).toBe(true);
|
||||||
|
expect(isValidPrivilegeKeyCode('SALES.INVOICE.LINE')).toBe(true);
|
||||||
|
expect(assertPrivilegeKeyCode('USERS')).toBe('USERS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid codes', () => {
|
||||||
|
expect(isValidPrivilegeKeyCode('sales.invoice')).toBe(false);
|
||||||
|
expect(isValidPrivilegeKeyCode('SALES.')).toBe(false);
|
||||||
|
expect(isValidPrivilegeKeyCode('.SALES')).toBe(false);
|
||||||
|
expect(isValidPrivilegeKeyCode('')).toBe(false);
|
||||||
|
expect(() => assertPrivilegeKeyCode('bad')).toThrow(TypeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export const PRIVILEGE_ACTIONS = [
|
||||||
|
'view',
|
||||||
|
'create',
|
||||||
|
'update',
|
||||||
|
'delete',
|
||||||
|
'import',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PrivilegeAction = (typeof PRIVILEGE_ACTIONS)[number];
|
||||||
|
|
||||||
|
export function assertPrivilegeAction(raw: string): PrivilegeAction {
|
||||||
|
if (
|
||||||
|
typeof raw !== 'string' ||
|
||||||
|
!(PRIVILEGE_ACTIONS as readonly string[]).includes(raw)
|
||||||
|
) {
|
||||||
|
throw new TypeError('Invalid privilege action');
|
||||||
|
}
|
||||||
|
return raw as PrivilegeAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPrivilegeAction(raw: string): raw is PrivilegeAction {
|
||||||
|
return (PRIVILEGE_ACTIONS as readonly string[]).includes(raw);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/** Dotted uppercase module levels: MODULE / MODULE.RESOURCE / MODULE.RESOURCE.SUB */
|
||||||
|
export const PRIVILEGE_KEY_CODE_PATTERN =
|
||||||
|
/^[A-Z][A-Z0-9_]*(\.[A-Z][A-Z0-9_]*)*$/;
|
||||||
|
|
||||||
|
export function isValidPrivilegeKeyCode(code: string): boolean {
|
||||||
|
return typeof code === 'string' && PRIVILEGE_KEY_CODE_PATTERN.test(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertPrivilegeKeyCode(code: string): string {
|
||||||
|
if (!isValidPrivilegeKeyCode(code)) {
|
||||||
|
throw new TypeError('Invalid privilege key code');
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||||
|
import { Status } from '../../common/value-objects/status/status';
|
||||||
|
import type { PrivilegeAction } from './privilege-action';
|
||||||
|
|
||||||
|
export type PrivilegeDetail = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly privilegeKeyId: string;
|
||||||
|
readonly keyCode: string;
|
||||||
|
readonly keyLabel: string;
|
||||||
|
readonly sortOrder: number;
|
||||||
|
readonly action: PrivilegeAction;
|
||||||
|
readonly value: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Privilege = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly code: string;
|
||||||
|
readonly status: Status;
|
||||||
|
readonly createdAt: DateTime;
|
||||||
|
readonly updatedAt: DateTime;
|
||||||
|
readonly createdBy: string;
|
||||||
|
readonly updatedBy: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PrivilegeWithDetails = Privilege & {
|
||||||
|
readonly details: readonly PrivilegeDetail[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PrivilegeDetailInput = {
|
||||||
|
readonly privilegeKeyId: string;
|
||||||
|
readonly action: PrivilegeAction;
|
||||||
|
readonly value: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreatePrivilegeInput = {
|
||||||
|
readonly name: string;
|
||||||
|
readonly code: string;
|
||||||
|
readonly status?: Status;
|
||||||
|
readonly details?: readonly PrivilegeDetailInput[];
|
||||||
|
readonly userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdatePrivilegeInput = {
|
||||||
|
readonly name?: string;
|
||||||
|
readonly code?: string;
|
||||||
|
readonly details?: readonly PrivilegeDetailInput[];
|
||||||
|
readonly userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PrivilegeKey = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly code: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly sortOrder: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListPrivilegesFilters = {
|
||||||
|
readonly name?: string;
|
||||||
|
readonly code?: string;
|
||||||
|
readonly status?: string;
|
||||||
|
readonly search?: string;
|
||||||
|
readonly limit: number;
|
||||||
|
readonly offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListPrivilegeKeysFilters = {
|
||||||
|
readonly search?: string;
|
||||||
|
readonly limit: number;
|
||||||
|
readonly offset: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiForbiddenResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { RequirePrivilege } from '../../common/decorators/require-privilege.decorator';
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
type PaginationResponse,
|
||||||
|
PaginationMetaDto,
|
||||||
|
} from '../../common/http/response';
|
||||||
|
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
|
||||||
|
import {
|
||||||
|
ListPrivilegeKeysQueryDto,
|
||||||
|
PrivilegeKeyDto,
|
||||||
|
} from './dto/privilege.dto';
|
||||||
|
import { PrivilegesService } from './privileges.service';
|
||||||
|
|
||||||
|
@ApiTags('privilege-keys')
|
||||||
|
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||||
|
@Controller('privilege-keys')
|
||||||
|
export class PrivilegeKeysController {
|
||||||
|
constructor(private readonly privilegesService: PrivilegesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Pagination()
|
||||||
|
@RequirePrivilege('PRIVILEGES', 'view')
|
||||||
|
@ApiOperation({ summary: 'List privilege keys catalog' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
schema: {
|
||||||
|
properties: {
|
||||||
|
data: {
|
||||||
|
type: 'array',
|
||||||
|
items: { $ref: '#/components/schemas/PrivilegeKeyDto' },
|
||||||
|
},
|
||||||
|
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
list(
|
||||||
|
@Query() query: ListPrivilegeKeysQueryDto,
|
||||||
|
): Promise<PaginationResponse<PrivilegeKeyDto>> {
|
||||||
|
return this.privilegesService.listKeys(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PaginationMetaDto;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiForbiddenResponse,
|
||||||
|
ApiNotFoundResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { RequirePrivilege } from '../../common/decorators/require-privilege.decorator';
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
type PaginationResponse,
|
||||||
|
PaginationMetaDto,
|
||||||
|
} from '../../common/http/response';
|
||||||
|
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
|
||||||
|
import {
|
||||||
|
ListPrivilegesQueryDto,
|
||||||
|
PrivilegeDetailResponseDto,
|
||||||
|
PrivilegeDto,
|
||||||
|
} from './dto/privilege.dto';
|
||||||
|
import { PrivilegesService } from './privileges.service';
|
||||||
|
|
||||||
|
@ApiTags('privileges')
|
||||||
|
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||||
|
@Controller('privileges')
|
||||||
|
export class PrivilegesReadController {
|
||||||
|
constructor(private readonly privilegesService: PrivilegesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Pagination()
|
||||||
|
@RequirePrivilege('PRIVILEGES', 'view')
|
||||||
|
@ApiOperation({ summary: 'List privileges' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
schema: {
|
||||||
|
properties: {
|
||||||
|
data: {
|
||||||
|
type: 'array',
|
||||||
|
items: { $ref: '#/components/schemas/PrivilegeDto' },
|
||||||
|
},
|
||||||
|
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
list(
|
||||||
|
@Query() query: ListPrivilegesQueryDto,
|
||||||
|
): Promise<PaginationResponse<PrivilegeDto>> {
|
||||||
|
return this.privilegesService.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePrivilege('PRIVILEGES', 'view')
|
||||||
|
@ApiOperation({ summary: 'Get privilege detail with matrix' })
|
||||||
|
@ApiOkResponse({ type: PrivilegeDetailResponseDto })
|
||||||
|
@ApiNotFoundResponse()
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
findOne(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
): Promise<PrivilegeDetailResponseDto> {
|
||||||
|
return this.privilegesService.findById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep PaginationMetaDto referenced for OpenAPI plugin consumers.
|
||||||
|
void PaginationMetaDto;
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiCreatedResponse,
|
||||||
|
ApiForbiddenResponse,
|
||||||
|
ApiNoContentResponse,
|
||||||
|
ApiNotFoundResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { RequirePrivilege } from '../../common/decorators/require-privilege.decorator';
|
||||||
|
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
|
||||||
|
import {
|
||||||
|
BulkIdsDto,
|
||||||
|
BulkStatusDto,
|
||||||
|
CreatePrivilegeDto,
|
||||||
|
PrivilegeDetailResponseDto,
|
||||||
|
PrivilegeDto,
|
||||||
|
UpdatePrivilegeDto,
|
||||||
|
UpdatePrivilegeStatusDto,
|
||||||
|
} from './dto/privilege.dto';
|
||||||
|
import { PrivilegesService } from './privileges.service';
|
||||||
|
|
||||||
|
@ApiTags('privileges')
|
||||||
|
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||||
|
@Controller('privileges')
|
||||||
|
export class PrivilegesWriteController {
|
||||||
|
constructor(private readonly privilegesService: PrivilegesService) {}
|
||||||
|
|
||||||
|
@Post('import')
|
||||||
|
@RequirePrivilege('PRIVILEGES', 'import')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
limits: { fileSize: 1_048_576 },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
if (
|
||||||
|
!file.mimetype.includes('csv') &&
|
||||||
|
!file.originalname.toLowerCase().endsWith('.csv')
|
||||||
|
) {
|
||||||
|
cb(new BadRequestException('Only CSV files are allowed'), false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file: { type: 'string', format: 'binary' },
|
||||||
|
},
|
||||||
|
required: ['file'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiOperation({ summary: 'Import privileges from CSV' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
schema: {
|
||||||
|
properties: { imported: { type: 'number' } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
importCsv(
|
||||||
|
@UploadedFile() file: { buffer?: Buffer } | undefined,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<{ imported: number }> {
|
||||||
|
const csv = file?.buffer?.toString('utf8') ?? '';
|
||||||
|
return this.privilegesService.importCsv(csv, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bulk-delete')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePrivilege('PRIVILEGES', 'delete')
|
||||||
|
@ApiOperation({ summary: 'Bulk delete privileges' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
schema: { properties: { deleted: { type: 'number' } } },
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
|
||||||
|
return this.privilegesService.bulkDelete(dto.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bulk-status')
|
||||||
|
@HttpCode(200)
|
||||||
|
@RequirePrivilege('PRIVILEGES', 'update')
|
||||||
|
@ApiOperation({ summary: 'Bulk update privilege status' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
schema: { properties: { updated: { type: 'number' } } },
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
bulkStatus(
|
||||||
|
@Body() dto: BulkStatusDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<{ updated: number }> {
|
||||||
|
return this.privilegesService.bulkUpdateStatus(dto.ids, dto.status, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePrivilege('PRIVILEGES', 'create')
|
||||||
|
@ApiOperation({ summary: 'Create privilege' })
|
||||||
|
@ApiCreatedResponse({ type: PrivilegeDetailResponseDto })
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
create(
|
||||||
|
@Body() dto: CreatePrivilegeDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<PrivilegeDetailResponseDto> {
|
||||||
|
return this.privilegesService.create({
|
||||||
|
name: dto.name,
|
||||||
|
code: dto.code,
|
||||||
|
status: dto.status,
|
||||||
|
details: dto.details,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/status')
|
||||||
|
@RequirePrivilege('PRIVILEGES', 'update')
|
||||||
|
@ApiOperation({ summary: 'Update privilege status' })
|
||||||
|
@ApiOkResponse({ type: PrivilegeDto })
|
||||||
|
@ApiNotFoundResponse()
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
updateStatus(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdatePrivilegeStatusDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<PrivilegeDto> {
|
||||||
|
return this.privilegesService.updateStatus(id, dto.status, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePrivilege('PRIVILEGES', 'update')
|
||||||
|
@ApiOperation({ summary: 'Update privilege (not status)' })
|
||||||
|
@ApiOkResponse({ type: PrivilegeDetailResponseDto })
|
||||||
|
@ApiNotFoundResponse()
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdatePrivilegeDto,
|
||||||
|
@CurrentUser('id') userId: string,
|
||||||
|
): Promise<PrivilegeDetailResponseDto> {
|
||||||
|
return this.privilegesService.update(id, {
|
||||||
|
name: dto.name,
|
||||||
|
code: dto.code,
|
||||||
|
details: dto.details,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
@RequirePrivilege('PRIVILEGES', 'delete')
|
||||||
|
@ApiOperation({ summary: 'Delete privilege' })
|
||||||
|
@ApiNoContentResponse()
|
||||||
|
@ApiNotFoundResponse()
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
async delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||||
|
await this.privilegesService.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrivilegeKeysController } from './privileges-keys.controller';
|
||||||
|
import { PrivilegesReadController } from './privileges-read.controller';
|
||||||
|
import { PrivilegesWriteController } from './privileges-write.controller';
|
||||||
|
import { PrivilegesRepository } from './privileges.repository';
|
||||||
|
import { PrivilegesService } from './privileges.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [
|
||||||
|
PrivilegesReadController,
|
||||||
|
PrivilegesWriteController,
|
||||||
|
PrivilegeKeysController,
|
||||||
|
],
|
||||||
|
providers: [PrivilegesRepository, PrivilegesService],
|
||||||
|
exports: [PrivilegesService],
|
||||||
|
})
|
||||||
|
export class PrivilegesModule {}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||||
|
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||||
|
import { Status } from '../../common/value-objects/status/status';
|
||||||
|
import { DRIZZLE, type DrizzleDB } from '../../database/database.module';
|
||||||
|
import {
|
||||||
|
privilegeDetails,
|
||||||
|
privilegeKeys,
|
||||||
|
privileges,
|
||||||
|
users,
|
||||||
|
type PrivilegeKeyRow,
|
||||||
|
type PrivilegeRow,
|
||||||
|
} from '../../database/schema';
|
||||||
|
import type { PrivilegeAction } from './privilege-action';
|
||||||
|
import { assertPrivilegeAction } from './privilege-action';
|
||||||
|
import type {
|
||||||
|
CreatePrivilegeInput,
|
||||||
|
ListPrivilegeKeysFilters,
|
||||||
|
ListPrivilegesFilters,
|
||||||
|
Privilege,
|
||||||
|
PrivilegeDetail,
|
||||||
|
PrivilegeDetailInput,
|
||||||
|
PrivilegeKey,
|
||||||
|
PrivilegeWithDetails,
|
||||||
|
UpdatePrivilegeInput,
|
||||||
|
} from './privilege';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrivilegesRepository {
|
||||||
|
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
filters: ListPrivilegesFilters,
|
||||||
|
): Promise<{ data: Privilege[]; total: number }> {
|
||||||
|
const where = this.buildListWhere(filters);
|
||||||
|
const [totalRow] = await this.db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(privileges)
|
||||||
|
.where(where);
|
||||||
|
|
||||||
|
let qb = this.db.select().from(privileges).$dynamic();
|
||||||
|
qb = this.extendListQuery(qb, filters);
|
||||||
|
const rows = await qb
|
||||||
|
.where(where)
|
||||||
|
.orderBy(asc(privileges.code))
|
||||||
|
.limit(filters.limit)
|
||||||
|
.offset(filters.offset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: rows.map((row) => this.toDomain(row)),
|
||||||
|
total: Number(totalRow?.total ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for modules to add joins/extra predicates without forking list.
|
||||||
|
*/
|
||||||
|
extendListQuery<T>(qb: T, filters: ListPrivilegesFilters): T {
|
||||||
|
void filters;
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<PrivilegeWithDetails | null> {
|
||||||
|
const [row] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(privileges)
|
||||||
|
.where(eq(privileges.id, id))
|
||||||
|
.limit(1);
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const details = await this.loadDetails(id);
|
||||||
|
return { ...this.toDomain(row), details };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByCode(code: string): Promise<Privilege | null> {
|
||||||
|
const [row] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(privileges)
|
||||||
|
.where(eq(privileges.code, code))
|
||||||
|
.limit(1);
|
||||||
|
return row ? this.toDomain(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreatePrivilegeInput): Promise<PrivilegeWithDetails> {
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
const status = input.status ?? Status.create(Status.DEFAULT);
|
||||||
|
try {
|
||||||
|
return await this.db.transaction(async (tx) => {
|
||||||
|
const [row] = await tx
|
||||||
|
.insert(privileges)
|
||||||
|
.values({
|
||||||
|
name: input.name,
|
||||||
|
code: input.code,
|
||||||
|
status: status.value,
|
||||||
|
createdAt: now.value,
|
||||||
|
updatedAt: now.value,
|
||||||
|
createdBy: input.userId,
|
||||||
|
updatedBy: input.userId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (input.details?.length) {
|
||||||
|
await tx.insert(privilegeDetails).values(
|
||||||
|
input.details.map((d) => ({
|
||||||
|
privilegeId: row.id,
|
||||||
|
privilegeKeyId: d.privilegeKeyId,
|
||||||
|
action: d.action,
|
||||||
|
value: d.value,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = await this.loadDetails(row.id, tx);
|
||||||
|
return { ...this.toDomain(row), details };
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.rethrowUniqueViolation(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMany(inputs: CreatePrivilegeInput[]): Promise<number> {
|
||||||
|
if (inputs.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
try {
|
||||||
|
await this.db.transaction(async (tx) => {
|
||||||
|
for (const input of inputs) {
|
||||||
|
const status = input.status ?? Status.create(Status.DEFAULT);
|
||||||
|
await tx.insert(privileges).values({
|
||||||
|
name: input.name,
|
||||||
|
code: input.code,
|
||||||
|
status: status.value,
|
||||||
|
createdAt: now.value,
|
||||||
|
updatedAt: now.value,
|
||||||
|
createdBy: input.userId,
|
||||||
|
updatedBy: input.userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return inputs.length;
|
||||||
|
} catch (error) {
|
||||||
|
this.rethrowUniqueViolation(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
input: UpdatePrivilegeInput,
|
||||||
|
): Promise<PrivilegeWithDetails> {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Privilege not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
try {
|
||||||
|
return await this.db.transaction(async (tx) => {
|
||||||
|
const [row] = await tx
|
||||||
|
.update(privileges)
|
||||||
|
.set({
|
||||||
|
name: input.name ?? existing.name,
|
||||||
|
code: input.code ?? existing.code,
|
||||||
|
updatedAt: now.value,
|
||||||
|
updatedBy: input.userId,
|
||||||
|
})
|
||||||
|
.where(eq(privileges.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (input.details !== undefined) {
|
||||||
|
await tx
|
||||||
|
.delete(privilegeDetails)
|
||||||
|
.where(eq(privilegeDetails.privilegeId, id));
|
||||||
|
if (input.details.length > 0) {
|
||||||
|
await tx.insert(privilegeDetails).values(
|
||||||
|
input.details.map((d) => ({
|
||||||
|
privilegeId: id,
|
||||||
|
privilegeKeyId: d.privilegeKeyId,
|
||||||
|
action: d.action,
|
||||||
|
value: d.value,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = await this.loadDetails(id, tx);
|
||||||
|
return { ...this.toDomain(row), details };
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.rethrowUniqueViolation(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
id: string,
|
||||||
|
status: Status,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Privilege> {
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
const [row] = await this.db
|
||||||
|
.update(privileges)
|
||||||
|
.set({
|
||||||
|
status: status.value,
|
||||||
|
updatedAt: now.value,
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
.where(eq(privileges.id, id))
|
||||||
|
.returning();
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException('Privilege not found');
|
||||||
|
}
|
||||||
|
return this.toDomain(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkUpdateStatus(
|
||||||
|
ids: string[],
|
||||||
|
status: Status,
|
||||||
|
userId: string,
|
||||||
|
): Promise<number> {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
const rows = await this.db
|
||||||
|
.update(privileges)
|
||||||
|
.set({
|
||||||
|
status: status.value,
|
||||||
|
updatedAt: now.value,
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
.where(inArray(privileges.id, ids))
|
||||||
|
.returning({ id: privileges.id });
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
const assigned = await this.countUsersWithPrivilege(id);
|
||||||
|
if (assigned > 0) {
|
||||||
|
throw new ConflictException('Privilege is assigned to users');
|
||||||
|
}
|
||||||
|
const deleted = await this.db
|
||||||
|
.delete(privileges)
|
||||||
|
.where(eq(privileges.id, id))
|
||||||
|
.returning({ id: privileges.id });
|
||||||
|
if (deleted.length === 0) {
|
||||||
|
throw new NotFoundException('Privilege not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkDelete(ids: string[]): Promise<number> {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
for (const id of ids) {
|
||||||
|
const assigned = await this.countUsersWithPrivilege(id);
|
||||||
|
if (assigned > 0) {
|
||||||
|
throw new ConflictException('Privilege is assigned to users');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const deleted = await this.db
|
||||||
|
.delete(privileges)
|
||||||
|
.where(inArray(privileges.id, ids))
|
||||||
|
.returning({ id: privileges.id });
|
||||||
|
return deleted.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async countUsersWithPrivilege(privilegeId: string): Promise<number> {
|
||||||
|
const [row] = await this.db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.privilegeId, privilegeId));
|
||||||
|
return Number(row?.total ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listKeys(
|
||||||
|
filters: ListPrivilegeKeysFilters,
|
||||||
|
): Promise<{ data: PrivilegeKey[]; total: number }> {
|
||||||
|
const where = filters.search
|
||||||
|
? or(
|
||||||
|
ilike(privilegeKeys.code, `%${filters.search}%`),
|
||||||
|
ilike(privilegeKeys.label, `%${filters.search}%`),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const [totalRow] = await this.db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(privilegeKeys)
|
||||||
|
.where(where);
|
||||||
|
|
||||||
|
const rows = await this.db
|
||||||
|
.select()
|
||||||
|
.from(privilegeKeys)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(asc(privilegeKeys.sortOrder), asc(privilegeKeys.code))
|
||||||
|
.limit(filters.limit)
|
||||||
|
.offset(filters.offset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: rows.map((row) => this.toKeyDomain(row)),
|
||||||
|
total: Number(totalRow?.total ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findKeyById(id: string): Promise<PrivilegeKey | null> {
|
||||||
|
const [row] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(privilegeKeys)
|
||||||
|
.where(eq(privilegeKeys.id, id))
|
||||||
|
.limit(1);
|
||||||
|
return row ? this.toKeyDomain(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findKeyByCode(code: string): Promise<PrivilegeKey | null> {
|
||||||
|
const [row] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(privilegeKeys)
|
||||||
|
.where(eq(privilegeKeys.code, code))
|
||||||
|
.limit(1);
|
||||||
|
return row ? this.toKeyDomain(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true only when the user's assigned privilege has value=true
|
||||||
|
* for the given key code and action.
|
||||||
|
*/
|
||||||
|
async checkPermission(
|
||||||
|
userId: string,
|
||||||
|
keyCode: string,
|
||||||
|
action: PrivilegeAction,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const [row] = await this.db
|
||||||
|
.select({ value: privilegeDetails.value })
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(privileges, eq(users.privilegeId, privileges.id))
|
||||||
|
.innerJoin(
|
||||||
|
privilegeDetails,
|
||||||
|
eq(privilegeDetails.privilegeId, privileges.id),
|
||||||
|
)
|
||||||
|
.innerJoin(
|
||||||
|
privilegeKeys,
|
||||||
|
eq(privilegeDetails.privilegeKeyId, privilegeKeys.id),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(users.id, userId),
|
||||||
|
eq(privileges.status, 'active'),
|
||||||
|
eq(privilegeKeys.code, keyCode),
|
||||||
|
eq(privilegeDetails.action, action),
|
||||||
|
eq(privilegeDetails.value, true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return row?.value === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPermissionsMap(
|
||||||
|
privilegeId: string,
|
||||||
|
): Promise<Record<string, Record<PrivilegeAction, boolean>>> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select({
|
||||||
|
code: privilegeKeys.code,
|
||||||
|
action: privilegeDetails.action,
|
||||||
|
value: privilegeDetails.value,
|
||||||
|
})
|
||||||
|
.from(privilegeDetails)
|
||||||
|
.innerJoin(
|
||||||
|
privilegeKeys,
|
||||||
|
eq(privilegeDetails.privilegeKeyId, privilegeKeys.id),
|
||||||
|
)
|
||||||
|
.where(eq(privilegeDetails.privilegeId, privilegeId));
|
||||||
|
|
||||||
|
const map: Record<string, Record<string, boolean>> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
const action = assertPrivilegeAction(row.action);
|
||||||
|
const current = map[row.code] ?? {
|
||||||
|
view: false,
|
||||||
|
create: false,
|
||||||
|
update: false,
|
||||||
|
delete: false,
|
||||||
|
import: false,
|
||||||
|
};
|
||||||
|
map[row.code] = { ...current, [action]: row.value };
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildListWhere(filters: ListPrivilegesFilters): SQL | undefined {
|
||||||
|
const parts: SQL[] = [];
|
||||||
|
if (filters.name) {
|
||||||
|
parts.push(ilike(privileges.name, `%${filters.name}%`));
|
||||||
|
}
|
||||||
|
if (filters.code) {
|
||||||
|
parts.push(ilike(privileges.code, `%${filters.code}%`));
|
||||||
|
}
|
||||||
|
if (filters.status) {
|
||||||
|
parts.push(eq(privileges.status, filters.status));
|
||||||
|
}
|
||||||
|
if (filters.search) {
|
||||||
|
const search = or(
|
||||||
|
ilike(privileges.name, `%${filters.search}%`),
|
||||||
|
ilike(privileges.code, `%${filters.search}%`),
|
||||||
|
);
|
||||||
|
if (search) {
|
||||||
|
parts.push(search);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return parts.length === 1 ? parts[0] : and(...parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadDetails(
|
||||||
|
privilegeId: string,
|
||||||
|
tx:
|
||||||
|
DrizzleDB | Parameters<Parameters<DrizzleDB['transaction']>[0]>[0] = this
|
||||||
|
.db,
|
||||||
|
): Promise<PrivilegeDetail[]> {
|
||||||
|
const rows = await tx
|
||||||
|
.select({
|
||||||
|
id: privilegeDetails.id,
|
||||||
|
privilegeKeyId: privilegeDetails.privilegeKeyId,
|
||||||
|
keyCode: privilegeKeys.code,
|
||||||
|
keyLabel: privilegeKeys.label,
|
||||||
|
sortOrder: privilegeKeys.sortOrder,
|
||||||
|
action: privilegeDetails.action,
|
||||||
|
value: privilegeDetails.value,
|
||||||
|
})
|
||||||
|
.from(privilegeDetails)
|
||||||
|
.innerJoin(
|
||||||
|
privilegeKeys,
|
||||||
|
eq(privilegeDetails.privilegeKeyId, privilegeKeys.id),
|
||||||
|
)
|
||||||
|
.where(eq(privilegeDetails.privilegeId, privilegeId))
|
||||||
|
.orderBy(asc(privilegeKeys.sortOrder), asc(privilegeDetails.action));
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
privilegeKeyId: row.privilegeKeyId,
|
||||||
|
keyCode: row.keyCode,
|
||||||
|
keyLabel: row.keyLabel,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
action: assertPrivilegeAction(row.action),
|
||||||
|
value: row.value,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(row: PrivilegeRow): Privilege {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
code: row.code,
|
||||||
|
status: Status.create(row.status),
|
||||||
|
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||||
|
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||||
|
createdBy: row.createdBy,
|
||||||
|
updatedBy: row.updatedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toKeyDomain(row: PrivilegeKeyRow): PrivilegeKey {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
code: row.code,
|
||||||
|
label: row.label,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private rethrowUniqueViolation(error: unknown): never {
|
||||||
|
const err = error as { code?: string; constraint?: string };
|
||||||
|
if (err.code === '23505') {
|
||||||
|
if (err.constraint?.includes('privilege_details')) {
|
||||||
|
throw new ConflictException('Duplicate privilege detail');
|
||||||
|
}
|
||||||
|
throw new ConflictException('Privilege code already exists');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { PrivilegeDetailInput };
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||||
|
import { Status } from '../../common/value-objects/status/status';
|
||||||
|
import type { PrivilegeWithDetails } from './privilege';
|
||||||
|
import { PrivilegesRepository } from './privileges.repository';
|
||||||
|
import { PrivilegesService } from './privileges.service';
|
||||||
|
|
||||||
|
describe('PrivilegesService', () => {
|
||||||
|
let service: PrivilegesService;
|
||||||
|
let repository: jest.Mocked<
|
||||||
|
Pick<
|
||||||
|
PrivilegesRepository,
|
||||||
|
| 'list'
|
||||||
|
| 'findById'
|
||||||
|
| 'create'
|
||||||
|
| 'createMany'
|
||||||
|
| 'update'
|
||||||
|
| 'updateStatus'
|
||||||
|
| 'bulkUpdateStatus'
|
||||||
|
| 'delete'
|
||||||
|
| 'bulkDelete'
|
||||||
|
| 'listKeys'
|
||||||
|
| 'findKeyById'
|
||||||
|
| 'checkPermission'
|
||||||
|
| 'getPermissionsMap'
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||||
|
const sample: PrivilegeWithDetails = {
|
||||||
|
id: 'priv-1',
|
||||||
|
name: 'Admin',
|
||||||
|
code: 'ADMIN',
|
||||||
|
status: Status.create('draft'),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
createdBy: 'user-1',
|
||||||
|
updatedBy: 'user-1',
|
||||||
|
details: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
repository = {
|
||||||
|
list: jest.fn(),
|
||||||
|
findById: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
createMany: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
updateStatus: jest.fn(),
|
||||||
|
bulkUpdateStatus: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
bulkDelete: jest.fn(),
|
||||||
|
listKeys: jest.fn(),
|
||||||
|
findKeyById: jest.fn(),
|
||||||
|
checkPermission: jest.fn(),
|
||||||
|
getPermissionsMap: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
PrivilegesService,
|
||||||
|
{ provide: PrivilegesRepository, useValue: repository },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = moduleRef.get(PrivilegesService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list maps visible fields and pagination', async () => {
|
||||||
|
repository.list.mockResolvedValue({ data: [sample], total: 1 });
|
||||||
|
const result = await service.list({ page: 1, limit: 10 });
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
expect(result.data[0]).toMatchObject({
|
||||||
|
id: 'priv-1',
|
||||||
|
status: 'draft',
|
||||||
|
createdAt: now.value,
|
||||||
|
});
|
||||||
|
expect(service.visibleFields).toContain('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findById throws when missing', async () => {
|
||||||
|
repository.findById.mockResolvedValue(null);
|
||||||
|
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create defaults status to draft', async () => {
|
||||||
|
repository.create.mockResolvedValue(sample);
|
||||||
|
await service.create({
|
||||||
|
name: 'Admin',
|
||||||
|
code: 'ADMIN',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
expect(repository.create).toHaveBeenCalled();
|
||||||
|
const arg = repository.create.mock.calls[0][0];
|
||||||
|
expect(arg.status?.value).toBe('draft');
|
||||||
|
expect(arg.details).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update rejects status field', async () => {
|
||||||
|
await expect(
|
||||||
|
service.update('priv-1', {
|
||||||
|
status: 'active',
|
||||||
|
userId: 'user-1',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateStatus updates via repository', async () => {
|
||||||
|
repository.updateStatus.mockResolvedValue(sample);
|
||||||
|
await service.updateStatus('priv-1', 'active', 'user-1');
|
||||||
|
expect(repository.updateStatus).toHaveBeenCalledWith(
|
||||||
|
'priv-1',
|
||||||
|
expect.objectContaining({ value: 'active' }),
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('importCsv creates rows and fails batch on invalid status', async () => {
|
||||||
|
await expect(
|
||||||
|
service.importCsv('name,code,status\nA,A1,nope', 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(repository.createMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('importCsv imports valid rows', async () => {
|
||||||
|
repository.createMany.mockResolvedValue(1);
|
||||||
|
const result = await service.importCsv(
|
||||||
|
'name,code,status\nAdmin,ADMIN,draft',
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
expect(result.imported).toBe(1);
|
||||||
|
expect(repository.createMany).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checkPermission delegates', async () => {
|
||||||
|
repository.checkPermission.mockResolvedValue(true);
|
||||||
|
await expect(
|
||||||
|
service.checkPermission('user-1', 'PRIVILEGES', 'view'),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { PaginationResponse } from '../../common/http/response';
|
||||||
|
import { toListPage } from '../../common/http/response';
|
||||||
|
import { Status } from '../../common/value-objects/status/status';
|
||||||
|
import type { PrivilegeAction } from './privilege-action';
|
||||||
|
import { assertPrivilegeAction } from './privilege-action';
|
||||||
|
import type {
|
||||||
|
CreatePrivilegeInput,
|
||||||
|
Privilege,
|
||||||
|
PrivilegeDetailInput,
|
||||||
|
PrivilegeKey,
|
||||||
|
PrivilegeWithDetails,
|
||||||
|
UpdatePrivilegeInput,
|
||||||
|
} from './privilege';
|
||||||
|
import { PrivilegesRepository } from './privileges.repository';
|
||||||
|
|
||||||
|
export type ListPrivilegesQuery = {
|
||||||
|
readonly name?: string;
|
||||||
|
readonly code?: string;
|
||||||
|
readonly status?: string;
|
||||||
|
readonly search?: string;
|
||||||
|
readonly page?: number;
|
||||||
|
readonly limit?: number;
|
||||||
|
readonly offset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListPrivilegeKeysQuery = {
|
||||||
|
readonly search?: string;
|
||||||
|
readonly page?: number;
|
||||||
|
readonly limit?: number;
|
||||||
|
readonly offset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VISIBLE_FIELDS = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'code',
|
||||||
|
'status',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
'createdBy',
|
||||||
|
'updatedBy',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrivilegesService {
|
||||||
|
constructor(private readonly privilegesRepository: PrivilegesRepository) {}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
query: ListPrivilegesQuery,
|
||||||
|
): Promise<PaginationResponse<ReturnType<PrivilegesService['toListItem']>>> {
|
||||||
|
const page = toListPage(query);
|
||||||
|
const { data, total } = await this.privilegesRepository.list({
|
||||||
|
name: query.name,
|
||||||
|
code: query.code,
|
||||||
|
status: query.status,
|
||||||
|
search: query.search,
|
||||||
|
limit: page.limit,
|
||||||
|
offset: page.offset,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
data: data.map((item) => this.toListItem(item)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(
|
||||||
|
id: string,
|
||||||
|
): Promise<ReturnType<PrivilegesService['toDetail']>> {
|
||||||
|
const privilege = await this.privilegesRepository.findById(id);
|
||||||
|
if (!privilege) {
|
||||||
|
throw new NotFoundException('Privilege not found');
|
||||||
|
}
|
||||||
|
return this.toDetail(privilege);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
input: Omit<CreatePrivilegeInput, 'status' | 'details'> & {
|
||||||
|
status?: string;
|
||||||
|
details?: readonly {
|
||||||
|
privilegeKeyId: string;
|
||||||
|
action: string;
|
||||||
|
value: boolean;
|
||||||
|
}[];
|
||||||
|
},
|
||||||
|
): Promise<ReturnType<PrivilegesService['toDetail']>> {
|
||||||
|
const details = await this.normalizeDetails(input.details);
|
||||||
|
const created = await this.privilegesRepository.create({
|
||||||
|
name: input.name.trim(),
|
||||||
|
code: input.code.trim(),
|
||||||
|
status: input.status
|
||||||
|
? Status.create(input.status)
|
||||||
|
: Status.create(Status.DEFAULT),
|
||||||
|
details,
|
||||||
|
userId: input.userId,
|
||||||
|
});
|
||||||
|
return this.toDetail(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
input: {
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
status?: unknown;
|
||||||
|
details?: readonly {
|
||||||
|
privilegeKeyId: string;
|
||||||
|
action: string;
|
||||||
|
value: boolean;
|
||||||
|
}[];
|
||||||
|
userId: string;
|
||||||
|
},
|
||||||
|
): Promise<ReturnType<PrivilegesService['toDetail']>> {
|
||||||
|
if (input.status !== undefined) {
|
||||||
|
throw new BadRequestException('status cannot be updated via PATCH');
|
||||||
|
}
|
||||||
|
const payload: UpdatePrivilegeInput = {
|
||||||
|
name: input.name?.trim(),
|
||||||
|
code: input.code?.trim(),
|
||||||
|
userId: input.userId,
|
||||||
|
details:
|
||||||
|
input.details !== undefined
|
||||||
|
? await this.normalizeDetails(input.details)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
const updated = await this.privilegesRepository.update(id, payload);
|
||||||
|
return this.toDetail(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
id: string,
|
||||||
|
statusRaw: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<ReturnType<PrivilegesService['toListItem']>> {
|
||||||
|
const status = Status.create(statusRaw);
|
||||||
|
const updated = await this.privilegesRepository.updateStatus(
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return this.toListItem(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkUpdateStatus(
|
||||||
|
ids: string[],
|
||||||
|
statusRaw: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ updated: number }> {
|
||||||
|
const status = Status.create(statusRaw);
|
||||||
|
const updated = await this.privilegesRepository.bulkUpdateStatus(
|
||||||
|
ids,
|
||||||
|
status,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return { updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.privilegesRepository.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
|
||||||
|
const deleted = await this.privilegesRepository.bulkDelete(ids);
|
||||||
|
return { deleted };
|
||||||
|
}
|
||||||
|
|
||||||
|
async importCsv(csv: string, userId: string): Promise<{ imported: number }> {
|
||||||
|
const lines = csv
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
if (lines.length === 0) {
|
||||||
|
throw new BadRequestException('CSV is empty');
|
||||||
|
}
|
||||||
|
if (lines.length > 501) {
|
||||||
|
throw new BadRequestException('CSV exceeds maximum of 500 data rows');
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = lines[0].split(',').map((h) => h.trim().toLowerCase());
|
||||||
|
const nameIdx = header.indexOf('name');
|
||||||
|
const codeIdx = header.indexOf('code');
|
||||||
|
const statusIdx = header.indexOf('status');
|
||||||
|
if (nameIdx < 0 || codeIdx < 0) {
|
||||||
|
throw new BadRequestException('CSV must include name and code headers');
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
const rows: { name: string; code: string; status?: string }[] = [];
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const cols = lines[i].split(',').map((c) => c.trim());
|
||||||
|
const name = cols[nameIdx] ?? '';
|
||||||
|
const code = cols[codeIdx] ?? '';
|
||||||
|
const status = statusIdx >= 0 ? cols[statusIdx] : undefined;
|
||||||
|
if (!name || !code) {
|
||||||
|
errors.push(`row ${i + 1}: name and code are required`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (name.length > 120 || code.length > 64) {
|
||||||
|
errors.push(`row ${i + 1}: name or code too long`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
try {
|
||||||
|
Status.create(status);
|
||||||
|
} catch {
|
||||||
|
errors.push(`row ${i + 1}: invalid status`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.push({ name, code, status: status || undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: 'CSV validation failed',
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.privilegesRepository.createMany(
|
||||||
|
rows.map((row) => ({
|
||||||
|
name: row.name,
|
||||||
|
code: row.code,
|
||||||
|
status: row.status
|
||||||
|
? Status.create(row.status)
|
||||||
|
: Status.create(Status.DEFAULT),
|
||||||
|
details: [],
|
||||||
|
userId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return { imported: rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listKeys(
|
||||||
|
query: ListPrivilegeKeysQuery,
|
||||||
|
): Promise<PaginationResponse<PrivilegeKey>> {
|
||||||
|
const page = toListPage(query);
|
||||||
|
return this.privilegesRepository.listKeys({
|
||||||
|
search: query.search,
|
||||||
|
limit: page.limit,
|
||||||
|
offset: page.offset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkPermission(
|
||||||
|
userId: string,
|
||||||
|
keyCode: string,
|
||||||
|
action: PrivilegeAction,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.privilegesRepository.checkPermission(userId, keyCode, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPermissionsMap(
|
||||||
|
privilegeId: string,
|
||||||
|
): Promise<Record<string, Record<PrivilegeAction, boolean>>> {
|
||||||
|
return this.privilegesRepository.getPermissionsMap(privilegeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPrivilegeSummary(privilegeId: string): Promise<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
status: string;
|
||||||
|
} | null> {
|
||||||
|
const privilege = await this.privilegesRepository.findById(privilegeId);
|
||||||
|
if (!privilege) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: privilege.id,
|
||||||
|
name: privilege.name,
|
||||||
|
code: privilege.code,
|
||||||
|
status: privilege.status.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async normalizeDetails(
|
||||||
|
details?: readonly {
|
||||||
|
privilegeKeyId: string;
|
||||||
|
action: string;
|
||||||
|
value: boolean;
|
||||||
|
}[],
|
||||||
|
): Promise<PrivilegeDetailInput[] | undefined> {
|
||||||
|
if (details === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const normalized: PrivilegeDetailInput[] = [];
|
||||||
|
for (const detail of details) {
|
||||||
|
const key = await this.privilegesRepository.findKeyById(
|
||||||
|
detail.privilegeKeyId,
|
||||||
|
);
|
||||||
|
if (!key) {
|
||||||
|
throw new BadRequestException('Unknown privilege key');
|
||||||
|
}
|
||||||
|
normalized.push({
|
||||||
|
privilegeKeyId: detail.privilegeKeyId,
|
||||||
|
action: assertPrivilegeAction(detail.action),
|
||||||
|
value: detail.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
toListItem(privilege: Privilege) {
|
||||||
|
return {
|
||||||
|
id: privilege.id,
|
||||||
|
name: privilege.name,
|
||||||
|
code: privilege.code,
|
||||||
|
status: privilege.status.value,
|
||||||
|
createdAt: privilege.createdAt.value,
|
||||||
|
updatedAt: privilege.updatedAt.value,
|
||||||
|
createdBy: privilege.createdBy,
|
||||||
|
updatedBy: privilege.updatedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
toDetail(privilege: PrivilegeWithDetails) {
|
||||||
|
return {
|
||||||
|
...this.toListItem(privilege),
|
||||||
|
details: privilege.details.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
privilegeKeyId: d.privilegeKeyId,
|
||||||
|
keyCode: d.keyCode,
|
||||||
|
keyLabel: d.keyLabel,
|
||||||
|
sortOrder: d.sortOrder,
|
||||||
|
action: d.action,
|
||||||
|
value: d.value,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expose whitelist for tests / documentation. */
|
||||||
|
get visibleFields(): readonly string[] {
|
||||||
|
return VISIBLE_FIELDS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsUUID, ValidateIf } from 'class-validator';
|
||||||
|
|
||||||
|
export class AssignPrivilegeDto {
|
||||||
|
@ApiProperty({
|
||||||
|
format: 'uuid',
|
||||||
|
nullable: true,
|
||||||
|
description: 'Privilege id to assign, or null to clear',
|
||||||
|
})
|
||||||
|
@ValidateIf((_, value) => value !== null)
|
||||||
|
@IsUUID()
|
||||||
|
privilegeId!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserPrivilegeResponseDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid', nullable: true })
|
||||||
|
privilegeId!: string | null;
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ export type User = {
|
|||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly username: string;
|
readonly username: string;
|
||||||
readonly passwordHash: string;
|
readonly passwordHash: string;
|
||||||
|
readonly privilegeId: string | null;
|
||||||
|
readonly isSuperadmin: boolean;
|
||||||
readonly createdAt: DateTime;
|
readonly createdAt: DateTime;
|
||||||
readonly updatedAt: DateTime;
|
readonly updatedAt: DateTime;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Body, Controller, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiForbiddenResponse,
|
||||||
|
ApiNotFoundResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { RequirePrivilege } from '../../common/decorators/require-privilege.decorator';
|
||||||
|
import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
|
||||||
|
import {
|
||||||
|
AssignPrivilegeDto,
|
||||||
|
UserPrivilegeResponseDto,
|
||||||
|
} from './dto/assign-privilege.dto';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
@ApiTags('users')
|
||||||
|
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||||
|
@Controller('users')
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
|
@Patch(':id/privilege')
|
||||||
|
@RequirePrivilege('USERS', 'update')
|
||||||
|
@ApiOperation({ summary: 'Assign or clear a user privilege' })
|
||||||
|
@ApiOkResponse({ type: UserPrivilegeResponseDto })
|
||||||
|
@ApiNotFoundResponse()
|
||||||
|
@ApiUnauthorizedResponse()
|
||||||
|
@ApiForbiddenResponse()
|
||||||
|
async assignPrivilege(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: AssignPrivilegeDto,
|
||||||
|
): Promise<UserPrivilegeResponseDto> {
|
||||||
|
const user = await this.usersService.assignPrivilege(id, dto.privilegeId);
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
privilegeId: user.privilegeId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrivilegesModule } from '../privileges/privileges.module';
|
||||||
|
import { UsersController } from './users.controller';
|
||||||
import { UsersRepository } from './users.repository';
|
import { UsersRepository } from './users.repository';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [PrivilegesModule],
|
||||||
|
controllers: [UsersController],
|
||||||
providers: [UsersRepository, UsersService],
|
providers: [UsersRepository, UsersService],
|
||||||
exports: [UsersService],
|
exports: [UsersService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,13 +29,20 @@ describe('UsersRepository', () => {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
passwordHash: 'hash',
|
passwordHash: 'hash',
|
||||||
|
privilegeId: null,
|
||||||
|
isSuperadmin: false,
|
||||||
createdAt: 1_700_000_000_000,
|
createdAt: 1_700_000_000_000,
|
||||||
updatedAt: 1_700_000_000_000,
|
updatedAt: 1_700_000_000_000,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const user = await repository.findById('user-1');
|
const user = await repository.findById('user-1');
|
||||||
expect(user).toMatchObject({ id: 'user-1', username: 'alice' });
|
expect(user).toMatchObject({
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
privilegeId: null,
|
||||||
|
isSuperadmin: false,
|
||||||
|
});
|
||||||
expect(user?.createdAt.value).toBe(1_700_000_000_000);
|
expect(user?.createdAt.value).toBe(1_700_000_000_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,6 +57,8 @@ describe('UsersRepository', () => {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
passwordHash: 'hash',
|
passwordHash: 'hash',
|
||||||
|
privilegeId: null,
|
||||||
|
isSuperadmin: false,
|
||||||
createdAt: 1_700_000_000_000,
|
createdAt: 1_700_000_000_000,
|
||||||
updatedAt: 1_700_000_000_000,
|
updatedAt: 1_700_000_000_000,
|
||||||
},
|
},
|
||||||
@@ -60,6 +69,7 @@ describe('UsersRepository', () => {
|
|||||||
passwordHash: 'hash',
|
passwordHash: 'hash',
|
||||||
});
|
});
|
||||||
expect(user.username).toBe('alice');
|
expect(user.username).toBe('alice');
|
||||||
|
expect(user.privilegeId).toBeNull();
|
||||||
expect(values).toHaveBeenCalledWith(
|
expect(values).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ username: 'alice', passwordHash: 'hash' }),
|
expect.objectContaining({ username: 'alice', passwordHash: 'hash' }),
|
||||||
);
|
);
|
||||||
@@ -71,6 +81,8 @@ describe('UsersRepository', () => {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
passwordHash: 'hash',
|
passwordHash: 'hash',
|
||||||
|
privilegeId: null,
|
||||||
|
isSuperadmin: false,
|
||||||
createdAt: 1_700_000_000_000,
|
createdAt: 1_700_000_000_000,
|
||||||
updatedAt: 1_700_000_000_000,
|
updatedAt: 1_700_000_000_000,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { ConflictException, Inject, Injectable } from '@nestjs/common';
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { users, type UserRow } from '../../database/schema';
|
import { users, type UserRow } from '../../database/schema';
|
||||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||||
@@ -49,11 +54,32 @@ export class UsersRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updatePrivilegeId(
|
||||||
|
userId: string,
|
||||||
|
privilegeId: string | null,
|
||||||
|
): Promise<User> {
|
||||||
|
const now = DateTime.fromUnixMs(Date.now());
|
||||||
|
const [row] = await this.db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
privilegeId,
|
||||||
|
updatedAt: now.value,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.returning();
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
return this.toDomain(row);
|
||||||
|
}
|
||||||
|
|
||||||
private toDomain(row: UserRow): User {
|
private toDomain(row: UserRow): User {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
passwordHash: row.passwordHash,
|
passwordHash: row.passwordHash,
|
||||||
|
privilegeId: row.privilegeId ?? null,
|
||||||
|
isSuperadmin: row.isSuperadmin,
|
||||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ConflictException } from '@nestjs/common';
|
import { ConflictException } from '@nestjs/common';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||||
|
import { PrivilegesService } from '../privileges/privileges.service';
|
||||||
import type { User } from './user';
|
import type { User } from './user';
|
||||||
import { UsersRepository } from './users.repository';
|
import { UsersRepository } from './users.repository';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
@@ -8,7 +9,13 @@ import { UsersService } from './users.service';
|
|||||||
describe('UsersService', () => {
|
describe('UsersService', () => {
|
||||||
let service: UsersService;
|
let service: UsersService;
|
||||||
let repository: jest.Mocked<
|
let repository: jest.Mocked<
|
||||||
Pick<UsersRepository, 'findById' | 'findByUsername' | 'create'>
|
Pick<
|
||||||
|
UsersRepository,
|
||||||
|
'findById' | 'findByUsername' | 'create' | 'updatePrivilegeId'
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
let privilegesService: jest.Mocked<
|
||||||
|
Pick<PrivilegesService, 'findPrivilegeSummary'>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||||
@@ -16,6 +23,8 @@ describe('UsersService', () => {
|
|||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
username: 'alice',
|
username: 'alice',
|
||||||
passwordHash: 'hashed',
|
passwordHash: 'hashed',
|
||||||
|
privilegeId: null,
|
||||||
|
isSuperadmin: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -25,12 +34,17 @@ describe('UsersService', () => {
|
|||||||
findById: jest.fn(),
|
findById: jest.fn(),
|
||||||
findByUsername: jest.fn(),
|
findByUsername: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
|
updatePrivilegeId: jest.fn(),
|
||||||
|
};
|
||||||
|
privilegesService = {
|
||||||
|
findPrivilegeSummary: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
UsersService,
|
UsersService,
|
||||||
{ provide: UsersRepository, useValue: repository },
|
{ provide: UsersRepository, useValue: repository },
|
||||||
|
{ provide: PrivilegesService, useValue: privilegesService },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -68,4 +82,21 @@ describe('UsersService', () => {
|
|||||||
repository.findByUsername.mockResolvedValue(sampleUser);
|
repository.findByUsername.mockResolvedValue(sampleUser);
|
||||||
await expect(service.findByUsername('Alice')).resolves.toEqual(sampleUser);
|
await expect(service.findByUsername('Alice')).resolves.toEqual(sampleUser);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('assignPrivilege validates privilege exists', async () => {
|
||||||
|
repository.findById.mockResolvedValue(sampleUser);
|
||||||
|
privilegesService.findPrivilegeSummary.mockResolvedValue({
|
||||||
|
id: 'priv-1',
|
||||||
|
name: 'Admin',
|
||||||
|
code: 'ADMIN',
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
repository.updatePrivilegeId.mockResolvedValue({
|
||||||
|
...sampleUser,
|
||||||
|
privilegeId: 'priv-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.assignPrivilege('user-1', 'priv-1');
|
||||||
|
expect(result.privilegeId).toBe('priv-1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { ConflictException, Injectable } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrivilegesService } from '../privileges/privileges.service';
|
||||||
import { UsersRepository } from './users.repository';
|
import { UsersRepository } from './users.repository';
|
||||||
import type { User } from './user';
|
import type { User } from './user';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
constructor(private readonly usersRepository: UsersRepository) {}
|
constructor(
|
||||||
|
private readonly usersRepository: UsersRepository,
|
||||||
|
private readonly privilegesService: PrivilegesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async findById(id: string): Promise<User | null> {
|
async findById(id: string): Promise<User | null> {
|
||||||
return this.usersRepository.findById(id);
|
return this.usersRepository.findById(id);
|
||||||
@@ -25,4 +34,25 @@ export class UsersService {
|
|||||||
passwordHash,
|
passwordHash,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async assignPrivilege(
|
||||||
|
userId: string,
|
||||||
|
privilegeId: string | null,
|
||||||
|
): Promise<User> {
|
||||||
|
const user = await this.usersRepository.findById(userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
if (privilegeId !== null) {
|
||||||
|
const privilege =
|
||||||
|
await this.privilegesService.findPrivilegeSummary(privilegeId);
|
||||||
|
if (!privilege) {
|
||||||
|
throw new NotFoundException('Privilege not found');
|
||||||
|
}
|
||||||
|
if (privilege.status !== 'active') {
|
||||||
|
throw new BadRequestException('Privilege must be active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.usersRepository.updatePrivilegeId(userId, privilegeId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-5
@@ -28,15 +28,15 @@ describe('Auth (e2e)', () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET / remains public', () => {
|
it('GET / remains public', async () => {
|
||||||
return request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get('/')
|
.get('/')
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.expect('Hello World!');
|
.expect('Hello World!');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /auth/me without token returns 401', () => {
|
it('GET /auth/me without token returns 401', async () => {
|
||||||
return request(app.getHttpServer()).get('/auth/me').expect(401);
|
await request(app.getHttpServer()).get('/auth/me').expect(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('register → me → refresh → revoke → me 401', async () => {
|
it('register → me → refresh → revoke → me 401', async () => {
|
||||||
@@ -61,7 +61,12 @@ describe('Auth (e2e)', () => {
|
|||||||
.set('Authorization', `Bearer ${accessToken}`)
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(me.body).toMatchObject({ username: username.toLowerCase() });
|
expect(me.body).toMatchObject({
|
||||||
|
username: username.toLowerCase(),
|
||||||
|
isSuperadmin: false,
|
||||||
|
privilege: null,
|
||||||
|
permissions: {},
|
||||||
|
});
|
||||||
|
|
||||||
const refreshed = await request(app.getHttpServer())
|
const refreshed = await request(app.getHttpServer())
|
||||||
.post('/auth/refresh')
|
.post('/auth/refresh')
|
||||||
|
|||||||
+4
-1
@@ -5,5 +5,8 @@
|
|||||||
"testRegex": ".e2e-spec.ts$",
|
"testRegex": ".e2e-spec.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
}
|
},
|
||||||
|
"maxWorkers": 1,
|
||||||
|
"testTimeout": 30000,
|
||||||
|
"forceExit": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
import { configureApp } from '../src/common/configure-app';
|
||||||
|
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
|
||||||
|
import { DRIZZLE, type DrizzleDB } from '../src/database/database.module';
|
||||||
|
import {
|
||||||
|
privilegeDetails,
|
||||||
|
privilegeKeys,
|
||||||
|
privileges,
|
||||||
|
users,
|
||||||
|
} from '../src/database/schema';
|
||||||
|
|
||||||
|
describe('Privileges (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
let db: DrizzleDB;
|
||||||
|
|
||||||
|
const password = 'password123';
|
||||||
|
const adminUsername = `admin_${Date.now()}`;
|
||||||
|
const otherUsername = `other_${Date.now()}`;
|
||||||
|
|
||||||
|
let adminAccessToken: string;
|
||||||
|
let adminUserId: string;
|
||||||
|
let otherAccessToken: string;
|
||||||
|
let otherUserId: string;
|
||||||
|
let adminPrivilegeId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
configureApp(app, {
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
SWAGGER_ENABLED: 'false',
|
||||||
|
});
|
||||||
|
await app.init();
|
||||||
|
db = app.get(DRIZZLE);
|
||||||
|
|
||||||
|
const adminReg = await request(app.getHttpServer())
|
||||||
|
.post('/auth/register')
|
||||||
|
.send({ username: adminUsername, password })
|
||||||
|
.expect(201);
|
||||||
|
adminAccessToken = (
|
||||||
|
adminReg.body as { accessToken: string }
|
||||||
|
).accessToken;
|
||||||
|
|
||||||
|
const adminMe = await request(app.getHttpServer())
|
||||||
|
.get('/auth/me')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
adminUserId = (adminMe.body as { id: string }).id;
|
||||||
|
expect(adminMe.body).toMatchObject({
|
||||||
|
privilege: null,
|
||||||
|
permissions: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const otherReg = await request(app.getHttpServer())
|
||||||
|
.post('/auth/register')
|
||||||
|
.send({ username: otherUsername, password })
|
||||||
|
.expect(201);
|
||||||
|
otherAccessToken = (
|
||||||
|
otherReg.body as { accessToken: string }
|
||||||
|
).accessToken;
|
||||||
|
const otherMe = await request(app.getHttpServer())
|
||||||
|
.get('/auth/me')
|
||||||
|
.set('Authorization', `Bearer ${otherAccessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
otherUserId = (otherMe.body as { id: string }).id;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const [priv] = await db
|
||||||
|
.insert(privileges)
|
||||||
|
.values({
|
||||||
|
name: 'Administrator',
|
||||||
|
code: `ADMIN_${now}`,
|
||||||
|
status: 'active',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
createdBy: adminUserId,
|
||||||
|
updatedBy: adminUserId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
adminPrivilegeId = priv.id;
|
||||||
|
|
||||||
|
const keys = await db.select().from(privilegeKeys);
|
||||||
|
expect(keys.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
const detailRows = keys.flatMap((key) =>
|
||||||
|
PRIVILEGE_ACTIONS.map((action) => ({
|
||||||
|
privilegeId: adminPrivilegeId,
|
||||||
|
privilegeKeyId: key.id,
|
||||||
|
action,
|
||||||
|
value: true,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
await db.insert(privilegeDetails).values(detailRows);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ privilegeId: adminPrivilegeId, updatedAt: Date.now() })
|
||||||
|
.where(eq(users.id, adminUserId));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids privileges list without permission', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get('/privileges')
|
||||||
|
.set('Authorization', `Bearer ${otherAccessToken}`)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists privilege keys for admin', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.get('/privilege-keys')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.data).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ code: 'PRIVILEGES' }),
|
||||||
|
expect.objectContaining({ code: 'USERS' }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(res.body.meta).toMatchObject({
|
||||||
|
totalItems: expect.any(Number),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CRUD privileges with details, status, and me permissions', async () => {
|
||||||
|
const keysRes = await request(app.getHttpServer())
|
||||||
|
.get('/privilege-keys?limit=50')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
const privilegesKey = (
|
||||||
|
keysRes.body.data as { id: string; code: string }[]
|
||||||
|
).find((k) => k.code === 'PRIVILEGES');
|
||||||
|
expect(privilegesKey).toBeDefined();
|
||||||
|
|
||||||
|
const created = await request(app.getHttpServer())
|
||||||
|
.post('/privileges')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({
|
||||||
|
name: 'Viewer',
|
||||||
|
code: `VIEWER_${Date.now()}`,
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
privilegeKeyId: privilegesKey!.id,
|
||||||
|
action: 'view',
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(created.body).toMatchObject({
|
||||||
|
name: 'Viewer',
|
||||||
|
status: 'draft',
|
||||||
|
createdBy: adminUserId,
|
||||||
|
details: [
|
||||||
|
expect.objectContaining({
|
||||||
|
keyCode: 'PRIVILEGES',
|
||||||
|
action: 'view',
|
||||||
|
value: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = created.body.id as string;
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(`/privileges/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/privileges/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ name: 'Viewer Updated' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/privileges/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ status: 'active' })
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/privileges/${id}/status`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ status: 'active' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const list = await request(app.getHttpServer())
|
||||||
|
.get('/privileges?search=Viewer')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(list.body.data.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(list.body.meta).toBeDefined();
|
||||||
|
|
||||||
|
const me = await request(app.getHttpServer())
|
||||||
|
.get('/auth/me')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(me.body.privilege).toMatchObject({
|
||||||
|
id: adminPrivilegeId,
|
||||||
|
});
|
||||||
|
expect(me.body.permissions.PRIVILEGES.view).toBe(true);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/users/${otherUserId}/privilege`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ privilegeId: id })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get('/privileges')
|
||||||
|
.set('Authorization', `Bearer ${otherAccessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/privileges')
|
||||||
|
.set('Authorization', `Bearer ${otherAccessToken}`)
|
||||||
|
.send({ name: 'Nope', code: `NOPE_${Date.now()}` })
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/users/${otherUserId}/privilege`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ privilegeId: null })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.delete(`/privileges/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.expect(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports privileges from CSV', async () => {
|
||||||
|
const csv = `name,code,status\nImported Role,IMP_${Date.now()},draft\n`;
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post('/privileges/import')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.attach('file', Buffer.from(csv, 'utf8'), 'privileges.csv')
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(res.body).toMatchObject({ imported: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bulk status and bulk delete', async () => {
|
||||||
|
const a = await request(app.getHttpServer())
|
||||||
|
.post('/privileges')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ name: 'Bulk A', code: `BA_${Date.now()}` })
|
||||||
|
.expect(201);
|
||||||
|
const b = await request(app.getHttpServer())
|
||||||
|
.post('/privileges')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ name: 'Bulk B', code: `BB_${Date.now()}` })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const ids = [a.body.id as string, b.body.id as string];
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/privileges/bulk-status')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ ids, status: 'archived' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/privileges/bulk-delete')
|
||||||
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
|
.send({ ids })
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('superadmin bypasses privilege checks without an assigned role', async () => {
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ isSuperadmin: true, updatedAt: Date.now() })
|
||||||
|
.where(eq(users.id, otherUserId));
|
||||||
|
|
||||||
|
const me = await request(app.getHttpServer())
|
||||||
|
.get('/auth/me')
|
||||||
|
.set('Authorization', `Bearer ${otherAccessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(me.body).toMatchObject({
|
||||||
|
isSuperadmin: true,
|
||||||
|
privilege: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get('/privileges')
|
||||||
|
.set('Authorization', `Bearer ${otherAccessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"tsBuildInfoFile": "./dist/tsconfig.build.tsbuildinfo"
|
||||||
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+1
-2
@@ -12,7 +12,6 @@
|
|||||||
"target": "ES2023",
|
"target": "ES2023",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
|
||||||
"types": ["node", "jest"],
|
"types": ["node", "jest"],
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
@@ -22,6 +21,6 @@
|
|||||||
"strictBindCallApply": false,
|
"strictBindCallApply": false,
|
||||||
"noFallthroughCasesInSwitch": false
|
"noFallthroughCasesInSwitch": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*", "test/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user