- Updated privilege key structure to use a 3- or 4-part dotted hierarchy (e.g., `GROUP.PARENT.MODULE`). - Modified the `RequirePrivilege` decorator to accept multiple keys, allowing for OR logic in privilege checks. - Enhanced `PrivilegesGuard` to validate against multiple privilege keys, improving access control logic. - Created migration scripts to update existing privilege keys in the database to the new format. - Updated related services, controllers, and tests to accommodate the new privilege key structure and validation logic.
564 lines
15 KiB
TypeScript
564 lines
15 KiB
TypeScript
import {
|
|
ConflictException,
|
|
Inject,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
|
import { toOrderClauses } from '../../common/http/response';
|
|
import { attachAuditUsers } from '../../database/load-user-refs';
|
|
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';
|
|
|
|
const PRIVILEGE_ORDER_COLUMNS = {
|
|
id: privileges.id,
|
|
name: privileges.name,
|
|
code: privileges.code,
|
|
status: privileges.status,
|
|
createdAt: privileges.createdAt,
|
|
updatedAt: privileges.updatedAt,
|
|
};
|
|
|
|
const PRIVILEGE_KEY_ORDER_COLUMNS = {
|
|
id: privilegeKeys.id,
|
|
code: privilegeKeys.code,
|
|
label: privilegeKeys.label,
|
|
sortOrder: privilegeKeys.sortOrder,
|
|
};
|
|
|
|
@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(
|
|
...toOrderClauses(PRIVILEGE_ORDER_COLUMNS, filters, [
|
|
{ column: 'code', type: 'ASC' },
|
|
]),
|
|
)
|
|
.limit(filters.limit)
|
|
.offset(filters.offset);
|
|
|
|
return {
|
|
data: await this.hydrate(rows),
|
|
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);
|
|
const [base] = await this.hydrate([row]);
|
|
return { ...base, 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.hydrateOne(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);
|
|
const [base] = await this.hydrate([row]);
|
|
return { ...base, 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);
|
|
const [base] = await this.hydrate([row]);
|
|
return { ...base, 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.hydrateOne(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(
|
|
...toOrderClauses(PRIVILEGE_KEY_ORDER_COLUMNS, filters, [
|
|
{ column: 'sortOrder', type: 'ASC' },
|
|
{ column: 'code', type: 'ASC' },
|
|
]),
|
|
)
|
|
.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 checkAnyPermission(
|
|
userId: string,
|
|
keyCodes: readonly string[],
|
|
action: PrivilegeAction,
|
|
): Promise<boolean> {
|
|
if (keyCodes.length === 0) {
|
|
return false;
|
|
}
|
|
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'),
|
|
inArray(privilegeKeys.code, [...keyCodes]),
|
|
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) {
|
|
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 async hydrate(rows: PrivilegeRow[]): Promise<Privilege[]> {
|
|
return attachAuditUsers(
|
|
this.db,
|
|
rows.map((row) => this.toDomain(row)),
|
|
);
|
|
}
|
|
|
|
private async hydrateOne(row: PrivilegeRow): Promise<Privilege> {
|
|
const [item] = await this.hydrate([row]);
|
|
return item;
|
|
}
|
|
|
|
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 };
|