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.
This commit is contained in:
Yun Chan 2026-09-28 20:45:52 +09:00
parent 2428ede03d
commit ba9ef9741e
161 changed files with 17056 additions and 2379 deletions

View file

@ -0,0 +1,358 @@
-- ============================================================================
-- 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;