Back to Journal

When departments see too much

Listen to this articleAI narration
0:00 / 0:00

The customer portal had two related access control issues. First, records were getting the wrong orgId when users selected a county different from their own organization. Second, departments within the same county could see each other's data when they should be isolated. Time to trace the data flow and fix both.

The OrgId Problem

When creating referrals or test reports, the orgId field determines which organization can view that record. The original implementation took orgId directly from session.user.orgId - the organization the logged-in user belongs to.

This works fine when users only create records for their own organization. But users with the referral_group role can see and select any county in the system. When they select a different county, the record should belong to that county's organization - not the user's home organization.

OrgId Assignment (Before)User SessionorgId: "county-A"role: referral_groupReferral Formcounty: "County B"department: "dept-123"customer: emailAPI RouteorgId = session.user.orgIdMongoDBorgId: "county-A"WRONG!Data Visibility ProblemUser selected County B, but record has County A's orgIdCounty A users can see it - County B users cannotRecord is visible to the wrong organization

Server-Side OrgId Resolution

The fix: resolve orgId from the selected county name on the server, not from the user's session. I created a helper function in the county data module that takes a county name and returns the corresponding organization ID.

The function checks if a county name was provided. If so, it looks up the county by name and returns its _id as the orgId. If no county was selected, it falls back to the user's session orgId - this preserves visibility for internal records that don't need county assignment.

OrgId Resolution LogicresolveOrgIdFromCountycountyprovided?YesgetCountyByName(county)→ return county._idNoUse fallback→ return session.user.orgIdCounty's OrganizationVisible to selected countyUser's OrganizationVisible to user's own orgorgId always matches the intended organization

The implementation required updates to four files. The county data module got the new resolveOrgIdFromCounty function. Both API routes for tests and referrals now call this function instead of using the session orgId directly. The referral server action got the same treatment.

I also removed client-side orgId handling from the test form. Previously it was sending session.user.orgId in the request body - now the server resolves it correctly based on the county selection.

The Department Isolation Problem

With orgId fixed, a second issue surfaced. Users with referral_group role could see all records from their entire county, but departments within that county should be isolated from each other.

The organizational hierarchy works like this: a county contains multiple departments, and each department has its own users. A user in Department A shouldn't see Department B's records, even though both departments belong to the same county.

Organizational HierarchyCounty (orgId)Department AdepartmentId: "A"User 1User 2RecordsDepartment BdepartmentId: "B"User 3User 4RecordsShould be isolatedAccess RulesDept A sees Dept A onlyDept B sees Dept B onlyAdmin sees entire county

The Access Control Fix

The existing access control logic filtered by orgId for all elevated roles. I needed to differentiate: admin users should see county-wide data, while group account users should see department-scoped data.

The key insight: users already have a departmentId field in the database, but it wasn't being passed through the session. Records also store which department created them. I just needed to wire these together.

First, I added departmentId to the authentication flow. The authorize functions for BankID, email, and impersonation now include the user's departmentId in the returned object. The JWT callback stores it in the token, and the session callback makes it available to API routes.

Then I updated the access control logic in both the test and referral data modules. The filter building now checks the user's role to determine scope:

Access Control by RoleRoleFilter ScopeCan Seeadmin{ orgId: user.orgId }County levelAll records in their countyreferral_groupresults_reviewer_group{ department:user.departmentId }Department levelOnly records from their departmentreferralresults_reviewerorder{ userId: user.id }User levelOnly records they created+ All users can see records where they're listed as customer (email match)

The access filter logic now builds an array of OR conditions. Every user gets their own records via userId match. Admin users additionally get county-wide access via orgId match. Group account users get department access via department match. Everyone gets records where their email is the customer field.

Files Modified

The authentication module needed updates to pass departmentId through the session. All three credential providers - BankID, email, and impersonation - now include departmentId in the returned user object. The JWT callback stores it, and the session callback exposes it.

Both data modules for tests and referrals received identical access control updates. The paginated query functions now accept departmentId as a parameter and use role-based filtering logic.

The API routes for tests and referrals were updated to pass the session's departmentId to the data layer queries.

The referral schema was updated to allow empty orgId strings, accommodating the edge case where resolution might not find a matching county.

Final Architecture

Access Control ArchitectureUser SessionuserIdorgId (county)departmentIdroleAPI RouteExtract session dataPass to data layerData LayerBuild access filter:• userId (always)• orgId (admin only)• departmentId (group)• customer emailMongoDBFilter appliedvia $orForm Submissioncounty: "County B"department: "dept-123"resolveOrgIdFromCountycounty provided?→ lookup county._id→ fallback to session.user.orgIdRecord CreatedorgId: resolved valuedepartment: from formSummary• orgId resolved from county selection, not user session• Department-level isolation for group account roles• Admin retains county-wide visibility

Summary

Two access control fixes shipped. The orgId resolution now correctly assigns records to the selected county's organization rather than the user's home organization. Department-level isolation ensures that group account users only see records from their own department, while admins retain county-wide visibility.

The session now carries departmentId alongside orgId and role. The data layer uses role-based logic to build appropriate access filters. Records created with a county selection get that county's orgId; records without a county selection fall back to the user's orgId for organizational tracking.

Both referral and test report flows received these updates. The fix scripts for correcting historical orgId values can be re-run to ensure existing records match their county assignments.