fix(auth): unverified members cannot exercise roles; single-membership join (D2)
require_approval was silently void: redeeming an admin-role invite into a require_approval space yielded role=admin,status=unverified and requireMemberWithRole never checked status -> instant admin pre-approval. - PRIVILEGE_STATUSES/isPrivilegeEligible (current|late only; excludes unverified/inactive). requireMemberWithRole now rejects non-privilege- eligible status with 'pending approval' before the role check; Member carries status (getAuthMember selects it). requireMember stays permissive so unverified members still see their own /me. - joinSpace rejects a second active membership (single-space model; prevents the .single() self-DoS). Deferred (HANDOFF): SECURITY DEFINER helpers user_has_role_in_space/ user_has_permission still ignore status (RLS defense-in-depth, own careful pass). 524 tests; build clean.
This commit is contained in:
@@ -4,6 +4,8 @@ import {
|
|||||||
ADMIN_ROLES,
|
ADMIN_ROLES,
|
||||||
TREASURER_ROLES,
|
TREASURER_ROLES,
|
||||||
ACTIVE_STATUSES,
|
ACTIVE_STATUSES,
|
||||||
|
PRIVILEGE_STATUSES,
|
||||||
|
isPrivilegeEligible,
|
||||||
hasRole,
|
hasRole,
|
||||||
type Role,
|
type Role,
|
||||||
} from '@/lib/permissions'
|
} from '@/lib/permissions'
|
||||||
@@ -50,6 +52,22 @@ describe('ACTIVE_STATUSES', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('isPrivilegeEligible / PRIVILEGE_STATUSES', () => {
|
||||||
|
it('only current + late may exercise a role/permission', () => {
|
||||||
|
expect([...PRIVILEGE_STATUSES].sort()).toEqual(['current', 'late'].sort())
|
||||||
|
expect(isPrivilegeEligible('current')).toBe(true)
|
||||||
|
expect(isPrivilegeEligible('late')).toBe(true)
|
||||||
|
})
|
||||||
|
it('unverified (pending approval) is NOT privilege-eligible', () => {
|
||||||
|
expect(isPrivilegeEligible('unverified')).toBe(false)
|
||||||
|
})
|
||||||
|
it('inactive / null / undefined are NOT privilege-eligible', () => {
|
||||||
|
expect(isPrivilegeEligible('inactive')).toBe(false)
|
||||||
|
expect(isPrivilegeEligible(null)).toBe(false)
|
||||||
|
expect(isPrivilegeEligible(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('hasRole', () => {
|
describe('hasRole', () => {
|
||||||
it('returns true when role is in the allowed list', () => {
|
it('returns true when role is in the allowed list', () => {
|
||||||
expect(hasRole('admin', ADMIN_ROLES)).toBe(true)
|
expect(hasRole('admin', ADMIN_ROLES)).toBe(true)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { createAdminClient } from '@/lib/supabase/admin'
|
import { createAdminClient } from '@/lib/supabase/admin'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { checkRateLimit, sanitizeString, sanitizeSlug } from '@/lib/security'
|
import { checkRateLimit, sanitizeString, sanitizeSlug } from '@/lib/security'
|
||||||
|
import { ACTIVE_STATUSES } from '@/lib/permissions'
|
||||||
|
|
||||||
function generateInviteCode() {
|
function generateInviteCode() {
|
||||||
// Cryptographically random, and wider (8 chars) so the code space is not
|
// Cryptographically random, and wider (8 chars) so the code space is not
|
||||||
@@ -157,6 +158,19 @@ export async function joinSpace(formData: {
|
|||||||
? invite.role
|
? invite.role
|
||||||
: 'member'
|
: 'member'
|
||||||
|
|
||||||
|
// Single active membership per user (the product's locked single-space
|
||||||
|
// model; getAuthMember's .single() fails closed on 2+). Reject a second
|
||||||
|
// join rather than letting it self-DoS the user out of every action.
|
||||||
|
const { data: existingMembership } = await admin
|
||||||
|
.from('space_members')
|
||||||
|
.select('id')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.in('status', ACTIVE_STATUSES as unknown as string[])
|
||||||
|
.maybeSingle()
|
||||||
|
if (existingMembership) {
|
||||||
|
return { error: 'You are already a member of a space.' }
|
||||||
|
}
|
||||||
|
|
||||||
const { data: newMember, error: memberErr } = await admin
|
const { data: newMember, error: memberErr } = await admin
|
||||||
.from('space_members')
|
.from('space_members')
|
||||||
.insert({
|
.insert({
|
||||||
|
|||||||
+10
-3
@@ -1,7 +1,7 @@
|
|||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
import type { Tables } from '@/types/database'
|
import type { Tables } from '@/types/database'
|
||||||
import type { ZodSchema } from 'zod'
|
import type { ZodSchema } from 'zod'
|
||||||
import { ACTIVE_STATUSES, hasRole, type Role } from '@/lib/permissions'
|
import { ACTIVE_STATUSES, hasRole, isPrivilegeEligible, type Role } from '@/lib/permissions'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loose Supabase client type for shared helpers.
|
* Loose Supabase client type for shared helpers.
|
||||||
@@ -20,7 +20,7 @@ export type ServerSupabase = SupabaseClient<any, any, any>
|
|||||||
/** Projection of `space_members` that server actions consume. */
|
/** Projection of `space_members` that server actions consume. */
|
||||||
export type Member = Pick<
|
export type Member = Pick<
|
||||||
Tables<'space_members'>,
|
Tables<'space_members'>,
|
||||||
'id' | 'space_id' | 'user_id' | 'role' | 'display_name' | 'handle'
|
'id' | 'space_id' | 'user_id' | 'role' | 'status' | 'display_name' | 'handle'
|
||||||
>
|
>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +50,7 @@ export async function getAuthMember(supabase: ServerSupabase): Promise<Member |
|
|||||||
// explicit active-space selector before this can change.
|
// explicit active-space selector before this can change.
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from('space_members')
|
.from('space_members')
|
||||||
.select('id, space_id, user_id, role, display_name, handle')
|
.select('id, space_id, user_id, role, status, display_name, handle')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
.in('status', ACTIVE_STATUSES as unknown as string[])
|
.in('status', ACTIVE_STATUSES as unknown as string[])
|
||||||
.single()
|
.single()
|
||||||
@@ -79,6 +79,13 @@ export async function requireMemberWithRole(
|
|||||||
): Promise<MemberResult> {
|
): Promise<MemberResult> {
|
||||||
const r = await requireMember(supabase)
|
const r = await requireMember(supabase)
|
||||||
if (!r.ok) return r
|
if (!r.ok) return r
|
||||||
|
// require_approval gate: an 'unverified' member holds NO privileged
|
||||||
|
// capability until an admin approves them, even if they redeemed a
|
||||||
|
// role-bearing invite. Without this, redeeming an admin invite into a
|
||||||
|
// require_approval space granted instant admin.
|
||||||
|
if (!isPrivilegeEligible(r.member.status)) {
|
||||||
|
return { ok: false, error: 'Your membership is pending approval.' }
|
||||||
|
}
|
||||||
if (!hasRole(r.member.role, allowed)) {
|
if (!hasRole(r.member.role, allowed)) {
|
||||||
return { ok: false, error: errorLabel }
|
return { ok: false, error: errorLabel }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,20 @@ export const ALL_ROLES = ROLES
|
|||||||
/** Statuses a member may have and still be allowed to act in the app. */
|
/** Statuses a member may have and still be allowed to act in the app. */
|
||||||
export const ACTIVE_STATUSES = ['current', 'unverified', 'late'] as const satisfies readonly MemberStatus[]
|
export const ACTIVE_STATUSES = ['current', 'unverified', 'late'] as const satisfies readonly MemberStatus[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statuses that may exercise a ROLE or PERMISSION (privileged actions).
|
||||||
|
* Deliberately excludes 'unverified': a member of a `require_approval` space
|
||||||
|
* is 'unverified' until an admin approves them, and must hold NO privileged
|
||||||
|
* capability in that window even if they redeemed a role-bearing invite
|
||||||
|
* (otherwise `require_approval` is silently void). 'late' keeps its role
|
||||||
|
* (dues lapse is grace -> late, never an authz downgrade).
|
||||||
|
*/
|
||||||
|
export const PRIVILEGE_STATUSES = ['current', 'late'] as const satisfies readonly MemberStatus[]
|
||||||
|
|
||||||
|
export function isPrivilegeEligible(status: MemberStatus | null | undefined): boolean {
|
||||||
|
return status === 'current' || status === 'late'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if `role` is one of the `allowed` roles.
|
* Returns true if `role` is one of the `allowed` roles.
|
||||||
* Use this everywhere instead of `role === 'admin' || role === 'board'` chains.
|
* Use this everywhere instead of `role === 'admin' || role === 'board'` chains.
|
||||||
|
|||||||
Reference in New Issue
Block a user