forked from Chatbot/v3-api
first commit v3 initialiazed a temporary repository for darmanet client
This commit is contained in:
456
src/acl/acl.service.ts
Normal file
456
src/acl/acl.service.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user