From 1c55c871fc17f07dbadd3d8e7feb9ac4d6bbacc3 Mon Sep 17 00:00:00 2001 From: lendry Date: Thu, 25 Jun 2026 15:14:50 +0300 Subject: [PATCH] roles update --- apps/api-gateway/src/app.module.ts | 4 +- .../src/controllers/rbac.controller.ts | 79 +++++++-- apps/api-gateway/src/dto/rbac.dto.ts | 24 +++ apps/api-gateway/src/guards/admin.guard.ts | 11 ++ apps/frontend/app/admin/oauth/page.tsx | 14 ++ apps/frontend/app/admin/rbac/page.tsx | 144 +++++++++++++---- apps/frontend/app/admin/users/page.tsx | 116 +++++++++++-- .../id/oauth-client-detail-dialog.tsx | 15 +- .../notifications/notification-bell.tsx | 8 + .../notifications/realtime-provider.tsx | 7 +- apps/frontend/lib/api.ts | 3 + apps/sso-core/prisma/schema.prisma | 14 ++ apps/sso-core/src/domain/access.service.ts | 46 ++++-- .../sso-core/src/domain/admin-seed.service.ts | 41 ++++- apps/sso-core/src/domain/admin.service.ts | 19 ++- .../src/domain/auth-grpc.controller.ts | 56 +++++++ apps/sso-core/src/domain/auth.service.ts | 13 +- apps/sso-core/src/domain/rbac.constants.ts | 1 + apps/sso-core/src/domain/rbac.service.ts | 152 +++++++++++++++++- shared/proto/admin.proto | 1 + shared/proto/rbac.proto | 46 ++++++ 21 files changed, 721 insertions(+), 93 deletions(-) create mode 100644 apps/sso-core/src/domain/rbac.constants.ts diff --git a/apps/api-gateway/src/app.module.ts b/apps/api-gateway/src/app.module.ts index aba81cc..7489d37 100644 --- a/apps/api-gateway/src/app.module.ts +++ b/apps/api-gateway/src/app.module.ts @@ -21,7 +21,7 @@ import { ChatController } from './controllers/chat.controller'; import { NotificationsController } from './controllers/notifications.controller'; import { MediaController } from './controllers/media.controller'; import { CoreGrpcService } from './core-grpc.service'; -import { AdminGuard, SuperAdminGuard } from './guards/admin.guard'; +import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.guard'; @Module({ imports: [ @@ -56,6 +56,6 @@ import { AdminGuard, SuperAdminGuard } from './guards/admin.guard'; ]) ], controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, WellKnownController, OAuthAuthorizeDiscoveryController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController], - providers: [CoreGrpcService, AdminGuard, SuperAdminGuard] + providers: [CoreGrpcService, AdminGuard, RbacManageGuard, SuperAdminGuard] }) export class AppModule {} diff --git a/apps/api-gateway/src/controllers/rbac.controller.ts b/apps/api-gateway/src/controllers/rbac.controller.ts index 32002a7..a8fa908 100644 --- a/apps/api-gateway/src/controllers/rbac.controller.ts +++ b/apps/api-gateway/src/controllers/rbac.controller.ts @@ -3,8 +3,22 @@ import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs import { map } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; import { CurrentAdmin } from '../decorators/current-admin.decorator'; -import { AssignUserRoleDto, CreateOAuthClientDto, CreateRoleDto, UpdateOAuthClientDto } from '../dto/rbac.dto'; -import { AdminGuard, AdminRequestUser, assertAdminAnyPermission, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard'; +import { + AssignUserPermissionDto, + AssignUserRoleDto, + CreateOAuthClientDto, + CreateRoleDto, + UpdateOAuthClientDto, + UpdateRoleDto +} from '../dto/rbac.dto'; +import { + AdminGuard, + AdminRequestUser, + assertAdminAnyPermission, + assertAdminPermission, + RbacManageGuard, + SuperAdminGuard +} from '../guards/admin.guard'; @ApiTags('RBAC и OAuth') @ApiBearerAuth() @@ -30,7 +44,7 @@ export class RbacController { } @Get('permissions') - @UseGuards(SuperAdminGuard) + @UseGuards(RbacManageGuard) @ApiOperation({ summary: 'Список прав', description: 'Возвращает все доступные permissions.' }) listPermissions() { return this.core.rbac.ListPermissions({}); @@ -51,16 +65,33 @@ export class RbacController { } @Post('roles') - @UseGuards(SuperAdminGuard) - @ApiOperation({ summary: 'Создать роль', description: 'Создаёт новую роль с набором прав. Только супер-администратор.' }) + @UseGuards(RbacManageGuard) + @ApiOperation({ summary: 'Создать роль', description: 'Создаёт новую роль с набором прав.' }) @ApiBody({ type: CreateRoleDto }) createRole(@Body() dto: CreateRoleDto, @CurrentAdmin() admin: AdminRequestUser) { return this.core.rbac.CreateRole({ actorUserId: admin.id, ...dto }); } + @Patch('roles/:roleSlug') + @UseGuards(RbacManageGuard) + @ApiOperation({ summary: 'Обновить роль', description: 'Изменяет название, описание и права роли.' }) + @ApiParam({ name: 'roleSlug', description: 'Slug роли' }) + @ApiBody({ type: UpdateRoleDto }) + updateRole(@Param('roleSlug') roleSlug: string, @Body() dto: UpdateRoleDto, @CurrentAdmin() admin: AdminRequestUser) { + return this.core.rbac.UpdateRole({ actorUserId: admin.id, roleSlug, ...dto }); + } + + @Delete('roles/:roleSlug') + @UseGuards(RbacManageGuard) + @ApiOperation({ summary: 'Удалить роль', description: 'Удаляет пользовательскую роль. Системные роли удалить нельзя.' }) + @ApiParam({ name: 'roleSlug', description: 'Slug роли' }) + deleteRole(@Param('roleSlug') roleSlug: string, @CurrentAdmin() admin: AdminRequestUser) { + return this.core.rbac.DeleteRole({ actorUserId: admin.id, roleSlug }); + } + @Post('users/:userId/roles') - @UseGuards(SuperAdminGuard) - @ApiOperation({ summary: 'Назначить роль пользователю', description: 'Только супер-администратор может назначать роли.' }) + @UseGuards(RbacManageGuard) + @ApiOperation({ summary: 'Назначить роль пользователю' }) @ApiParam({ name: 'userId', description: 'ID пользователя' }) @ApiBody({ type: AssignUserRoleDto }) assignRole(@Param('userId') userId: string, @Body() dto: AssignUserRoleDto, @CurrentAdmin() admin: AdminRequestUser) { @@ -68,14 +99,36 @@ export class RbacController { } @Delete('users/:userId/roles/:roleSlug') - @UseGuards(SuperAdminGuard) - @ApiOperation({ summary: 'Снять роль с пользователя', description: 'Только супер-администратор может снимать роли.' }) + @UseGuards(RbacManageGuard) + @ApiOperation({ summary: 'Снять роль с пользователя' }) @ApiParam({ name: 'userId', description: 'ID пользователя' }) @ApiParam({ name: 'roleSlug', description: 'Slug роли' }) removeRole(@Param('userId') userId: string, @Param('roleSlug') roleSlug: string, @CurrentAdmin() admin: AdminRequestUser) { return this.core.rbac.RemoveUserRole({ actorUserId: admin.id, userId, roleSlug }); } + @Post('users/:userId/permissions') + @UseGuards(RbacManageGuard) + @ApiOperation({ summary: 'Назначить право пользователю', description: 'Прямое назначение permission без смены роли.' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiBody({ type: AssignUserPermissionDto }) + assignPermission(@Param('userId') userId: string, @Body() dto: AssignUserPermissionDto, @CurrentAdmin() admin: AdminRequestUser) { + return this.core.rbac.AssignUserPermission({ actorUserId: admin.id, userId, permissionSlug: dto.permissionSlug }); + } + + @Delete('users/:userId/permissions/:permissionSlug') + @UseGuards(RbacManageGuard) + @ApiOperation({ summary: 'Снять право с пользователя' }) + @ApiParam({ name: 'userId', description: 'ID пользователя' }) + @ApiParam({ name: 'permissionSlug', description: 'Slug права' }) + removePermission( + @Param('userId') userId: string, + @Param('permissionSlug') permissionSlug: string, + @CurrentAdmin() admin: AdminRequestUser + ) { + return this.core.rbac.RemoveUserPermission({ actorUserId: admin.id, userId, permissionSlug }); + } + @Post('oauth-clients') @ApiOperation({ summary: 'Создать OAuth-приложение', description: 'Создаёт OAuth2-клиент и возвращает client secret один раз.' }) @ApiBody({ type: CreateOAuthClientDto }) @@ -93,6 +146,14 @@ export class RbacController { return this.core.rbac.UpdateOAuthClient({ actorUserId: admin.id, clientId, ...dto }); } + @Delete('oauth-clients/:clientId') + @ApiOperation({ summary: 'Удалить OAuth-приложение', description: 'Полностью удаляет OAuth2-клиент и связанные данные.' }) + @ApiParam({ name: 'clientId', description: 'Client ID приложения' }) + deleteOAuthClient(@Param('clientId') clientId: string, @CurrentAdmin() admin: AdminRequestUser) { + assertAdminPermission(admin, 'canManageOAuth'); + return this.core.rbac.DeleteOAuthClient({ actorUserId: admin.id, clientId }); + } + @Post('oauth-clients/:clientId/rotate-secret') @ApiOperation({ summary: 'Перевыпустить client secret', description: 'Генерирует новый secret для confidential-клиента.' }) @ApiParam({ name: 'clientId', description: 'Client ID приложения' }) diff --git a/apps/api-gateway/src/dto/rbac.dto.ts b/apps/api-gateway/src/dto/rbac.dto.ts index df98068..886c05c 100644 --- a/apps/api-gateway/src/dto/rbac.dto.ts +++ b/apps/api-gateway/src/dto/rbac.dto.ts @@ -33,6 +33,30 @@ export class AssignUserRoleDto { roleSlug!: string; } +export class AssignUserPermissionDto { + @ApiProperty({ description: 'Slug права', example: 'oauth.manage' }) + @IsString({ message: 'Slug права должен быть строкой' }) + permissionSlug!: string; +} + +export class UpdateRoleDto { + @ApiPropertyOptional({ description: 'Название роли' }) + @IsOptional() + @IsString({ message: 'Название роли должно быть строкой' }) + name?: string; + + @ApiPropertyOptional({ description: 'Описание роли' }) + @IsOptional() + @IsString({ message: 'Описание должно быть строкой' }) + description?: string; + + @ApiPropertyOptional({ description: 'Список slug прав', type: [String] }) + @IsOptional() + @IsArray({ message: 'Права должны быть массивом' }) + @IsString({ each: true, message: 'Каждое право должно быть строкой' }) + permissionSlugs?: string[]; +} + export class CreateOAuthClientDto { @ApiProperty({ description: 'Название приложения', example: 'Lendry Docs' }) @IsString({ message: 'Название должно быть строкой' }) diff --git a/apps/api-gateway/src/guards/admin.guard.ts b/apps/api-gateway/src/guards/admin.guard.ts index f80afb7..44ac470 100644 --- a/apps/api-gateway/src/guards/admin.guard.ts +++ b/apps/api-gateway/src/guards/admin.guard.ts @@ -66,6 +66,17 @@ export class AdminGuard implements CanActivate { } } +@Injectable() +export class RbacManageGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ adminUser?: AdminRequestUser }>(); + if (request.adminUser?.isSuperAdmin || request.adminUser?.canManageRoles) { + return true; + } + throw new ForbiddenException('Недостаточно прав для управления ролями и правами'); + } +} + @Injectable() export class SuperAdminGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { diff --git a/apps/frontend/app/admin/oauth/page.tsx b/apps/frontend/app/admin/oauth/page.tsx index 5ea9a3e..0d4da22 100644 --- a/apps/frontend/app/admin/oauth/page.tsx +++ b/apps/frontend/app/admin/oauth/page.tsx @@ -133,6 +133,19 @@ export default function AdminOAuthPage() { } } + async function handleDeleteClient(client: OAuthClient) { + if (!token) return; + if (!window.confirm(`Удалить приложение «${client.name}»? Это действие необратимо.`)) return; + try { + await apiFetch(`/admin/rbac/oauth-clients/${client.clientId}`, { method: 'DELETE' }, token); + showToast('Приложение удалено'); + setSelectedClient(null); + await loadData(); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Не удалось удалить приложение'); + } + } + function copyText(value: string, label: string) { void navigator.clipboard.writeText(value); showToast(`${label} скопирован`); @@ -213,6 +226,7 @@ export default function AdminOAuthPage() { onCopy={copyText} onRotateSecret={(clientId) => void handleRotateSecret(clientId)} onToggleActive={(client) => void handleToggleActive(client)} + onDelete={(client) => void handleDeleteClient(client)} /> diff --git a/apps/frontend/app/admin/rbac/page.tsx b/apps/frontend/app/admin/rbac/page.tsx index 9b1ff1e..cb94f81 100644 --- a/apps/frontend/app/admin/rbac/page.tsx +++ b/apps/frontend/app/admin/rbac/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Loader2, Plus, ShieldCheck } from 'lucide-react'; +import { Loader2, Pencil, Plus, ShieldCheck, Trash2 } from 'lucide-react'; import { AdminShell } from '@/components/id/admin-shell'; import { useAuth } from '@/components/id/auth-provider'; import { useToast } from '@/components/id/toast-provider'; @@ -11,15 +11,31 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { Input } from '@/components/ui/input'; import { AdminPermission, AdminRole, apiFetch } from '@/lib/api'; +type RoleFormState = { + slug: string; + name: string; + description: string; + permissionSlugs: string[]; +}; + +const emptyForm: RoleFormState = { slug: '', name: '', description: '', permissionSlugs: [] }; + export default function AdminRbacPage() { const { token, user } = useAuth(); const { showToast } = useToast(); const [roles, setRoles] = useState([]); const [permissions, setPermissions] = useState([]); const [loading, setLoading] = useState(true); - const [creating, setCreating] = useState(false); + const [saving, setSaving] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); - const [form, setForm] = useState({ slug: '', name: '', description: '', permissionSlugs: [] as string[] }); + const [editingRole, setEditingRole] = useState(null); + const [form, setForm] = useState(emptyForm); + + async function reloadRoles() { + if (!token) return; + const response = await apiFetch<{ roles: AdminRole[] }>('/admin/rbac/roles', {}, token); + setRoles(response.roles ?? []); + } useEffect(() => { if (!token || !user?.canManageRoles) return; @@ -35,30 +51,74 @@ export default function AdminRbacPage() { .finally(() => setLoading(false)); }, [showToast, token, user?.canManageRoles]); - async function handleCreateRole() { + function openCreateDialog() { + setEditingRole(null); + setForm(emptyForm); + setDialogOpen(true); + } + + function openEditDialog(role: AdminRole) { + setEditingRole(role); + setForm({ + slug: role.slug, + name: role.name, + description: role.description ?? '', + permissionSlugs: role.permissions.map((permission) => permission.slug) + }); + setDialogOpen(true); + } + + async function handleSaveRole() { if (!token) return; - setCreating(true); + setSaving(true); try { - await apiFetch('/admin/rbac/roles', { - method: 'POST', - body: JSON.stringify(form) - }, token); - showToast('Роль создана'); + if (editingRole) { + await apiFetch(`/admin/rbac/roles/${editingRole.slug}`, { + method: 'PATCH', + body: JSON.stringify({ + name: form.name, + description: form.description || undefined, + permissionSlugs: form.permissionSlugs + }) + }, token); + showToast('Роль обновлена'); + } else { + await apiFetch('/admin/rbac/roles', { + method: 'POST', + body: JSON.stringify(form) + }, token); + showToast('Роль создана'); + } setDialogOpen(false); - setForm({ slug: '', name: '', description: '', permissionSlugs: [] }); - const response = await apiFetch<{ roles: AdminRole[] }>('/admin/rbac/roles', {}, token); - setRoles(response.roles ?? []); + setForm(emptyForm); + setEditingRole(null); + await reloadRoles(); } catch (error) { - showToast(error instanceof Error ? error.message : 'Не удалось создать роль'); + showToast(error instanceof Error ? error.message : 'Не удалось сохранить роль'); } finally { - setCreating(false); + setSaving(false); + } + } + + async function handleDeleteRole(role: AdminRole) { + if (!token || role.isSystem) return; + if (!window.confirm(`Удалить роль «${role.name}»? Она будет снята со всех пользователей.`)) return; + setSaving(true); + try { + await apiFetch(`/admin/rbac/roles/${role.slug}`, { method: 'DELETE' }, token); + showToast('Роль удалена'); + await reloadRoles(); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Не удалось удалить роль'); + } finally { + setSaving(false); } } if (!user?.canManageRoles) { return ( -

Управление ролями доступно только супер-администратору.

+

Управление ролями доступно пользователям с правом rbac.manage.

); } @@ -68,9 +128,9 @@ export default function AdminRbacPage() {

Роли и права

-

Только супер-администратор может создавать роли и назначать их пользователям

+

Редактируйте роли, назначайте права всем пользователям или отдельным аккаунтам

- @@ -86,17 +146,37 @@ export default function AdminRbacPage() { {roles.map((role) => ( - +
+ +
+ + {!role.isSystem ? ( + + ) : null} +
+
{role.name} - {role.permissions.length} прав · {role.slug} + + {role.permissions.length} прав · {role.slug} + {role.isSystem ? ' · системная' : ''} + {role.isDefault ? ' · по умолчанию' : ''} +
- {role.permissions.map((permission) => ( -
-
{permission.name}
-
{permission.slug}
-
- ))} + {role.permissions.length ? ( + role.permissions.map((permission) => ( +
+
{permission.name}
+
{permission.slug}
+
+ )) + ) : ( +

Права не назначены

+ )}
))} @@ -106,10 +186,14 @@ export default function AdminRbacPage() { - Новая роль + {editingRole ? `Редактирование: ${editingRole.name}` : 'Новая роль'}
- setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="slug, например support" /> + {!editingRole ? ( + setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="slug, например support" /> + ) : ( +

Slug: {editingRole.slug}

+ )} setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Название роли" /> setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Описание" />
@@ -132,8 +216,8 @@ export default function AdminRbacPage() { ))}
-
diff --git a/apps/frontend/app/admin/users/page.tsx b/apps/frontend/app/admin/users/page.tsx index 9c1d7ec..6f1b7bf 100644 --- a/apps/frontend/app/admin/users/page.tsx +++ b/apps/frontend/app/admin/users/page.tsx @@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { AdminRole, AdminUser, apiFetch } from '@/lib/api'; +import { AdminPermission, AdminRole, AdminUser, apiFetch } from '@/lib/api'; import { getAdminLandingPath } from '@/lib/admin-access'; const statusLabels: Record = { @@ -21,18 +21,22 @@ const statusLabels: Record = { }; const roleLabels: Record = { + user: 'Пользователь', admin: 'Администратор', moderator: 'Модератор', manager: 'Менеджер', 'super-admin': 'Супер-админ' }; +const DEFAULT_USER_ROLE = 'user'; + export default function AdminUsersPage() { const router = useRouter(); const { token, user: currentUser } = useAuth(); const { showToast } = useToast(); const [users, setUsers] = useState([]); const [roles, setRoles] = useState([]); + const [permissions, setPermissions] = useState([]); const [search, setSearch] = useState(''); const [loading, setLoading] = useState(true); const [selectedUser, setSelectedUser] = useState(null); @@ -68,12 +72,26 @@ export default function AdminUsersPage() { useEffect(() => { if (!token || !currentUser?.canManageRoles) return; - apiFetch<{ roles: AdminRole[] }>('/admin/rbac/roles', {}, token) - .then((response) => setRoles(response.roles ?? [])) + Promise.all([ + apiFetch<{ roles: AdminRole[] }>('/admin/rbac/roles', {}, token), + apiFetch<{ permissions: AdminPermission[] }>('/admin/rbac/permissions', {}, token) + ]) + .then(([rolesResponse, permissionsResponse]) => { + setRoles(rolesResponse.roles ?? []); + setPermissions(permissionsResponse.permissions ?? []); + }) .catch(() => undefined); }, [currentUser?.canManageRoles, token]); - const assignableRoles = useMemo(() => roles.filter((role) => role.slug !== 'super-admin'), [roles]); + const assignableRoles = useMemo( + () => roles.filter((role) => role.slug !== 'super-admin' && !role.isDefault), + [roles] + ); + + const permissionLabelBySlug = useMemo( + () => Object.fromEntries(permissions.map((permission) => [permission.slug, permission.name])), + [permissions] + ); async function handleSuspend(user: AdminUser) { if (!token) return; @@ -171,11 +189,52 @@ export default function AdminUsersPage() { } } + async function handleAssignPermission(permissionSlug: string) { + if (!token || !selectedUser) return; + setActionLoading(true); + try { + const response = await apiFetch<{ permissions: string[] }>(`/admin/rbac/users/${selectedUser.id}/permissions`, { + method: 'POST', + body: JSON.stringify({ permissionSlug }) + }, token); + setSelectedUser({ ...selectedUser, directPermissions: response.permissions ?? [] }); + showToast('Право назначено'); + await loadUsers(); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Не удалось назначить право'); + } finally { + setActionLoading(false); + } + } + + async function handleRemovePermission(permissionSlug: string) { + if (!token || !selectedUser) return; + setActionLoading(true); + try { + const response = await apiFetch<{ permissions: string[] }>(`/admin/rbac/users/${selectedUser.id}/permissions/${permissionSlug}`, { + method: 'DELETE' + }, token); + setSelectedUser({ ...selectedUser, directPermissions: response.permissions ?? [] }); + showToast('Право снято'); + await loadUsers(); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Не удалось снять право'); + } finally { + setActionLoading(false); + } + } + function renderRoles(user: AdminUser) { const userRoles = user.roles ?? []; - const labels = user.isSuperAdmin ? ['Супер-админ', ...userRoles.map((role) => roleLabels[role] ?? role)] : userRoles.map((role) => roleLabels[role] ?? role); - if (!labels.length) return 'Пользователь'; - return labels.join(', '); + const extraRoles = userRoles.filter((role) => role !== DEFAULT_USER_ROLE); + const direct = user.directPermissions ?? []; + const labels = user.isSuperAdmin + ? ['Супер-админ', ...extraRoles.map((role) => roleLabels[role] ?? role)] + : extraRoles.map((role) => roleLabels[role] ?? role); + const directLabels = direct.map((slug) => permissionLabelBySlug[slug] ?? slug); + const parts = [...labels, ...directLabels.map((label) => `+ ${label}`)]; + if (!parts.length) return roleLabels.user; + return parts.join(', '); } return ( @@ -312,10 +371,14 @@ export default function AdminUsersPage() {
{(selectedUser?.roles ?? []).map((role) => (
- {roleLabels[role] ?? role} - + {roleLabels[role] ?? role}{role === DEFAULT_USER_ROLE ? ' (стандартная)' : ''} + {role === DEFAULT_USER_ROLE ? ( + Нельзя снять + ) : ( + + )}
))} {!(selectedUser?.roles ?? []).length ?

Роли не назначены

: null} @@ -335,6 +398,37 @@ export default function AdminUsersPage() { ))}
+
+

Прямые права

+

Дополнительные permissions без смены роли. Например, oauth.manage для одного пользователя.

+ {(selectedUser?.directPermissions ?? []).map((permissionSlug) => ( +
+ {permissionLabelBySlug[permissionSlug] ?? permissionSlug} + +
+ ))} + {!(selectedUser?.directPermissions ?? []).length ? ( +

Прямые права не назначены

+ ) : null} +
+ {permissions + .filter((permission) => !(selectedUser?.directPermissions ?? []).includes(permission.slug)) + .map((permission) => ( + + ))} +
+
diff --git a/apps/frontend/components/id/oauth-client-detail-dialog.tsx b/apps/frontend/components/id/oauth-client-detail-dialog.tsx index 550e38e..da238aa 100644 --- a/apps/frontend/components/id/oauth-client-detail-dialog.tsx +++ b/apps/frontend/components/id/oauth-client-detail-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Copy, ExternalLink, KeyRound, RefreshCw } from 'lucide-react'; +import { Copy, ExternalLink, KeyRound, RefreshCw, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { OAuthClient } from '@/lib/api'; @@ -27,7 +27,8 @@ export function OAuthClientDetailDialog({ onOpenChange, onCopy, onRotateSecret, - onToggleActive + onToggleActive, + onDelete }: { client: OAuthClient | null; endpoints: OAuthEndpoints; @@ -36,6 +37,7 @@ export function OAuthClientDetailDialog({ onCopy: (value: string, label: string) => void; onRotateSecret: (clientId: string) => void; onToggleActive: (client: OAuthClient) => void; + onDelete: (client: OAuthClient) => void; }) { if (!client) return null; @@ -125,6 +127,15 @@ export function OAuthClientDetailDialog({ +
) : null} diff --git a/apps/frontend/components/notifications/notification-bell.tsx b/apps/frontend/components/notifications/notification-bell.tsx index 95602b3..6cd0b0f 100644 --- a/apps/frontend/components/notifications/notification-bell.tsx +++ b/apps/frontend/components/notifications/notification-bell.tsx @@ -63,6 +63,9 @@ export function NotificationBell() { if (event.type === 'family_invite') { showToast(event.message || 'Новое приглашение в семью'); } + if (event.type === 'role_assigned' || event.type === 'role_removed') { + showToast(event.message || 'Изменение ролей'); + } }); }, [load, showToast, subscribe]); @@ -80,6 +83,11 @@ export function NotificationBell() { if (payload.groupId) { setOpen(false); router.push(`/family/${payload.groupId}`); + return; + } + if (item.type === 'role_assigned' || item.type === 'role_removed') { + setOpen(false); + router.push('/admin'); } } diff --git a/apps/frontend/components/notifications/realtime-provider.tsx b/apps/frontend/components/notifications/realtime-provider.tsx index f6b51ba..e466899 100644 --- a/apps/frontend/components/notifications/realtime-provider.tsx +++ b/apps/frontend/components/notifications/realtime-provider.tsx @@ -74,7 +74,12 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) { try { const data = JSON.parse(event.data as string) as RealtimeEvent; emit(data); - if (data.type === 'family_invite' || (data.type === 'chat_message' && data.payload?.notificationId)) { + if ( + data.type === 'family_invite' || + data.type === 'role_assigned' || + data.type === 'role_removed' || + (data.type === 'chat_message' && data.payload?.notificationId) + ) { setUnreadCount((count) => count + 1); } } catch { diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index cdec3a8..2c2bdef 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -91,6 +91,7 @@ export interface AdminUser { status: string; createdAt: string; roles: string[]; + directPermissions?: string[]; } export interface AdminRole { @@ -98,6 +99,8 @@ export interface AdminRole { slug: string; name: string; description?: string; + isSystem?: boolean; + isDefault?: boolean; permissions: { id: string; slug: string; name: string; description?: string }[]; } diff --git a/apps/sso-core/prisma/schema.prisma b/apps/sso-core/prisma/schema.prisma index c3bdfb5..a5885ae 100644 --- a/apps/sso-core/prisma/schema.prisma +++ b/apps/sso-core/prisma/schema.prisma @@ -61,6 +61,7 @@ model User { authCodes AuthCode[] linkedAccounts LinkedAccount[] userRoles UserRole[] + userPermissions UserPermission[] profile UserProfile? documents UserDocument[] addresses Address[] @@ -183,6 +184,8 @@ model Role { slug String @unique name String description String? + isSystem Boolean @default(false) + isDefault Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt users UserRole[] @@ -196,6 +199,17 @@ model Permission { description String? createdAt DateTime @default(now()) roles RolePermission[] + users UserPermission[] +} + +model UserPermission { + userId String + permissionId String + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade) + + @@id([userId, permissionId]) } model UserRole { diff --git a/apps/sso-core/src/domain/access.service.ts b/apps/sso-core/src/domain/access.service.ts index 9accab8..63474cd 100644 --- a/apps/sso-core/src/domain/access.service.ts +++ b/apps/sso-core/src/domain/access.service.ts @@ -1,4 +1,4 @@ -import { ForbiddenException, Injectable } from '@nestjs/common'; +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../infra/prisma.service'; export interface UserAccessContext { @@ -55,21 +55,29 @@ export class AccessService { }; } - const links = await this.prisma.userRole.findMany({ - where: { userId }, - include: { - role: { - include: { - permissions: { - include: { permission: true } + const [roleLinks, directLinks] = await Promise.all([ + this.prisma.userRole.findMany({ + where: { userId }, + include: { + role: { + include: { + permissions: { + include: { permission: true } + } } } } - } - }); + }), + this.prisma.userPermission.findMany({ + where: { userId }, + include: { permission: true } + }) + ]); - const roles = links.map((link) => link.role.slug); - const permissions = [...new Set(links.flatMap((link) => link.role.permissions.map((item) => item.permission.slug)))]; + const roles = roleLinks.map((link) => link.role.slug); + const rolePermissions = roleLinks.flatMap((link) => link.role.permissions.map((item) => item.permission.slug)); + const directPermissions = directLinks.map((link) => link.permission.slug); + const permissions = [...new Set([...rolePermissions, ...directPermissions])]; const canViewOAuth = hasAnyPermission(permissions, 'oauth.view', 'oauth.view.all', 'oauth.manage', 'oauth.manage.all'); const canManageOAuth = hasAnyPermission(permissions, 'oauth.manage', 'oauth.manage.all'); @@ -82,7 +90,7 @@ export class AccessService { roles, permissions, canAccessAdmin: permissions.includes('admin.access'), - canManageRoles: false, + canManageRoles: permissions.includes('rbac.manage'), canViewOAuth, canManageOAuth, canManageAllOAuth, @@ -104,6 +112,18 @@ export class AccessService { return false; } + async assertRbacManage(actorUserId: string) { + const actor = await this.prisma.user.findUnique({ where: { id: actorUserId } }); + if (!actor) { + throw new NotFoundException('Пользователь не найден'); + } + if (actor.isSuperAdmin) return; + const access = await this.getUserAccess(actorUserId, false); + if (!access.canManageRoles) { + throw new ForbiddenException('Недостаточно прав для управления ролями и правами'); + } + } + async assertSuperAdmin(actorUserId: string) { const actor = await this.prisma.user.findUnique({ where: { id: actorUserId } }); if (!actor?.isSuperAdmin) { diff --git a/apps/sso-core/src/domain/admin-seed.service.ts b/apps/sso-core/src/domain/admin-seed.service.ts index cff51a6..f12c669 100644 --- a/apps/sso-core/src/domain/admin-seed.service.ts +++ b/apps/sso-core/src/domain/admin-seed.service.ts @@ -1,5 +1,6 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; import { PrismaService } from '../infra/prisma.service'; +import { DEFAULT_USER_ROLE_SLUG } from './rbac.constants'; import { RbacService } from './rbac.service'; const PERMISSIONS = [ @@ -14,28 +15,42 @@ const PERMISSIONS = [ { slug: 'users.manage.all', name: 'Полное управление пользователями', description: 'Расширенное управление всеми пользователями' }, { slug: 'users.read', name: 'Просмотр пользователей (устар.)', description: 'Устаревшее право, заменено на users.view' }, { slug: 'settings.manage', name: 'Управление настройками', description: 'Изменение глобальных SystemSetting' }, - { slug: 'rbac.manage', name: 'Управление ролями', description: 'Назначение ролей и прав (только супер-админ)' }, + { slug: 'rbac.manage', name: 'Управление ролями и правами', description: 'Назначение ролей, прав пользователям и редактирование ролей' }, { slug: 'documents.view_others', name: 'Просмотр документов пользователей', description: 'Просмотр документов и фото других пользователей' } ] as const; const ROLES = [ + { + slug: DEFAULT_USER_ROLE_SLUG, + name: 'Пользователь', + description: 'Стандартная роль всех пользователей системы', + permissions: [] as string[], + isSystem: true, + isDefault: true + }, { slug: 'admin', name: 'Администратор', - description: 'Полный доступ к админ-панели без назначения ролей', - permissions: ['admin.access', 'oauth.manage.all', 'oauth.view.all', 'users.manage.all', 'users.view.all', 'settings.manage'] + description: 'Полный доступ к админ-панели', + permissions: ['admin.access', 'oauth.manage.all', 'oauth.view.all', 'users.manage.all', 'users.view.all', 'settings.manage', 'rbac.manage'], + isSystem: false, + isDefault: false }, { slug: 'moderator', name: 'Модератор', description: 'Просмотр пользователей и базовый доступ к админке', - permissions: ['admin.access', 'users.view', 'users.view.all'] + permissions: ['admin.access', 'users.view', 'users.view.all'], + isSystem: false, + isDefault: false }, { slug: 'manager', name: 'Менеджер', description: 'Управление OAuth-приложениями', - permissions: ['admin.access', 'oauth.manage', 'oauth.view'] + permissions: ['admin.access', 'oauth.manage', 'oauth.view'], + isSystem: false, + isDefault: false } ] as const; @@ -76,13 +91,20 @@ export class AdminSeedService implements OnModuleInit { data: { slug: role.slug, name: role.name, - description: role.description + description: role.description, + isSystem: role.isSystem, + isDefault: role.isDefault } })); await this.prisma.role.update({ where: { id: roleRecord.id }, - data: { name: role.name, description: role.description } + data: { + name: role.name, + description: role.description, + isSystem: role.isSystem, + isDefault: role.isDefault + } }); for (const slug of role.permissions) { @@ -95,5 +117,10 @@ export class AdminSeedService implements OnModuleInit { }); } } + + const users = await this.prisma.user.findMany({ where: { deletedAt: null }, select: { id: true } }); + for (const user of users) { + await this.rbac.ensureDefaultUserRole(user.id); + } } } diff --git a/apps/sso-core/src/domain/admin.service.ts b/apps/sso-core/src/domain/admin.service.ts index c37acf4..9529a90 100644 --- a/apps/sso-core/src/domain/admin.service.ts +++ b/apps/sso-core/src/domain/admin.service.ts @@ -24,7 +24,10 @@ export class AdminService { } : undefined, orderBy: { createdAt: 'desc' }, - include: { userRoles: { include: { role: true } } } + include: { + userRoles: { include: { role: true } }, + userPermissions: { include: { permission: true } } + } }); return users.map((user) => this.toAdminUser(user)); @@ -35,7 +38,7 @@ export class AdminService { const user = await this.prisma.user.update({ where: { id: userId }, data, - include: { userRoles: { include: { role: true } } } + include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } } } }); return this.toAdminUser(user); } @@ -49,7 +52,7 @@ export class AdminService { const user = await this.prisma.user.update({ where: { id: userId }, data: { passwordHash: await bcrypt.hash(newPassword, 12) }, - include: { userRoles: { include: { role: true } } } + include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } } } }); return this.toAdminUser(user); } @@ -60,7 +63,7 @@ export class AdminService { const user = await this.prisma.user.update({ where: { id: userId }, data: { status: UserStatus.SUSPENDED }, - include: { userRoles: { include: { role: true } } } + include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } } } }); return this.toAdminUser(user); } @@ -84,7 +87,7 @@ export class AdminService { const user = await this.prisma.user.update({ where: { id: userId }, data: { isSuperAdmin }, - include: { userRoles: { include: { role: true } } } + include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } } } }); return this.toAdminUser(user); } @@ -94,7 +97,7 @@ export class AdminService { const user = await this.prisma.user.update({ where: { id: userId }, data: { status: UserStatus.DELETED, deletedAt: new Date() }, - include: { userRoles: { include: { role: true } } } + include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } } } }); return this.toAdminUser(user); } @@ -111,6 +114,7 @@ export class AdminService { status: UserStatus; createdAt: Date; userRoles?: { role: { slug: string } }[]; + userPermissions?: { permission: { slug: string } }[]; }) { return { id: user.id, @@ -123,7 +127,8 @@ export class AdminService { isSuperAdmin: user.isSuperAdmin, status: user.status, createdAt: user.createdAt.toISOString(), - roles: user.userRoles?.map((link) => link.role.slug) ?? [] + roles: user.userRoles?.map((link) => link.role.slug) ?? [], + directPermissions: user.userPermissions?.map((link) => link.permission.slug) ?? [] }; } diff --git a/apps/sso-core/src/domain/auth-grpc.controller.ts b/apps/sso-core/src/domain/auth-grpc.controller.ts index a41cfed..ce01843 100644 --- a/apps/sso-core/src/domain/auth-grpc.controller.ts +++ b/apps/sso-core/src/domain/auth-grpc.controller.ts @@ -166,6 +166,8 @@ export class AuthGrpcController { slug: role.slug, name: role.name, description: role.description ?? undefined, + isSystem: role.isSystem, + isDefault: role.isDefault, permissions: role.permissions.map((link) => ({ id: link.permission.id, slug: link.permission.slug, @@ -234,6 +236,8 @@ export class AuthGrpcController { slug: role.slug, name: role.name, description: role.description ?? undefined, + isSystem: role.isSystem, + isDefault: role.isDefault, permissions: role.permissions.map((link) => ({ id: link.permission.id, slug: link.permission.slug, @@ -243,6 +247,41 @@ export class AuthGrpcController { }; } + @GrpcMethod('RbacService', 'UpdateRole') + async updateRole(command: { + actorUserId: string; + roleSlug: string; + name?: string; + description?: string; + permissionSlugs?: string[]; + }) { + const role = await this.rbac.updateRole(command.actorUserId, command.roleSlug, { + name: command.name, + description: command.description, + permissionSlugs: command.permissionSlugs + }); + return { + id: role.id, + slug: role.slug, + name: role.name, + description: role.description ?? undefined, + isSystem: role.isSystem, + isDefault: role.isDefault, + permissions: role.permissions.map((link) => ({ + id: link.permission.id, + slug: link.permission.slug, + name: link.permission.name, + description: link.permission.description ?? undefined + })) + }; + } + + @GrpcMethod('RbacService', 'DeleteRole') + async deleteRole(command: { actorUserId: string; roleSlug: string }) { + await this.rbac.deleteRole(command.actorUserId, command.roleSlug); + return {}; + } + @GrpcMethod('RbacService', 'AssignUserRole') async assignUserRole(command: { actorUserId: string; userId: string; roleSlug: string }) { const roles = await this.rbac.assignRole(command.actorUserId, command.userId, command.roleSlug); @@ -255,6 +294,18 @@ export class AuthGrpcController { return {}; } + @GrpcMethod('RbacService', 'AssignUserPermission') + async assignUserPermission(command: { actorUserId: string; userId: string; permissionSlug: string }) { + const permissions = await this.rbac.assignUserPermission(command.actorUserId, command.userId, command.permissionSlug); + return { userId: command.userId, permissions }; + } + + @GrpcMethod('RbacService', 'RemoveUserPermission') + async removeUserPermission(command: { actorUserId: string; userId: string; permissionSlug: string }) { + const permissions = await this.rbac.removeUserPermission(command.actorUserId, command.userId, command.permissionSlug); + return { userId: command.userId, permissions }; + } + @GrpcMethod('RbacService', 'CreateOAuthClient') async createOAuthClient(command: { actorUserId: string; name: string; redirectUris: string[]; scopes: string[]; type?: string }) { const { client, clientSecret } = await this.rbac.createOAuthClient(command.actorUserId, { @@ -306,6 +357,11 @@ export class AuthGrpcController { return this.rbac.rotateOAuthSecret(command.actorUserId, command.clientId); } + @GrpcMethod('RbacService', 'DeleteOAuthClient') + async deleteOAuthClient(command: { actorUserId: string; clientId: string }) { + return this.rbac.deleteOAuthClient(command.actorUserId, command.clientId); + } + @GrpcMethod('SecurityService', 'ListActiveDevices') async listActiveDevices(command: { userId: string; exceptSessionId?: string }) { const devices = await this.security.listActiveDevices(command.userId, command.exceptSessionId); diff --git a/apps/sso-core/src/domain/auth.service.ts b/apps/sso-core/src/domain/auth.service.ts index ded0382..52d6e03 100644 --- a/apps/sso-core/src/domain/auth.service.ts +++ b/apps/sso-core/src/domain/auth.service.ts @@ -15,6 +15,7 @@ import { MessagingService } from '../infra/messaging.service'; import { NotificationsService } from './notifications.service'; import { canonicalPhoneE164, phoneLookupVariants } from '../infra/phone.util'; import { TotpService } from './totp.service'; +import { RbacService } from './rbac.service'; const FIRST_ADMIN_LOCK = 'locks:first-super-admin'; const TOTP_LOGIN_CHALLENGE_TTL_SECONDS = 5 * 60; @@ -33,7 +34,8 @@ export class AuthService { private readonly ldapClient: LdapClientService, private readonly messaging: MessagingService, private readonly notifications: NotificationsService, - private readonly totp: TotpService + private readonly totp: TotpService, + private readonly rbac: RbacService ) {} async register(command: RegisterCommand): Promise { @@ -66,6 +68,7 @@ export class AuthService { { isolationLevel: Prisma.TransactionIsolationLevel.Serializable } ); + await this.rbac.ensureDefaultUserRole(user.id); return await this.toPublicUser(user); } catch (error) { if (this.isUniqueError(error)) { @@ -533,7 +536,7 @@ export class AuthService { } try { - return await this.prisma.$transaction( + const user = await this.prisma.$transaction( async (tx) => { const usersCount = await tx.user.count({ where: { deletedAt: null } }); return tx.user.create({ @@ -557,6 +560,8 @@ export class AuthService { }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable } ); + await this.rbac.ensureDefaultUserRole(user.id); + return user; } finally { await this.redis.releaseLock(FIRST_ADMIN_LOCK); } @@ -726,7 +731,7 @@ export class AuthService { const lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000); if (!lockAcquired) throw new BadRequestException('Регистрация временно недоступна, повторите попытку'); try { - return this.prisma.$transaction( + const user = await this.prisma.$transaction( async (tx) => { const usersCount = await tx.user.count({ where: { deletedAt: null } }); return tx.user.create({ @@ -741,6 +746,8 @@ export class AuthService { }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable } ); + await this.rbac.ensureDefaultUserRole(user.id); + return user; } finally { await this.redis.releaseLock(FIRST_ADMIN_LOCK); } diff --git a/apps/sso-core/src/domain/rbac.constants.ts b/apps/sso-core/src/domain/rbac.constants.ts new file mode 100644 index 0000000..be479e0 --- /dev/null +++ b/apps/sso-core/src/domain/rbac.constants.ts @@ -0,0 +1 @@ +export const DEFAULT_USER_ROLE_SLUG = 'user'; diff --git a/apps/sso-core/src/domain/rbac.service.ts b/apps/sso-core/src/domain/rbac.service.ts index 8b9becb..5739172 100644 --- a/apps/sso-core/src/domain/rbac.service.ts +++ b/apps/sso-core/src/domain/rbac.service.ts @@ -3,6 +3,8 @@ 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; @@ -21,11 +23,12 @@ export interface OAuthClientListItem { export class RbacService { constructor( private readonly prisma: PrismaService, - private readonly access: AccessService + private readonly access: AccessService, + private readonly notifications: NotificationsService ) {} async createRole(actorUserId: string, data: { slug: string; name: string; description?: string; permissionSlugs?: string[] }) { - await this.access.assertSuperAdmin(actorUserId); + await this.access.assertRbacManage(actorUserId); return this.prisma.role.create({ data: { slug: data.slug, @@ -51,28 +54,105 @@ export class RbacService { }); } + 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.assertSuperAdmin(actorUserId); + await this.access.assertRbacManage(actorUserId); const role = await this.prisma.role.findUnique({ where: { slug: roleSlug } }); if (!role) { throw new NotFoundException('Роль не найдена'); } await this.ensureUserExists(userId); - await this.prisma.userRole.upsert({ - where: { userId_roleId: { userId, roleId: role.id } }, - create: { userId, roleId: role.id }, - update: {} + + 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.assertSuperAdmin(actorUserId); + 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); } @@ -163,6 +243,54 @@ export class RbacService { 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) { @@ -256,6 +384,14 @@ export class RbacService { 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) { diff --git a/shared/proto/admin.proto b/shared/proto/admin.proto index ea2d603..5b7b428 100644 --- a/shared/proto/admin.proto +++ b/shared/proto/admin.proto @@ -51,6 +51,7 @@ message AdminUser { string status = 9; string createdAt = 10; repeated string roles = 11; + repeated string directPermissions = 12; } message ListUsersResponse { diff --git a/shared/proto/rbac.proto b/shared/proto/rbac.proto index 670f6f7..f232fed 100644 --- a/shared/proto/rbac.proto +++ b/shared/proto/rbac.proto @@ -8,10 +8,15 @@ service RbacService { rpc ListOAuthScopes (Empty) returns (ListOAuthScopesResponse); rpc ListOAuthClients (ListOAuthClientsRequest) returns (ListOAuthClientsResponse); rpc CreateRole (CreateRoleRequest) returns (Role); + rpc UpdateRole (UpdateRoleRequest) returns (Role); + rpc DeleteRole (DeleteRoleRequest) returns (Empty); rpc AssignUserRole (AssignUserRoleRequest) returns (AssignUserRoleResponse); rpc RemoveUserRole (RemoveUserRoleRequest) returns (Empty); + rpc AssignUserPermission (AssignUserPermissionRequest) returns (AssignUserPermissionResponse); + rpc RemoveUserPermission (RemoveUserPermissionRequest) returns (AssignUserPermissionResponse); rpc CreateOAuthClient (CreateOAuthClientRequest) returns (OAuthClientDetail); rpc UpdateOAuthClient (UpdateOAuthClientRequest) returns (OAuthClientDetail); + rpc DeleteOAuthClient (DeleteOAuthClientRequest) returns (DeleteOAuthClientResponse); rpc RotateOAuthSecret (RotateOAuthSecretRequest) returns (RotateOAuthSecretResponse); } @@ -30,6 +35,8 @@ message Role { string name = 3; optional string description = 4; repeated Permission permissions = 5; + bool isSystem = 6; + bool isDefault = 7; } message OAuthScope { @@ -94,6 +101,19 @@ message CreateRoleRequest { repeated string permissionSlugs = 5; } +message UpdateRoleRequest { + string actorUserId = 1; + string roleSlug = 2; + optional string name = 3; + optional string description = 4; + repeated string permissionSlugs = 5; +} + +message DeleteRoleRequest { + string actorUserId = 1; + string roleSlug = 2; +} + message AssignUserRoleRequest { string actorUserId = 1; string userId = 2; @@ -111,6 +131,23 @@ message RemoveUserRoleRequest { string roleSlug = 3; } +message AssignUserPermissionRequest { + string actorUserId = 1; + string userId = 2; + string permissionSlug = 3; +} + +message AssignUserPermissionResponse { + string userId = 1; + repeated string permissions = 2; +} + +message RemoveUserPermissionRequest { + string actorUserId = 1; + string userId = 2; + string permissionSlug = 3; +} + message CreateOAuthClientRequest { string actorUserId = 1; string name = 2; @@ -128,6 +165,15 @@ message UpdateOAuthClientRequest { optional bool isActive = 6; } +message DeleteOAuthClientRequest { + string actorUserId = 1; + string clientId = 2; +} + +message DeleteOAuthClientResponse { + string clientId = 1; +} + message RotateOAuthSecretRequest { string actorUserId = 1; string clientId = 2;