Перейти к основному содержимому

ADR-002: Унификация изоляции отделов

  • Статус: Принято
  • Дата: 14 апреля 2026
  • Автор: CTO Agent (KALA-108)

Контекст

Изоляция отделов — механизм, который не даёт пользователю из отдела A получить доступ к данным отдела B. У каждого AuthUser в JWT есть departmentId, а четыре Prisma-модели имеют department scope: User, KnowledgeBase, Namespace и Template.

Сейчас фильтрация по отделу реализована ad-hoc, минимум 5 разными паттернами на трёх слоях:

Текущее состояние

СлойФайл(ы)ПаттернПроблема
SQL RAGsrc/knowledge/rag.ts (3 запроса)Raw SQL WHERE kb."departmentId" = u."departmentId" с джоином через conversation→userКонсистентно внутри, но обходит Prisma — нельзя централизовать
REST — knowledge-маршрутыsrc/api/routes/knowledge.tsЛокальный хелпер deptScope() возвращает { departmentId } для Prisma whereРаботает, но хелпер локальный и дублируется
REST — agent-taskssrc/routes/agent-tasks.tsДругая сигнатура deptScope(); вложенное { namespace: { departmentId } }Возвращает другую форму, отличается от knowledge-версии
REST — approvalssrc/routes/approvals.tsapprovalDepartmentScope() через глубокий nesting { message: { conversation: { user: { departmentId } } } }Уникальный traversal, нельзя шарить с другими хелперами
REST — namespacessrc/routes/namespaces.tsInline role === 'admin' ? query.departmentId : userDeptIdНет переиспользуемой абстракции
REST — tracessrc/routes/traces.tsPost-load проверка: сначала fetch, потом сравнение departmentIdЗагружает данные до access-проверки — расточительно и ошибкоопасно
WebSocketsrc/plugins/websocket.tscanSubscribeToNamespace() проверяет отдел на подписку, но на broadcast нет per-event проверкиУтечка между отделами, если namespace-подписки пережили смену отдела
Agent runnersrc/agent-runner/worker.tsfindApprover() скоупится через conv.user.departmentIdКорректно, но one-off

Риски текущего подхода

  1. Легко забыть — новые маршруты требуют от разработчика не забыть добавить фильтрацию по отделу; нет compile-time или runtime страховки.
  2. Непоследовательный admin-bypass — большинство проверок используют role === 'admin', но точные условия могут различаться.
  3. WebSocket-дыра — broadcast-события долетают до всех подписчиков namespace независимо от department membership, что нарушает изоляцию данных, если namespace-подписки пережили смену отдела.
  4. Нет автоматического регрессионного теста — нет теста, который систематически проверяет, что пользователь из отдела X не видит данные из отдела Y по всем эндпоинтам.

Решение

Вариант B: явный department scope через forDepartment(user) — с параллельным шаблоном для raw SQL на уровне RAG.

Почему не альтернативы

ВариантВердиктПричина
A — Prisma middleware / client extensionОтклоненоPrisma client extensions могут инжектить where-клаузы через $allOperations, но они молча меняют запросы — сложно дебажить, невозможно отключить для admin cross-department views, и не покрывают raw SQL (RAG). Риск неявных регрессий в query plan.
C — Fastify hook + request wrapperОтклоненоpreHandler hook может навесить scope-инфу на request, но не гарантирует, что downstream-код её использует. Это подсказка, а не гарантия. К тому же не распространяется на WebSocket и background-воркеры.
B — Utility/service layer с явным scopeПринятоЯвно, проверяемо, покрывает все слои. Caller должен opt-in, admin-bypass централизован, raw SQL может использовать тот же department id source.

Дизайн решения

1. Утилита DepartmentScope (src/lib/department-scope.ts)

import { PrismaClient, Prisma } from '@prisma/client';
import type { AuthUser } from '../types/auth.js';

export interface DepartmentScope {
/** The effective departmentId, or undefined for admin (all departments). */
departmentId: string | undefined;

/** Returns a Prisma where-clause fragment for a direct departmentId field. */
directWhere(): { departmentId?: string };

/** Returns a Prisma where-clause fragment for a nested path like { namespace: { departmentId } }. */
nestedWhere<T extends string>(
path: T,
): Record<T, { departmentId: string }> | Record<string, never>;

/** For raw SQL: returns the departmentId or throws if called without scope. */
requireDepartmentId(): string;

/** Whether this scope allows cross-department access. */
isAdmin: boolean;
}

export function forDepartment(user: Pick<AuthUser, 'role' | 'departmentId'>): DepartmentScope {
const isAdmin = user.role === 'admin';
const departmentId = isAdmin ? undefined : user.departmentId;

return {
departmentId,
isAdmin,

directWhere() {
return departmentId ? { departmentId } : {};
},

nestedWhere<T extends string>(path: T) {
if (!departmentId) return {} as Record<string, never>;
return { [path]: { departmentId } } as Record<T, { departmentId: string }>;
},

requireDepartmentId() {
if (!departmentId) {
throw new Error('DepartmentScope: requireDepartmentId() called on admin scope');
}
return departmentId;
},
};
}

2. План миграции по слоям

REST-маршруты

Заменить все локальные deptScope()-хелперы и inline-чеки на forDepartment(request.user):

// Before (knowledge.ts)
function deptScope(request) { ... }
const bases = await prisma.knowledgeBase.findMany({ where: { ...deptScope(request) } });

// After
import { forDepartment } from '../lib/department-scope.js';
const scope = forDepartment(request.user);
const bases = await prisma.knowledgeBase.findMany({ where: { ...scope.directWhere() } });
// Before (agent-tasks.ts) — nested scoping
where.namespace = { departmentId: effectiveDeptId };

// After
Object.assign(where, scope.nestedWhere('namespace'));
// Before (approvals.ts) — deep traversal
{
message: {
conversation: {
user: {
departmentId;
}
}
}
}

// After — approvals are a special case, keep explicit but derive ID from scope
const deptId = scope.departmentId;
if (deptId) {
where.message = { conversation: { user: { departmentId: deptId } } };
}

Post-load проверки (traces, детали agent task) лучше по возможности переделать в query-time фильтрацию. Если форма запроса делает это непрактичным — оставляйте post-load проверку, но последовательно используйте scope.departmentId.

SQL RAG

Три raw SQL-запроса в rag.ts уже используют консистентный паттерн. Рефакторим, чтобы принимали DepartmentScope-параметр:

// Before
AND kb."departmentId" = u."departmentId"

// After — pass scope.departmentId as a SQL parameter
${scope.departmentId ? Prisma.sql`AND kb."departmentId" = ${scope.departmentId}` : Prisma.empty}

Это ещё и развязывает RAG-запросы с необходимостью джоинить conversationId только ради получения отдела.

WebSocket

  1. Хранить departmentId в сокете рядом с AuthUser.
  2. В broadcast-пути (broadcastToNamespace) фильтровать получателей:
function broadcastToNamespace(namespaceId: string, event: string, data: unknown) {
const sockets = namespaceSubscriptions.get(namespaceId);
if (!sockets) return;
for (const socket of sockets) {
const user = socketUsers.get(socket);
// Namespace's departmentId must match the user's departmentId (or user is admin)
if (
user &&
(user.role === 'admin' || user.departmentId === namespaceDeptMap.get(namespaceId))
) {
sendSocket(socket, event, data);
}
}
}
  1. Кешировать маппинг namespace→departmentId при подписке (уже fetched в canSubscribeToNamespace).

Agent runner

findApprover уже корректно скоупится. Рефакторим на forDepartment() ради консистентности, поведение не меняется.

3. Fastify-декоратор (удобство)

Зарегистрировать forDepartment как request-декоратор Fastify, чтобы маршруты могли использовать request.departmentScope:

// src/plugins/department-scope.ts
fastify.decorateRequest('departmentScope', null);
fastify.addHook('preHandler', async (request) => {
if (request.user) {
request.departmentScope = forDepartment(request.user);
}
});

Это слой удобства — основная логика остаётся в чистой функции.

4. Тест-сет

Создать tests/department-isolation.test.ts со структурой:

describe('Department Isolation', () => {
// Setup: two departments, one user per department, seeded data in each

const endpoints = [
{ method: 'GET', path: '/api/v1/knowledge/bases' },
{ method: 'GET', path: '/api/v1/knowledge/documents' },
{ method: 'GET', path: '/api/v1/namespaces' },
{ method: 'GET', path: '/api/v1/agent-tasks' },
{ method: 'GET', path: '/api/v1/approvals' },
{ method: 'GET', path: '/api/v1/analytics/dashboard' },
// ... all dept-sensitive endpoints
];

for (const { method, path } of endpoints) {
it(`${method} ${path} — user cannot see other department data`, async () => {
// Authenticate as dept-A user
// Request data
// Assert no dept-B records in response
});
}

describe('WebSocket', () => {
it('cannot subscribe to namespace in another department', ...);
it('does not receive broadcast events for other department namespaces', ...);
});

describe('RAG', () => {
it('vector search returns only same-department knowledge', ...);
it('keyword search returns only same-department knowledge', ...);
});
});

Последствия

Плюсы

  • Один source of truth — вся фильтрация по отделу идёт через forDepartment(user).
  • Обнаружение на compile-time — grep по forDepartment находит все scoped-запросы; grep по departmentId находит всё, что его обходит.
  • Admin-bypass централизован — одно место для аудита role === 'admin' проверок.
  • WebSocket-дыра закрыта — broadcast-события проверяются per-socket.
  • Регрессионная страховка — тест-сет ловит новые эндпоинты, которые забыли про изоляцию.

Минусы

  • Миграция — рефакторинг примерно 10 файлов. Риск на файл низкий, но в сумме нетривиально.
  • Raw SQL всё ещё ручной — RAG может использовать scope.departmentId, но SQL-инъекция фильтра всё равно пишется руками.
  • Не невидимо — в отличие от Prisma middleware (вариант A), разработчик должен не забыть использовать scope. Компенсируется тестами и code review.

Риски

  • Admin-семантика — сейчас admin означает "все отделы". Если когда-то понадобится single-department admin, API DepartmentScope потребует forAdmin(departmentId?)-варианта.
  • Опциональность TemplateTemplate.departmentId nullable: scope-утилита должна обрабатывать шаблоны с departmentId: null как "global" (видимые всем отделам).

Порядок реализации

  1. Создать src/lib/department-scope.ts с forDepartment().
  2. Зарегистрировать Fastify-декоратор в src/plugins/department-scope.ts.
  3. Мигрировать REST-маршруты (knowledge → namespaces → agent-tasks → approvals → traces).
  4. Мигрировать RAG-слой.
  5. Починить WebSocket broadcast-фильтрацию.
  6. Написать tests/department-isolation.test.ts.
  7. Удалить все локальные deptScope/approvalDepartmentScope хелперы.

Реализовано

Принятый дизайн применён в текущей кодовой базе:

  • src/lib/department-scope.ts определяет forDepartment(), directWhere(), nestedWhere() и requireDepartmentId().
  • src/plugins/department-scope.ts декорирует аутентифицированные запросы через request.departmentScope.
  • REST-маршруты knowledge, namespaces, agent tasks, approvals, traces, conversations и employee profiles используют общий scope-паттерн.
  • src/knowledge/rag.ts берёт scope из conversation пользователя и применяет его в vector-, question- и keyword-SQL retrieval.
  • src/plugins/websocket.ts проверяет namespace department membership при подписке и снова при каждом broadcast, закрывая устаревшие подписки.
  • tests/department-isolation.test.ts покрывает list, detail, mutation, analytics, RAG retrieval и WebSocket-пути изоляции.