Files
IdP/apps/sso-core/src/domain/rbac.service.ts
2026-06-25 15:14:50 +03:00

402 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('Пользователь не найден');
}
}
}