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:
parent
2428ede03d
commit
ba9ef9741e
161 changed files with 17056 additions and 2379 deletions
|
|
@ -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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue