Cross-Tenant RLS Contract QA Guide
Scope: Manual verification of the cross-tenant RLS wrap contract pinned by the regression net shipped in PR #1618 (issue #1606). The spec auto-validates four behaviors; this guide is the human smoke test that runs the same flows against staging via the UI + Rails console, so we can catch environment-specific drift (RLS policy attached at the wrong layer, GUC plumbing missing in a worker context, etc.) that unit specs won't surface.
What's being verified (regression net contract — see
backend/domains/identity/spec/services/cross_tenant_rls_contract_spec.rb):
- The inner block of a cross-tenant service runs under the TARGET tenant's GUC, not the caller's.
- Rows written by the inner block land with
tenant_id = TARGET.- The caller's GUC is restored on normal exit AND on exceptions.
Prerequisites:
- Staging access (Rails console + browser).
- Two staging tenants you can act on: one where you're an admin (call it Tenant A), and one with a pending practitioner OR staff invite addressed to your email (call it Tenant B). If you don't have a pending invite, see §0 to seed one.
- A test user with
activememberships in both tenants.Related: Issue #1606 · PR #1618 · Issue #1603 (the refactor this gates) · Issue #1619 (ProfileOrchestrationService follow-up).
0. Seed a pending invite (skip if you already have one)
# Rails console on staging. Pick a tenant you don't own.
tenant_b = Identity::Tenant.find(<tenant_b_id>)
facility_b = tenant_b.facilities.first
inviter = Identity::User.joins(:user_memberships)
.where(user_memberships: { tenant_id: tenant_b.id, role: 'admin' })
.first
Shared::Tenancy::RlsContext.with(tenant_b.id) do
Identity::PractitionerInvite.create!(
tenant: tenant_b,
facility: facility_b,
email: '<your-test-user-email>',
status: :pending,
invited_by_id: inviter.id
)
endSame shape for Identity::StaffInvite if you want to QA the staff
path — add phone_country_code, phone_number, and
staff_role: 'receptionist'.
1. Accept Practitioner Invite — cross-tenant write
1.1 Setup
Sign in as the test user. Your active tenant should be Tenant A. Verify the current GUC in Rails console:
# In a separate Rails console tailing the session
session = Identity::Session.where(user_id: <test_user_id>, status: 'ACTIVE').last
# Active profile switch should pin you to tenant_a
Identity::ProfileSwitch.current_for_session(session).tenant_id
# => <tenant_a_id>1.2 Action
In the UI, open the pending invite for Tenant B (notifications → invites, or the invite acceptance email link). Accept it.
1.3 Expectations
Acceptance returns success and the role/membership rows land under Tenant B, not Tenant A. Verify in Rails console:
# Read across tenants explicitly — unscoped_by_tenant disables
# the RLS default scope so you can see both tenants.
Identity::PractitionerRole.unscoped_by_tenant
.where(practitioner_id: Identity::Practitioner.find_by(user_id: <test_user_id>).id)
.pluck(:tenant_id, :facility_id)
# => [[<tenant_b_id>, <facility_b_id>], ...]
Identity::UserMembership.unscoped_by_tenant
.where(user_id: <test_user_id>, tenant_id: <tenant_b_id>)
.pluck(:role, :status)
# => [["practitioner", "active"]]❌ Fail if any PractitionerRole or UserMembership row written
by this acceptance carries tenant_id = <tenant_a_id>. That means
the inner block ran under the caller's GUC instead of the target's —
the contract is broken.
2. Accept Staff Invite — staff variant
Same flow as §1 but with a StaffInvite. Verify
Identity::StaffRole.unscoped_by_tenant instead of
PractitionerRole.
3. Decline Invite
3.1 Action
From the UI, decline a pending invite for Tenant B (a fresh one — don't re-decline §1's accepted invite).
3.2 Expectations
The invite status flips to declined AND an ActivityLog row lands
under Tenant B's RLS scope:
invite = Identity::PractitionerInvite.unscoped_by_tenant.find(<invite_id>)
invite.status # => "declined"
# Look for the decline audit row. ActivityLog should have
# tenant_id = tenant_b.
ActivityLog.unscoped_by_tenant
.where(action: 'invite.decline', context: { invite_id: invite.id })
.pluck(:tenant_id)
# => [<tenant_b_id>]4. Get Available Profiles — multi-tenant fan-out
4.1 Action
Sign out, then sign in again. The profile-picker step calls
GetAvailableProfilesService. The response should list Tenant A
AND Tenant B with their respective facilities.
4.2 Inspection
In the browser DevTools network panel, capture the POST /identity/sessions
response. The available_profiles array should have two entries:
{
"available_profiles": [
{
"tenant": { "id": "<tenant_a_id>", ... },
"facilities": [ { "id": "<facility_a_id>", ... } ]
},
{
"tenant": { "id": "<tenant_b_id>", ... },
"facilities": [ { "id": "<facility_b_id>", ... } ]
}
]
}❌ Fail if Tenant B's facilities list includes any
facility_a_id, or vice versa. That's a cross-tenant data leak —
the fan-out's GUC switch didn't isolate the per-tenant reads.
❌ Fail if either tenant's facility list is empty when it shouldn't be (suggests RLS was active but the GUC wasn't actually set inside the wrap — the default-empty result is what you'd see without an active tenant GUC against an RLS-forced table).
5. Switch Profile
5.1 Action
After signing in, switch from Tenant A to Tenant B via the profile-picker UI (or the in-app tenant switcher).
5.2 Expectations
- The switch completes; you land in Tenant B's CMS.
- A new
ProfileSwitchrow lands under Tenant B:Identity::ProfileSwitch.unscoped_by_tenant .where(user_id: <test_user_id>, is_active: true) .pluck(:tenant_id, :facility_id) # => [[<tenant_b_id>, <facility_b_id>]] - New JWT issued via TokenIssuanceService — the
tenant_idclaim matches Tenant B. Decode theAuthorization: Bearer ...token client-side or via jwt.io.
5.3 GUC restoration smoke check
This is the hard-to-test-in-prod behavior. The contract says: even if the inner facility read had raised, the caller's GUC must be restored. In staging, you can simulate this from Rails console:
# Pre-condition: pick a target tenant the user has membership in
# but use a fake facility_id to force the facility lookup to fail.
Shared::Tenancy::RlsContext.activate(<tenant_a_id>)
pre = Shared::Tenancy::RlsContext.current_guc # => "<tenant_a_id>"
result = Identity::Profiles::SwitchProfileService.call(
user: Identity::User.find(<test_user_id>),
session: Identity::Session.find(<active_session_id>),
target_tenant_id: <tenant_b_id>,
target_facility_id: SecureRandom.uuid, # bogus — facility lookup fails
request_metadata: { ip_address: '127.0.0.1' }
)
result.success? # => false
Shared::Tenancy::RlsContext.current_guc # MUST equal `pre`❌ Fail if current_guc after the failed switch ≠ pre. The
caller's GUC leaked to the target — every subsequent query in that
console session is running under the wrong tenant, which is the
exact data-confusion failure mode this contract prevents.
6. Background-job context
The cross-tenant services run primarily in the auth path (sync
requests), but the same RlsContext.with machinery is used in
Sidekiq workers. Quick sanity check:
# Enqueue a job that internally uses RlsContext.with — e.g. an
# invite-reminder mailer if one's been wired up. Watch the job log
# for the GUC switch.Tail Sidekiq logs (bundle exec sidekiq locally, or staging Sidekiq
dashboard). Look for log lines containing
[RlsContext] (if instrumented) or verify the rows written by the
job carry the correct tenant_id.
7. Reporting
After completing §1–§5 (and §6 if applicable):
- All pass: comment on the issue (#1606) with "QA verified staging — all checks pass" and the date. Close the issue.
- Any fail: keep the issue open, comment with the §number that failed and the exact assertion that didn't hold (Rails console output is most useful). Tag @shanbhardwaj.
Notes for future maintainers
- This guide is the manual counterpart to the automated
regression net at
backend/domains/identity/spec/services/cross_tenant_rls_contract_spec.rb. If the spec adds coverage for a new service (e.g. when #1619 ships, ProfileOrchestrationService will be covered), add a §section here for the same flow. - When #1603 (extract
Shared::Tenancy::CrossTenantService) ships, re-run this guide as a smoke test on staging. Pass = the refactor preserved the contract end-to-end. - The
unscoped_by_tenantreads in this guide are the only way to see across tenants from a Rails console — they bypass the default scope. Don't ever use this pattern in production application code; it's a QA / admin-tooling primitive only.