import { ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { Prisma } from '../../generated/prisma'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { CreateRoleDto } from './dto/create-role.dto'; import { UpdateRoleDto } from './dto/update-role.dto'; export interface RoleSummary { id: string; key: string; name: string; description: string | null; isSystem: boolean; status: string; userCount: number; permissionKeys: string[]; createdAt: Date; updatedAt: Date; deactivatedAt: Date | null; } const ROLE_INCLUDE = { permissions: { select: { permission: { select: { key: true } } } }, _count: { select: { users: true } }, } satisfies Prisma.RoleInclude; type RoleWithRelations = Prisma.RoleGetPayload<{ include: typeof ROLE_INCLUDE; }>; @Injectable() export class RolesService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly permissionResolver: PermissionResolverService, ) {} private toSummary(role: RoleWithRelations): RoleSummary { return { id: role.id, key: role.key, name: role.name, description: role.description, isSystem: role.isSystem, status: role.status, userCount: role._count.users, permissionKeys: role.permissions.map((grant) => grant.permission.key), createdAt: role.createdAt, updatedAt: role.updatedAt, deactivatedAt: role.deactivatedAt, }; } async findAll(): Promise { const roles = await this.prisma.role.findMany({ include: ROLE_INCLUDE, orderBy: [{ isSystem: 'desc' }, { name: 'asc' }], }); return roles.map((role) => this.toSummary(role)); } async findById(id: string): Promise { const role = await this.prisma.role.findUnique({ where: { id }, include: ROLE_INCLUDE, }); if (!role) { throw new NotFoundException('Role not found'); } return role; } async findSummaryById(id: string): Promise { return this.toSummary(await this.findById(id)); } /** * Nobody can grant a role a permission they do not hold themselves - a * caller with only roles:update could otherwise turn any role (including * one they belong to) into a shadow-admin by adding every catalog key, no * users:assign-role needed at all since editing a role's own grants * doesn't touch User.roleId. This is the standard "can't escalate beyond * what you have" RBAC rule. */ private async assertCanGrantPermissions( permissionKeys: string[], actingUserId?: string, ): Promise { if (!actingUserId) { return; } const callerPermissions = await this.permissionResolver.getPermissions(actingUserId); const missing = permissionKeys.filter((key) => !callerPermissions.has(key)); if (missing.length > 0) { throw new ForbiddenException( `Cannot grant permissions you do not hold yourself: ${missing.join(', ')}`, ); } } async create( dto: CreateRoleDto, actingUserId?: string, ): Promise { const existing = await this.prisma.role.findUnique({ where: { key: dto.key }, }); if (existing) { throw new ConflictException('Role key already in use'); } await this.assertCanGrantPermissions(dto.permissionKeys, actingUserId); const role = await this.prisma.$transaction(async (tx) => { const created = await tx.role.create({ data: { key: dto.key, name: dto.name, description: dto.description, }, }); await this.replacePermissions(tx, created.id, dto.permissionKeys); return tx.role.findUniqueOrThrow({ where: { id: created.id }, include: ROLE_INCLUDE, }); }); this.permissionResolver.invalidateAll(); const summary = this.toSummary(role); await this.auditLog.record({ entityType: 'Role', entityId: role.id, action: 'CREATE', userId: actingUserId, afterData: summary, }); return summary; } async update( id: string, dto: UpdateRoleDto, actingUserId?: string, ): Promise { const before = await this.findById(id); if (before.isSystem) { throw new ForbiddenException('System roles cannot be edited'); } if (dto.permissionKeys) { await this.assertCanGrantPermissions(dto.permissionKeys, actingUserId); } const role = await this.prisma.$transaction(async (tx) => { await tx.role.update({ where: { id }, data: { key: dto.key, name: dto.name, description: dto.description }, }); if (dto.permissionKeys) { await this.replacePermissions(tx, id, dto.permissionKeys); } return tx.role.findUniqueOrThrow({ where: { id }, include: ROLE_INCLUDE, }); }); this.permissionResolver.invalidateAll(); const summary = this.toSummary(role); await this.auditLog.record({ entityType: 'Role', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: this.toSummary(before), afterData: summary, }); return summary; } async deactivate(id: string, actingUserId?: string): Promise { const before = await this.findById(id); if (before.isSystem) { throw new ForbiddenException('System roles cannot be deactivated'); } if (before._count.users > 0) { throw new ConflictException( 'Role still has users assigned; reassign them before deactivating', ); } const role = await this.prisma.role.update({ where: { id }, data: { status: 'INACTIVE', deactivatedAt: new Date() }, include: ROLE_INCLUDE, }); this.permissionResolver.invalidateAll(); const summary = this.toSummary(role); await this.auditLog.record({ entityType: 'Role', entityId: id, action: 'DEACTIVATE', userId: actingUserId, beforeData: this.toSummary(before), afterData: summary, }); return summary; } async reactivate(id: string, actingUserId?: string): Promise { const before = await this.findById(id); if (before.status === 'ACTIVE') { throw new ConflictException('Role is already active'); } const role = await this.prisma.role.update({ where: { id }, data: { status: 'ACTIVE', deactivatedAt: null }, include: ROLE_INCLUDE, }); this.permissionResolver.invalidateAll(); const summary = this.toSummary(role); await this.auditLog.record({ entityType: 'Role', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: this.toSummary(before), afterData: summary, }); return summary; } private async replacePermissions( tx: Prisma.TransactionClient, roleId: string, permissionKeys: string[], ): Promise { const permissions = await tx.permission.findMany({ where: { key: { in: permissionKeys } }, select: { id: true }, }); await tx.rolePermission.deleteMany({ where: { roleId } }); if (permissions.length > 0) { await tx.rolePermission.createMany({ data: permissions.map((permission) => ({ roleId, permissionId: permission.id, })), }); } } }