d3ro-voice/server/supabase/migrations/20260929010000_team_rpc_null_role_guard.sql
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

358 lines
13 KiB
PL/PgSQL

-- ============================================================================
-- 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;