d3ro-voice/server/supabase/migrations/20260821000008_atomic_team_security.sql
2026-08-29 18:33:45 +09:00

611 lines
20 KiB
PL/PgSQL

BEGIN;
-- Direct multi-row team mutations cannot preserve ownership invariants. All
-- writes below are routed through locked SECURITY DEFINER RPCs.
DROP POLICY IF EXISTS "teams_insert_own" ON public.teams;
DROP POLICY IF EXISTS "team_members_insert_admin" ON public.team_members;
DROP POLICY IF EXISTS "team_members_update_admin" ON public.team_members;
DROP POLICY IF EXISTS "team_members_delete_admin_or_self" ON public.team_members;
DROP POLICY IF EXISTS "team_invites_insert_admin" ON public.team_invites;
DROP POLICY IF EXISTS "team_invites_read" ON public.team_invites;
DROP POLICY IF EXISTS "team_invites_delete" ON public.team_invites;
CREATE TEMP TABLE duplicate_active_team_invites ON COMMIT DROP AS
SELECT
id,
row_number() OVER (
PARTITION BY team_id, lower(btrim(email))
ORDER BY created_at DESC, id
) AS duplicate_rank
FROM public.team_invites
WHERE accepted_at IS NULL;
DELETE FROM public.team_invites AS invite
USING duplicate_active_team_invites AS duplicate
WHERE invite.id = duplicate.id AND duplicate.duplicate_rank > 1;
CREATE UNIQUE INDEX IF NOT EXISTS idx_team_invites_active_email
ON public.team_invites(team_id, lower(btrim(email)))
WHERE accepted_at IS NULL;
CREATE OR REPLACE FUNCTION public.create_team(team_name text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
normalized_name text := btrim(team_name);
created_team public.teams;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF normalized_name = '' OR char_length(normalized_name) > 80 THEN
RAISE EXCEPTION 'invalid_team_name' USING ERRCODE = '22023';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(current_user_id::text, 73041));
IF (
SELECT count(*)
FROM public.teams
WHERE owner_id = current_user_id
) >= 25 THEN
RAISE EXCEPTION 'team_limit_reached' USING ERRCODE = '54000';
END IF;
INSERT INTO public.teams (name, owner_id)
VALUES (normalized_name, current_user_id)
RETURNING * INTO created_team;
INSERT INTO public.team_members (team_id, user_id, role)
VALUES (created_team.id, current_user_id, 'owner');
RETURN jsonb_build_object(
'id', created_team.id,
'name', created_team.name,
'owner_id', created_team.owner_id,
'role', 'owner',
'member_count', 1,
'created_at', created_team.created_at,
'updated_at', created_team.updated_at
);
END;
$$;
CREATE OR REPLACE FUNCTION public.create_team_invite(
target_team_id uuid,
invited_email text,
invited_role text DEFAULT 'member'
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
current_user_email text;
caller_team_role text;
normalized_email text := lower(btrim(invited_email));
existing_invite public.team_invites;
created_invite public.team_invites;
invite_token text;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF target_team_id IS NULL
OR normalized_email !~ '^[^[:space:]@]+@[^[:space:]@]+[.][^[:space:]@]+$'
OR char_length(normalized_email) > 254
OR invited_role NOT IN ('admin', 'member') THEN
RAISE EXCEPTION 'invalid_invite' USING ERRCODE = '22023';
END IF;
SELECT lower(email) INTO current_user_email FROM auth.users WHERE id = current_user_id;
IF current_user_email = normalized_email THEN
RAISE EXCEPTION 'cannot_invite_self' USING ERRCODE = '22023';
END IF;
SELECT role INTO caller_team_role
FROM public.team_members
WHERE team_id = target_team_id AND user_id = current_user_id;
IF caller_team_role NOT IN ('owner', 'admin') THEN
RAISE EXCEPTION 'team_admin_required' USING ERRCODE = '42501';
END IF;
IF invited_role = 'admin' AND caller_team_role <> 'owner' THEN
RAISE EXCEPTION 'owner_required_for_admin_invite' USING ERRCODE = '42501';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(target_team_id::text || ':' || normalized_email, 73042));
IF (
SELECT count(*)
FROM public.team_invites
WHERE invited_by = current_user_id
AND created_at >= now() - interval '24 hours'
) >= 50 THEN
RAISE EXCEPTION 'invite_rate_limited' USING ERRCODE = '54000';
END IF;
IF EXISTS (
SELECT 1
FROM public.team_members AS member
JOIN auth.users AS account ON account.id = member.user_id
WHERE member.team_id = target_team_id AND lower(account.email) = normalized_email
) THEN
RAISE EXCEPTION 'already_team_member' USING ERRCODE = '23505';
END IF;
SELECT * INTO existing_invite
FROM public.team_invites
WHERE team_id = target_team_id
AND lower(email) = normalized_email
AND accepted_at IS NULL
ORDER BY created_at DESC
LIMIT 1
FOR UPDATE;
IF existing_invite.id IS NOT NULL AND existing_invite.expires_at > now() THEN
IF existing_invite.role <> invited_role THEN
UPDATE public.team_invites
SET role = invited_role
WHERE id = existing_invite.id
RETURNING * INTO existing_invite;
END IF;
RETURN jsonb_build_object(
'id', existing_invite.id,
'team_id', existing_invite.team_id,
'email', existing_invite.email,
'token', existing_invite.token,
'role', existing_invite.role,
'expires_at', existing_invite.expires_at,
'duplicate', true
);
END IF;
IF existing_invite.id IS NOT NULL THEN
DELETE FROM public.team_invites WHERE id = existing_invite.id;
END IF;
invite_token := public.generate_invite_token();
INSERT INTO public.team_invites (
team_id, invited_by, email, role, token
)
VALUES (
target_team_id, current_user_id, normalized_email, invited_role, invite_token
)
RETURNING * INTO created_invite;
RETURN jsonb_build_object(
'id', created_invite.id,
'team_id', created_invite.team_id,
'email', created_invite.email,
'token', created_invite.token,
'role', created_invite.role,
'expires_at', created_invite.expires_at,
'duplicate', false
);
END;
$$;
CREATE OR REPLACE FUNCTION public.accept_team_invite(invite_token text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
current_email text;
invite public.team_invites;
final_role text;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF invite_token IS NULL OR char_length(invite_token) NOT BETWEEN 20 AND 128 THEN
RAISE EXCEPTION 'invalid_invite_token' USING ERRCODE = '22023';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(invite_token, 73043));
SELECT * INTO invite
FROM public.team_invites
WHERE token = invite_token
FOR UPDATE;
IF invite.id IS NULL THEN
RAISE EXCEPTION 'invite_not_found' USING ERRCODE = 'P0002';
END IF;
IF invite.accepted_at IS NOT NULL THEN
IF invite.accepted_by = current_user_id THEN
SELECT role INTO final_role
FROM public.team_members
WHERE team_id = invite.team_id AND user_id = current_user_id;
IF final_role IS NULL THEN
RAISE EXCEPTION 'invite_already_accepted' USING ERRCODE = '23505';
END IF;
RETURN jsonb_build_object(
'team_id', invite.team_id,
'role', final_role,
'duplicate', true,
'already_member', true
);
END IF;
RAISE EXCEPTION 'invite_already_accepted' USING ERRCODE = '23505';
END IF;
IF invite.expires_at <= now() THEN
RAISE EXCEPTION 'invite_expired' USING ERRCODE = '22023';
END IF;
SELECT lower(email) INTO current_email FROM auth.users WHERE id = current_user_id;
IF current_email IS NULL OR current_email <> lower(invite.email) THEN
RAISE EXCEPTION 'invite_email_mismatch' USING ERRCODE = '42501';
END IF;
INSERT INTO public.team_members (team_id, user_id, role)
VALUES (invite.team_id, current_user_id, invite.role)
ON CONFLICT (team_id, user_id) DO NOTHING;
SELECT role INTO final_role
FROM public.team_members
WHERE team_id = invite.team_id AND user_id = current_user_id
FOR UPDATE;
IF final_role IS NULL THEN
RAISE EXCEPTION 'team_membership_failed';
END IF;
UPDATE public.team_invites
SET accepted_at = now(), accepted_by = current_user_id
WHERE id = invite.id AND accepted_at IS NULL;
IF NOT FOUND THEN
RAISE EXCEPTION 'invite_acceptance_conflict';
END IF;
RETURN jsonb_build_object(
'team_id', invite.team_id,
'role', final_role,
'duplicate', false,
'already_member', false
);
END;
$$;
CREATE OR REPLACE FUNCTION public.update_team_member_role(
target_team_id uuid,
member_user_id uuid,
new_role text
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
caller_role text;
prior_role text;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF new_role NOT IN ('admin', 'member') THEN
RAISE EXCEPTION 'invalid_team_role' USING ERRCODE = '22023';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(target_team_id::text, 73044));
SELECT role INTO caller_role FROM public.team_members
WHERE team_id = target_team_id AND user_id = current_user_id;
IF caller_role <> 'owner' THEN
RAISE EXCEPTION 'team_owner_required' USING ERRCODE = '42501';
END IF;
SELECT role INTO prior_role FROM public.team_members
WHERE team_id = target_team_id AND user_id = member_user_id
FOR UPDATE;
IF prior_role IS NULL THEN
RAISE EXCEPTION 'team_member_not_found' USING ERRCODE = 'P0002';
END IF;
IF prior_role = 'owner' OR member_user_id = current_user_id THEN
RAISE EXCEPTION 'team_owner_immutable' USING ERRCODE = '42501';
END IF;
UPDATE public.team_members SET role = new_role
WHERE team_id = target_team_id AND user_id = member_user_id;
RETURN jsonb_build_object(
'team_id', target_team_id,
'user_id', member_user_id,
'role', new_role
);
END;
$$;
CREATE OR REPLACE FUNCTION public.remove_team_member(
target_team_id uuid,
member_user_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
caller_role text;
target_role text;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(target_team_id::text, 73045));
SELECT role INTO caller_role FROM public.team_members
WHERE team_id = target_team_id AND user_id = current_user_id;
SELECT role INTO target_role FROM public.team_members
WHERE team_id = target_team_id AND user_id = member_user_id
FOR UPDATE;
IF target_role IS NULL THEN
RAISE EXCEPTION 'team_member_not_found' USING ERRCODE = 'P0002';
END IF;
IF target_role = 'owner' THEN
RAISE EXCEPTION 'team_owner_cannot_leave' USING ERRCODE = '42501';
END IF;
IF member_user_id <> current_user_id AND (
caller_role NOT IN ('owner', 'admin')
OR (target_role = 'admin' AND caller_role <> 'owner')
) THEN
RAISE EXCEPTION 'team_admin_required' USING ERRCODE = '42501';
END IF;
DELETE FROM public.team_members
WHERE team_id = target_team_id AND user_id = member_user_id;
RETURN jsonb_build_object(
'team_id', target_team_id,
'user_id', member_user_id,
'removed', true
);
END;
$$;
CREATE OR REPLACE FUNCTION public.list_team_members(target_team_id uuid)
RETURNS TABLE (
user_id uuid,
role text,
joined_at timestamptz,
display_name text,
avatar_url text,
is_current boolean
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
SELECT
member.user_id,
member.role,
member.joined_at,
profile.name,
profile.avatar_url,
member.user_id = auth.uid()
FROM public.team_members AS member
LEFT JOIN public.profiles AS profile ON profile.id = member.user_id
WHERE member.team_id = target_team_id
AND EXISTS (
SELECT 1 FROM public.team_members AS caller
WHERE caller.team_id = target_team_id AND caller.user_id = auth.uid()
)
ORDER BY
CASE member.role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,
member.joined_at,
member.user_id;
$$;
CREATE OR REPLACE FUNCTION public.list_team_invites(target_team_id uuid)
RETURNS TABLE (
id uuid,
email text,
role text,
expires_at timestamptz,
created_at timestamptz
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
SELECT invite.id, invite.email, invite.role, invite.expires_at, invite.created_at
FROM public.team_invites AS invite
WHERE invite.team_id = target_team_id
AND invite.accepted_at IS NULL
AND EXISTS (
SELECT 1 FROM public.team_members AS caller
WHERE caller.team_id = target_team_id
AND caller.user_id = auth.uid()
AND caller.role IN ('owner', 'admin')
)
ORDER BY invite.created_at DESC, invite.id;
$$;
CREATE OR REPLACE FUNCTION public.cancel_team_invite(invite_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
invite public.team_invites;
caller_role text;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
SELECT * INTO invite FROM public.team_invites WHERE id = invite_id FOR UPDATE;
IF invite.id IS NULL OR invite.accepted_at IS NOT NULL THEN
RAISE EXCEPTION 'invite_not_found' USING ERRCODE = 'P0002';
END IF;
SELECT role INTO caller_role FROM public.team_members
WHERE team_id = invite.team_id AND user_id = current_user_id;
IF invite.invited_by <> current_user_id AND caller_role NOT IN ('owner', 'admin') THEN
RAISE EXCEPTION 'team_admin_required' USING ERRCODE = '42501';
END IF;
DELETE FROM public.team_invites WHERE id = invite.id;
RETURN jsonb_build_object('id', invite.id, 'cancelled', true);
END;
$$;
REVOKE ALL ON FUNCTION public.create_team(text) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.create_team_invite(uuid, text, text) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.accept_team_invite(text) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.update_team_member_role(uuid, uuid, text) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.remove_team_member(uuid, uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.list_team_members(uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.list_team_invites(uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.cancel_team_invite(uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.create_team(text) TO authenticated;
GRANT EXECUTE ON FUNCTION public.create_team_invite(uuid, text, text) TO authenticated;
GRANT EXECUTE ON FUNCTION public.accept_team_invite(text) TO authenticated;
GRANT EXECUTE ON FUNCTION public.update_team_member_role(uuid, uuid, text) TO authenticated;
GRANT EXECUTE ON FUNCTION public.remove_team_member(uuid, uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.list_team_members(uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.list_team_invites(uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.cancel_team_invite(uuid) TO authenticated;
-- Replace the legacy Expo-only registration shape with provider-aware,
-- device-owned registrations. Old tokens cannot be proven to belong to a bare
-- RN installation, so they are invalidated and clients must register again.
DELETE FROM public.push_tokens;
UPDATE public.devices SET push_token = NULL WHERE push_token IS NOT NULL;
DROP INDEX IF EXISTS public.idx_devices_push_token;
DROP POLICY IF EXISTS "push_tokens_own" ON public.push_tokens;
ALTER TABLE public.push_tokens
ADD COLUMN IF NOT EXISTS provider text,
ADD COLUMN IF NOT EXISTS last_registered_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE public.push_tokens
ALTER COLUMN device_id SET NOT NULL,
ALTER COLUMN provider SET NOT NULL,
ADD CONSTRAINT push_tokens_provider_check
CHECK (provider IN ('fcm', 'apns', 'webpush'));
CREATE POLICY "push_tokens_no_direct_access" ON public.push_tokens
FOR SELECT USING (false);
DROP POLICY IF EXISTS "devices_insert_own" ON public.devices;
CREATE POLICY "devices_insert_own" ON public.devices
FOR INSERT WITH CHECK (
user_id = auth.uid() AND revoked_at IS NULL AND push_token IS NULL
);
CREATE OR REPLACE FUNCTION public.reject_direct_device_push_token()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = ''
AS $$
BEGIN
IF NEW.push_token IS DISTINCT FROM OLD.push_token THEN
RAISE EXCEPTION 'device_push_token_is_server_managed' USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER reject_direct_device_push_token_update
BEFORE UPDATE OF push_token ON public.devices
FOR EACH ROW EXECUTE FUNCTION public.reject_direct_device_push_token();
CREATE OR REPLACE FUNCTION public.register_push_registration(
push_device_id uuid,
push_provider text,
registration_id text
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
owned_device public.devices;
existing_owner uuid;
registered public.push_tokens;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF push_provider NOT IN ('fcm', 'apns', 'webpush')
OR registration_id IS NULL
OR char_length(registration_id) NOT BETWEEN 20 AND 4096
OR registration_id ~ '[[:space:]]' THEN
RAISE EXCEPTION 'invalid_push_registration' USING ERRCODE = '22023';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(push_device_id::text, 73046));
PERFORM pg_advisory_xact_lock(hashtextextended(registration_id, 73047));
SELECT * INTO owned_device
FROM public.devices
WHERE id = push_device_id AND user_id = current_user_id
FOR UPDATE;
IF owned_device.id IS NULL OR owned_device.revoked_at IS NOT NULL THEN
RAISE EXCEPTION 'active_device_not_found' USING ERRCODE = 'P0002';
END IF;
IF (owned_device.platform = 'android' AND push_provider <> 'fcm')
OR (owned_device.platform = 'ios' AND push_provider <> 'apns')
OR (owned_device.platform = 'web' AND push_provider <> 'webpush') THEN
RAISE EXCEPTION 'push_provider_platform_mismatch' USING ERRCODE = '22023';
END IF;
SELECT user_id INTO existing_owner
FROM public.push_tokens
WHERE token = registration_id
FOR UPDATE;
IF existing_owner IS NOT NULL AND existing_owner <> current_user_id THEN
RAISE EXCEPTION 'push_registration_owned_by_other_user' USING ERRCODE = '23505';
END IF;
DELETE FROM public.push_tokens
WHERE device_id = push_device_id OR token = registration_id;
INSERT INTO public.push_tokens (
user_id, token, platform, device_name, device_id, provider, last_registered_at
)
VALUES (
current_user_id,
registration_id,
owned_device.platform,
owned_device.device_name,
owned_device.id,
push_provider,
now()
)
RETURNING * INTO registered;
RETURN jsonb_build_object(
'device_id', registered.device_id,
'provider', registered.provider,
'registered_at', registered.last_registered_at
);
END;
$$;
CREATE OR REPLACE FUNCTION public.unregister_push_registration(push_device_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
removed_count integer;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
DELETE FROM public.push_tokens
WHERE device_id = push_device_id AND user_id = current_user_id;
GET DIAGNOSTICS removed_count = ROW_COUNT;
RETURN jsonb_build_object(
'device_id', push_device_id,
'removed', removed_count > 0
);
END;
$$;
REVOKE ALL ON FUNCTION public.register_push_registration(uuid, text, text) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.unregister_push_registration(uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.register_push_registration(uuid, text, text) TO authenticated;
GRANT EXECUTE ON FUNCTION public.unregister_push_registration(uuid) TO authenticated;
COMMIT;