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:
Org API:
Admin Portal:
Org Portal:
https://stg-rcme.membervu.com/loginOrg API:
https://stg-api.membervu.comAdmin 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
| Step | Action (EXACT clicks/typing) | Expected Result (EXACT text/behavior) | Test Value |
|---|---|---|---|
| 1 | Log in as admin (admin@rcme.membervu.com / Admin123!). | Dashboard loads. | - |
| 2 | Sidebar → Events → All Events. Open (or create) an event with open registration. | Event detail loads. | Event: any RCME seeded event with registration open |
| 3 | Note the event's eventId from the URL (/admin/events/:eventId). | — | — |
| 4 | Log 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. | - |
| 5 | While 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)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Log 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. | - |
| 2 | Call GET {API}/api/events/{eventId}/registrations/guests with this member's token. | Status 200. Response body { guests: [...] }. | GET /api/events/<eventId>/registrations/guests |
| 3 | In 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. | - |
| 4 | Locate 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. | - |
| 5 | Confirm 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)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Using Maria's OWN token (testmember@rcme.membervu.com), call GET {API}/api/events/{eventId}/registrations/guests. | Status 200. | - |
| 2 | Locate 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. | - |
| 3 | In 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)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Log 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. | - |
| 2 | Inspect 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. | - |
| 3 | Inspect 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. | - |
| 4 | Repeat 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.
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Log 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 |
| 2 | Log 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). | - |
| 3 | Navigate 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. | - |
| 4 | Open 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. | - |
| 5 | After 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. | - |
| 6 | Navigate 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. | - |
| 7 | Log 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)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Log 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"]. | - |
| 2 | Call 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"]} |
| 3 | Repeat with an arbitrary garbage string. | Same result: 400 VALIDATION_ERROR, roles unchanged. | Body: {"roles": ["NOT_A_REAL_ROLE"]} |
| 4 | Repeat 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"]} |
| 5 | Restore 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)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Log 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 |
| 2 | Log 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). | - |
| 3 | Log 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. | - |
| 4 | Log 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
- Delete/deactivate the
qa-platform-finance@zeniark.complatform test user created in Step 5 (if not already done). - Confirm Maria Cruz has ONLY her original
MEMBERrole (Step 6.5) — no leftover OFFICER grant. - Remove the throwaway "Jane Guest" registration and any test event created in Step 1, if they were newly created for this test.