diff --git a/lib/actions/area-leads.ts b/lib/actions/area-leads.ts index e0f03f6..0338d04 100644 --- a/lib/actions/area-leads.ts +++ b/lib/actions/area-leads.ts @@ -28,6 +28,19 @@ export async function upsertAreaLead(formData: { if (!auth.ok) return { error: auth.error } const { member } = auth + // If a lead member is referenced, confirm they belong to this space (the + // row is space-scoped and RLS scopes reads, but reject a foreign id at the + // boundary for consistency with the rest of the codebase). + if (v.data.lead_id) { + const { data: lead } = await supabase + .from('space_members') + .select('id') + .eq('id', v.data.lead_id) + .eq('space_id', member.space_id) + .maybeSingle() + if (!lead) return { error: 'The selected lead is not a member of this space.' } + } + const { data, error } = await supabase .from('area_leads') .upsert( diff --git a/lib/actions/classes.ts b/lib/actions/classes.ts index 7c3a744..ced43ba 100644 --- a/lib/actions/classes.ts +++ b/lib/actions/classes.ts @@ -531,6 +531,8 @@ export async function signUpForClass(input: unknown) { session.capacity as number | null, cls?.capacity ?? null, ) + // session was fetched scoped by member.space_id above, so counting by + // session_id alone is space-safe (do not loosen the session lookup). const { count: registered } = await admin .from('class_signups') .select('id', { count: 'exact', head: true }) @@ -571,9 +573,13 @@ export async function cancelMySignup(input: unknown) { if (!v.ok) return { error: v.error } const admin = createAdminClient() + // All admin-client queries below are additionally scoped by member.space_id: + // member.id can only hold a signup in its own space, but the writes must not + // be reachable cross-space even if a future edit loosens the member filter. const { data: signup } = await admin .from('class_signups') .select('id, status') + .eq('space_id', member.space_id) .eq('session_id', v.data.sessionId) .eq('member_id', member.id) .neq('status', 'cancelled') @@ -585,6 +591,7 @@ export async function cancelMySignup(input: unknown) { .from('class_signups') .update({ status: 'cancelled' }) .eq('id', signup.id) + .eq('space_id', member.space_id) if (error) return { error: error.message } // If a registered seat freed up, promote the earliest waitlisted member. @@ -593,6 +600,7 @@ export async function cancelMySignup(input: unknown) { .from('class_sessions') .select('capacity, classes(capacity)') .eq('id', v.data.sessionId) + .eq('space_id', member.space_id) .maybeSingle() const cap = effectiveCapacity( (session as { capacity: number | null } | null)?.capacity ?? null, @@ -601,6 +609,7 @@ export async function cancelMySignup(input: unknown) { const { data: all } = await admin .from('class_signups') .select('id, status, signed_up_at') + .eq('space_id', member.space_id) .eq('session_id', v.data.sessionId) .neq('status', 'cancelled') const promoteId = pickPromotion( @@ -608,7 +617,7 @@ export async function cancelMySignup(input: unknown) { cap, ) if (promoteId) { - await admin.from('class_signups').update({ status: 'registered' }).eq('id', promoteId) + await admin.from('class_signups').update({ status: 'registered' }).eq('id', promoteId).eq('space_id', member.space_id) } } diff --git a/lib/actions/proposals.ts b/lib/actions/proposals.ts index 019fdef..3956736 100644 --- a/lib/actions/proposals.ts +++ b/lib/actions/proposals.ts @@ -151,6 +151,19 @@ export async function castVote(formData: { if (!auth.ok) return { error: auth.error } const { member } = auth + // Defense in depth: the votes RLS policies already enforce same-space, + // open status and the voting window, but this is a sensitive mutation, so + // validate at the action boundary too (a future RLS regression must not + // silently allow cross-space vote injection). + const { data: proposal } = await supabase + .from('proposals') + .select('id, status') + .eq('id', v.data.proposalId) + .eq('space_id', member.space_id) + .maybeSingle() + if (!proposal) return { error: 'Proposal not found' } + if (proposal.status !== 'open') return { error: 'Voting is closed for this proposal.' } + const { error } = await supabase .from('proposal_votes') .upsert(