- SQL migrations: users, collectives, collective_members, collective_invitations,
full RLS policies, is_active_member/is_member/is_admin helpers,
accept_invitation SECURITY DEFINER function, admin auto-promote trigger,
avatars storage bucket
- Auth refactor: replace keycloak-js with Supabase OAuth (GoTrue OIDC proxy,
Option B). signInWithOAuth({ provider: 'keycloak' }) + PKCE flow
- GoTrue config: GOTRUE_EXTERNAL_KEYCLOAK_URL + GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI
added to docker-compose.dev.yml; Keycloak realm updated with GoTrue callback URI
- New routes: /auth/callback, /invitation/[token], /(app)/collective/manage
- Functional onboarding: create collective or join via invite link
- Full settings page: display name (debounced), avatar (initials/emoji/upload),
language switcher, Keycloak account console link
- Components: Avatar.svelte (initials/emoji/upload with fallback),
ImageCropper.svelte (cropperjs, 1:1 crop, lazy-loaded)
- i18n: added all Fase 1 message keys (en + es); renamed 'delete' → 'action_delete'
(JS reserved word); fixed plugin-m-function-matcher major-version URL format
- Database types: manually seeded packages/types/src/database.ts from migrations
- .env.development: committed public dev defaults for SvelteKit type checking
- CLAUDE.md: documented /etc/hosts requirement for keycloak hostname
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
230 lines
7.7 KiB
PL/PgSQL
230 lines
7.7 KiB
PL/PgSQL
-- Migration 003: RLS policies + is_active_member helper
|
|
-- All business access rules live here. Every table has RLS enabled in its own
|
|
-- migration; this file only adds the policies.
|
|
|
|
-- ── Helper: is_active_member ──────────────────────────────────────────────────
|
|
-- Returns true if the calling user is a non-guest member of the collective.
|
|
-- Used by all content-table policies. Declared STABLE so PostgreSQL can cache
|
|
-- the result within a single statement.
|
|
|
|
CREATE OR REPLACE FUNCTION public.is_active_member(p_collective_id uuid)
|
|
RETURNS boolean
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM public.collective_members
|
|
WHERE collective_id = p_collective_id
|
|
AND user_id = auth.uid()
|
|
AND role IN ('admin', 'member')
|
|
);
|
|
$$;
|
|
|
|
-- Variant that also accepts guests (read access).
|
|
CREATE OR REPLACE FUNCTION public.is_member(p_collective_id uuid)
|
|
RETURNS boolean
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM public.collective_members
|
|
WHERE collective_id = p_collective_id
|
|
AND user_id = auth.uid()
|
|
);
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION public.is_admin(p_collective_id uuid)
|
|
RETURNS boolean
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM public.collective_members
|
|
WHERE collective_id = p_collective_id
|
|
AND user_id = auth.uid()
|
|
AND role = 'admin'
|
|
);
|
|
$$;
|
|
|
|
-- ── public.users policies ─────────────────────────────────────────────────────
|
|
|
|
-- Any authenticated user can read their own row.
|
|
CREATE POLICY users_select_own
|
|
ON public.users FOR SELECT
|
|
USING (id = auth.uid());
|
|
|
|
-- Members can read profiles of users in the same collective.
|
|
CREATE POLICY users_select_collective_peers
|
|
ON public.users FOR SELECT
|
|
USING (
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM public.collective_members cm1
|
|
JOIN public.collective_members cm2
|
|
ON cm2.collective_id = cm1.collective_id
|
|
AND cm2.user_id = auth.uid()
|
|
WHERE cm1.user_id = public.users.id
|
|
)
|
|
);
|
|
|
|
-- A user can only update their own row.
|
|
CREATE POLICY users_update_own
|
|
ON public.users FOR UPDATE
|
|
USING (id = auth.uid())
|
|
WITH CHECK (id = auth.uid());
|
|
|
|
-- Insert is handled by the trigger (SECURITY DEFINER). No direct INSERT allowed.
|
|
CREATE POLICY users_insert_deny
|
|
ON public.users FOR INSERT
|
|
WITH CHECK (false);
|
|
|
|
-- Delete cascades from auth.users deletion; no direct DELETE allowed here.
|
|
CREATE POLICY users_delete_deny
|
|
ON public.users FOR DELETE
|
|
USING (false);
|
|
|
|
-- ── public.collectives policies ───────────────────────────────────────────────
|
|
|
|
-- Members (any role) can read collectives they belong to.
|
|
CREATE POLICY collectives_select
|
|
ON public.collectives FOR SELECT
|
|
USING (public.is_member(id));
|
|
|
|
-- Any authenticated user can create a collective (they become its first admin).
|
|
CREATE POLICY collectives_insert
|
|
ON public.collectives FOR INSERT
|
|
WITH CHECK (created_by = auth.uid());
|
|
|
|
-- Only admins can update the collective name / emoji.
|
|
CREATE POLICY collectives_update
|
|
ON public.collectives FOR UPDATE
|
|
USING (public.is_admin(id))
|
|
WITH CHECK (public.is_admin(id));
|
|
|
|
-- Admins can delete a collective.
|
|
CREATE POLICY collectives_delete
|
|
ON public.collectives FOR DELETE
|
|
USING (public.is_admin(id));
|
|
|
|
-- ── public.collective_members policies ───────────────────────────────────────
|
|
|
|
-- Members of a collective can read its member list.
|
|
CREATE POLICY collective_members_select
|
|
ON public.collective_members FOR SELECT
|
|
USING (public.is_member(collective_id));
|
|
|
|
-- Only admins can add members (accepting invitations handled via server action
|
|
-- with SECURITY DEFINER function, not direct INSERT).
|
|
CREATE POLICY collective_members_insert
|
|
ON public.collective_members FOR INSERT
|
|
WITH CHECK (public.is_admin(collective_id));
|
|
|
|
-- Only admins can change roles; users cannot promote themselves.
|
|
CREATE POLICY collective_members_update
|
|
ON public.collective_members FOR UPDATE
|
|
USING (public.is_admin(collective_id))
|
|
WITH CHECK (public.is_admin(collective_id));
|
|
|
|
-- Admins can remove any member; members can remove themselves.
|
|
CREATE POLICY collective_members_delete
|
|
ON public.collective_members FOR DELETE
|
|
USING (
|
|
public.is_admin(collective_id)
|
|
OR user_id = auth.uid()
|
|
);
|
|
|
|
-- ── public.collective_invitations policies ────────────────────────────────────
|
|
|
|
-- Members of a collective can read its invitations (to copy the link).
|
|
CREATE POLICY collective_invitations_select
|
|
ON public.collective_invitations FOR SELECT
|
|
USING (public.is_member(collective_id));
|
|
|
|
-- Only admins can create invitations.
|
|
CREATE POLICY collective_invitations_insert
|
|
ON public.collective_invitations FOR INSERT
|
|
WITH CHECK (
|
|
public.is_admin(collective_id)
|
|
AND created_by = auth.uid()
|
|
);
|
|
|
|
-- Only admins can revoke (delete) invitations.
|
|
CREATE POLICY collective_invitations_delete
|
|
ON public.collective_invitations FOR DELETE
|
|
USING (public.is_admin(collective_id));
|
|
|
|
-- Accepting an invitation (setting accepted_at) is done via a SECURITY DEFINER
|
|
-- server action — no UPDATE policy needed here.
|
|
CREATE POLICY collective_invitations_update_deny
|
|
ON public.collective_invitations FOR UPDATE
|
|
USING (false);
|
|
|
|
-- ── SECURITY DEFINER: accept invitation ──────────────────────────────────────
|
|
-- Called from the SvelteKit server action for /invitation/[token].
|
|
-- Validates token, expiry, and that user isn't already a member, then inserts
|
|
-- into collective_members and stamps accepted_at.
|
|
|
|
CREATE OR REPLACE FUNCTION public.accept_invitation(p_token uuid)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
DECLARE
|
|
v_invitation public.collective_invitations%ROWTYPE;
|
|
v_user_id uuid := auth.uid();
|
|
BEGIN
|
|
IF v_user_id IS NULL THEN
|
|
RETURN jsonb_build_object('error', 'unauthenticated');
|
|
END IF;
|
|
|
|
SELECT * INTO v_invitation
|
|
FROM public.collective_invitations
|
|
WHERE token = p_token
|
|
FOR UPDATE;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN jsonb_build_object('error', 'not_found');
|
|
END IF;
|
|
|
|
IF v_invitation.accepted_at IS NOT NULL THEN
|
|
RETURN jsonb_build_object('error', 'already_used');
|
|
END IF;
|
|
|
|
IF v_invitation.expires_at < now() THEN
|
|
RETURN jsonb_build_object('error', 'expired');
|
|
END IF;
|
|
|
|
IF EXISTS (
|
|
SELECT 1 FROM public.collective_members
|
|
WHERE collective_id = v_invitation.collective_id
|
|
AND user_id = v_user_id
|
|
) THEN
|
|
RETURN jsonb_build_object('error', 'already_member');
|
|
END IF;
|
|
|
|
INSERT INTO public.collective_members (collective_id, user_id, role)
|
|
VALUES (v_invitation.collective_id, v_user_id, v_invitation.role);
|
|
|
|
UPDATE public.collective_invitations
|
|
SET accepted_at = now(),
|
|
accepted_by = v_user_id
|
|
WHERE id = v_invitation.id;
|
|
|
|
RETURN jsonb_build_object(
|
|
'ok', true,
|
|
'collective_id', v_invitation.collective_id,
|
|
'role', v_invitation.role
|
|
);
|
|
END;
|
|
$$;
|