Перейти до основного вмісту

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 RAGsrc/knowledge/rag.ts (3 запити)Raw SQL WHERE kb."departmentId" = u."departmentId" через join conversation→userВнутрішньо консистентний, але обходить Prisma — централізувати не вийде
REST — knowledge routessrc/api/routes/knowledge.tsЛокальний хелпер deptScope() повертає { departmentId } для Prisma whereПрацює, але хелпер локальний і дублюється
REST — agent taskssrc/routes/agent-tasks.tsІнший signature deptScope(); вкладений { namespace: { departmentId } }Форма повернення не така сама, як у knowledge-версії
REST — approvalssrc/routes/approvals.tsapprovalDepartmentScope() через глибоку вкладеність { message: { conversation: { user: { departmentId } } } }Унікальний traversal, хелпери не шерити
REST — namespacessrc/routes/namespaces.tsInline role === 'admin' ? query.departmentId : userDeptIdЖодної переваживної абстракції
REST — tracessrc/routes/traces.tsPost-load перевірка: завантажити запис, потім порівняти departmentIdЗавантажує дані до перевірки доступу — марно і прогнозовано помилково
WebSocketsrc/plugins/websocket.tscanSubscribeToNamespace() перевіряє департамент на підписку, але бродкаст не перевіряє per-eventКрос-департаментний витік, якщо namespace-підписки переживають зміни відділу
Agent runnersrc/agent-runner/worker.tsfindApprover() scope через conv.user.departmentIdКоректно, але one-off

Ризики поточного підходу

  1. Легко забути: нові маршрути вимагають, щоб розробник не забув додати department-фільтрацію — ні compile-time, ні runtime safety-net.
  2. Непослідовний admin bypass: більшість перевірок використовують role === 'admin', але точні умови відрізняються.
  3. Прогалина у WebSocket: broadcast-події доходять до всіх підписників простору імен незалежно від членства у відділі, що ламає ізоляцію даних, якщо підписки на простір імен переживають переведення між відділами.
  4. Нема автоматизованого регресійного тесту: нема тесту, який би систематично перевіряв, що користувач відділу 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

  1. Зберігайте departmentId у socket connection поруч із 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 уже коректно 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, обмежений одним відділом — DepartmentScope API потребуватиме варіанта forAdmin(departmentId?).
  • Template nullability: Template.departmentId nullable — scope utility має обробляти шаблони з 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. Зафіксити broadcast-фільтрацію WebSocket.
  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 користувача розмови і застосовує його до vector-, question-vector- та keyword-SQL пошуків.
  • src/plugins/websocket.ts перевіряє namespace-department-членство і на підписку, і повторно на кожен broadcast — охоплюючи stale-підписки.
  • tests/department-isolation.test.ts покриває list, detail, mutation, analytics, RAG-пошук і WebSocket-ізоляцію.