d3ro-voice/server/supabase/tests/team-rpc-null-role-guard.integration.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

338 lines
12 KiB
PL/PgSQL

\set ON_ERROR_STOP on
-- Regression: team management RPCs must reject callers that hold no membership
-- row in the target team (see migrations/20260929010000_team_rpc_null_role_guard.sql).
--
-- Before that migration each RPC read the caller role with
-- SELECT role INTO caller_role FROM team_members WHERE ... user_id = auth.uid()
-- and compared it with `<>` / `NOT IN`. For a non-member caller_role is NULL,
-- every guard evaluated to NULL, plpgsql skipped the RAISE, and any signed-in
-- user who knew a team id could mint an admin invite for a second account,
-- change member roles, remove members and cancel invites.
--
-- Local only: psql against the local Supabase stack. Runs in a transaction and
-- rolls back.
BEGIN;
CREATE OR REPLACE FUNCTION pg_temp.assert_true(condition boolean, message text)
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
IF condition IS NOT TRUE THEN
RAISE EXCEPTION 'assertion_failed: %', message;
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION pg_temp.act_as(user_id uuid)
RETURNS void
LANGUAGE sql
AS $$
SELECT set_config(
'request.jwt.claims',
pg_catalog.json_build_object('sub', user_id, 'role', 'authenticated')::text,
true
);
$$;
-- Runs `statement` and requires it to fail with SQLSTATE 42501 and the given
-- error message. Anything else (success, another error) fails the test.
CREATE OR REPLACE FUNCTION pg_temp.expect_forbidden(
statement text,
expected_message text,
label text
)
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
BEGIN
EXECUTE statement;
EXCEPTION
WHEN insufficient_privilege THEN
IF SQLERRM <> expected_message THEN
RAISE EXCEPTION 'assertion_failed: % raised % instead of %',
label, SQLERRM, expected_message;
END IF;
RETURN;
END;
RAISE EXCEPTION 'assertion_failed: % was allowed', label;
END;
$$;
-- ---------------------------------------------------------------------------
-- 1. Pure role policy: a missing role (NULL) never satisfies any requirement.
-- ---------------------------------------------------------------------------
SELECT pg_temp.assert_true(
public.team_role_at_least_v1(NULL, 'member') IS FALSE,
'no membership does not satisfy member'
);
SELECT pg_temp.assert_true(
public.team_role_at_least_v1(NULL, 'owner') IS FALSE,
'no membership does not satisfy owner'
);
SELECT pg_temp.assert_true(
public.team_role_at_least_v1('owner', NULL) IS FALSE,
'an unknown requirement is never satisfied'
);
SELECT pg_temp.assert_true(
public.team_role_at_least_v1('superuser', 'member') IS FALSE,
'an unknown role does not satisfy member'
);
SELECT pg_temp.assert_true(
public.team_role_at_least_v1('owner', 'admin')
AND public.team_role_at_least_v1('owner', 'owner')
AND public.team_role_at_least_v1('admin', 'admin')
AND public.team_role_at_least_v1('admin', 'member')
AND public.team_role_at_least_v1('member', 'member'),
'role hierarchy owner > admin > member is honoured'
);
SELECT pg_temp.assert_true(
NOT public.team_role_at_least_v1('admin', 'owner')
AND NOT public.team_role_at_least_v1('member', 'admin'),
'lower roles do not satisfy higher requirements'
);
SELECT pg_temp.assert_true(
NOT has_function_privilege('authenticated', 'public.require_team_role_v1(uuid, text)', 'EXECUTE')
AND NOT has_function_privilege('anon', 'public.require_team_role_v1(uuid, text)', 'EXECUTE'),
'role guard helper is internal to the team RPCs'
);
-- ---------------------------------------------------------------------------
-- 2. Fixtures: team T with owner O, admin A, member M; outsider X with a second
-- account X2; a pending invite for I.
-- ---------------------------------------------------------------------------
INSERT INTO auth.users (
id, aud, role, email, encrypted_password, email_confirmed_at,
raw_app_meta_data, raw_user_meta_data, created_at, updated_at
)
SELECT
fixture.id, 'authenticated', 'authenticated', fixture.email,
crypt('fixture-password', gen_salt('bf')), now(),
'{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now()
FROM (VALUES
('43100000-0000-4000-8000-000000000001'::uuid, 'null-guard-owner@example.invalid'),
('43100000-0000-4000-8000-000000000002'::uuid, 'null-guard-admin@example.invalid'),
('43100000-0000-4000-8000-000000000003'::uuid, 'null-guard-member@example.invalid'),
('43100000-0000-4000-8000-000000000004'::uuid, 'null-guard-outsider@example.invalid'),
('43100000-0000-4000-8000-000000000005'::uuid, 'null-guard-outsider-2@example.invalid'),
('43100000-0000-4000-8000-000000000006'::uuid, 'null-guard-invitee@example.invalid')
) AS fixture(id, email);
SET LOCAL ROLE authenticated;
SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000001');
SELECT (public.create_team('Null Role Guard Team')->>'id')::uuid AS team_id \gset
SELECT set_config('test.team_id', :'team_id', true);
SELECT public.create_team_invite(:'team_id', 'null-guard-admin@example.invalid', 'admin')
->>'token' AS admin_token \gset
SELECT public.create_team_invite(:'team_id', 'null-guard-member@example.invalid', 'member')
->>'token' AS member_token \gset
SELECT (public.create_team_invite(:'team_id', 'null-guard-invitee@example.invalid', 'member')
->>'id')::uuid AS pending_invite_id \gset
SELECT set_config('test.pending_invite_id', :'pending_invite_id', true);
SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000002');
SELECT public.accept_team_invite(:'admin_token');
SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000003');
SELECT public.accept_team_invite(:'member_token');
-- ---------------------------------------------------------------------------
-- 3. Non-member X: every management RPC is refused.
-- ---------------------------------------------------------------------------
SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000004');
SELECT pg_temp.expect_forbidden(
format(
'SELECT public.create_team_invite(%L::uuid, %L, %L)',
:'team_id', 'null-guard-outsider-2@example.invalid', 'admin'
),
'team_admin_required',
'non-member minting an admin invite'
);
SELECT pg_temp.expect_forbidden(
format(
'SELECT public.create_team_invite(%L::uuid, %L, %L)',
:'team_id', 'null-guard-outsider-2@example.invalid', 'member'
),
'team_admin_required',
'non-member minting a member invite'
);
SELECT pg_temp.expect_forbidden(
format(
'SELECT public.update_team_member_role(%L::uuid, %L::uuid, %L)',
:'team_id', '43100000-0000-4000-8000-000000000003', 'admin'
),
'team_owner_required',
'non-member promoting a member'
);
SELECT pg_temp.expect_forbidden(
format(
'SELECT public.update_team_member_role(%L::uuid, %L::uuid, %L)',
:'team_id', '43100000-0000-4000-8000-000000000002', 'member'
),
'team_owner_required',
'non-member demoting an admin'
);
SELECT pg_temp.expect_forbidden(
format(
'SELECT public.remove_team_member(%L::uuid, %L::uuid)',
:'team_id', '43100000-0000-4000-8000-000000000003'
),
'team_admin_required',
'non-member removing a member'
);
SELECT pg_temp.expect_forbidden(
format(
'SELECT public.remove_team_member(%L::uuid, %L::uuid)',
:'team_id', '43100000-0000-4000-8000-000000000002'
),
'team_admin_required',
'non-member removing an admin'
);
SELECT pg_temp.expect_forbidden(
format(
'SELECT public.remove_team_member(%L::uuid, %L::uuid)',
:'team_id', '43100000-0000-4000-8000-000000000099'
),
'team_admin_required',
'non-member probing membership of an unknown user'
);
SELECT pg_temp.expect_forbidden(
format('SELECT public.cancel_team_invite(%L::uuid)', :'pending_invite_id'),
'team_admin_required',
'non-member cancelling a pending invite'
);
-- The second attacker account has nothing to accept.
SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000005');
SELECT pg_temp.assert_true(
(SELECT count(*) FROM public.list_team_members(:'team_id')) = 0,
'second outsider account sees no team members'
);
-- ---------------------------------------------------------------------------
-- 4. Existing role rules still hold for members.
-- ---------------------------------------------------------------------------
SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000003');
SELECT pg_temp.expect_forbidden(
format(
'SELECT public.create_team_invite(%L::uuid, %L, %L)',
:'team_id', 'null-guard-outsider-2@example.invalid', 'member'
),
'team_admin_required',
'regular member inviting'
);
SELECT pg_temp.expect_forbidden(
format(
'SELECT public.update_team_member_role(%L::uuid, %L::uuid, %L)',
:'team_id', '43100000-0000-4000-8000-000000000002', 'member'
),
'team_owner_required',
'regular member demoting an admin'
);
SELECT pg_temp.expect_forbidden(
format(
'SELECT public.remove_team_member(%L::uuid, %L::uuid)',
:'team_id', '43100000-0000-4000-8000-000000000002'
),
'team_admin_required',
'regular member removing an admin'
);
SELECT pg_temp.expect_forbidden(
format('SELECT public.cancel_team_invite(%L::uuid)', :'pending_invite_id'),
'team_admin_required',
'regular member cancelling another user''s invite'
);
SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000002');
SELECT pg_temp.expect_forbidden(
format(
'SELECT public.create_team_invite(%L::uuid, %L, %L)',
:'team_id', 'null-guard-outsider-2@example.invalid', 'admin'
),
'owner_required_for_admin_invite',
'admin minting another admin invite'
);
SELECT pg_temp.expect_forbidden(
format(
'SELECT public.update_team_member_role(%L::uuid, %L::uuid, %L)',
:'team_id', '43100000-0000-4000-8000-000000000003', 'admin'
),
'team_owner_required',
'admin changing roles'
);
SELECT pg_temp.assert_true(
(public.create_team_invite(:'team_id', 'null-guard-outsider-2@example.invalid', 'member')
->>'role') = 'member',
'admin can still invite a member'
);
SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000001');
SELECT pg_temp.assert_true(
public.update_team_member_role(
:'team_id', '43100000-0000-4000-8000-000000000003', 'admin'
)->>'role' = 'admin'
AND public.update_team_member_role(
:'team_id', '43100000-0000-4000-8000-000000000003', 'member'
)->>'role' = 'member',
'owner can still change roles'
);
SELECT pg_temp.assert_true(
(public.cancel_team_invite(:'pending_invite_id')->>'cancelled')::boolean,
'owner can still cancel an invite'
);
-- A member can still leave on their own; leaving twice reports not found.
SELECT pg_temp.act_as('43100000-0000-4000-8000-000000000003');
SELECT pg_temp.assert_true(
(public.remove_team_member(:'team_id', '43100000-0000-4000-8000-000000000003')
->>'removed')::boolean,
'member can leave the team'
);
DO $$
BEGIN
PERFORM public.remove_team_member(
current_setting('test.team_id')::uuid,
'43100000-0000-4000-8000-000000000003'
);
RAISE EXCEPTION 'assertion_failed: leaving twice succeeded';
EXCEPTION
WHEN no_data_found THEN NULL;
END;
$$;
RESET ROLE;
-- ---------------------------------------------------------------------------
-- 5. State: the outsider changed nothing.
-- ---------------------------------------------------------------------------
SELECT pg_temp.assert_true(
NOT EXISTS (
SELECT 1 FROM public.team_invites
WHERE invited_by = '43100000-0000-4000-8000-000000000004'
),
'outsider created no invite'
);
SELECT pg_temp.assert_true(
(SELECT role FROM public.team_members
WHERE team_id = :'team_id'
AND user_id = '43100000-0000-4000-8000-000000000002') = 'admin',
'admin role is unchanged'
);
SELECT pg_temp.assert_true(
NOT EXISTS (
SELECT 1 FROM public.team_members
WHERE team_id = :'team_id'
AND user_id IN (
'43100000-0000-4000-8000-000000000004',
'43100000-0000-4000-8000-000000000005'
)
),
'neither outsider account joined the team'
);
ROLLBACK;