import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { randomBytes } from 'node:crypto'; import { OAuthClientType } from '../generated/prisma/client'; import { PrismaService } from '../infra/prisma.service'; import { AccessService, UserAccessContext } from './access.service'; import { NotificationsService } from './notifications.service'; import { DEFAULT_USER_ROLE_SLUG } from './rbac.constants'; export interface OAuthClientListItem { id: string; name: string; clientId: string; redirectUris: string[]; scopes: string[]; isActive: boolean; type: OAuthClientType; createdById?: string | null; createdByDisplayName?: string | null; canManage: boolean; } @Injectable() export class RbacService { constructor( private readonly prisma: PrismaService, private readonly access: AccessService, private readonly notifications: NotificationsService ) {} async createRole(actorUserId: string, data: { slug: string; name: string; description?: string; permissionSlugs?: string[] }) { await this.access.assertRbacManage(actorUserId); return this.prisma.role.create({ data: { slug: data.slug, name: data.name, description: data.description, permissions: data.permissionSlugs?.length ? { create: data.permissionSlugs.map((slug) => ({ permission: { connect: { slug } } })) } : undefined }, include: { permissions: { include: { permission: true } } } }); } async upsertPermission(data: { slug: string; name: string; description?: string }) { return this.prisma.permission.upsert({ where: { slug: data.slug }, create: data, update: { name: data.name, description: data.description } }); } async updateRole( actorUserId: string, roleSlug: string, data: { name?: string; description?: string; permissionSlugs?: string[] } ) { await this.access.assertRbacManage(actorUserId); const role = await this.prisma.role.findUnique({ where: { slug: roleSlug } }); if (!role) { throw new NotFoundException('Роль не найдена'); } if (data.permissionSlugs !== undefined) { await this.prisma.rolePermission.deleteMany({ where: { roleId: role.id } }); for (const slug of data.permissionSlugs) { const permission = await this.prisma.permission.findUnique({ where: { slug } }); if (!permission) continue; await this.prisma.rolePermission.create({ data: { roleId: role.id, permissionId: permission.id } }); } } return this.prisma.role.update({ where: { id: role.id }, data: { name: data.name, description: data.description }, include: { permissions: { include: { permission: true } } } }); } async deleteRole(actorUserId: string, roleSlug: string) { await this.access.assertRbacManage(actorUserId); const role = await this.prisma.role.findUnique({ where: { slug: roleSlug } }); if (!role) { throw new NotFoundException('Роль не найдена'); } if (role.isSystem) { throw new BadRequestException('Системную роль нельзя удалить'); } await this.prisma.userRole.deleteMany({ where: { roleId: role.id } }); await this.prisma.rolePermission.deleteMany({ where: { roleId: role.id } }); await this.prisma.role.delete({ where: { id: role.id } }); return { slug: roleSlug }; } async assignRole(actorUserId: string, userId: string, roleSlug: string) { await this.access.assertRbacManage(actorUserId); const role = await this.prisma.role.findUnique({ where: { slug: roleSlug } }); if (!role) { throw new NotFoundException('Роль не найдена'); } await this.ensureUserExists(userId); const existingLink = await this.prisma.userRole.findUnique({ where: { userId_roleId: { userId, roleId: role.id } } }); if (existingLink) { return this.getUserRoleSlugs(userId); } await this.prisma.userRole.create({ data: { userId, roleId: role.id } }); await this.notifications.create(userId, 'role_assigned', 'Роли', `Вам назначена роль: ${role.name}`, { roleSlug: role.slug, roleName: role.name }); return this.getUserRoleSlugs(userId); } async removeRole(actorUserId: string, userId: string, roleSlug: string) { await this.access.assertRbacManage(actorUserId); const role = await this.prisma.role.findUnique({ where: { slug: roleSlug } }); if (!role) { throw new NotFoundException('Роль не найдена'); } if (role.isSystem || role.isDefault || role.slug === DEFAULT_USER_ROLE_SLUG) { throw new BadRequestException('Стандартную роль «Пользователь» нельзя снять'); } const existingLink = await this.prisma.userRole.findUnique({ where: { userId_roleId: { userId, roleId: role.id } } }); if (!existingLink) { return this.getUserRoleSlugs(userId); } await this.prisma.userRole.deleteMany({ where: { userId, roleId: role.id } }); await this.notifications.create(userId, 'role_removed', 'Роли', `С вас снята роль: ${role.name}`, { roleSlug: role.slug, roleName: role.name }); return this.getUserRoleSlugs(userId); } async listRoles() { return this.prisma.role.findMany({ orderBy: { name: 'asc' }, include: { permissions: { include: { permission: true } } } }); } async listPermissions() { return this.prisma.permission.findMany({ orderBy: { slug: 'asc' } }); } async listOAuthScopes() { return this.prisma.oAuthScope.findMany({ orderBy: { slug: 'asc' } }); } async createOAuthClient(actorUserId: string, data: { name: string; redirectUris: string[]; scopes: string[]; type?: OAuthClientType }) { await this.assertCanCreateOAuth(actorUserId); if (!data.redirectUris.length) { throw new BadRequestException('Укажите хотя бы один redirect URI'); } if (!data.scopes.length) { throw new BadRequestException('Выберите хотя бы один scope'); } const clientSecret = data.type === OAuthClientType.PUBLIC ? null : randomBytes(32).toString('base64url'); const client = await this.prisma.oAuthClient.create({ data: { name: data.name, clientId: `lendry_${randomBytes(16).toString('hex')}`, clientSecret, redirectUris: data.redirectUris, type: data.type ?? OAuthClientType.CONFIDENTIAL, createdById: actorUserId, scopes: { create: data.scopes.map((slug) => ({ scope: { connect: { slug } } })) } }, include: { scopes: { include: { scope: true } }, createdBy: true } }); return { client, clientSecret }; } async updateOAuthClient( actorUserId: string, clientId: string, data: { name?: string; redirectUris?: string[]; scopes?: string[]; isActive?: boolean } ) { const existing = await this.prisma.oAuthClient.findUnique({ where: { clientId }, include: { scopes: true } }); if (!existing) { throw new NotFoundException('OAuth-приложение не найдено'); } await this.assertCanManageOAuthClient(actorUserId, existing); if (data.redirectUris && !data.redirectUris.length) { throw new BadRequestException('Укажите хотя бы один redirect URI'); } if (data.scopes) { await this.prisma.oAuthClientScope.deleteMany({ where: { clientId: existing.id } }); for (const slug of data.scopes) { const scope = await this.prisma.oAuthScope.findUnique({ where: { slug } }); if (!scope) continue; await this.prisma.oAuthClientScope.create({ data: { clientId: existing.id, scopeId: scope.id } }); } } const updated = await this.prisma.oAuthClient.update({ where: { id: existing.id }, data: { name: data.name, redirectUris: data.redirectUris, isActive: data.isActive }, include: { scopes: { include: { scope: true } }, createdBy: true } }); return { client: updated, clientSecret: undefined as string | undefined }; } async deleteOAuthClient(actorUserId: string, clientId: string) { const existing = await this.prisma.oAuthClient.findUnique({ where: { clientId } }); if (!existing) { throw new NotFoundException('OAuth-приложение не найдено'); } await this.assertCanManageOAuthClient(actorUserId, existing); await this.prisma.oAuthClient.delete({ where: { id: existing.id } }); return { clientId }; } async ensureDefaultUserRole(userId: string) { const role = await this.prisma.role.findUnique({ where: { slug: DEFAULT_USER_ROLE_SLUG } }); if (!role) return; await this.prisma.userRole.upsert({ where: { userId_roleId: { userId, roleId: role.id } }, create: { userId, roleId: role.id }, update: {} }); } async assignUserPermission(actorUserId: string, userId: string, permissionSlug: string) { await this.access.assertRbacManage(actorUserId); await this.ensureUserExists(userId); const permission = await this.prisma.permission.findUnique({ where: { slug: permissionSlug } }); if (!permission) { throw new NotFoundException('Право не найдено'); } await this.prisma.userPermission.upsert({ where: { userId_permissionId: { userId, permissionId: permission.id } }, create: { userId, permissionId: permission.id }, update: {} }); return this.getUserPermissionSlugs(userId); } async removeUserPermission(actorUserId: string, userId: string, permissionSlug: string) { await this.access.assertRbacManage(actorUserId); const permission = await this.prisma.permission.findUnique({ where: { slug: permissionSlug } }); if (!permission) { throw new NotFoundException('Право не найдено'); } await this.prisma.userPermission.deleteMany({ where: { userId, permissionId: permission.id } }); return this.getUserPermissionSlugs(userId); } async rotateOAuthSecret(actorUserId: string, clientId: string) { const existing = await this.prisma.oAuthClient.findUnique({ where: { clientId } }); if (!existing) { throw new NotFoundException('OAuth-приложение не найдено'); } await this.assertCanManageOAuthClient(actorUserId, existing); if (existing.type === OAuthClientType.PUBLIC) { throw new BadRequestException('У публичного клиента нет client secret'); } const clientSecret = randomBytes(32).toString('base64url'); await this.prisma.oAuthClient.update({ where: { id: existing.id }, data: { clientSecret } }); return { clientId, clientSecret }; } async listOAuthClients(actorUserId: string): Promise { const { access } = await this.getActorAccess(actorUserId); if (!access.canViewOAuth) { throw new ForbiddenException('Недостаточно прав для просмотра OAuth-приложений'); } const clients = await this.prisma.oAuthClient.findMany({ orderBy: { createdAt: 'desc' }, include: { scopes: { include: { scope: true } }, createdBy: true } }); return clients.map((client) => this.toOAuthClientListItem(client, actorUserId, access)); } private toOAuthClientListItem( client: { id: string; name: string; clientId: string; redirectUris: string[]; isActive: boolean; type: OAuthClientType; createdById: string | null; createdBy?: { displayName: string } | null; scopes: Array<{ scope: { slug: string } }>; }, actorUserId: string, access: UserAccessContext ): OAuthClientListItem { return { id: client.id, name: client.name, clientId: client.clientId, redirectUris: client.redirectUris, scopes: client.scopes.map((link) => link.scope.slug), isActive: client.isActive, type: client.type, createdById: client.createdById, createdByDisplayName: client.createdBy?.displayName ?? null, canManage: this.access.canManageOAuthClient(access, actorUserId, client) }; } private async getActorAccess(actorUserId: string) { const actor = await this.prisma.user.findUnique({ where: { id: actorUserId } }); if (!actor) { throw new NotFoundException('Пользователь не найден'); } const access = await this.access.getUserAccess(actor.id, actor.isSuperAdmin); return { actor, access }; } private async assertCanCreateOAuth(actorUserId: string) { const { access } = await this.getActorAccess(actorUserId); if (!access.canManageOAuth) { throw new ForbiddenException('Недостаточно прав для управления OAuth-приложениями'); } } private async assertCanManageOAuthClient(actorUserId: string, client: { createdById: string | null }) { const { access } = await this.getActorAccess(actorUserId); if (!this.access.canManageOAuthClient(access, actorUserId, client)) { throw new ForbiddenException('Недостаточно прав для изменения этого OAuth-приложения'); } } private async getUserRoleSlugs(userId: string) { const links = await this.prisma.userRole.findMany({ where: { userId }, include: { role: true } }); return links.map((link) => link.role.slug); } private async getUserPermissionSlugs(userId: string) { const links = await this.prisma.userPermission.findMany({ where: { userId }, include: { permission: true } }); return links.map((link) => link.permission.slug); } private async ensureUserExists(userId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new NotFoundException('Пользователь не найден'); } } }