Files
v3-api/src/acl/acl.service.ts

457 lines
14 KiB
TypeScript

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<AdminModel>,
@InjectModel(StaffRoleModel.name)
private readonly staffRoleModel: Model<StaffRoleModel>,
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<string>('OWNER_EMAIL') || process.env.OWNER_EMAIL;
const password =
this.configService.get<string>('OWNER_PASSWORD') ||
process.env.OWNER_PASSWORD;
const mobile =
this.configService.get<string>('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 };
}
}