Step-by-Step Guide — Follow each step exactly as written. All values are provided — do not improvise.

TC-RBAC-004: PII Redaction & Permission Boundaries (Detailed)

Module: RBAC — PII Redaction & Permission Boundaries

Test Users: testmember@rcme.membervu.com / Member123! (Maria Cruz, MEMBER); admin@rcme.membervu.com / Admin123!; events@demo.membervu.com / Events123! (EVENT_MANAGER); treasurer@demo.membervu.com / Treasurer123! (FINANCE_MANAGER); officer@rcme.membervu.com / Officer123! (OFFICER); superadmin@zeniark.com / SuperAdmin123!

Priority: P1 (security / PII exposure)

URLs for this test:
Org Portal: https://stg-rcme.membervu.com/login
Org API: https://stg-api.membervu.com
Admin Portal: https://stg-admin.membervu.com
This is a PII/security-boundary test — do not skip the "absent" assertions. The bug class this guards against is a redacted field silently coming back as null/empty string instead of being omitted from the response entirely — a client that only checks truthiness would pass a broken build. Check for the key's presence, not just its value.

Step 1: Set up an event with a self registration and a guest registration

StepAction (EXACT clicks/typing)Expected Result (EXACT text/behavior)Test Value
1Log in as admin (admin@rcme.membervu.com / Admin123!).Dashboard loads.-
2Sidebar → Events → All Events. Open (or create) an event with open registration.Event detail loads.Event: any RCME seeded event with registration open
3Note the event's eventId from the URL (/admin/events/:eventId).
4Log out. Log in as Maria Cruz (testmember@rcme.membervu.com / Member123!). Register herself for the event (SELF registration).Registration succeeds; Maria has a confirmed registration for this event.-
5While still logged in as Maria, use the event's "Invite a guest" / "Register a guest" flow (if available) to register one guest under her invitation, OR log out and use the event's public guest-registration link.A GUEST registration is created with invitedByMemberId = Maria's member id.Guest name: Jane Guest

Step 2: Non-privileged caller — other attendee's row is redacted (RB4-01, RB4-04)

StepActionExpected ResultTest Value
1Log out. Log in as a SECOND plain member (any seeded MEMBER other than Maria — e.g. re-use a member without EVENTS:VIEW:registrations). Open browser DevTools → Network, or call directly with the member's bearer token.Authenticated as a plain MEMBER.-
2Call GET {API}/api/events/{eventId}/registrations/guests with this member's token.Status 200. Response body { guests: [...] }.GET /api/events/<eventId>/registrations/guests
3In the response, locate Maria's SELF registration row (NOT the caller's own row).The row is present (attendeeName visible) but has NO attendeeEmail key, NO memberEmail key, NO checkInCode key, NO displayTicketCode key. invoice is null.-
4Locate the "Jane Guest" GUEST registration row.NO guestEmail key, NO checkInCode, NO displayTicketCode. invoice is null. The invitedBy object is present with id/firstName/lastName (Maria's) but has NO email key.-
5Confirm non-sensitive display fields ARE still present on both rows.attendeeName, guestName, status, avatar keys present and populated as expected — only PII/financial fields are redacted, not the whole row.-
Data assertion: use your HTTP client's raw JSON view (not a UI screen) so you can distinguish "key absent" from "key present with empty/null value" — the redaction contract (eventRegistrationsHandler.ts, #486) explicitly OMITS the key via undefined, it does not send null or "".

Step 3: Non-privileged caller — own row keeps sensitive fields (RB4-02)

StepActionExpected ResultTest Value
1Using Maria's OWN token (testmember@rcme.membervu.com), call GET {API}/api/events/{eventId}/registrations/guests.Status 200.-
2Locate Maria's own SELF registration row in the response.checkInCode and displayTicketCode ARE present with real values; attendeeEmail/memberEmail equal Maria's real email (testmember@rcme.membervu.com); invoice is populated (not null) if the event has a fee.-
3In the same response, locate the "Jane Guest" row (not Maria's own).Still redacted exactly as in Step 2 — being logged in as the inviter does NOT unlock the guest's own row; only the caller's own registration is unredacted.-

Step 4: Privileged caller — full roster (RB4-03, RB4-04)

StepActionExpected ResultTest Value
1Log in as Carlos Garcia (events@demo.membervu.com / Events123!, EVENT_MANAGER — holds EVENTS:VIEW:registrations). Call GET {API}/api/events/{eventId}/registrations/guests.Status 200.-
2Inspect every row in the response.ALL rows — Maria's, Jane Guest's, and any others — carry checkInCode, displayTicketCode, and the real email fields. invoice is populated where applicable.-
3Inspect the "Jane Guest" row's invitedBy object specifically.invitedBy.email IS present and equals Maria's real email — the field EVENT_MANAGER's own eyes should see, unlike Step 2's plain-member view.-
4Repeat as ADMIN (admin@rcme.membervu.com / Admin123!).Same full-visibility result (ADMIN holds EVENTS:*:* which includes EVENTS:VIEW:registrations).-
PASS if: the ONLY difference between the plain-member view (Step 2/3) and the privileged view (Step 4) is presence/absence of the six redacted keys — never a difference in row count or attendee names.

Step 5: PLATFORM_SUPPORT-gated admin-portal route → 403, not logout (RB4-05)

No seeded platform user lacks PLATFORM_SUPPORT. Only SuperAdmin (superadmin@zeniark.com, platformRoles: ["SUPER_ADMIN"]) is seeded, and SUPER_ADMIN passes every requirePlatformSupport check. To exercise the DENIAL path you must create a platform user with a role that is NOT SUPER_ADMIN/PLATFORM_SUPPORT (e.g. PLATFORM_FINANCE) via the admin portal's Users → New screen, or call the API directly with a token that carries no PLATFORM_SUPPORT/SUPER_ADMIN claim.
StepActionExpected ResultTest Value
1Log in to the admin portal as SuperAdmin (superadmin@zeniark.com / SuperAdmin123!). Sidebar → Platform Users → New. Create a platform user with role PLATFORM_FINANCE only (not PLATFORM_SUPPORT, not SUPER_ADMIN).New platform user created.Email qa-platform-finance@zeniark.com, role: PLATFORM_FINANCE
2Log out. Log in as the new PLATFORM_FINANCE-only user at https://stg-admin.membervu.com.Login succeeds; dashboard loads (platform login itself only needs a valid platform account, not a specific role).-
3Navigate to any tenant's detail page (/tenants/:id), then click the Events tab.The tab either renders empty/errors, or the underlying GET /api/platform/tenants/:slug/events call fails.-
4Open DevTools → Network and inspect the response to GET /api/platform/tenants/{slug}/events.Status 403 (not 401) — requirePlatformSupport allows only SUPER_ADMIN/PLATFORM_SUPPORT; PLATFORM_FINANCE is not in that allowlist.-
5After the 403, check the admin-portal UI state.You are still logged in — no redirect to /login, session/localStorage platform tokens intact. The admin-portal client (platformClient.ts) only auto-acts (silent refresh + possible logout) on 401; a 403 is surfaced as an in-page error only.-
6Navigate directly to an Event Attendees URL: /tenants/{id}/events/{eventId}/attendees.Same result: 403 from the underlying attendance API, session remains authenticated, no forced logout.-
7Log out. Log back in as SuperAdmin. Delete/deactivate the qa-platform-finance@zeniark.com test user.Test user removed.-
PASS if: both routes return 403 for the PLATFORM_FINANCE-only user AND the operator's session survives the 403 (still authenticated afterward).

Step 6: Role-assignment allowlist rejection (RB4-06)

StepActionExpected ResultTest Value
1Log in as admin (admin@rcme.membervu.com / Admin123!). Note Maria Cruz's current roles via All Members → Maria Cruz (should be MEMBER only).Baseline roles: ["MEMBER"].-
2Call PUT {API}/api/membership/members/{mariaId}/roles directly with body {"roles": ["SUPER_ADMIN"]} (a platform code, not a tenant role).Status 400, error code VALIDATION_ERROR, message references the allowed role list. Maria's stored roles are UNCHANGED (still MEMBER).Body: {"roles": ["SUPER_ADMIN"]}
3Repeat with an arbitrary garbage string.Same result: 400 VALIDATION_ERROR, roles unchanged.Body: {"roles": ["NOT_A_REAL_ROLE"]}
4Repeat with a valid tenant role code in lowercase.200 — the schema normalizes to uppercase before the enum check, so this is accepted (not a rejection case).Body: {"roles": ["officer"]}
5Restore Maria to MEMBER only via the UI (Manage Roles → uncheck Officer → Save).Maria's roles reset to ["MEMBER"].-
Data assertion: after each rejected call (Steps 2–3), re-fetch Maria's member record and confirm roles in the DB/API response is exactly unchanged from baseline — a partial-write bug would show a mutated array even though the endpoint returned 400.

Step 7: Bank-accounts view — ADMIN / OFFICER / FINANCE_MANAGER allowed (RB4-07)

StepActionExpected ResultTest Value
1Log in as ADMIN (admin@rcme.membervu.com / Admin123!). Sidebar → Finance → Bank Accounts (/admin/bank-accounts), or call GET {API}/api/billing/bank-accounts directly.Status 200; bank account list renders/returns (account numbers masked).GET /api/billing/bank-accounts
2Log out. Log in as Juan Santos (officer@rcme.membervu.com / Officer123!, OFFICER). Repeat.Status 200 — OFFICER holds FINANCE:VIEW:bank-accounts (read-only; OFFICER has no FINANCE:EDIT:bank-accounts, so create/edit actions should be hidden or 403).-
3Log out. Log in as Ana Reyes (treasurer@demo.membervu.com / Treasurer123!, FINANCE_MANAGER). Repeat.Status 200; FINANCE_MANAGER also holds FINANCE:EDIT:bank-accounts, so create/edit controls should be visible/functional.-
4Log out. Log in as Carlos Garcia (events@demo.membervu.com / Events123!, EVENT_MANAGER — no finance permissions). Call GET {API}/api/billing/bank-accounts.Status 403 — EVENT_MANAGER does not hold FINANCE:VIEW:bank-accounts. Negative control confirming the grant is role-scoped, not global.-
PASS if: ADMIN/OFFICER/FINANCE_MANAGER all get 200, EVENT_MANAGER gets 403, and OFFICER's UI exposes no edit affordance.

CLEANUP