ADR-002: Унификация изоляции отделов
- Статус: Принято
- Дата: 14 апреля 2026
- Автор: CTO Agent (KALA-108)
Контекст
Изоляция отделов — механизм, который не даёт пользователю из отдела A получить доступ к данным отдела B. У каждого AuthUser в JWT есть departmentId, а четыре Prisma-модели имеют department scope: User, KnowledgeBase, Namespace и Template.
Сейчас фильтрация по отделу реализована ad-hoc, минимум 5 разными паттернами на трёх слоях:
Текущее состояние
| Слой | Файл(ы) | Паттерн | Проблема |
|---|---|---|---|
| SQL RAG | src/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-tasks | src/routes/agent-tasks.ts | Другая сигнатура deptScope(); вложенное { namespace: { departmentId } } | Возвращает другую форму, отличается от knowledge-версии |
| REST — approvals | src/routes/approvals.ts | approvalDepartmentScope() через глубокий nesting { message: { conversation: { user: { departmentId } } } } | Уникальный traversal, нельзя шарить с другими хелперами |
| REST — namespaces | src/routes/namespaces.ts | Inline role === 'admin' ? query.departmentId : userDeptId | Нет переиспользуемой абстракции |
| REST — traces | src/routes/traces.ts | Post-load проверка: сначала fetch, потом сравнение departmentId | Загружает данные до access-проверки — расточительно и ошибкоопасно |
| WebSocket | src/plugins/websocket.ts | canSubscribeToNamespace() проверяет отдел на подписку, но на broadcast нет per-event проверки | Утечка между отделами, если namespace-подписки пережили смену отдела |
| Agent runner | src/agent-runner/worker.ts | findApprover() скоупится через conv.user.departmentId | Корректно, но one-off |
Риски текущего подхода
- Легко забыть — новые маршруты требуют от разработчика не забыть добавить фильтрацию по отделу; нет compile-time или runtime страховки.
- Непоследовательный admin-bypass — большинство проверок используют
role === 'admin', но точные условия могут различаться. - WebSocket-дыра — broadcast-события долетают до всех подписчиков namespace независимо от department membership, что нарушает изоляцию данных, если namespace-подписки пережили смену отдела.
- Нет автоматического регрессионного теста — нет теста, который систематически проверяет, что пользователь из отдела 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
- Хранить
departmentIdв сокете рядом сAuthUser. - В 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);
}
}
}
- Кешировать маппинг 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, APIDepartmentScopeпотребуетforAdmin(departmentId?)-варианта. - Опциональность Template —
Template.departmentIdnullable: scope-утилита должна обрабатывать шаблоны сdepartmentId: nullкак "global" (видимые всем отделам).
Порядок реализации
- Создать
src/lib/department-scope.tsсforDepartment(). - Зарегистрировать Fastify-декоратор в
src/plugins/department-scope.ts. - Мигрировать REST-маршруты (knowledge → namespaces → agent-tasks → approvals → traces).
- Мигрировать RAG-слой.
- Починить WebSocket broadcast-фильтрацию.
- Написать
tests/department-isolation.test.ts. - Удалить все локальные
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-пути изоляции.