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:
@@ -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);
|
||||
@@ -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 { 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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user