Roles and Permissions (RBAC) - QA Guide
Scope: Manual checks for roles and permissions. The checks cover sign-in, refresh paths, the Roles & Permissions screen, owner-only matrix edits, invites, tenant-scoped user management, verified identifier changes, and one gated screen for each domain.
Related: Developer guide · Pundit guide · User guide
Each step names the account role, the action, and the expected result. Tick the box when the result matches. If a result does not match, write down the step number and attach a screenshot or the network response.
0. Setup
0.1 Environment
- Developer: In
backend, runbundle exec rails db:migrate. Expected: the migrations finish with no error. - Developer: In
backend, runbundle exec rake new_schema:seed_reference. Expected: the seed finishes with no error. - Developer: In
backend, runbundle exec rake rbac:normalize_tenant_permissions. Expected: the output starts withdeleted <N> tenant row(s) that repeated their role default. - Developer: Run the same rake task again. Expected: the output says
deleted 0 tenant row(s) that repeated their role default. - Developer: Start the backend and the shell (
cd frontend && pnpm dev:shell). Expected: the shell opens at http://localhost:3000.
0.2 Accounts
The dev seed (backend/db/seeds/identity/) creates these accounts. Sign in with the phone
number and the password.
| Short name | Phone | Password | Role and tenant |
|---|---|---|---|
| Raj | 9876543210 | Doctor123! | Practitioner at MedCare Clinic. Admin at City Hospital Group. SMS 2FA. |
| Lisa | 5555555558 | FacilityAdmin123! | Admin at City Hospital Group. No 2FA. |
| Robert | 5555555559 | Hospital123! | Admin at City Hospital Group. No 2FA. |
| Rani | 9876543212 | Nurse123! | Staff at City Hospital Group. |
| Amit | 9876543213 | Surgeon123! | Practitioner at City Hospital Group. |
| James | 5555555557 | Facility123! | Owner of Dr. Smith's Practice. |
| Emily | 5559876543 | Chen123! | Practitioner at MedCare Clinic only. TOTP 2FA. |
Notes:
- City Hospital Group has no owner in the seed. Do the owner steps in Dr. Smith's Practice.
- An SMS code in development goes to the dev SMS sink. A TOTP code needs an authenticator app or a recovery code.
- Development email goes to letter_opener. Each message opens in the browser. letter_opener
also keeps a copy under the backend
tmpfolder.
0.3 An admin in the owner's tenant
- Owner (James): In Dr. Smith's Practice, open Account > Organization > Users and invite Lisa's phone with the Admin role. Expected: the invite shows as sent.
- Admin (Lisa): Accept the invite. Expected: Lisa can switch to Dr. Smith's Practice, and her role there is Admin.
0.4 Tools
- Open the browser DevTools. Keep the Network tab open. Filter on
my/permissions. - After each step that changes the matrix, put the matrix back with Reset and Save changes, unless the next step needs the change.
1. Sign-in on a new device
- Staff (Rani): Open a new private window. Sign in. Expected: one
GET /identity/my/permissionsrequest returns 200. The response has"role": "staff","owner": false, and apermissionslist. - Staff (Rani): Check the response headers. Expected:
Cache-Controlcontainsno-store. - Staff (Rani): Open CMS Patients. Expected: the patient list shows. The console has no permission error.
- Staff (Rani): While the page loads, watch the gated controls. Expected: no control shows and then goes away. No "not allowed" message shows before the answer arrives.
- Staff (Rani): Open Application > Local Storage and find the auth store entry.
Expected: the entry has no
enabledPermissions,permissionsOwner, orpermissionsRolefield.
2. The owner rule
- Owner (James): Sign in to Dr. Smith's Practice. Expected: the
/identity/my/permissionsresponse has"owner": true, andpermissionslists every key. - Owner (James): Open Roles & Permissions. Expected: the Owner column shows a lock and all boxes are checked. The boxes cannot change.
- Owner (James): In the
/identity/my/permissionsresponse, findmanage_own_patients. Its default does not include owners. Expected: the key is in thepermissionslist.
Sign-up creates an owner (#2368). Use a phone number that has no account.
- New user: Open the sign-up page. Select Facility Owner / Manager. Complete the OTP
step. Expected: the
/identity/my/permissionsresponse has"role": "owner"and"owner": true. - New user: Open Roles & Permissions. Expected: the Admin, Staff, and Practitioner columns have no lock and show Reset. The Owner column has a lock.
- Developer: In a Rails console, find the new user's membership. Expected: the
membership role is
owner, and the tenant status isdraft.
3. Profile switch with a network failure
- Raj: Sign in and select City Hospital Group. Expected: the response has
"role": "admin". - Raj: In DevTools, block the request URL
*/identity/my/permissions*. Switch the profile to MedCare Clinic. Expected: the notice "Could not check your permissions. Retrying." shows at the bottom of the screen. - Raj: Look for admin controls, for example the Users and Roles & Permissions links. Expected: no admin control from City Hospital Group shows. No gated control shows.
- Raj: Remove the block. Move focus to another window, then back to the shell tab.
Expected: a new request returns 200 with
"role": "practitioner". The notice goes away. The practitioner UI of MedCare Clinic shows. - Raj: Stop the backend. Reload the shell. Expected: the notice shows, and no gated control shows. Start the backend again.
- Raj: Block
*/identity/my/permissions*again. Reload the shell, so no earlier answer is kept, and open Connect My facilities (/connect/facilities). Expected: a strip says "We could not check what your role is allowed to do, so some actions may be missing." with Retry. Select Retry. Expected: the button reads Retrying and is disabled, then the strip says "We still could not check what your role is allowed to do. Check your connection and try again." Open an encounter in CMS. Expected: "Failed to Load Permissions" shows with Retry, not a blank page. Remove the block and select Retry. Expected: the encounter opens, and the notice goes away. - Developer: Make
GET /identity/my/permissionsreturn 403 for Raj (for example, end his membership in a Rails console). Reload the shell. Expected: the notice reads "Could not check your permissions. Sign in again, or ask your practice administrator." On/connect/facilitiesthe strip says "We could not confirm your access. Sign in again, or ask your practice administrator." with no Retry. An encounter in CMS shows "Access Could Not Be Confirmed" with no Retry. Restore the membership.
4. A revoke in the middle of a session
- Staff (Rani): Sign in to City Hospital Group in browser A. Open CMS Patients. Expected: the patient list shows.
- Admin (Lisa): In browser B, open Roles & Permissions. Clear View patients in the Staff column. Select Save changes. Expected: "Permissions saved" shows. The Staff box shows the "Changed from the default" dot.
- Staff (Rani): In browser A, move focus to another window, then back. Expected: a
new
/identity/my/permissionsresponse has noview_patients. Controls that needview_patientsgo away. - Staff (Rani): Do not change focus. Wait 5 minutes. Expected: the request repeats in 5 minutes at most, with no sign-out.
- Staff (Rani): Before the refresh, use a patient action that still shows. Expected:
the API returns 403 with the code
permission_denied. One new/identity/my/permissionsrequest follows at once. - Staff (Rani): Cause a second
permission_denied403 in the next 60 seconds. Expected: no second refetch starts from that denial. - Admin (Lisa): Select Reset in the Staff column, then Save changes.
Expected: the dot goes away, and Rani gets
view_patientsback on her next refresh.
5. Logout during a refetch
- Staff (Rani): In DevTools, set network speed to Slow 3G. Move focus away and back to start a refetch.
- Staff (Rani): Log out before the request ends. Expected: the login screen shows. No permissions notice shows.
- Admin (Lisa): Sign in in the same tab. Expected: the response for Lisa has
"role": "admin". No key or control from Rani's session shows. - Developer: Check Local Storage. Expected: no permission field is present.
6. Two tabs
- Raj: Sign in to City Hospital Group in tab A. Open the shell in tab B. Expected: both tabs show City Hospital Group.
- Raj: In tab A, switch the profile to MedCare Clinic. Expected: tab B reloads and shows MedCare Clinic with the practitioner UI.
- Admin (Lisa): Revoke View patients from Staff. Staff (Rani): Keep two tabs
open. Focus each tab. Expected: each tab hides the controls that need
view_patients. Put the matrix back after this step.
7. The Roles and permissions screen
The screen shows APPS and AREAS with a level per role, not about 92 keys. Picking a level writes a fixed key set, so a level can REMOVE access as well as add it. Section 7b checks exactly that.
-
Staff (Rani): Go to
/account/tenant-permissions. Expected: the page says only an owner or admin can see how access is set up. -
Staff (Rani): Send
GET /identity/tenants/<tenant_id>/permissions/access. Expected: 403. -
Admin (Lisa): Open Roles and permissions. Expected: the screen lists only the apps this practice has. Team and settings, Connect and Kulcare subscription always show. CMS, Marketing and Practice finance show only when the practice has them, so do not report an absent app as a fault until you check step 7e. Every level button is disabled, and a notice says only the owner can change it.
-
Admin (Lisa): Send
PUT /identity/tenants/<tenant_id>/permissions/accesswithrole: staffandchanges: [{area: "clinical", level: "manage"}]. Expected: 403. Nothing changes. The Audit Trail shows a rejected change with its reason. -
Owner (James): Open the screen. Expected: Connect says "Open to everyone" and offers no levels. Kulcare subscription says "Owner only" and offers none either.
-
Owner (James): Find Payouts under Practice finance. Expected: it says it is granted to a person rather than a role, and shows no level buttons. Tax identity under Team and settings does the same.
-
Owner (James): Send
PUT .../permissions/accesswithchanges: [{area: "subscription", level: "manage"}]. Expected: 422. A role can never be given the Kulcare plan, whatever the request says. Repeat withpayoutsand withtax_identity. Expected: 422 each time. -
Owner (James): Set Marketing to View for Staff. Expected: the save bar says 1 unsaved change across 1 role. Nothing is written yet.
-
Owner (James): Select Discard. Expected: the bar goes away and the level returns to where it was.
-
Owner (James): Set Marketing to View for Staff, and Appointments to Manage for Practitioner. Expected: the bar says 2 unsaved changes across 2 roles.
-
Owner (James): Set one of them back to the level it started at. Expected: the count drops by one. A choice that repeats the saved value is not a change.
-
Owner (James): Stage a change for two roles and select Review and save. Expected: every level button on the grid goes inactive while the review panel is open and while the save runs. A cell cannot move under a save that is already on its way, so the practice gets exactly the change the panel described.
7a. The screen says what a save will remove
- Owner (James): Move an area DOWN a level, for example Marketing from Manage to View for Staff. Select Review and save. Expected: a review panel opens, names the keys that role LOSES, and the button reads "Remove access and save".
- Owner (James): Select Cancel. Expected: nothing is written, and the staged change is still there.
- Owner (James): Move an area UP a level and review. Expected: the panel names the keys gained, and the button reads "Save changes".
- Owner (James): Find an area showing the Custom badge. Point at it. Expected: it names the keys that match no level. Pick a level and review. Expected: the panel warns that the area was set key by key, and lists what goes.
- Owner (James): Confirm the save. Expected: the change applies, and every OTHER area of that role is the same as before. Check two other areas before and after.
- Owner (James): Set one person's access in an area (section 7c), then change that same area for their whole role and review. Expected: the panel warns that some people have their own setting here and the change will not reach them, and the area carries a "1 set individually" badge. This is the documented order, not a conflict: a person's own setting wins over their role.
- Owner (James): Review a change to an area nobody is set individually in. Expected: no such warning and no badge.
7b. Every permission, behind the disclosure
- Owner (James): Select Show every permission. Expected: a table of keys opens, with an area column and Yes or No for each role.
- Owner (James): Stage a level change and do NOT save it. Open the table. Expected: the table shows the staged change. The two views never disagree.
- Owner (James): Search for a key. Expected: only rows that match show.
- Owner (James): Look at an area that shows Custom. Expected: it reads as Custom in this table too, not as its nearest level.
7c. One person's own access
A person's own setting beats their role (#2340). Only an owner can change it. The dialog shows areas and levels, with a fourth state: Same as role.
- Owner (James): Open Account > Organization > Users, find a staff member, and select Permissions. Expected: the dialog lists the areas. Each reads Same as role and names the role's own level beside it.
- Owner (James): Set one area to a level above the role's, and save. Expected: "Access updated".
- Staff (that member): Sign in. Expected: the screens that area opens are reachable.
- Staff (a different staff member): Sign in. Expected: they are not. The setting applies to one person.
- Owner (James): Move the SAME area for the whole staff role on the Roles and permissions screen. Expected: the person keeps their own setting, because it was set for them by name.
- Owner (James): Reopen the dialog, set that area back to Same as role, and save. Then move the staff role again. Expected: the person follows the role this time. This is the point of Same as role: it stores nothing, so the person is not frozen at the level the role had on the day you looked.
- Owner (James): Open the dialog for a person with one key set by hand. Expected: that area reads Custom and names the pinned keys. A save of another area leaves it alone.
- Admin (Lisa): Open the same dialog. Expected: every button is disabled, no Save shows, and the notice says only the owner can change it.
- Admin (Lisa): With the dialog open read-only, confirm there is no way to send the
change from the screen: no Save button and every button disabled. The refusal
itself — 403, plus an Audit Trail record naming the reason and what was attempted — is
covered by request tests (
membership_permissions_test.rb), because the screen gives an admin nothing to press. Send the request by hand only if you have a REST client:PUT /identity/users/<user_id>/permissions/accesswith{ "changes": [{ "area": "clinical", "level": "view" }] }. - Owner (James): Open the dialog for the OWNER's own row, which is listed with the admins and carries an Owner badge. Expected: it says there is nothing to set, because an owner holds every permission, and no Save shows. There is no way to add a second owner, so the owner's own row is the case to check.
- Owner (James): Open Audit Trail. Expected: each save names who changed it, the member, and the keys granted, revoked or cleared.
7d. The Marketing role
- Owner (James): Invite a person as Marketing. Expected: the role is offered.
- Marketing member: Sign in. Expected: the Marketing app opens. The CMS, Practice finance and Team and settings screens do not. No patient, bill or commission is reachable.
- Owner (James): Open the Roles and permissions screen. Expected: a Marketing column shows, at Manage all for Marketing and None everywhere else.
- Admin (Lisa): Open the invite screen. Expected: Marketing is offered. An admin may give it, because an admin already holds those keys and Marketing is narrower than the staff role an admin could already give.
7e. An app the practice does not have
The screen sets access to apps. An app the practice does not have therefore has nothing to set, so the screen leaves it out rather than offers a dial that reaches nothing.
Three apps always show: Team and settings is the shell itself, Connect is open to everyone, and the Kulcare subscription belongs to the owner. CMS, Marketing and Practice finance each depend on the practice.
A hidden app keeps its permissions. The screen hides a dial; it takes no access away. Prove that with the last step, which matters more than the first two.
- Owner (James): Turn the Growth app off for this practice. Open Roles and permissions. Expected: the Marketing app is no longer listed. CMS and Practice finance still are.
- Owner (James): Open a person on Account > Organization > Users. Expected: the per-person dialog leaves Marketing out as well.
- Owner (James): With the Growth app still off, set Clinical to View for Staff and save. Turn the Growth app back on and open the screen. Expected: the Marketing levels read exactly as they did before. A save must never revoke the keys of an app it cannot see.
8. The invite limit
- Admin (Lisa): In City Hospital Group, open Users and invite a new staff member. Expected: the invite is sent. The audit trail shows "Invited a person with the staff role".
- Admin (Lisa): Open the practitioner invite. Expected: the practitioner role shows
as unavailable, with the reason. The
/identity/my/permissionsresponse has nopractitionerininvitable_roles. - Admin (Lisa): Send
POST /identity/practitioner_inviteswith the API. Expected: 403 with the codepermission_denied. The message lists the keys that Lisa lacks, for examplemanage_own_patients. The audit trail shows a rejected invite. - Admin (Lisa): Check Manage users for Staff and save. Staff (Rani): Focus the
tab. Expected: Rani sees the Users link.
invitable_rolesis["staff"]. - Staff (Rani): Try to invite an admin and a practitioner. Expected: both roles are unavailable. An API attempt for admin returns 422. Put the matrix back after this step.
- Owner (James): In Dr. Smith's Practice, clear Manage users for Admin and save.
Admin (Lisa): Switch to Dr. Smith's Practice and focus the tab. Expected: the
Users link goes away.
invitable_rolesis[]. An invite through the API returns 403. - Admin (Lisa): In Dr. Smith's Practice, open Roles & Permissions. Expected: the page still opens. Admins keep the matrix. Owner (James): put the matrix back.
9. Cross-tenant user update
Use Lisa's session token in City Hospital Group. Emily is a member of MedCare Clinic only.
- Admin (Lisa): Send
GET /identity/users/<emily_user_id>. Expected: 404. - Admin (Lisa): Send
PATCH /identity/users/<emily_user_id>with a newfirst_name. Expected: 404. Emily's name does not change. - Admin (Lisa): Send
GET /identity/users. Expected: no user from outside City Hospital Group is in the list. - Admin (Lisa): Send
PATCH /identity/users/<rani_user_id>with a newemailand a newphone_number. Expected: Rani's email and phone do not change. - Staff (Rani): Change your own first name on your profile. Expected: the name changes.
10. Verified identifier change
- Practitioner (Amit): Sign in to a second browser. Keep it open.
- Practitioner (Amit): In the first browser, open a screen with the Sign-in email field. Today it shows in the profile step of the shell onboarding modal and on the Connect vendor profile page. Select Change email. Enter a new email and your current password. Select Send code. Expected: a code email opens in letter_opener, sent to the NEW address.
- Practitioner (Amit): Enter a wrong code. Select Confirm. Expected: an error shows. The email does not change.
- Practitioner (Amit): Enter the correct code. Select Confirm. Expected: "Your sign-in email was changed" shows.
- Practitioner (Amit): Check letter_opener. Expected: a notice went to the OLD email address.
- Practitioner (Amit): Use the second browser. Expected: the next request signs it out.
- Admin (Lisa): Open Account > Audit Trail. Expected: a record of Amit's identifier change shows.
- Practitioner (Amit): Send
POST /identity/identifier_changes/request_codewith a newphone_country_codeandphone_number. Expected: a code goes to the new phone (dev SMS sink). The phone changes only afterPOST /identity/identifier_changes/confirmwith that code. - Account with no password: Sign in by OTP with an account that never set a password. Open Change email. Leave Current password empty and select Send code. Expected: "Verification code for your current sign-in is required" shows. No code goes to the new email.
- Account with no password: Select Get a code instead. Expected: a code goes to the current phone (dev SMS sink). The dialog shows the masked phone number.
- Account with no password: Enter that code and the new email. Select Send code. Then confirm with the code that went to the new email. Expected: the email changes.
- Practitioner (Amit): Select Get a code instead on an account with a password. Expected: "Your account has a password. Enter your current password instead." shows.
11. One gated screen for each domain
For each row, do these steps in City Hospital Group:
- Admin (Lisa): Clear the key for the role in the table, and save.
- Role in the table: Focus the tab, then open the area. Expected: the control that needs the key is hidden.
- Role in the table: Call the API for that action. Expected: 403 with the code
permission_denied. - Admin (Lisa): Select Reset for the role, and save. Expected: the control shows again after the next refresh.
| Domain | Key | Role | Area to open |
|---|---|---|---|
| Patients | view_patients | Staff (Rani) | CMS > Patients |
| Appointments | manage_appointments | Staff (Rani) | CMS appointment booking |
| Clinical | view_care_plans | Practitioner (Amit) | Patient detail > Care plans |
| Clinical | manage_encounters | Practitioner (Amit) | Patient detail > Encounters |
| Identity | manage_users | Admin (Robert), with James's help in Dr. Smith's Practice | Account > Organization > Users |
| Facilities | manage_facilities | Admin, in Dr. Smith's Practice (owner James clears it) | Account > Facilities, Add facility |
| Catalog | share_packages | Staff (Rani) | Service packages, Share |
| Growth | manage_campaigns | Staff (Rani) | Growth > Campaigns, New campaign |
| Audit | view_analytics | Admin, in Dr. Smith's Practice (owner James clears it) | AI usage analytics |
| IPD | view_ipd | Practitioner (Amit) | IPD wards and beds |
- Patients row passes.
- Appointments row passes.
- Clinical rows pass.
- Identity row passes.
- Facilities row passes.
- Catalog row passes.
- Growth row passes.
- Audit row passes.
- IPD row passes.
12. Endpoint limits
- Staff (Rani): Send
GET /identity/my/permissions?tenant_id=<other_tenant_id>. Expected: the response gives City Hospital Group. The parameter has no effect. - Staff (Rani): Send the request more than 60 times in one minute. Expected: the extra requests return 429. Other users are not affected.
- Developer: Send the request with no session token. Expected: 401.
13. Sign-off
- Section 0: setup and the two rake runs pass.
- Sections 1 to 6: sign-in, owner rule, profile switch, revoke, logout, and two tabs pass.
- Section 7: the matrix screen and owner-only edits pass.
- Section 8: the invite limit passes.
- Section 9: cross-tenant user management fails closed.
- Section 10: the verified identifier change passes.
- Section 11: each domain row passes.
- Section 12: the endpoint limits pass.