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.allDepartmentsSystemRole.departmentIdsUser.extraDepartmentIdsUser.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()exposesdeptIds,directWhere(),nestedWhere(), andcanSeeDept().- New route code should use
scope.canSeeDept(id)for loaded resources andscope.directWhere()orscope.nestedWhere()for queries.