Kulcare Docs
Feature QA checklists

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):

  1. The inner block of a cross-tenant service runs under the TARGET tenant's GUC, not the caller's.
  2. Rows written by the inner block land with tenant_id = TARGET.
  3. 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 active memberships 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
  )
end

Same 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 ProfileSwitch row 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_id claim matches Tenant B. Decode the Authorization: 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_tenant reads 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.

On this page