-- ============================================================================ -- 20260929010000_team_rpc_null_role_guard.sql -- -- Team management RPCs must refuse callers that are not members of the team. -- -- Bug -- create_team_invite (20260821000010), update_team_member_role, -- remove_team_member and cancel_team_invite (20260821000008) read the caller -- role with -- SELECT role INTO caller_role FROM team_members -- WHERE team_id = ... AND user_id = auth.uid(); -- and then guarded with `caller_role <> 'owner'` / `caller_role NOT IN (...)`. -- For a caller without a membership row caller_role is NULL, every guard -- evaluates to NULL, and plpgsql skips an IF whose condition is NULL. The -- functions are SECURITY DEFINER and granted to authenticated, so any -- signed-in user who knew a team id could mint an admin invite for a second -- account (and join as admin through accept_team_invite), change member -- roles, remove members and cancel invites. -- -- Fix -- Role policy lives in one place instead of being re-implemented as ad-hoc -- comparisons in every RPC: -- * team_role_at_least_v1(role, minimum) pure, NULL-safe hierarchy check -- (owner > admin > member); a -- missing role never qualifies. -- * require_team_role_v1(team, minimum) resolves the caller's membership -- and raises 42501 unless it meets -- the minimum; returns the role. -- The four RPCs are redefined on top of these helpers. Everything else in -- their bodies (validation, locks, rate limit, idempotency, response shape, -- error names) is unchanged, with two deliberate ordering changes: -- * remove_team_member authorizes a caller removing someone else before it -- looks the target up, so a non-member cannot probe who is in a team. -- Leaving (removing yourself) keeps its existing behaviour. -- * NULL inputs (email, role) are rejected by validation instead of -- slipping through a NULL comparison. -- -- Both helpers are SECURITY INVOKER and not executable by client roles; the -- SECURITY DEFINER RPCs call them as their owner. The function-acl allowlist -- (tests/function-acl.integration.sql) is therefore unaffected. -- ============================================================================ -- ---------------------------------------------------------------------------- -- 1. Role policy -- ---------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION public.team_role_at_least_v1( team_role text, minimum_role text ) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE SET search_path = '' AS $$ -- Unknown or NULL values rank 0; a requirement that ranks 0 is never met. SELECT required.rank > 0 AND held.rank >= required.rank FROM ( SELECT CASE team_role WHEN 'owner' THEN 3 WHEN 'admin' THEN 2 WHEN 'member' THEN 1 ELSE 0 END AS rank ) AS held CROSS JOIN ( SELECT CASE minimum_role WHEN 'owner' THEN 3 WHEN 'admin' THEN 2 WHEN 'member' THEN 1 ELSE 0 END AS rank ) AS required; $$; CREATE OR REPLACE FUNCTION public.require_team_role_v1( target_team_id uuid, minimum_role text ) RETURNS text LANGUAGE plpgsql STABLE SET search_path = '' AS $$ DECLARE current_user_id uuid := auth.uid(); caller_role text; BEGIN IF current_user_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; SELECT member.role INTO caller_role FROM public.team_members AS member WHERE member.team_id = target_team_id AND member.user_id = current_user_id; IF NOT public.team_role_at_least_v1(caller_role, minimum_role) THEN RAISE EXCEPTION '%', CASE minimum_role WHEN 'owner' THEN 'team_owner_required' WHEN 'admin' THEN 'team_admin_required' ELSE 'team_member_required' END USING ERRCODE = '42501'; END IF; RETURN caller_role; END; $$; REVOKE ALL ON FUNCTION public.team_role_at_least_v1(text, text) FROM PUBLIC, anon, authenticated; REVOKE ALL ON FUNCTION public.require_team_role_v1(uuid, text) FROM PUBLIC, anon, authenticated; -- ---------------------------------------------------------------------------- -- 2. create_team_invite (was 20260821000010) -- ---------------------------------------------------------------------------- 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 IS NULL OR normalized_email !~ '^[^[:space:]@]+@[^[:space:]@]+[.][^[:space:]@]+$' OR char_length(normalized_email) > 254 OR invited_role IS NULL OR invited_role NOT IN ('admin', 'member') THEN RAISE EXCEPTION 'invalid_invite' USING ERRCODE = '22023'; END IF; SELECT lower(account.email) INTO current_user_email FROM auth.users AS account WHERE account.id = current_user_id; IF current_user_email = normalized_email THEN RAISE EXCEPTION 'cannot_invite_self' USING ERRCODE = '22023'; END IF; caller_team_role := public.require_team_role_v1(target_team_id, 'admin'); IF invited_role = 'admin' AND NOT public.team_role_at_least_v1(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 AS recent_invite WHERE recent_invite.invited_by = current_user_id AND recent_invite.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 active_invite.* INTO existing_invite FROM public.team_invites AS active_invite WHERE active_invite.team_id = target_team_id AND lower(active_invite.email) = normalized_email AND active_invite.accepted_at IS NULL ORDER BY active_invite.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; $$; -- ---------------------------------------------------------------------------- -- 3. update_team_member_role (was 20260821000008) -- ---------------------------------------------------------------------------- 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(); prior_role text; BEGIN IF current_user_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; IF new_role IS NULL OR 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)); PERFORM public.require_team_role_v1(target_team_id, 'owner'); 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; $$; -- ---------------------------------------------------------------------------- -- 4. remove_team_member (was 20260821000008) -- ---------------------------------------------------------------------------- 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(); is_leaving boolean; caller_role text; target_role text; BEGIN IF current_user_id IS NULL THEN RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; END IF; is_leaving := member_user_id IS NOT DISTINCT FROM current_user_id; PERFORM pg_advisory_xact_lock(hashtextextended(target_team_id::text, 73045)); -- Removing someone else requires at least admin. Checked before the target -- lookup so a non-member cannot learn who belongs to the team. IF NOT is_leaving THEN caller_role := public.require_team_role_v1(target_team_id, 'admin'); END IF; 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 NOT is_leaving AND target_role = 'admin' AND NOT public.team_role_at_least_v1(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; $$; -- ---------------------------------------------------------------------------- -- 5. cancel_team_invite (was 20260821000008) -- ---------------------------------------------------------------------------- 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; 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; -- The inviter may withdraw their own invite; anyone else needs admin. IF invite.invited_by IS DISTINCT FROM current_user_id THEN PERFORM public.require_team_role_v1(invite.team_id, 'admin'); END IF; DELETE FROM public.team_invites WHERE id = invite.id; RETURN jsonb_build_object('id', invite.id, 'cancelled', true); END; $$; -- CREATE OR REPLACE keeps existing ACLs; restate them so this file is the -- complete contract for the redefined RPCs. REVOKE ALL ON FUNCTION public.create_team_invite(uuid, text, 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.cancel_team_invite(uuid) FROM PUBLIC, anon; GRANT EXECUTE ON FUNCTION public.create_team_invite(uuid, text, 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.cancel_team_invite(uuid) TO authenticated;