ADR-002: Уніфікована ізоляція відділу
- Статус: прийнято
- Дата: 2026-04-14
- Автор: CTO Agent (KALA-108)
Контекст
Ізоляція відділу — механізм, що не дає користувачу з відділу A доступитись до даних відділу B. Кожен AuthUser несе departmentId у JWT, а чотири Prisma-моделі мають department scope: User, KnowledgeBase, Namespace, Template.
Сьогодні department-фільтрація реалізована ad-hoc у щонайменше 5 різних моделях на трьох рівнях:
Поточний стан
| Шар | Файл(и) | Патерн | Проблема |
|---|---|---|---|
| SQL RAG | src/knowledge/rag.ts (3 запити) | Raw SQL WHERE kb."departmentId" = u."departmentId" через join conversation→user | Внутрішньо консистентний, але обходить Prisma — централізувати не вийде |
| REST — knowledge routes | src/api/routes/knowledge.ts | Локальний хелпер deptScope() повертає { departmentId } для Prisma where | Працює, але хелпер локальний і дублюється |
| REST — agent tasks | src/routes/agent-tasks.ts | Інший signature deptScope(); вкладений { namespace: { departmentId } } | Форма повернення не така сама, як у knowledge-версії |
| REST — approvals | src/routes/approvals.ts | approvalDepartmentScope() через глибоку вкладеність { 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 перевірка: завантажити запис, потім порівняти departmentId | Завантажує дані до перевірки доступу — марно і прогнозовано помилково |
| WebSocket | src/plugins/websocket.ts | canSubscribeToNamespace() перевіряє департамент на підписку, але бродкаст не перевіряє per-event | Крос-департаментний витік, якщо namespace-підписки переживають зміни відділу |
| Agent runner | src/agent-runner/worker.ts | findApprover() scope через conv.user.departmentId | Коректно, але one-off |
Ризики поточного підходу
- Легко забути: нові маршрути вимагають, щоб розробник не забув додати department-фільтрацію — ні compile-time, ні runtime safety-net.
- Непослідовний admin bypass: більшість перевірок використовують
role === 'admin', але точні умови відрізняються. - Прогалина у WebSocket: broadcast-події доходять до всіх підписників простору імен незалежно від членства у відділі, що ламає ізоляцію даних, якщо підписки на простір імен переживають переведення між відділами.
- Нема автоматизованого регресійного тесту: нема тесту, який би систематично перевіряв, що користувач відділу X не бачить дані відділу Y у всіх ендпоінтах.
Рішення
Варіант B: явний department scope через forDepartment(user) — з супутнім патерном для raw-SQL рівня RAG.
Чому не альтернативи?
| Варіант | Вердикт | Причина |
|---|---|---|
| A — Prisma client middleware/extension | Відхилено | Prisma client extensions можуть вставляти where-клаузи через $allOperations, але вони мовчки змінюють запити — важко дебажити, неможливо opt-out для admin cross-department перегляду, і вони не покривають raw SQL (RAG). Ризик тонких query-plan регресій. |
| C — Fastify hook + query wrapper | Відхилено | preHandler-хук може додати scope-інфо у request, але не може примусити downstream-код її використати. Це hint, не гарантія. Також не покриває WebSocket чи background-воркери. |
| B — Явний scope utility/service layer | Прийнято | Явний, перевіряємий, покриває всі рівні. Викликачу треба opt-in, admin bypass централізований, а raw SQL може використовувати те саме джерело department id. |
Дизайн рішення
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 routes
Замініть усі локальні 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 detail) треба, де можливо, перевести на фільтрацію на рівні запиту. Якщо структура запиту це робить непрактичним — лишайте 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 лише для отримання department.
WebSocket
- Зберігайте
departmentIdу socket connection поруч із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 уже коректно scope-обмежений. Рефакторимо, щоб використовував forDepartment() для узгодженості, поведінка лишається.
3. Fastify decorator (зручність)
Зареєструйте forDepartment як Fastify request decorator, щоб маршрути могли використовувати 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', ...);
});
});
Наслідки
Позитивні
- Єдине джерело правди: усі department-scope-и йдуть через
forDepartment(user). - Compile-time дискавер: grep по
forDepartmentзнаходить усі scope-обмежені запити; grep поdepartmentIdзнаходить будь-які, що його обходять. - Admin bypass централізований: одне місце для аудиту перевірок
role === 'admin'. - Прогалину WebSocket закрито: бродкаст-події перевіряються per-socket.
- Регресійний safety-net: тестовий харнес вилучить нові ендпоінти, де забули ізоляцію.
Негативні
- Migration effort: ~10 файлів треба рефакторити. Low-risk на файл, але нетривіальний сумарно.
- Raw SQL все одно ручний: RAG-рівень може використовувати
scope.departmentId, але вставка filter-SQL — все одно рукописний фрагмент. - Не invisible: на відміну від Prisma-middleware (варіант A), розробник має памʼятати використовувати scope. Мітигується harness-тестом і code review.
Ризики
- Semantics для admin: наразі
adminозначає «всі відділи». Якщо колись знадобиться admin, обмежений одним відділом —DepartmentScopeAPI потребуватиме варіантаforAdmin(departmentId?). - Template nullability:
Template.departmentIdnullable — scope utility має обробляти шаблони зdepartmentId: nullяк «global» (видимі всім відділам).
План реалізації
- Створити
src/lib/department-scope.tsізforDepartment(). - Зареєструвати Fastify-декоратор у
src/plugins/department-scope.ts. - Мігрувати REST-маршрути (knowledge → namespaces → agent-tasks → approvals → traces).
- Мігрувати RAG-рівень.
- Зафіксити broadcast-фільтрацію WebSocket.
- Написати
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 користувача розмови і застосовує його до vector-, question-vector- та keyword-SQL пошуків.src/plugins/websocket.tsперевіряє namespace-department-членство і на підписку, і повторно на кожен broadcast — охоплюючи stale-підписки.tests/department-isolation.test.tsпокриває list, detail, mutation, analytics, RAG-пошук і WebSocket-ізоляцію.