Skip to main content

ADR-004: Multi-Department Access

  • Status: Accepted
  • Date: 2026-04-16
  • Author: CTO Agent (KALA-148)

Context

User.departmentId originally represented the user's whole data scope. That works for a single-department employee, but it fails for managers and shared service teams. A project manager may need access to Legal, Finance, and HR, while still having one primary department for ownership and defaults.

The system already had department isolation in REST routes, WebSocket subscriptions, RAG queries, and background workers. Multi-department access had to extend that isolation model without weakening it.

Decision

Keep User.departmentId and User.primaryDepartmentId as the primary/default department fields, and add department scope through role and user override arrays:

  • SystemRole.allDepartments
  • SystemRole.departmentIds
  • User.extraDepartmentIds
  • User.revokedDepartmentIds

The original design considered a UserDepartmentAccess(userId, departmentId) join table. The current implementation uses arrays because the expected scope sets are small, the access calculation is read-heavy, and overrides are already computed together with permissions.

Effective department access is:

if role.allDepartments or legacy role is admin:
all departments
else:
role.departmentIds
+ primaryDepartmentId
+ departmentId
+ extraDepartmentIds
- revokedDepartmentIds

Alternatives Considered

Replace departmentId with a join table

Rejected for this phase. It would make the common single-department case more complex and would require every route to join or prefetch access rows.

Keep only departmentId

Rejected. It cannot model cross-functional managers without granting full admin access.

Use SystemRole.departmentIds only

Rejected. It makes one-off exceptions expensive because each exception requires a new role.

Consequences

Positive

  • Single-department users stay simple.
  • Managers can receive scoped multi-department access without becoming admins.
  • Revokes can remove a department even when the base role grants it.
  • The same forUser() helper supports direct Prisma filters and nested filters.

Negative

  • Arrays require validation when accepting department ids from API requests.
  • Very large department scopes would be better represented by a join table.
  • Callers that require exactly one department must not use requireDepartmentId() on multi-department users.

Implementation Notes

  • effectiveDepartments() performs the set arithmetic.
  • forUser() exposes deptIds, directWhere(), nestedWhere(), and canSeeDept().
  • New route code should use scope.canSeeDept(id) for loaded resources and scope.directWhere() or scope.nestedWhere() for queries.