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:
Moheeb Zara
2026-05-18 18:30:20 -07:00
parent a513ad0fab
commit 2239e7a92d
4 changed files with 56 additions and 3 deletions
+14
View File
@@ -24,6 +24,20 @@ export const ALL_ROLES = ROLES
/** 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[]
/**
* 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.
* Use this everywhere instead of `role === 'admin' || role === 'board'` chains.