forked from Chatbot/v3-api
457 lines
14 KiB
TypeScript
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 };
|
|
}
|
|
}
|