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:
shancheas
2026-08-24 11:31:13 +07:00
parent 0550cbe764
commit 07550b3167
51 changed files with 3731 additions and 27 deletions
+1
View File
@@ -2,6 +2,7 @@ export type AuthUser = {
readonly id: string;
readonly username: string;
readonly jti: string;
readonly isSuperadmin: boolean;
};
export type JwtAccessPayload = {
@@ -21,6 +21,7 @@ describe('CurrentUser decorator', () => {
id: 'user-1',
username: 'alice',
jti: 'jti-1',
isSuperadmin: false,
};
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);
+102
View File
@@ -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),
);
});
});
+52
View File
@@ -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;
}
}
+13
View File
@@ -5,6 +5,8 @@ import { AppController } from '../../app.controller';
import { AppService } from '../../app.service';
import { AuthController } from '../../modules/auth/auth.controller';
import { AuthService } from '../../modules/auth/auth.service';
import { PrivilegesService } from '../../modules/privileges/privileges.service';
import { UsersService } from '../../modules/users/users.service';
import {
createOpenApiDocument,
isSwaggerEnabled,
@@ -56,6 +58,17 @@ describe('createOpenApiDocument', () => {
revoke: jest.fn(),
},
},
{
provide: UsersService,
useValue: { findById: jest.fn() },
},
{
provide: PrivilegesService,
useValue: {
findPrivilegeSummary: jest.fn(),
getPermissionsMap: jest.fn(),
},
},
],
}).compile();