update oauth

This commit is contained in:
lendry
2026-06-25 14:23:55 +03:00
parent c3e06e03cf
commit 1796008a28
21 changed files with 340 additions and 64 deletions

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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
}))
};
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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<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 } } }
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 },