Permissions, Tenancy & Rollout
Business view
DHS touches money, patient identity and a national health exchange. Three separate controls decide whether any given click is allowed:
- Is the feature on for this clinic? A feature flag, off for everyone except the beta
cohort.
- Does this user have the right permission? Nine new, deliberately granular
permissions, configured per permission group.
- Does this record belong to this clinic? Tenant rules, enforced server-side even for
users who pass the first two.
The nine permissions
They appear as a new DHS Settings tab when editing a permission group (Settings → Permission Groups → group):
| Permission | Lets the user… |
|---|---|
| View DHS Approvals | See the Approvals tab and the approvals log |
| Check DHS Insurance | Run insurance discovery; also gates Check Status |
| Check DHS Eligibility | Run eligibility checks |
| Create DHS Approval | Submit a pre-auth |
| Create Manual DHS Approval | Hand-reconcile an approval |
| Cancel DHS Approval | Withdraw a pre-auth |
| Get DHS Approval | Retry a failed approval poll |
Two more live on the Settings tab, under a DHS Integration row:
| Permission | Lets the user… |
|---|---|
| View DHS Integration | See the Settings → Integrations → DHS tab |
| Edit DHS Integration | Save / rotate the client secret, set branch NPHIES codes |
For that row the View-Created, Add and Delete checkboxes are deliberately disabled — only View and Edit make sense for a singleton integration record.
Sensible defaults to recommend
Dentolize ships no DHS permissions enabled on any seeded group. A reasonable starting point:
| Role | Suggested grants |
|---|---|
| Owner / Manager | All nine, plus View + Edit DHS Integration |
| Receptionist | Check DHS Insurance, Check DHS Eligibility, View DHS Approvals |
| Doctor | Create DHS Approval, View DHS Approvals, Get DHS Approval |
| Accountant | View DHS Approvals, Create Manual DHS Approval |
| Nobody by default | Cancel DHS Approval, Edit DHS Integration |
The rollout flag
FEATURE_DHS_INTEGRATION is seeded active: true with the positive rule Rule('isBeta', 'EQUALS', true). In plain terms: the feature is live only for companies flagged as beta. Turning it on for a clinic is a matter of flipping that company's beta flag (or editing the rule), not a code deploy.
When the flag is off, every DHS GraphQL operation is refused, every DHS UI surface is hidden, the background job skips the company entirely, and — importantly — the invoicing guards do not block anything. A non-beta clinic behaves exactly as it did before this PR.
Technical view
Permission constants
packages/prisma/schema.prisma:6348-6356 and enums.graphql:622-630, identical sets:
VIEW_DHS_INTEGRATION EDIT_DHS_INTEGRATION
VIEW_DHS_APPROVALS CHECK_DHS_INSURANCE
CHECK_DHS_ELIGIBILITY CREATE_DHS_APPROVAL
CANCEL_DHS_APPROVAL CREATE_MANUAL_DHS_APPROVAL
GET_DHS_APPROVAL
Documented discrepancy. The PR description namesMANAGE_DHS_INTEGRATION,MANAGE_DHS_APPROVALSandCHECK_INSURANCE. Those were added to the Postgres enum by migration20260615152553_dhs_permissionsand then superseded by the nine above in20260616100525_add_dhs_permissions. Because Postgres cannot drop enum values, they still exist in the database type while being absent fromschema.prismaandenums.graphql. A repo-wide grep forMANAGE_DHSreturns zero hits. The PR description is out of date; the code is correct.
Front-end mapping: packages/clinic-mobile/src/shared/utils/getUserPermissions.js:459-468 (shared by web and mobile). Note getDhsApprovals is backed by the singular GET_DHS_APPROVAL constant.
Permission-group UI: settings/Groups/tableData.js:471-479 (the seven-row DhsDataSource, exported at :1133), rendered as its own tab at settings/Groups/Group.js:251. The DHS_INTEGRATION row lives in the Settings data source at tableData.js:126-129, with View-Created / Add / Delete disabled at :515, :529, :562.
Shield wiring
packages/server/src/permissions/permissions.js. Every DHS field uses the same shape:
and(
chain(isAuthenticated, hasPermission(X)[, tenantRule]),
hasFeatureFlag('FEATURE_DHS_INTEGRATION')
)
Queries (:2727-2734):
| Operation | Permission |
|---|---|
getDHSClientSecret, dhsBranches | VIEW_DHS_INTEGRATION |
getInsuranceCompanyDhsInsuranceHierarchy, resolveInsuranceCoverages | CHECK_DHS_INSURANCE |
getDHSApprovals, totalDHSApprovals, dhsApprovalDetails | VIEW_DHS_APPROVALS |
Mutations (:2770-2781):
| Operation | Permission | Tenant rule |
|---|---|---|
saveDHSIntegration, testDHSConnection, rotateDHSClientSecret, updateBranchesNphiesCodes, dhsAuthentication | EDIT_DHS_INTEGRATION | — |
dhsApprovalSubmission | CREATE_DHS_APPROVAL | isSameCompanyAsDhsInputPatient |
dhsCancelApproval | CANCEL_DHS_APPROVAL | isSameCompanyAsDhsApprovalId |
dhsGetApproval | GET_DHS_APPROVAL | isSameCompanyAsDhsApprovalId |
dhsManualUpdateOperations | CREATE_MANUAL_DHS_APPROVAL | isSameCompanyAsDhsManualOperations |
checkInsuranceEligibility | CHECK_DHS_INSURANCE | — |
dhsCheckEligibility | CHECK_DHS_ELIGIBILITY | — |
updatePatientDetails | EDIT_PATIENTS_DETAILS + isSameCompanyAsPatient | no feature-flag wrapper (:2782) |
The move out of the public patientApp namespace into the authenticated clinic namespace — called out in the PR's security-review section — is what these entries represent.
Tenant rules
packages/server/src/permissions/rules.js:
| Rule | What it compares | Line |
|---|---|---|
isSameCompanyAsDhsInputPatient | patient.companyId via patientCache | :1927-1933 |
isSameCompanyAsDhsApprovalId | dHSApproval.patient.companyId | :1935-1945 |
isSameCompanyAsDhsManualOperations | Every operation.branch.companyId | :1947-1957 |
All three return true when the relevant id is absent — they are permissive on absence, not fail-closed. The resolvers compensate: dhsGetApproval.js:30-31 and dhsCancelApproval.js:29-37 both add an explicit !dhsApprovalId guard whose comment explains that Prisma drops undefined filters, which would otherwise degrade the lookup to "any approval in this company".
isSameCompanyAsDhsManualOperations checks the branch's company, not operation.companyId, and its .every does not verify that all requested ids were found — a nonexistent id silently drops out of the result set. The resolver's own count assertion (dhsManualUpdateOperations.js:46-51) is the real guard.
Note: the read side (getDHSApprovals,dhsApprovalDetails) has no shield-level tenant rule. Scoping is enforced inside the resolvers —getDHSApprovals.js:16-17always includesAND: [{ companyId }], anddhsApprovalDetails.js:24-26comparesapproval.companyIdafter an unscopedfindUniqueand throws'Not Authorised'.
Defence in depth: layered gating
Even with the flag and permission satisfied, most patient-facing surfaces require a third condition — the selected branch has an NPHIES code:
| Surface | File:line |
|---|---|
| Chart → Approvals tab | ChartTabs.js:130 |
| Patient-profile buttons | PatientProfile.js:147 |
| Patient-form insurance link | PatientCommonFields.js:737 |
| Chart footer submission | ChartTableFooter.js:407 |
| Status popover interactivity | DHSStatusPopover.js:47,53,135 |
| Chart-table status tags | ChartTable.js:319 |
| Invoice expanded row | InvoiceExpendedRow.js:274 |
This is why, on a freshly flagged clinic, none of the patient-side DHS controls appear until step 2 of the setup wizard has been completed. It was reproduced exactly on the branch sandbox: setting a branch NPHIES code made Check Eligibility, Check Insurance, the Approvals tab and GET Approval all appear at once.
Feature-flag enforcement — three independent places
The flag is genuinely enforced, not a decorative seed row:
- GraphQL shield — all 18 DHS operations, via
hasFeatureFlag
(rules.js:174-193), which loads the flag row and evaluates positive/negative rules against the caller's company. All rejection causes collapse to the same 'Feature not enabled' message, deliberately (comment :169-170).
- The cron — a global
activecheck atdhsApprovalsCron.js:68-75, then per-company
rule evaluation at :124-145.
- Resolver-side guard —
isDhsFeatureActiveForCompanyinside
assertNoPendingDhsApproval (dhsGuards.js:6,12-28,51), so that flag-off never blocks invoicing.
All three share evaluateFeatureFlagRules and the same company field set { id, country, isBeta, isVIP, tier, createdAt }. rules.js:165-167 explains why the company is loaded directly rather than via companyCache — the cache omits those fields.
Seed: packages/server/src/generateServerData/seedFeatureFlags.js:39-45, upserted by key at :50-54.
The seed description says "off by default", but the row is seededactive: truewith a beta-only positive rule. The gating is theisBetarule, not theactivebit — worth knowing before someone flipsactiveexpecting it to be the master switch.
Client-side, useFeatureFlag('FEATURE_DHS_INTEGRATION') appears in ~20 files across clinic-web and clinic-web-canary. That is presentation only; the server is the authority.
Environment prerequisites
| Variable | In .env.example? | Failure mode if missing |
|---|---|---|
DHS_AUTH_URL | Yes (:36-40) | Process throws at import (dhsConfig.js:1-5) |
DHS_PREAUTH_URL | Yes | Same |
DHS_ELIGIBILITY_URL | Yes | Same |
ENCRYPTION_MASTER_KEY | Yes (:47-51) | Process throws at import (encryption.js:6-8) |
S3_BUCKET_AWS | No | validateAttachmentUrl.js:9-11 throws at import |
S3_REGION_AWS | No | Same |
All hardcoded staging-*.motalabatech.health URLs were replaced by these variables, as the PR describes. required() also strips one trailing slash (dhsConfig.js:4).