diff --git a/apps/api-gateway/src/controllers/rbac.controller.ts b/apps/api-gateway/src/controllers/rbac.controller.ts index 6a79453..32002a7 100644 --- a/apps/api-gateway/src/controllers/rbac.controller.ts +++ b/apps/api-gateway/src/controllers/rbac.controller.ts @@ -4,7 +4,7 @@ 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, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard'; +import { AdminGuard, AdminRequestUser, assertAdminAnyPermission, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard'; @ApiTags('RBAC и OAuth') @ApiBearerAuth() @@ -39,15 +39,15 @@ export class RbacController { @Get('oauth-scopes') @ApiOperation({ summary: 'OAuth scopes', description: 'Возвращает доступные scopes для OAuth-приложений.' }) listOAuthScopes(@CurrentAdmin() admin: AdminRequestUser) { - assertAdminPermission(admin, 'canManageOAuth'); + assertAdminAnyPermission(admin, 'canViewOAuth', 'canManageOAuth'); return this.core.rbac.ListOAuthScopes({}); } @Get('oauth-clients') @ApiOperation({ summary: 'OAuth приложения', description: 'Возвращает OAuth-клиенты и доступные scopes.' }) listOAuthClients(@CurrentAdmin() admin: AdminRequestUser) { - assertAdminPermission(admin, 'canManageOAuth'); - return this.core.rbac.ListOAuthClients({}); + assertAdminPermission(admin, 'canViewOAuth'); + return this.core.rbac.ListOAuthClients({ actorUserId: admin.id }); } @Post('roles') diff --git a/apps/api-gateway/src/guards/admin.guard.ts b/apps/api-gateway/src/guards/admin.guard.ts index b0e2088..f80afb7 100644 --- a/apps/api-gateway/src/guards/admin.guard.ts +++ b/apps/api-gateway/src/guards/admin.guard.ts @@ -9,8 +9,11 @@ export interface AdminRequestUser { isSuperAdmin: boolean; canAccessAdmin: boolean; canManageRoles: boolean; + canViewOAuth: boolean; canManageOAuth: boolean; + canManageAllOAuth: boolean; canManageUsers: boolean; + canManageAllUsers: boolean; canViewUsers: boolean; canManageSettings: boolean; roles: string[]; @@ -48,8 +51,11 @@ export class AdminGuard implements CanActivate { isSuperAdmin: profile.isSuperAdmin, canAccessAdmin: Boolean(profile.canAccessAdmin), canManageRoles: Boolean(profile.canManageRoles), + canViewOAuth: Boolean(profile.canViewOAuth), canManageOAuth: Boolean(profile.canManageOAuth), + canManageAllOAuth: Boolean(profile.canManageAllOAuth), canManageUsers: Boolean(profile.canManageUsers), + canManageAllUsers: Boolean(profile.canManageAllUsers), canManageSettings: Boolean(profile.canManageSettings), canViewUsers: Boolean(profile.canViewUsers), roles: profile.roles ?? [], @@ -71,8 +77,19 @@ export class SuperAdminGuard implements CanActivate { } } -export function assertAdminPermission(user: AdminRequestUser | undefined, permission: keyof Pick) { +type AdminPermissionKey = keyof Pick< + AdminRequestUser, + 'canViewOAuth' | 'canManageOAuth' | 'canManageUsers' | 'canViewUsers' | 'canManageSettings' +>; + +export function assertAdminPermission(user: AdminRequestUser | undefined, permission: AdminPermissionKey) { if (!user?.[permission]) { throw new ForbiddenException('Недостаточно прав для выполнения действия'); } } + +export function assertAdminAnyPermission(user: AdminRequestUser | undefined, ...permissions: AdminPermissionKey[]) { + if (!user || !permissions.some((permission) => user[permission])) { + throw new ForbiddenException('Недостаточно прав для выполнения действия'); + } +} diff --git a/apps/frontend/app/admin/oauth/page.tsx b/apps/frontend/app/admin/oauth/page.tsx index 4bc11a1..76fe882 100644 --- a/apps/frontend/app/admin/oauth/page.tsx +++ b/apps/frontend/app/admin/oauth/page.tsx @@ -1,7 +1,8 @@ 'use client'; import { useCallback, useEffect, useState } from 'react'; -import { Copy, KeyRound, Loader2, LockKeyhole, Plus, RefreshCw } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { Copy, KeyRound, Loader2, LockKeyhole, Plus, RefreshCw, UserRound } from 'lucide-react'; import { AdminShell } from '@/components/id/admin-shell'; import { useAuth } from '@/components/id/auth-provider'; import { useToast } from '@/components/id/toast-provider'; @@ -10,9 +11,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { OAuthClient, OAuthScope, apiFetch } from '@/lib/api'; +import { getAdminLandingPath } from '@/lib/admin-access'; export default function AdminOAuthPage() { - const { token } = useAuth(); + const router = useRouter(); + const { token, user } = useAuth(); const { showToast } = useToast(); const [clients, setClients] = useState([]); const [scopes, setScopes] = useState([]); @@ -23,25 +26,30 @@ export default function AdminOAuthPage() { const [form, setForm] = useState({ name: '', redirectUris: '', type: 'CONFIDENTIAL', selectedScopes: ['openid', 'profile', 'email'] as string[] }); const loadData = useCallback(async () => { - if (!token) return; + if (!token || !user?.canViewOAuth) return; setLoading(true); try { - const [clientsResponse, scopesResponse] = await Promise.all([ - apiFetch<{ clients: OAuthClient[] }>('/admin/rbac/oauth-clients', {}, token), - apiFetch<{ scopes: OAuthScope[] }>('/admin/rbac/oauth-scopes', {}, token) - ]); + const clientsResponse = await apiFetch<{ clients: OAuthClient[] }>('/admin/rbac/oauth-clients', {}, token); setClients(clientsResponse.clients ?? []); - setScopes(scopesResponse.scopes ?? []); + if (user.canManageOAuth) { + const scopesResponse = await apiFetch<{ scopes: OAuthScope[] }>('/admin/rbac/oauth-scopes', {}, token); + setScopes(scopesResponse.scopes ?? []); + } } catch (error) { showToast(error instanceof Error ? error.message : 'Не удалось загрузить OAuth-приложения'); } finally { setLoading(false); } - }, [showToast, token]); + }, [showToast, token, user?.canManageOAuth, user?.canViewOAuth]); useEffect(() => { + if (!user) return; + if (!user.canViewOAuth && !user.canManageOAuth) { + router.replace(getAdminLandingPath(user)); + return; + } void loadData(); - }, [loadData]); + }, [loadData, router, user]); async function handleCreate() { if (!token) return; @@ -116,7 +124,7 @@ export default function AdminOAuthPage() {

OAuth-приложения

Создание клиентов как на oauth.yandex.ru: client_id, secret, redirect URI и scopes

- @@ -134,7 +142,17 @@ export default function AdminOAuthPage() { {client.name} - {client.type === 'PUBLIC' ? 'Публичное приложение' : 'Confidential приложение'} + + {client.type === 'PUBLIC' ? 'Публичное приложение' : 'Confidential приложение'} + {client.createdByDisplayName ? ( + + + Создал: {client.createdByDisplayName} + + ) : client.createdById ? null : ( + Создатель не указан + )} +
@@ -161,17 +179,21 @@ export default function AdminOAuthPage() {

{client.scopes.join(', ')}

-
- {client.type !== 'PUBLIC' ? ( - + ) : null} + - ) : null} - -
+ + ) : ( +

Только просмотр — управление доступно создателю или администратору с полными правами

+ )}
))} diff --git a/apps/frontend/app/admin/page.tsx b/apps/frontend/app/admin/page.tsx index 96e411f..64a3b33 100644 --- a/apps/frontend/app/admin/page.tsx +++ b/apps/frontend/app/admin/page.tsx @@ -1,5 +1,22 @@ -import { redirect } from 'next/navigation'; +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/components/id/auth-provider'; +import { getAdminLandingPath } from '@/lib/admin-access'; export default function AdminIndexPage() { - redirect('/admin/users'); + const router = useRouter(); + const { user, isLoading } = useAuth(); + + useEffect(() => { + if (isLoading || !user) return; + router.replace(getAdminLandingPath(user)); + }, [isLoading, router, user]); + + return ( +
+ Перенаправление в админ-панель... +
+ ); } diff --git a/apps/frontend/app/admin/users/page.tsx b/apps/frontend/app/admin/users/page.tsx index 0d709b6..9c1d7ec 100644 --- a/apps/frontend/app/admin/users/page.tsx +++ b/apps/frontend/app/admin/users/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { Ban, Crown, FileText, KeyRound, Loader2, Search, ShieldPlus, UserCheck, UserCog } from 'lucide-react'; import { UserDocumentsDialog } from '@/components/documents/user-documents-dialog'; import { AdminShell } from '@/components/id/admin-shell'; @@ -11,6 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u 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 { getAdminLandingPath } from '@/lib/admin-access'; const statusLabels: Record = { ACTIVE: 'Активен', @@ -26,6 +28,7 @@ const roleLabels: Record = { }; export default function AdminUsersPage() { + const router = useRouter(); const { token, user: currentUser } = useAuth(); const { showToast } = useToast(); const [users, setUsers] = useState([]); @@ -52,8 +55,16 @@ export default function AdminUsersPage() { }, [search, showToast, token]); useEffect(() => { + if (!currentUser) return; + if (!currentUser.canViewUsers && !currentUser.canManageUsers) { + router.replace(getAdminLandingPath(currentUser)); + } + }, [currentUser, router]); + + useEffect(() => { + if (!currentUser?.canViewUsers && !currentUser?.canManageUsers) return; void loadUsers(); - }, [loadUsers]); + }, [currentUser?.canManageUsers, currentUser?.canViewUsers, loadUsers]); useEffect(() => { if (!token || !currentUser?.canManageRoles) return; diff --git a/apps/frontend/components/id/admin-badge.tsx b/apps/frontend/components/id/admin-badge.tsx index 4b95c37..48c8436 100644 --- a/apps/frontend/components/id/admin-badge.tsx +++ b/apps/frontend/components/id/admin-badge.tsx @@ -3,11 +3,12 @@ import { ShieldCheck, Sparkles } from 'lucide-react'; import { cn } from '@/lib/utils'; import { PublicUser } from '@/lib/api'; +import { getPrimaryRoleLabel } from '@/lib/admin-access'; export function AdminBadge({ user, className }: { user: PublicUser; className?: string }) { if (!user.isSuperAdmin && !user.canAccessAdmin) return null; - const label = user.isSuperAdmin ? 'Супер-админ' : user.roles?.includes('admin') ? 'Администратор' : 'Админ'; + const label = getPrimaryRoleLabel(user); return ( { if (link.superAdminOnly && !user.canManageRoles) return false; - return Boolean(user[link.permission]) || (link.altPermission ? Boolean(user[link.altPermission]) : false) || user.isSuperAdmin; + return link.permissions.some((permission) => Boolean(user[permission])) || user.isSuperAdmin; }); return ( diff --git a/apps/frontend/components/id/admin-shell.tsx b/apps/frontend/components/id/admin-shell.tsx index 25daf33..aeb5b2f 100644 --- a/apps/frontend/components/id/admin-shell.tsx +++ b/apps/frontend/components/id/admin-shell.tsx @@ -30,7 +30,7 @@ export function AdminShell({ active, children }: { active: string; children: Rea } return ( - +
diff --git a/apps/frontend/components/id/sidebar.tsx b/apps/frontend/components/id/sidebar.tsx index 6e2048a..05949ac 100644 --- a/apps/frontend/components/id/sidebar.tsx +++ b/apps/frontend/components/id/sidebar.tsx @@ -4,6 +4,7 @@ import Link from 'next/link'; import { FileText, Heart, Home, LockKeyhole, LogOut, ShieldCheck, UserRound } from 'lucide-react'; import { BrandLogo } from '@/components/id/brand-logo'; import { cn } from '@/lib/utils'; +import { getAdminLandingPath } from '@/lib/admin-access'; import { useAuth } from './auth-provider'; const baseItems = [ @@ -17,7 +18,7 @@ const baseItems = [ export function Sidebar({ active }: { active: string }) { const { logout, user } = useAuth(); const items = user?.canAccessAdmin - ? [...baseItems, { href: '/admin/users', label: 'Админка', icon: ShieldCheck }] + ? [...baseItems, { href: getAdminLandingPath(user), label: 'Админка', icon: ShieldCheck }] : baseItems; return ( diff --git a/apps/frontend/components/id/user-menu.tsx b/apps/frontend/components/id/user-menu.tsx index 14a0479..71d6447 100644 --- a/apps/frontend/components/id/user-menu.tsx +++ b/apps/frontend/components/id/user-menu.tsx @@ -15,6 +15,7 @@ import { import { AdminBadge } from '@/components/id/admin-badge'; import { useAuth } from '@/components/id/auth-provider'; import { useAvatarUrl } from '@/hooks/use-avatar-url'; +import { getAdminLandingPath } from '@/lib/admin-access'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; @@ -94,11 +95,11 @@ export function UserMenu({ className }: { className?: string }) { ))} {user.canAccessAdmin ? ( - +
Админ-панель
-
Пользователи, OAuth и настройки
+
OAuth, пользователи и настройки
diff --git a/apps/frontend/lib/admin-access.ts b/apps/frontend/lib/admin-access.ts new file mode 100644 index 0000000..06fcfbe --- /dev/null +++ b/apps/frontend/lib/admin-access.ts @@ -0,0 +1,41 @@ +import { PublicUser } from '@/lib/api'; + +export const ROLE_LABELS: Record = { + admin: 'Администратор', + moderator: 'Модератор', + manager: 'Менеджер', + 'super-admin': 'Супер-админ' +}; + +export function getPrimaryRoleLabel(user: PublicUser): string { + if (user.isSuperAdmin) return ROLE_LABELS['super-admin']; + const slugs = user.roles ?? []; + if (slugs.includes('admin')) return ROLE_LABELS.admin; + for (const slug of slugs) { + if (ROLE_LABELS[slug]) return ROLE_LABELS[slug]; + } + return slugs[0] ?? 'Админ'; +} + +export function getAdminLandingPath(user: PublicUser): string { + if (user.canViewUsers || user.canManageUsers) return '/admin/users'; + if (user.canViewOAuth || user.canManageOAuth) return '/admin/oauth'; + if (user.canManageSettings) return '/admin/settings'; + if (user.canManageRoles) return '/admin/rbac'; + return '/admin/oauth'; +} + +export function canAccessAdminSection(user: PublicUser, section: 'users' | 'oauth' | 'settings' | 'rbac') { + switch (section) { + case 'users': + return Boolean(user.canViewUsers || user.canManageUsers); + case 'oauth': + return Boolean(user.canViewOAuth || user.canManageOAuth); + case 'settings': + return Boolean(user.canManageSettings); + case 'rbac': + return Boolean(user.canManageRoles); + default: + return false; + } +} diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index 679c044..463efcc 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -70,8 +70,11 @@ export interface PublicUser { permissions?: string[]; canAccessAdmin?: boolean; canManageRoles?: boolean; + canViewOAuth?: boolean; canManageOAuth?: boolean; + canManageAllOAuth?: boolean; canManageUsers?: boolean; + canManageAllUsers?: boolean; canManageSettings?: boolean; canViewUsers?: boolean; canViewUserDocuments?: boolean; @@ -121,6 +124,9 @@ export interface OAuthClient { isActive: boolean; type: string; clientSecret?: string; + createdById?: string | null; + createdByDisplayName?: string | null; + canManage?: boolean; } export interface AuthTokens { diff --git a/apps/sso-core/prisma/schema.prisma b/apps/sso-core/prisma/schema.prisma index f96c76d..c3bdfb5 100644 --- a/apps/sso-core/prisma/schema.prisma +++ b/apps/sso-core/prisma/schema.prisma @@ -75,6 +75,7 @@ model User { chatMessages ChatMessage[] chatPollVotes ChatPollVote[] totpSecret UserTotpSecret? + createdOAuthClients OAuthClient[] @relation("OAuthClientCreator") } model UserTotpSecret { @@ -226,10 +227,14 @@ model OAuthClient { redirectUris String[] scopes OAuthClientScope[] isActive Boolean @default(true) + createdById String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + createdBy User? @relation("OAuthClientCreator", fields: [createdById], references: [id], onDelete: SetNull) consents OAuthConsent[] authCodes OAuthAuthorizationCode[] + + @@index([createdById]) } model OAuthAuthorizationCode { diff --git a/apps/sso-core/src/domain/access.service.ts b/apps/sso-core/src/domain/access.service.ts index 3f90dbc..9accab8 100644 --- a/apps/sso-core/src/domain/access.service.ts +++ b/apps/sso-core/src/domain/access.service.ts @@ -6,13 +6,20 @@ export interface UserAccessContext { permissions: string[]; canAccessAdmin: boolean; canManageRoles: boolean; + canViewOAuth: boolean; canManageOAuth: boolean; + canManageAllOAuth: boolean; canManageUsers: boolean; + canManageAllUsers: boolean; canViewUsers: boolean; canManageSettings: boolean; canViewUserDocuments: boolean; } +function hasAnyPermission(permissions: string[], ...slugs: string[]) { + return slugs.some((slug) => permissions.includes(slug)); +} + @Injectable() export class AccessService { constructor(private readonly prisma: PrismaService) {} @@ -21,11 +28,27 @@ export class AccessService { if (isSuperAdmin) { return { roles: ['super-admin'], - permissions: ['admin.access', 'oauth.manage', 'users.manage', 'users.read', 'settings.manage', 'rbac.manage', 'documents.view_others'], + permissions: [ + 'admin.access', + 'oauth.view', + 'oauth.view.all', + 'oauth.manage', + 'oauth.manage.all', + 'users.view', + 'users.view.all', + 'users.manage', + 'users.manage.all', + 'settings.manage', + 'rbac.manage', + 'documents.view_others' + ], canAccessAdmin: true, canManageRoles: true, + canViewOAuth: true, canManageOAuth: true, + canManageAllOAuth: true, canManageUsers: true, + canManageAllUsers: true, canViewUsers: true, canManageSettings: true, canViewUserDocuments: true @@ -48,19 +71,39 @@ export class AccessService { const roles = links.map((link) => link.role.slug); const permissions = [...new Set(links.flatMap((link) => link.role.permissions.map((item) => item.permission.slug)))]; + const canViewOAuth = hasAnyPermission(permissions, 'oauth.view', 'oauth.view.all', 'oauth.manage', 'oauth.manage.all'); + const canManageOAuth = hasAnyPermission(permissions, 'oauth.manage', 'oauth.manage.all'); + const canManageAllOAuth = permissions.includes('oauth.manage.all'); + const canViewUsers = hasAnyPermission(permissions, 'users.view', 'users.view.all', 'users.manage', 'users.manage.all', 'users.read'); + const canManageUsers = hasAnyPermission(permissions, 'users.manage', 'users.manage.all'); + const canManageAllUsers = permissions.includes('users.manage.all'); + return { roles, permissions, canAccessAdmin: permissions.includes('admin.access'), canManageRoles: false, - canManageOAuth: permissions.includes('oauth.manage'), - canManageUsers: permissions.includes('users.manage'), - canViewUsers: permissions.includes('users.manage') || permissions.includes('users.read'), + canViewOAuth, + canManageOAuth, + canManageAllOAuth, + canManageUsers, + canManageAllUsers, + canViewUsers, canManageSettings: permissions.includes('settings.manage'), canViewUserDocuments: permissions.includes('documents.view_others') }; } + canManageOAuthClient( + access: UserAccessContext, + actorUserId: string, + client: { createdById: string | null } + ) { + if (access.canManageAllOAuth) return true; + if (access.canManageOAuth && client.createdById === actorUserId) return true; + return false; + } + 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 0a7c949..cff51a6 100644 --- a/apps/sso-core/src/domain/admin-seed.service.ts +++ b/apps/sso-core/src/domain/admin-seed.service.ts @@ -4,9 +4,15 @@ import { RbacService } from './rbac.service'; const PERMISSIONS = [ { slug: 'admin.access', name: 'Доступ к админ-панели', description: 'Позволяет открывать раздел администрирования' }, - { slug: 'oauth.manage', name: 'Управление OAuth-приложениями', description: 'Создание и редактирование OAuth2-клиентов' }, + { slug: 'oauth.view', name: 'Просмотр OAuth-приложений', description: 'Просмотр списка OAuth2-клиентов' }, + { slug: 'oauth.view.all', name: 'Просмотр всех OAuth-приложений', description: 'Просмотр OAuth2-клиентов, созданных другими пользователями' }, + { slug: 'oauth.manage', name: 'Управление своими OAuth-приложениями', description: 'Создание и редактирование собственных OAuth2-клиентов' }, + { slug: 'oauth.manage.all', name: 'Управление всеми OAuth-приложениями', description: 'Создание и редактирование любых OAuth2-клиентов' }, + { slug: 'users.view', name: 'Просмотр пользователей', description: 'Чтение списка пользователей без изменений' }, + { slug: 'users.view.all', name: 'Просмотр всех пользователей', description: 'Просмотр полного списка пользователей системы' }, { slug: 'users.manage', name: 'Управление пользователями', description: 'Редактирование, блокировка и сброс паролей' }, - { slug: 'users.read', name: 'Просмотр пользователей', description: 'Чтение списка пользователей без изменений' }, + { 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: 'documents.view_others', name: 'Просмотр документов пользователей', description: 'Просмотр документов и фото других пользователей' } @@ -17,19 +23,19 @@ const ROLES = [ slug: 'admin', name: 'Администратор', description: 'Полный доступ к админ-панели без назначения ролей', - permissions: ['admin.access', 'oauth.manage', 'users.manage', 'settings.manage'] + permissions: ['admin.access', 'oauth.manage.all', 'oauth.view.all', 'users.manage.all', 'users.view.all', 'settings.manage'] }, { slug: 'moderator', name: 'Модератор', description: 'Просмотр пользователей и базовый доступ к админке', - permissions: ['admin.access', 'users.read'] + permissions: ['admin.access', 'users.view', 'users.view.all'] }, { slug: 'manager', name: 'Менеджер', description: 'Управление OAuth-приложениями', - permissions: ['admin.access', 'oauth.manage'] + permissions: ['admin.access', 'oauth.manage', 'oauth.view'] } ] as const; diff --git a/apps/sso-core/src/domain/auth-grpc.controller.ts b/apps/sso-core/src/domain/auth-grpc.controller.ts index 3f0f346..a41cfed 100644 --- a/apps/sso-core/src/domain/auth-grpc.controller.ts +++ b/apps/sso-core/src/domain/auth-grpc.controller.ts @@ -177,17 +177,20 @@ export class AuthGrpcController { } @GrpcMethod('RbacService', 'ListOAuthClients') - async listOAuthClients() { - const clients = await this.rbac.listOAuthClients(); + async listOAuthClients(command: { actorUserId: string }) { + const clients = await this.rbac.listOAuthClients(command.actorUserId); return { clients: clients.map((client) => ({ id: client.id, name: client.name, clientId: client.clientId, redirectUris: client.redirectUris, - scopes: client.scopes.map((link) => link.scope.slug), + scopes: client.scopes, isActive: client.isActive, - type: client.type + type: client.type, + createdById: client.createdById ?? undefined, + createdByDisplayName: client.createdByDisplayName ?? undefined, + canManage: client.canManage })) }; } diff --git a/apps/sso-core/src/domain/auth.service.ts b/apps/sso-core/src/domain/auth.service.ts index 71129ff..83d334b 100644 --- a/apps/sso-core/src/domain/auth.service.ts +++ b/apps/sso-core/src/domain/auth.service.ts @@ -735,8 +735,11 @@ export class AuthService { permissions: access.permissions, canAccessAdmin: access.canAccessAdmin, canManageRoles: access.canManageRoles, + canViewOAuth: access.canViewOAuth, canManageOAuth: access.canManageOAuth, + canManageAllOAuth: access.canManageAllOAuth, canManageUsers: access.canManageUsers, + canManageAllUsers: access.canManageAllUsers, canManageSettings: access.canManageSettings, canViewUsers: access.canViewUsers, canViewUserDocuments: access.canViewUserDocuments diff --git a/apps/sso-core/src/domain/dto.ts b/apps/sso-core/src/domain/dto.ts index 4ceb482..deb152b 100644 --- a/apps/sso-core/src/domain/dto.ts +++ b/apps/sso-core/src/domain/dto.ts @@ -43,8 +43,11 @@ export interface PublicUser { permissions?: string[]; canAccessAdmin?: boolean; canManageRoles?: boolean; + canViewOAuth?: boolean; canManageOAuth?: boolean; + canManageAllOAuth?: boolean; canManageUsers?: boolean; + canManageAllUsers?: boolean; canManageSettings?: boolean; canViewUsers?: boolean; canViewUserDocuments?: boolean; diff --git a/apps/sso-core/src/domain/rbac.service.ts b/apps/sso-core/src/domain/rbac.service.ts index d49c7f5..8b9becb 100644 --- a/apps/sso-core/src/domain/rbac.service.ts +++ b/apps/sso-core/src/domain/rbac.service.ts @@ -2,7 +2,20 @@ import { BadRequestException, ForbiddenException, Injectable, NotFoundException import { randomBytes } from 'node:crypto'; import { OAuthClientType } from '../generated/prisma/client'; import { PrismaService } from '../infra/prisma.service'; -import { AccessService } from './access.service'; +import { AccessService, UserAccessContext } from './access.service'; + +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 { @@ -79,7 +92,7 @@ export class RbacService { } async createOAuthClient(actorUserId: string, data: { name: string; redirectUris: string[]; scopes: string[]; type?: OAuthClientType }) { - await this.assertOAuthPermission(actorUserId); + await this.assertCanCreateOAuth(actorUserId); if (!data.redirectUris.length) { throw new BadRequestException('Укажите хотя бы один redirect URI'); } @@ -95,13 +108,14 @@ export class RbacService { 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 } } } + include: { scopes: { include: { scope: true } }, createdBy: true } }); return { client, clientSecret }; @@ -112,7 +126,6 @@ export class RbacService { clientId: string, data: { name?: string; redirectUris?: string[]; scopes?: string[]; isActive?: boolean } ) { - await this.assertOAuthPermission(actorUserId); const existing = await this.prisma.oAuthClient.findUnique({ where: { clientId }, include: { scopes: true } @@ -120,6 +133,7 @@ export class RbacService { if (!existing) { throw new NotFoundException('OAuth-приложение не найдено'); } + await this.assertCanManageOAuthClient(actorUserId, existing); if (data.redirectUris && !data.redirectUris.length) { throw new BadRequestException('Укажите хотя бы один redirect URI'); @@ -143,18 +157,18 @@ export class RbacService { redirectUris: data.redirectUris, isActive: data.isActive }, - include: { scopes: { include: { scope: true } } } + include: { scopes: { include: { scope: true } }, createdBy: true } }); return { client: updated, clientSecret: undefined as string | undefined }; } async rotateOAuthSecret(actorUserId: string, clientId: string) { - await this.assertOAuthPermission(actorUserId); 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'); } @@ -168,24 +182,72 @@ export class RbacService { return { clientId, clientSecret }; } - async listOAuthClients() { - return this.prisma.oAuthClient.findMany({ + 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 } } } + include: { scopes: { include: { scope: true } }, createdBy: true } }); + + return clients.map((client) => this.toOAuthClientListItem(client, actorUserId, access)); } - private async assertOAuthPermission(actorUserId: string) { + 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 }, diff --git a/shared/proto/auth.proto b/shared/proto/auth.proto index a8e973d..1fd8c66 100644 --- a/shared/proto/auth.proto +++ b/shared/proto/auth.proto @@ -185,6 +185,9 @@ message PublicUser { bool canViewUsers = 19; bool canViewUserDocuments = 20; bool hasPassword = 21; + bool canViewOAuth = 22; + bool canManageAllOAuth = 23; + bool canManageAllUsers = 24; } message AuthTokens { diff --git a/shared/proto/rbac.proto b/shared/proto/rbac.proto index f42a14e..670f6f7 100644 --- a/shared/proto/rbac.proto +++ b/shared/proto/rbac.proto @@ -6,7 +6,7 @@ service RbacService { rpc ListRoles (Empty) returns (ListRolesResponse); rpc ListPermissions (Empty) returns (ListPermissionsResponse); rpc ListOAuthScopes (Empty) returns (ListOAuthScopesResponse); - rpc ListOAuthClients (Empty) returns (ListOAuthClientsResponse); + rpc ListOAuthClients (ListOAuthClientsRequest) returns (ListOAuthClientsResponse); rpc CreateRole (CreateRoleRequest) returns (Role); rpc AssignUserRole (AssignUserRoleRequest) returns (AssignUserRoleResponse); rpc RemoveUserRole (RemoveUserRoleRequest) returns (Empty); @@ -47,6 +47,9 @@ message OAuthClient { repeated string scopes = 5; bool isActive = 6; string type = 7; + optional string createdById = 8; + optional string createdByDisplayName = 9; + bool canManage = 10; } message OAuthClientDetail { @@ -58,6 +61,9 @@ message OAuthClientDetail { bool isActive = 6; string type = 7; optional string clientSecret = 8; + optional string createdById = 9; + optional string createdByDisplayName = 10; + bool canManage = 11; } message ListRolesResponse { @@ -76,6 +82,10 @@ message ListOAuthClientsResponse { repeated OAuthClient clients = 1; } +message ListOAuthClientsRequest { + string actorUserId = 1; +} + message CreateRoleRequest { string actorUserId = 1; string slug = 2;