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,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 };
|
||||
Reference in New Issue
Block a user