Skip to main content

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

LayerFile(s)PatternProblem
SQL RAGsrc/knowledge/rag.ts (3 queries)Raw SQL WHERE kb."departmentId" = u."departmentId" joined through conversation→userConsistent internally, but bypasses Prisma — can't be centralized
REST — knowledge routessrc/api/routes/knowledge.tsLocal deptScope() helper returns { departmentId } for Prisma whereWorks, but the helper is local and duplicated
REST — agent-taskssrc/routes/agent-tasks.tsDifferent deptScope() signature; nested { namespace: { departmentId } }Different return shape than knowledge's version
REST — approvalssrc/routes/approvals.tsapprovalDepartmentScope() via deep nested { message: { conversation: { user: { departmentId } } } }Unique traversal, cannot share helpers
REST — namespacessrc/routes/namespaces.tsInline role === 'admin' ? query.departmentId : userDeptIdNo reusable abstraction
REST — tracessrc/routes/traces.tsPost-load check: fetch record, then compare departmentIdLoads data before checking access — wasteful and error-prone
WebSocketsrc/plugins/websocket.tscanSubscribeToNamespace() checks dept on subscribe, but broadcast has no per-event validationCross-department leak if namespace subscriptions are stale
Agent runnersrc/agent-runner/worker.tsfindApprover() scopes by conv.user.departmentIdCorrect but one-off

Risks of current approach

  1. Easy to forget: new routes require developers to remember to add department filtering — no compile-time or runtime safety net.
  2. Inconsistent admin bypass: most checks use role === 'admin' but the exact condition varies.
  3. WebSocket gap: broadcast events reach all namespace subscribers regardless of department membership, which is a data isolation violation if namespace subscriptions outlive department changes.
  4. 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?

OptionVerdictReason
A — Prisma middleware / client extensionRejectedPrisma 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 wrapperRejectedA 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 layerAcceptedExplicit, 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

  1. Store departmentId on the socket connection alongside the AuthUser.
  2. 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);
}
}
}
  1. 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 forDepartment to find all scoped queries; grep for departmentId to 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.departmentId but 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 admin means "all departments". If we ever need admin-scoped-to-one-department, the DepartmentScope API needs a forAdmin(departmentId?) variant.
  • Template optionality: Template.departmentId is nullable — the scope utility should handle departmentId: null templates as "global" (visible to all departments).

Implementation Order

  1. Create src/lib/department-scope.ts with forDepartment().
  2. Register Fastify decorator in src/plugins/department-scope.ts.
  3. Migrate REST routes (knowledge → namespaces → agent-tasks → approvals → traces).
  4. Migrate RAG layer.
  5. Fix WebSocket broadcast filtering.
  6. Write tests/department-isolation.test.ts.
  7. Remove all local deptScope / approvalDepartmentScope helpers.

Implemented

The accepted design has been applied in the current codebase:

  • src/lib/department-scope.ts defines forDepartment(), directWhere(), nestedWhere(), and requireDepartmentId().
  • src/plugins/department-scope.ts decorates authenticated requests with request.departmentScope.
  • REST routes for knowledge, namespaces, agent tasks, approvals, traces, conversations, and employee profiles use the shared scope pattern.
  • src/knowledge/rag.ts receives the conversation user's scope and applies it to vector, question-vector, and keyword SQL retrieval.
  • src/plugins/websocket.ts validates namespace department membership on subscribe and again on every broadcast, including stale subscriptions.
  • tests/department-isolation.test.ts covers list, detail, mutation, analytics, RAG retrieval, and WebSocket isolation paths.