402 lines
14 KiB
TypeScript
402 lines
14 KiB
TypeScript
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<OAuthClientListItem[]> {
|
||
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('Пользователь не найден');
|
||
}
|
||
}
|
||
}
|