import { BadRequestException, ForbiddenException, Injectable, NotFoundException, OnModuleInit, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectModel } from '@nestjs/mongoose'; import { createHash } from 'node:crypto'; import { Model } from 'mongoose'; import { AuditLogService } from 'src/common/services/audit-log.service'; import { assertAssignablePermissions, createPermissionForRole, DEFAULT_ROLE_PERMISSIONS, Permission, } from 'src/common/types/permissions.catalog'; import { isOwnerRole, isSystemStaffRole, Role, SYSTEM_STAFF_ROLES, } from 'src/common/types/role.type'; import { AdminModel } from 'src/database/model/admin.model'; import { StaffRoleModel } from 'src/database/model/staff-role.model'; import { PermissionsService } from './permissions.service'; import { CreateStaffDto } from './dto/create-staff.dto'; import { CreateCustomRoleDto } from './dto/create-custom-role.dto'; import { UpdateRolePermissionsDto } from './dto/update-role-permissions.dto'; import { UpdateUserOverridesDto } from './dto/update-user-overrides.dto'; import { ChangeStaffRoleDto } from './dto/change-staff-role.dto'; @Injectable() export class AclService implements OnModuleInit { constructor( @InjectModel(AdminModel.name) private readonly adminModel: Model, @InjectModel(StaffRoleModel.name) private readonly staffRoleModel: Model, private readonly permissionsService: PermissionsService, private readonly auditLogService: AuditLogService, private readonly configService: ConfigService, ) {} async onModuleInit() { await this.seedSystemRoleTemplates(); await this.seedOwnerFromEnv(); } private async seedSystemRoleTemplates() { for (const role of SYSTEM_STAFF_ROLES) { await this.staffRoleModel.updateOne( { name: role }, { $setOnInsert: { name: role, displayName: role.charAt(0).toUpperCase() + role.slice(1), permissions: DEFAULT_ROLE_PERMISSIONS[role], isSystem: true, }, }, { upsert: true }, ); } } private async seedOwnerFromEnv() { const existingOwner = await this.adminModel.findOne({ role: Role.Owner }); if (existingOwner) { return; } const email = this.configService.get('OWNER_EMAIL') || process.env.OWNER_EMAIL; const password = this.configService.get('OWNER_PASSWORD') || process.env.OWNER_PASSWORD; const mobile = this.configService.get('OWNER_MOBILE') || process.env.OWNER_MOBILE; if (!email || !password) { console.warn( '[ACL] No Owner in DB and OWNER_EMAIL/OWNER_PASSWORD not set — skipping Owner seed', ); return; } const hashedPassword = createHash('sha256').update(password).digest('hex'); await this.adminModel.create({ email, username: email, password: hashedPassword, mobile: mobile || undefined, role: Role.Owner, name: 'Owner', family: '', isActive: true, permissionGrants: [], permissionDenies: [], }); await this.auditLogService.log({ action: 'acl.owner_seeded', resource: 'Admin', metadata: { email }, }); } async assertActorIsOwner(actorId: string) { const actor = await this.adminModel.findById(actorId).select('role isActive'); if (!actor?.isActive || !isOwnerRole(actor.role)) { throw new ForbiddenException('Only Owner can manage ACL'); } } listPermissionCatalog() { return { permissions: Object.values(Permission), note: 'ACL meta operations are Owner-only and not assignable', }; } async listRoles() { return this.staffRoleModel.find().sort({ isSystem: -1, name: 1 }).lean(); } async getRole(name: string) { const role = await this.staffRoleModel.findOne({ name }).lean(); if (!role) throw new NotFoundException('role_not_found'); return role; } async updateRolePermissions( actorId: string, roleName: string, dto: UpdateRolePermissionsDto, req?: any, ) { await this.assertActorIsOwner(actorId); if (roleName === Role.Owner) { throw new BadRequestException('Owner is not an editable role template'); } const permissions = assertAssignablePermissions(dto.permissions); const role = await this.staffRoleModel.findOne({ name: roleName }); if (!role) throw new NotFoundException('role_not_found'); const oldPermissions = [...role.permissions]; role.permissions = permissions; if (dto.displayName !== undefined) { role.displayName = dto.displayName; } await role.save(); await this.auditLogService.logHttpRequest('acl.role_permissions_updated', req || {}, { userId: actorId, resource: 'StaffRole', resourceId: roleName, oldValues: { permissions: oldPermissions }, newValues: { permissions }, }); return role.toObject(); } async createCustomRole(actorId: string, dto: CreateCustomRoleDto, req?: any) { await this.assertActorIsOwner(actorId); const name = dto.name.trim().toLowerCase(); if (!/^[a-z][a-z0-9_]{1,63}$/.test(name)) { throw new BadRequestException( 'Role name must be lowercase alphanumeric/underscore, starting with a letter', ); } if (isOwnerRole(name) || isSystemStaffRole(name) || name === Role.User) { throw new BadRequestException('Cannot create a role with a reserved name'); } const existing = await this.staffRoleModel.findOne({ name }); if (existing) throw new BadRequestException('role_already_exists'); const permissions = assertAssignablePermissions(dto.permissions); const role = await this.staffRoleModel.create({ name, displayName: dto.displayName || name, permissions, isSystem: false, }); await this.auditLogService.logHttpRequest('acl.role_created', req || {}, { userId: actorId, resource: 'StaffRole', resourceId: name, newValues: { permissions, displayName: role.displayName }, }); return role.toObject(); } async deleteCustomRole(actorId: string, roleName: string, req?: any) { await this.assertActorIsOwner(actorId); const role = await this.staffRoleModel.findOne({ name: roleName }); if (!role) throw new NotFoundException('role_not_found'); if (role.isSystem) { throw new BadRequestException('Cannot delete a system role'); } const usersCount = await this.adminModel.countDocuments({ role: roleName }); if (usersCount > 0) { throw new BadRequestException( `Cannot delete role while ${usersCount} user(s) are assigned; reassign them first`, ); } await role.deleteOne(); await this.auditLogService.logHttpRequest('acl.role_deleted', req || {}, { userId: actorId, resource: 'StaffRole', resourceId: roleName, }); return { deleted: true, name: roleName }; } async getUserEffectivePermissions(userId: string) { const admin = await this.adminModel .findById(userId) .select('role permissionGrants permissionDenies isActive email username') .lean(); if (!admin) throw new NotFoundException('staff_not_found'); const effective = await this.permissionsService.getEffectivePermissionsForStaff( admin, ); return { userId, role: admin.role, permissionGrants: admin.permissionGrants || [], permissionDenies: admin.permissionDenies || [], effectivePermissions: [...effective], }; } async updateUserOverrides( actorId: string, userId: string, dto: UpdateUserOverridesDto, req?: any, ) { await this.assertActorIsOwner(actorId); const admin = await this.adminModel.findById(userId); if (!admin) throw new NotFoundException('staff_not_found'); if (isOwnerRole(admin.role)) { throw new BadRequestException('Cannot set overrides on Owner'); } const oldValues = { permissionGrants: admin.permissionGrants || [], permissionDenies: admin.permissionDenies || [], }; if (dto.permissionGrants !== undefined) { admin.permissionGrants = assertAssignablePermissions(dto.permissionGrants); } if (dto.permissionDenies !== undefined) { admin.permissionDenies = assertAssignablePermissions(dto.permissionDenies); } await admin.save(); await this.auditLogService.logHttpRequest('acl.user_overrides_updated', req || {}, { userId: actorId, resource: 'Admin', resourceId: userId, oldValues, newValues: { permissionGrants: admin.permissionGrants, permissionDenies: admin.permissionDenies, }, }); return this.getUserEffectivePermissions(userId); } async changeStaffRole( actorId: string, userId: string, dto: ChangeStaffRoleDto, req?: any, ) { await this.assertActorIsOwner(actorId); const admin = await this.adminModel.findById(userId); if (!admin) throw new NotFoundException('staff_not_found'); if (isOwnerRole(admin.role)) { throw new BadRequestException('Cannot change Owner role via API'); } if (isOwnerRole(dto.role)) { throw new BadRequestException('Cannot promote to Owner via API'); } const roleExists = await this.staffRoleModel.findOne({ name: dto.role }); if (!roleExists) throw new NotFoundException('role_not_found'); const oldValues = { role: admin.role, permissionGrants: admin.permissionGrants || [], permissionDenies: admin.permissionDenies || [], }; admin.role = dto.role as Role; admin.permissionGrants = []; admin.permissionDenies = []; await admin.save(); await this.auditLogService.logHttpRequest('acl.staff_role_changed', req || {}, { userId: actorId, resource: 'Admin', resourceId: userId, oldValues, newValues: { role: admin.role, permissionGrants: [], permissionDenies: [], }, }); return this.getUserEffectivePermissions(userId); } async createStaff(actorId: string, dto: CreateStaffDto, req?: any) { const actor = await this.adminModel.findById(actorId); if (!actor?.isActive) throw new ForbiddenException('inactive_actor'); if (isOwnerRole(dto.role)) { throw new BadRequestException('Cannot create Owner via API'); } const requiredPerm = createPermissionForRole(dto.role); if (!requiredPerm) { // Custom role: only Owner may create users with custom roles for v1 if (!isOwnerRole(actor.role)) { throw new ForbiddenException( 'Only Owner can create staff with custom roles', ); } } else if (!isOwnerRole(actor.role)) { const ok = await this.permissionsService.hasPermission(actorId, requiredPerm); if (!ok) { throw new ForbiddenException(`Missing permission: ${requiredPerm}`); } } const roleExists = await this.staffRoleModel.findOne({ name: dto.role }); if (!roleExists) throw new NotFoundException('role_not_found'); const existing = await this.adminModel.findOne({ $or: [{ email: dto.email }, { mobile: dto.mobile }], }); if (existing) { throw new BadRequestException( 'Staff with this email or mobile already exists', ); } const hashedPassword = createHash('sha256') .update(dto.password) .digest('hex'); const created = await this.adminModel.create({ email: dto.email, username: dto.email, password: hashedPassword, mobile: dto.mobile, name: dto.name, family: dto.family, role: dto.role, isActive: true, permissionGrants: [], permissionDenies: [], }); const { password, ...safe } = created.toObject(); await this.auditLogService.logHttpRequest('acl.staff_created', req || {}, { userId: actorId, resource: 'Admin', resourceId: String(created._id), newValues: { email: dto.email, role: dto.role }, }); return safe; } async setStaffActive( actorId: string, targetId: string, isActive: boolean, req?: any, ) { const actor = await this.adminModel.findById(actorId); if (!actor?.isActive) throw new ForbiddenException('inactive_actor'); const target = await this.adminModel.findById(targetId); if (!target) throw new NotFoundException('staff_not_found'); if (isOwnerRole(target.role)) { throw new BadRequestException('Cannot deactivate Owner via API'); } if (String(target._id) === String(actorId)) { throw new BadRequestException('Cannot deactivate yourself'); } const requiredPerm = createPermissionForRole(target.role); if (!requiredPerm) { if (!isOwnerRole(actor.role)) { throw new ForbiddenException( 'Only Owner can deactivate staff with custom roles', ); } } else if (!isOwnerRole(actor.role)) { const ok = await this.permissionsService.hasPermission(actorId, requiredPerm); if (!ok) { throw new ForbiddenException(`Missing permission: ${requiredPerm}`); } } const oldActive = target.isActive; target.isActive = isActive; await target.save(); await this.auditLogService.logHttpRequest( isActive ? 'acl.staff_activated' : 'acl.staff_deactivated', req || {}, { userId: actorId, resource: 'Admin', resourceId: targetId, oldValues: { isActive: oldActive }, newValues: { isActive }, }, ); const { password, resetToken, ...safe } = target.toObject(); return safe; } async listAuditLogs(actorId: string, limit = 50, skip = 0) { await this.assertActorIsOwner(actorId); // Queried via AuditLogService model indirectly — use inject in controller path return { limit, skip }; } }