fix(rls): status-gate user_has_role_in_space + user_has_permission (046)
Lifts the deliberately-deferred SECURITY DEFINER status-gate. The
deferral's blocking reason ('no way to verify it breaks nothing') is
resolved by the integration harness, so the gap is now closed at the
RLS layer too (defense-in-depth beyond D2's app layer): an unverified/
inactive member can no longer exercise a role or permission via direct
PostgREST. Scoped to the two privilege entrypoints; get_user_space_ids
+ user_effective_roles deliberately untouched so unverified self-reads/
onboarding still work. Migration 046 + schema.sql mirror. New
integration test (5) proves both halves: gap closed AND
current/late/approval/unverified-reads all unbroken. 31 integration
tests green; default pnpm test 532/26 hermetic; build clean.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
// Validates migration 046 (RLS-layer privilege gate, defense-in-depth for
|
||||
// D2). Proves BOTH halves of the deferral's blocking concern: the gap is
|
||||
// closed at the RLS layer for unverified/inactive, AND no legitimate flow
|
||||
// (current/late access, unverified self-reads, the approval flow) breaks.
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import {
|
||||
dbReady,
|
||||
rows,
|
||||
execAsUser,
|
||||
execService,
|
||||
seedSpace,
|
||||
seedMember,
|
||||
dropSpace,
|
||||
} from './db'
|
||||
|
||||
const d = describe.skipIf(!dbReady)
|
||||
|
||||
d('privilege status gate (migration 046)', () => {
|
||||
let spaceId: string
|
||||
let adminCurrent: { memberId: string; userId: string }
|
||||
let adminUnverified: { memberId: string; userId: string }
|
||||
let adminLate: { memberId: string; userId: string }
|
||||
let memberUnverified: { memberId: string; userId: string }
|
||||
|
||||
const hasRole = (uid: string) =>
|
||||
rows<{ ok: boolean }>(
|
||||
`select public.user_has_role_in_space('${uid}','${spaceId}',ARRAY['admin','board','treasurer']) ok`,
|
||||
)[0].ok
|
||||
const hasPerm = (uid: string) =>
|
||||
rows<{ ok: boolean }>(
|
||||
`select public.user_has_permission('${uid}','${spaceId}','members.manage') ok`,
|
||||
)[0].ok
|
||||
|
||||
beforeAll(() => {
|
||||
spaceId = seedSpace().spaceId
|
||||
adminCurrent = seedMember(spaceId, { role: 'admin', status: 'current' })
|
||||
adminUnverified = seedMember(spaceId, { role: 'admin', status: 'unverified' })
|
||||
adminLate = seedMember(spaceId, { role: 'admin', status: 'late' })
|
||||
memberUnverified = seedMember(spaceId, { role: 'member', status: 'unverified' })
|
||||
})
|
||||
afterAll(() => dropSpace(spaceId))
|
||||
|
||||
// ── gap closed ────────────────────────────────────────────────────────
|
||||
it('unverified admin is NOT role-privileged at the RLS layer', () => {
|
||||
expect(hasRole(adminUnverified.userId)).toBe(false)
|
||||
expect(hasPerm(adminUnverified.userId)).toBe(false)
|
||||
})
|
||||
|
||||
it('unverified admin cannot perform a privileged RLS-gated write (defense-in-depth)', () => {
|
||||
const before = rows<{ display_name: string }>(
|
||||
`select display_name from space_members where id='${adminCurrent.memberId}'`,
|
||||
)[0].display_name
|
||||
// RLS members_update USING relies on user_has_role_in_space -> now false
|
||||
// for the unverified admin, so the row is not visible to update.
|
||||
execAsUser(
|
||||
adminUnverified.userId,
|
||||
`update space_members set display_name='hacked' where id='${adminCurrent.memberId}'`,
|
||||
)
|
||||
const after = rows<{ display_name: string }>(
|
||||
`select display_name from space_members where id='${adminCurrent.memberId}'`,
|
||||
)[0].display_name
|
||||
expect(after).toBe(before) // unchanged — escalation blocked at RLS
|
||||
})
|
||||
|
||||
// ── nothing legitimate broken ─────────────────────────────────────────
|
||||
it('current + late admins remain fully privileged', () => {
|
||||
expect(hasRole(adminCurrent.userId)).toBe(true)
|
||||
expect(hasPerm(adminCurrent.userId)).toBe(true) // admin short-circuit
|
||||
expect(hasRole(adminLate.userId)).toBe(true) // lapse keeps role
|
||||
expect(hasPerm(adminLate.userId)).toBe(true)
|
||||
})
|
||||
|
||||
it('unverified member still resolves its own space (reads/onboarding intact)', () => {
|
||||
const n = rows<{ n: number }>(
|
||||
`select count(*)::int n from public.get_user_space_ids('${memberUnverified.userId}') g where g='${spaceId}'`,
|
||||
)[0].n
|
||||
expect(n).toBe(1) // get_user_space_ids deliberately NOT gated
|
||||
})
|
||||
|
||||
it('approval flow still works: a current admin can approve an unverified member', () => {
|
||||
const r = execAsUser(
|
||||
adminCurrent.userId,
|
||||
`update space_members set status='current' where id='${memberUnverified.memberId}'`,
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
const st = rows<{ status: string }>(
|
||||
`select status from space_members where id='${memberUnverified.memberId}'`,
|
||||
)[0].status
|
||||
expect(st).toBe('current')
|
||||
// and now that member IS privilege-eligible for reads as before
|
||||
execService(`update space_members set status='unverified' where id='${memberUnverified.memberId}'`)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user