Kulcare Docs
Feature QA checklists

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, run bundle exec rails db:migrate. Expected: the migrations finish with no error.
  • Developer: In backend, run bundle exec rake new_schema:seed_reference. Expected: the seed finishes with no error.
  • Developer: In backend, run bundle exec rake rbac:normalize_tenant_permissions. Expected: the output starts with deleted <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 namePhonePasswordRole and tenant
Raj9876543210Doctor123!Practitioner at MedCare Clinic. Admin at City Hospital Group. SMS 2FA.
Lisa5555555558FacilityAdmin123!Admin at City Hospital Group. No 2FA.
Robert5555555559Hospital123!Admin at City Hospital Group. No 2FA.
Rani9876543212Nurse123!Staff at City Hospital Group.
Amit9876543213Surgeon123!Practitioner at City Hospital Group.
James5555555557Facility123!Owner of Dr. Smith's Practice.
Emily5559876543Chen123!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 tmp folder.

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/permissions request returns 200. The response has "role": "staff", "owner": false, and a permissions list.
  • Staff (Rani): Check the response headers. Expected: Cache-Control contains no-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, or permissionsRole field.

2. The owner rule

  • Owner (James): Sign in to Dr. Smith's Practice. Expected: the /identity/my/permissions response has "owner": true, and permissions lists 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/permissions response, find manage_own_patients. Its default does not include owners. Expected: the key is in the permissions list.

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/permissions response 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 is draft.

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/permissions return 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/facilities the 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/permissions response has no view_patients. Controls that need view_patients go 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/permissions request follows at once.
  • Staff (Rani): Cause a second permission_denied 403 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_patients back 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/access with role: staff and changes: [{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/access with changes: [{area: "subscription", level: "manage"}]. Expected: 422. A role can never be given the Kulcare plan, whatever the request says. Repeat with payouts and with tax_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/access with { "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/permissions response has no practitioner in invitable_roles.
  • Admin (Lisa): Send POST /identity/practitioner_invites with the API. Expected: 403 with the code permission_denied. The message lists the keys that Lisa lacks, for example manage_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_roles is ["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_roles is []. 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 new first_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 new email and a new phone_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_code with a new phone_country_code and phone_number. Expected: a code goes to the new phone (dev SMS sink). The phone changes only after POST /identity/identifier_changes/confirm with 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:

  1. Admin (Lisa): Clear the key for the role in the table, and save.
  2. Role in the table: Focus the tab, then open the area. Expected: the control that needs the key is hidden.
  3. Role in the table: Call the API for that action. Expected: 403 with the code permission_denied.
  4. Admin (Lisa): Select Reset for the role, and save. Expected: the control shows again after the next refresh.
DomainKeyRoleArea to open
Patientsview_patientsStaff (Rani)CMS > Patients
Appointmentsmanage_appointmentsStaff (Rani)CMS appointment booking
Clinicalview_care_plansPractitioner (Amit)Patient detail > Care plans
Clinicalmanage_encountersPractitioner (Amit)Patient detail > Encounters
Identitymanage_usersAdmin (Robert), with James's help in Dr. Smith's PracticeAccount > Organization > Users
Facilitiesmanage_facilitiesAdmin, in Dr. Smith's Practice (owner James clears it)Account > Facilities, Add facility
Catalogshare_packagesStaff (Rani)Service packages, Share
Growthmanage_campaignsStaff (Rani)Growth > Campaigns, New campaign
Auditview_analyticsAdmin, in Dr. Smith's Practice (owner James clears it)AI usage analytics
IPDview_ipdPractitioner (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.

On this page