ADR-002: Unify Department Isolation
- Status: Accepted
- Date: 2026-04-14
- Author: CTO Agent (KALA-108)
Context
Department isolation is the mechanism that prevents users in department A from accessing data belonging to department B. Every AuthUser carries a departmentId in the JWT, and four Prisma models are department-scoped: User, KnowledgeBase, Namespace, and Template.
Today, department filtering is implemented ad-hoc in at least 5 distinct patterns across three layers:
Current state
| Layer | File(s) | Pattern | Problem |
|---|---|---|---|
| SQL RAG | src/knowledge/rag.ts (3 queries) | Raw SQL WHERE kb."departmentId" = u."departmentId" joined through conversation→user | Consistent internally, but bypasses Prisma — can't be centralized |
| REST — knowledge routes | src/api/routes/knowledge.ts | Local deptScope() helper returns { departmentId } for Prisma where | Works, but the helper is local and duplicated |
| REST — agent-tasks | src/routes/agent-tasks.ts | Different deptScope() signature; nested { namespace: { departmentId } } | Different return shape than knowledge's version |
| REST — approvals | src/routes/approvals.ts | approvalDepartmentScope() via deep nested { message: { conversation: { user: { departmentId } } } } | Unique traversal, cannot share helpers |
| REST — namespaces | src/routes/namespaces.ts | Inline role === 'admin' ? query.departmentId : userDeptId | No reusable abstraction |
| REST — traces | src/routes/traces.ts | Post-load check: fetch record, then compare departmentId | Loads data before checking access — wasteful and error-prone |
| WebSocket | src/plugins/websocket.ts | canSubscribeToNamespace() checks dept on subscribe, but broadcast has no per-event validation | Cross-department leak if namespace subscriptions are stale |
| Agent runner | src/agent-runner/worker.ts | findApprover() scopes by conv.user.departmentId | Correct but one-off |
Risks of current approach
- Easy to forget: new routes require developers to remember to add department filtering — no compile-time or runtime safety net.
- Inconsistent admin bypass: most checks use
role === 'admin'but the exact condition varies. - WebSocket gap: broadcast events reach all namespace subscribers regardless of department membership, which is a data isolation violation if namespace subscriptions outlive department changes.
- No automated regression test: there is no test that systematically verifies a user in dept X cannot access data from dept Y across all endpoints.
Decision
Option B: explicit department scope via forDepartment(user) — with a raw-SQL companion pattern for the RAG layer.
Why not the alternatives?
| Option | Verdict | Reason |
|---|---|---|
| A — Prisma middleware / client extension | Rejected | Prisma client extensions can inject where clauses via $allOperations, but they silently mutate queries — hard to debug, impossible to opt-out for cross-department admin views, and they don't cover raw SQL (RAG). Risk of subtle query plan regressions. |
| C — Fastify hook + query wrapper | Rejected | A preHandler hook can attach scoping info to the request, but it cannot enforce that downstream code actually uses it. It's a hint, not a guarantee. Also doesn't cover WebSocket or background workers. |
| B — Explicit scope utility/service layer | Accepted | Explicit, auditable, covers all layers. The caller must opt in, admin bypass is centralized, and raw SQL can use the same department ID source. |
Solution Design
1. DepartmentScope utility (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. Migration plan per layer
REST routes
Replace all local deptScope() helpers and inline checks with 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 checks (traces, agent-tasks detail) should be converted to query-time filtering where possible. Where the query structure makes it impractical, keep the post-load check but use scope.departmentId consistently.
SQL RAG
The three raw SQL queries in rag.ts already use a consistent pattern. Refactor to accept a DepartmentScope parameter:
// 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}
This also decouples RAG queries from requiring a conversationId join just to get the department.
WebSocket
- Store
departmentIdon the socket connection alongside theAuthUser. - In the broadcast path (
broadcastToNamespace), filter recipients:
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);
}
}
}
- Cache namespace→departmentId mapping on subscribe (already fetched in
canSubscribeToNamespace).
Agent runner
findApprover already scopes correctly. Refactor to use forDepartment() for consistency but behavior stays the same.
3. Fastify decorator (convenience)
Register forDepartment as a Fastify request decorator so routes can use request.departmentScope:
// src/plugins/department-scope.ts
fastify.decorateRequest('departmentScope', null);
fastify.addHook('preHandler', async (request) => {
if (request.user) {
request.departmentScope = forDepartment(request.user);
}
});
This is a convenience layer — the core logic stays in the pure function.
4. Test harness
Create tests/department-isolation.test.ts with the following structure:
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', ...);
});
});
Consequences
Positive
- Single source of truth: all department scoping derives from
forDepartment(user). - Compile-time discoverability: grep for
forDepartmentto find all scoped queries; grep fordepartmentIdto find any that bypass it. - Admin bypass is centralized: one place to audit the
role === 'admin'check. - WebSocket gap closed: broadcast events validated per-socket.
- Regression safety: the test harness catches new endpoints that forget isolation.
Negative
- Migration effort: ~10 files need refactoring. Low risk per-file but non-trivial total.
- Raw SQL still manual: the RAG layer can use
scope.departmentIdbut SQL injection of the filter is still a hand-written fragment. - Not invisible: unlike Prisma middleware (Option A), developers must remember to use the scope. Mitigated by the test harness and code review.
Risks
- Admin semantics: currently
adminmeans "all departments". If we ever need admin-scoped-to-one-department, theDepartmentScopeAPI needs aforAdmin(departmentId?)variant. - Template optionality:
Template.departmentIdis nullable — the scope utility should handledepartmentId: nulltemplates as "global" (visible to all departments).
Implementation Order
- Create
src/lib/department-scope.tswithforDepartment(). - Register Fastify decorator in
src/plugins/department-scope.ts. - Migrate REST routes (knowledge → namespaces → agent-tasks → approvals → traces).
- Migrate RAG layer.
- Fix WebSocket broadcast filtering.
- Write
tests/department-isolation.test.ts. - Remove all local
deptScope/approvalDepartmentScopehelpers.
Implemented
The accepted design has been applied in the current codebase:
src/lib/department-scope.tsdefinesforDepartment(),directWhere(),nestedWhere(), andrequireDepartmentId().src/plugins/department-scope.tsdecorates authenticated requests withrequest.departmentScope.- REST routes for knowledge, namespaces, agent tasks, approvals, traces, conversations, and employee profiles use the shared scope pattern.
src/knowledge/rag.tsreceives the conversation user's scope and applies it to vector, question-vector, and keyword SQL retrieval.src/plugins/websocket.tsvalidates namespace department membership on subscribe and again on every broadcast, including stale subscriptions.tests/department-isolation.test.tscovers list, detail, mutation, analytics, RAG retrieval, and WebSocket isolation paths.