From 80a60b0d2d5fe822fb7ab6b77517b7a32f8639da Mon Sep 17 00:00:00 2001 From: beyondworks Date: Thu, 3 Sep 2026 13:38:31 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(msgr):=20=ED=8C=80=20=EB=A9=94?= =?UTF-8?q?=EC=8B=A0=EC=A0=80=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=E2=80=94=20?= =?UTF-8?q?=EC=A1=B0=EC=A7=81=C2=B7=EB=A9=A4=EB=B2=84=C2=B7=EC=B4=88?= =?UTF-8?q?=EB=8C=80=C2=B7=EC=A2=8C=EC=84=9D=C2=B7=EC=B1=84=EB=84=90=C2=B7?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=C2=B7=ED=81=AC=EB=A3=A8=20=EC=8B=A0?= =?UTF-8?q?=EC=9B=90=C2=B7=EA=B2=B0=EC=9E=AC=20=EB=AF=B8=EB=9F=AC=C2=B7?= =?UTF-8?q?=EA=B0=90=EC=82=AC=20+=20RLS=C2=B7Realtime=20=EB=B0=A9=EC=86=A1?= =?UTF-8?q?=C2=B7is=5Fpro=20=EC=A2=8C=EC=84=9D=20OR=20(pg=20=EB=93=9C?= =?UTF-8?q?=EB=A6=B4=2011=EA=B1=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - supabase/migrations/20260903120000_msgr.sql: msgr_* 11테이블, security definer 멤버십 헬퍼(anon 회수), 트리거(owner 자동 멤버·좌석/채널 한도·org_id 채움·realtime.send 최소 payload·감사), private topic org: 인가, 버킷 msgr 정책, is_pro()에 활성 Team 좌석 OR - 드릴 실측 3건 반영: insert…returning은 select 정책도 본다(소유자·생성자 조건), STABLE 함수는 자기 행을 못 본다(채널 select는 행 컬럼), channels↔channel_members 정책 순환은 definer 헬퍼로 절단 - test/msgr-pg-integration.test.mjs + helpers/msgr-cases.mjs(역할 경계표 6×8 단일 정본), 드릴 스크립트는 파일마다 별도 DB Co-Authored-By: Claude Fable 5.1 --- scripts/billing-pg-drill.sh | 22 +- supabase/migrations/20260903120000_msgr.sql | 546 ++++++++++++++++++++ test/helpers/msgr-cases.mjs | 19 + test/msgr-pg-integration.test.mjs | 269 ++++++++++ 4 files changed, 849 insertions(+), 7 deletions(-) create mode 100644 supabase/migrations/20260903120000_msgr.sql create mode 100644 test/helpers/msgr-cases.mjs create mode 100644 test/msgr-pg-integration.test.mjs diff --git a/scripts/billing-pg-drill.sh b/scripts/billing-pg-drill.sh index 90ab3617..6a9a34c0 100755 --- a/scripts/billing-pg-drill.sh +++ b/scripts/billing-pg-drill.sh @@ -1,9 +1,11 @@ #!/usr/bin/env bash -# apply_ls_event 실행 검증 드릴(분리 검수 F6) — Docker 불필요. -# Homebrew postgresql의 initdb/pg_ctl로 임시 인스턴스를 띄우고, 실 마이그레이션을 적용해 -# test/billing-pg-integration.test.mjs(경계표·권한·동시성)를 돌린 뒤 흔적 없이 정리한다. +# 실 Postgres 드릴(분리 검수 F6) — Docker 불필요. Homebrew postgresql의 initdb/pg_ctl로 임시 인스턴스를 띄우고, +# 배포될 마이그레이션 파일을 그대로 적용해 pg 통합 테스트를 돌린 뒤 흔적 없이 정리한다. +# 대상 파일(순차·파일마다 별도 DB — node --test는 파일을 병렬 프로세스로 돌리므로 한 DB를 공유하면 경합한다): +# test/billing-pg-integration.test.mjs (apply_ls_event·is_pro 만료·대사 쿨다운·동시성) +# test/msgr-pg-integration.test.mjs (팀 메신저 RLS·역할 경계표·좌석·결재 미러·Realtime 방송) # 요구: psql·initdb·pg_ctl (예: brew install postgresql@14). 포트 충돌 시 ARGO_PG_DRILL_PORT 지정. -# 대안: supabase start 후 ARGO_PG_TEST_URL을 직접 지정해 node --test test/billing-pg-integration.test.mjs +# 대안: supabase start 후 ARGO_PG_TEST_URL을 직접 지정해 node --test test/<파일> set -euo pipefail cd "$(dirname "$0")/.." @@ -20,8 +22,14 @@ trap cleanup EXIT echo "[drill] 임시 Postgres 기동 (port $PORT, $DIR)" initdb -D "$DIR/data" -A trust -U postgres >/dev/null pg_ctl -D "$DIR/data" -o "-p $PORT -k $DIR -c listen_addresses=127.0.0.1" -l "$DIR/pg.log" start >/dev/null -psql "postgresql://postgres@127.0.0.1:$PORT/postgres" -X -q -c 'create database argo_billing_drill' -ARGO_PG_TEST_URL="postgresql://postgres@127.0.0.1:$PORT/argo_billing_drill" \ - node --test test/billing-pg-integration.test.mjs +FILES=("${@:-}") +if [ -z "${FILES[0]}" ]; then FILES=(test/billing-pg-integration.test.mjs test/msgr-pg-integration.test.mjs); fi +i=0 +for f in "${FILES[@]}"; do + i=$((i + 1)); db="argo_drill_$i" + psql "postgresql://postgres@127.0.0.1:$PORT/postgres" -X -q -c "create database $db" + echo "[drill] $f → $db" + ARGO_PG_TEST_URL="postgresql://postgres@127.0.0.1:$PORT/$db" node --test "$f" +done echo "[drill] 통과 — 임시 인스턴스 정리" diff --git a/supabase/migrations/20260903120000_msgr.sql b/supabase/migrations/20260903120000_msgr.sql new file mode 100644 index 00000000..bd72c129 --- /dev/null +++ b/supabase/migrations/20260903120000_msgr.sql @@ -0,0 +1,546 @@ +-- 아르고 팀 메신저(msgr) 스키마 — 조직·멤버·초대·채널·메시지·크루 신원·결재 미러·감사. 설계 정본: 루트 MESSENGER-DESIGN.md. +-- 원칙: 기존 개인 스키마(entitlements·account_keys·device_keys…)는 손대지 않고 `msgr_` 접두로 얹는다. +-- 크루는 소유자 회사 소속으로 남는다(조직은 크루를 소유하지 않는다) — msgr_crews는 (owner_user_id, ws_id, slug) +-- 문자열 참조일 뿐 회사 데이터는 로컬이 정본. 서버는 메시지·첨부를 평문 보관한다(docs/privacy-sync.md 고지 대상). +-- 멤버십 판정 함수는 is_pro()(20260723001629) 관례: security definer + 고정 search_path + auth.uid()만 참조 → +-- 호출자 본인의 멤버십만 반환하므로 /rest/v1/rpc 노출돼도 오라클이 되지 않는다. anon EXECUTE는 명시 회수. +-- 이 파일 하나가 셀프호스트 배포 단위다(고객 Supabase/Postgres에 그대로 적용). 멱등(if not exists / drop policy if exists). +-- 드릴: test/msgr-pg-integration.test.mjs(npm run test:pg) — auth·storage·realtime 스텁 위에 이 파일을 그대로 적용한다. + +create extension if not exists pgcrypto; + +-- ── 조직 ──────────────────────────────────────────────────────────────────────── +create table if not exists public.msgr_orgs ( + id uuid primary key default gen_random_uuid(), + name text not null check (length(name) between 1 and 80), + slug text not null unique check (slug ~ '^[a-z0-9][a-z0-9-]{1,39}$'), + owner_user_id uuid not null references auth.users (id) on delete restrict, + created_at timestamptz not null default now(), + deleted_at timestamptz +); + +create table if not exists public.msgr_org_members ( + org_id uuid not null references public.msgr_orgs (id) on delete cascade, + user_id uuid not null references auth.users (id) on delete cascade, + role text not null check (role in ('owner', 'admin', 'member', 'guest')), + display_name text, + joined_at timestamptz not null default now(), + removed_at timestamptz, -- 오프보딩 = 행 삭제가 아니라 시각 기록(귀속 표기·감사 유지) + primary key (org_id, user_id) +); + +-- 조직 요금(plan·좌석) — 쓰기 정책 없음: 엣지 펑션(ls-webhook)·서비스 롤만 바꾼다(entitlements와 같은 관례). +create table if not exists public.msgr_org_entitlements ( + org_id uuid primary key references public.msgr_orgs (id) on delete cascade, + plan text not null default 'free' check (plan in ('free', 'team')), + seats int not null default 3 check (seats >= 0), + ls_subscription_id text, + ls_customer_id text, + ls_status text, + ls_updated_at timestamptz, + ends_at timestamptz, -- 해지 예약·만료 = 접근 종료 시각(LS 계약, entitlements와 동일 의미) + addons jsonb not null default '{}'::jsonb, -- 예: {"resident_node": true} + updated_at timestamptz not null default now() +); + +create table if not exists public.msgr_invites ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references public.msgr_orgs (id) on delete cascade, + code text not null unique default encode(gen_random_bytes(24), 'hex'), -- 앱 로그인 브리지(app/api/auth/pair)와 같은 24B hex + role text not null default 'member' check (role in ('admin', 'member', 'guest')), + email text, + expires_at timestamptz not null default (now() + interval '7 days'), + accepted_by uuid references auth.users (id), + accepted_at timestamptz, + created_by uuid not null references auth.users (id), + created_at timestamptz not null default now() +); + +-- ── 멤버십 판정(정책이 참조하는 정본) ───────────────────────────────────────────── +create or replace function public.msgr_role(org uuid) returns text + language sql stable security definer set search_path = public, pg_temp as $$ + select m.role from public.msgr_org_members m + join public.msgr_orgs o on o.id = m.org_id and o.deleted_at is null + where m.org_id = org and m.user_id = auth.uid() and m.removed_at is null +$$; +create or replace function public.msgr_is_member(org uuid) returns boolean + language sql stable security definer set search_path = public, pg_temp as $$ + select public.msgr_role(org) is not null +$$; +create or replace function public.msgr_is_admin(org uuid) returns boolean + language sql stable security definer set search_path = public, pg_temp as $$ + select public.msgr_role(org) in ('owner', 'admin') +$$; +-- 조직 plan(행 없음 = free). 좌석 한도·is_pro OR 조건이 함께 본다. +create or replace function public.msgr_org_plan(org uuid) returns text + language sql stable security definer set search_path = public, pg_temp as $$ + select coalesce((select case when e.plan = 'team' and (e.ends_at is null or e.ends_at > now()) then 'team' else 'free' end + from public.msgr_org_entitlements e where e.org_id = org), 'free') +$$; + +-- ── 채널 ──────────────────────────────────────────────────────────────────────── +create table if not exists public.msgr_channels ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references public.msgr_orgs (id) on delete cascade, + kind text not null check (kind in ('public', 'private', 'dm')), + name text not null check (length(name) between 1 and 80), + topic text, + crew_memory boolean not null default true, -- false = 이 채널 발 크루 턴은 소유자 vault 일지에 남기지 않는다(noJournal) + created_by uuid not null references auth.users (id), + created_at timestamptz not null default now(), + archived_at timestamptz +); +create index if not exists msgr_channels_org on public.msgr_channels (org_id); + +create table if not exists public.msgr_channel_members ( + channel_id uuid not null references public.msgr_channels (id) on delete cascade, + member_kind text not null check (member_kind in ('user', 'crew')), + member_id uuid not null, -- user: auth.users.id / crew: msgr_crews.id + added_by uuid references auth.users (id), + added_at timestamptz not null default now(), + primary key (channel_id, member_kind, member_id) +); + +-- 채널 열람: public은 owner·admin·member(guest 제외), private/dm은 channel_members. 보관된 채널도 읽기는 유지. +create or replace function public.msgr_can_read_channel(ch uuid) returns boolean + language sql stable security definer set search_path = public, pg_temp as $$ + select exists ( + select 1 from public.msgr_channels c + where c.id = ch and ( + (c.kind = 'public' and public.msgr_role(c.org_id) in ('owner', 'admin', 'member')) + or (c.created_by = auth.uid() and public.msgr_is_member(c.org_id)) -- 생성자(비공개 채널 insert … returning이 멤버 등록 전에 select 정책을 본다) + or exists (select 1 from public.msgr_channel_members m + where m.channel_id = c.id and m.member_kind = 'user' and m.member_id = auth.uid() + and public.msgr_is_member(c.org_id)) + ) + ) +$$; +-- 채널 select 정책 전용: 호출자가 이 채널의 user 멤버인가(security definer라 channel_members RLS를 타지 않는다 — +-- channels 정책 → channel_members 정책 → channels 정책 순환("infinite recursion detected", 드릴 실측)을 끊는다). +create or replace function public.msgr_is_channel_user(ch uuid) returns boolean + language sql stable security definer set search_path = public, pg_temp as $$ + select exists (select 1 from public.msgr_channel_members m + where m.channel_id = ch and m.member_kind = 'user' and m.member_id = auth.uid()) +$$; +create or replace function public.msgr_can_write_channel(ch uuid) returns boolean + language sql stable security definer set search_path = public, pg_temp as $$ + select public.msgr_can_read_channel(ch) + and exists (select 1 from public.msgr_channels c where c.id = ch and c.archived_at is null) +$$; + +-- ── 크루 신원(조직에 등록된 크루) ─────────────────────────────────────────────── +create table if not exists public.msgr_crews ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references public.msgr_orgs (id) on delete cascade, + owner_user_id uuid not null references auth.users (id) on delete cascade, + ws_id text not null check (ws_id ~ '^[a-z0-9][a-z0-9-]{0,127}$'), -- src/workspace.mjs WS_ID_RE + slug text not null check (length(slug) between 1 and 120), + display_name text not null, + role_text text, + hosting text not null default 'local' check (hosting in ('local', 'resident')), + status text not null default 'active' check (status in ('active', 'detached')), + allow text not null default 'all' check (allow in ('all', 'list', 'owner')), -- 누가 이 크루에게 일을 시킬 수 있나 + allow_users uuid[] not null default '{}', + last_seen_at timestamptz, -- 브리지 하트비트(30s). 90s 초과 = 부재중 표시 + cursor_msg_id bigint not null default 0, -- 서버측 커서 — 적재 직후 전진(텔레그램 offset 규율) + registered_at timestamptz not null default now(), + unique (org_id, owner_user_id, ws_id, slug) +); +create index if not exists msgr_crews_org on public.msgr_crews (org_id); + +-- ── 메시지·첨부 ───────────────────────────────────────────────────────────────── +create table if not exists public.msgr_messages ( + id bigint generated always as identity primary key, + org_id uuid not null references public.msgr_orgs (id) on delete cascade, + channel_id uuid not null references public.msgr_channels (id) on delete cascade, + author_kind text not null check (author_kind in ('user', 'crew', 'system')), + author_user_id uuid references auth.users (id) on delete set null, + crew_id uuid references public.msgr_crews (id) on delete set null, + kind text not null default 'text' check (kind in ('text', 'approval_card', 'system')), + body text not null default '' check (length(body) <= 20000), + mentions jsonb not null default '[]'::jsonb, -- [{kind:'user'|'crew', id}] + reply_to bigint references public.msgr_messages (id) on delete set null, + thread_root bigint references public.msgr_messages (id) on delete set null, + client_msg_id text, -- 멱등 삽입 키. 브리지 답글 = 'reply::' → 리더 교체 창 중복을 DB가 거른다 + created_at timestamptz not null default now(), + edited_at timestamptz, + deleted_at timestamptz, + check ((author_kind = 'user' and author_user_id is not null and crew_id is null) + or (author_kind = 'crew' and crew_id is not null) + or (author_kind = 'system')) +); +create unique index if not exists msgr_messages_client_id on public.msgr_messages (channel_id, client_msg_id) where client_msg_id is not null; +create index if not exists msgr_messages_org_id_id on public.msgr_messages (org_id, id); +create index if not exists msgr_messages_channel_id_id on public.msgr_messages (channel_id, id); + +create table if not exists public.msgr_attachments ( + id uuid primary key default gen_random_uuid(), + message_id bigint not null references public.msgr_messages (id) on delete cascade, + org_id uuid not null references public.msgr_orgs (id) on delete cascade, + storage_path text not null, -- 버킷 msgr 안 경로 /// (Storage name — 버킷명은 포함하지 않는다) + name text not null, + mime text, + bytes bigint not null default 0 check (bytes >= 0), + created_at timestamptz not null default now() +); +create index if not exists msgr_attachments_msg on public.msgr_attachments (message_id); + +-- ── 결재 미러(정본은 로컬 approvals.json — 여기는 채널에 보이는 투영 + 확정 권한의 서버측 집행) ── +create table if not exists public.msgr_crew_approvals ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references public.msgr_orgs (id) on delete cascade, + channel_id uuid not null references public.msgr_channels (id) on delete cascade, + crew_id uuid not null references public.msgr_crews (id) on delete cascade, + approval_id text not null, -- 로컬 'ap-…' + action text not null, + reason text, + status text not null default 'pending' check (status in ('pending', 'approved', 'rejected', 'expired')), + decided_by uuid references auth.users (id), + decided_at timestamptz, + message_id bigint references public.msgr_messages (id) on delete set null, + created_at timestamptz not null default now(), + unique (crew_id, approval_id) +); + +-- ── 감사 로그(직접 insert 정책 없음 — 트리거·security definer만) ─────────────── +create table if not exists public.msgr_audit_log ( + id bigint generated always as identity primary key, + org_id uuid not null references public.msgr_orgs (id) on delete cascade, + actor_user_id uuid, + actor_crew_id uuid, + action text not null, + target_kind text, + target_id text, + meta jsonb not null default '{}'::jsonb, + at timestamptz not null default now() +); +create index if not exists msgr_audit_org_at on public.msgr_audit_log (org_id, at); + +create or replace function public.msgr_audit(org uuid, act text, tkind text, tid text, m jsonb default '{}'::jsonb) returns void + language sql security definer set search_path = public, pg_temp as $$ + insert into public.msgr_audit_log (org_id, actor_user_id, action, target_kind, target_id, meta) + values (org, auth.uid(), act, tkind, tid, coalesce(m, '{}'::jsonb)) +$$; + +-- ── 트리거 ────────────────────────────────────────────────────────────────────── +-- 조직 생성자는 자동 owner 멤버(security definer — 아직 멤버가 아니라 members insert 정책을 못 지난다). +create or replace function public.msgr_org_after_insert() returns trigger + language plpgsql security definer set search_path = public, pg_temp as $$ +begin + insert into public.msgr_org_members (org_id, user_id, role) values (new.id, new.owner_user_id, 'owner'); + insert into public.msgr_org_entitlements (org_id) values (new.id) on conflict do nothing; + perform public.msgr_audit(new.id, 'org.create', 'org', new.id::text); + return new; +end $$; +drop trigger if exists msgr_org_after_insert on public.msgr_orgs; +create trigger msgr_org_after_insert after insert on public.msgr_orgs for each row execute function public.msgr_org_after_insert(); + +-- 좌석 한도: free = 3명(guest 포함), team = seats. 활성(removed_at null) 멤버 수로 센다. 되살림(update)도 같은 게이트. +create or replace function public.msgr_member_seat_gate() returns trigger + language plpgsql security definer set search_path = public, pg_temp as $$ +declare lim int; n int; +begin + if new.removed_at is not null then return new; end if; + if tg_op = 'UPDATE' and old.removed_at is null then return new; end if; -- 활성→활성(역할 변경)은 좌석 불변 + select case when public.msgr_org_plan(new.org_id) = 'team' then coalesce(e.seats, 0) else 3 end + into lim from public.msgr_org_entitlements e where e.org_id = new.org_id; + if lim is null then lim := 3; end if; + select count(*) into n from public.msgr_org_members where org_id = new.org_id and removed_at is null and user_id <> new.user_id; + if n >= lim then raise exception 'msgr_seat_limit' using detail = format('%s/%s', n, lim); end if; + return new; +end $$; +drop trigger if exists msgr_member_seat_gate on public.msgr_org_members; +create trigger msgr_member_seat_gate before insert or update on public.msgr_org_members for each row execute function public.msgr_member_seat_gate(); + +create or replace function public.msgr_member_audit() returns trigger + language plpgsql security definer set search_path = public, pg_temp as $$ +begin + if tg_op = 'INSERT' then perform public.msgr_audit(new.org_id, 'member.add', 'user', new.user_id::text, jsonb_build_object('role', new.role)); + elsif tg_op = 'DELETE' then perform public.msgr_audit(old.org_id, 'member.delete', 'user', old.user_id::text); return old; + elsif new.removed_at is not null and old.removed_at is null then perform public.msgr_audit(new.org_id, 'member.remove', 'user', new.user_id::text); + elsif new.role <> old.role then perform public.msgr_audit(new.org_id, 'member.role', 'user', new.user_id::text, jsonb_build_object('from', old.role, 'to', new.role)); + end if; + return new; +end $$; +drop trigger if exists msgr_member_audit on public.msgr_org_members; +create trigger msgr_member_audit after insert or update or delete on public.msgr_org_members for each row execute function public.msgr_member_audit(); + +-- free 조직은 공개 채널 1개. +create or replace function public.msgr_channel_gate() returns trigger + language plpgsql security definer set search_path = public, pg_temp as $$ +begin + if new.kind = 'public' and public.msgr_org_plan(new.org_id) = 'free' + and (select count(*) from public.msgr_channels where org_id = new.org_id and kind = 'public' and archived_at is null) >= 1 then + raise exception 'msgr_channel_limit'; + end if; + return new; +end $$; +drop trigger if exists msgr_channel_gate on public.msgr_channels; +create trigger msgr_channel_gate before insert on public.msgr_channels for each row execute function public.msgr_channel_gate(); + +-- 메시지: org_id는 채널에서 채운다(클라이언트가 남의 org_id를 적어도 무시), reply/thread는 같은 채널만. +create or replace function public.msgr_message_fill() returns trigger + language plpgsql security definer set search_path = public, pg_temp as $$ +begin + select org_id into new.org_id from public.msgr_channels where id = new.channel_id; + if new.org_id is null then raise exception 'msgr_channel_missing'; end if; + if new.reply_to is not null and not exists (select 1 from public.msgr_messages where id = new.reply_to and channel_id = new.channel_id) then + raise exception 'msgr_reply_cross_channel'; + end if; + if new.thread_root is null then new.thread_root := new.reply_to; end if; + return new; +end $$; +drop trigger if exists msgr_message_fill on public.msgr_messages; +create trigger msgr_message_fill before insert on public.msgr_messages for each row execute function public.msgr_message_fill(); + +-- Realtime 방송: 본문 없이 id·채널·멘션만(수신자는 PostgREST에서 RLS를 통과한 행만 받는다). private topic org:. +create or replace function public.msgr_message_broadcast() returns trigger + language plpgsql security definer set search_path = public, pg_temp as $$ +begin + perform realtime.send( + jsonb_build_object('id', new.id, 'channel_id', new.channel_id, 'author_kind', new.author_kind, 'crew_id', new.crew_id, + 'kind', new.kind, 'mentions', new.mentions, 'reply_to', new.reply_to), + 'message', 'org:' || new.org_id::text, true); + return new; +end $$; +drop trigger if exists msgr_message_broadcast on public.msgr_messages; +create trigger msgr_message_broadcast after insert on public.msgr_messages for each row execute function public.msgr_message_broadcast(); + +create or replace function public.msgr_approval_broadcast() returns trigger + language plpgsql security definer set search_path = public, pg_temp as $$ +begin + perform realtime.send( + jsonb_build_object('id', new.id, 'channel_id', new.channel_id, 'crew_id', new.crew_id, 'approval_id', new.approval_id, 'status', new.status), + 'approval', 'org:' || new.org_id::text, true); + if tg_op = 'UPDATE' and new.status <> old.status then + perform public.msgr_audit(new.org_id, 'approval.' || new.status, 'approval', new.approval_id, jsonb_build_object('crew_id', new.crew_id)); + end if; + return new; +end $$; +drop trigger if exists msgr_approval_broadcast on public.msgr_crew_approvals; +create trigger msgr_approval_broadcast after insert or update on public.msgr_crew_approvals for each row execute function public.msgr_approval_broadcast(); + +-- 초대 수락: 코드 일치·미만료·미사용이면 호출자를 멤버로. 좌석 게이트는 members 트리거가 집행한다. +create or replace function public.msgr_accept_invite(code text) returns uuid + language plpgsql security definer set search_path = public, pg_temp as $$ +declare inv public.msgr_invites%rowtype; +begin + if auth.uid() is null then raise exception 'msgr_auth_required'; end if; + select * into inv from public.msgr_invites i where i.code = msgr_accept_invite.code and i.accepted_at is null and i.expires_at > now() for update; + if inv.id is null then raise exception 'msgr_invite_invalid'; end if; + insert into public.msgr_org_members (org_id, user_id, role) values (inv.org_id, auth.uid(), inv.role) + on conflict (org_id, user_id) do update set role = excluded.role, removed_at = null, joined_at = now(); + update public.msgr_invites set accepted_by = auth.uid(), accepted_at = now() where id = inv.id; + perform public.msgr_audit(inv.org_id, 'invite.accept', 'invite', inv.id::text); + return inv.org_id; +end $$; + +-- ── RLS ───────────────────────────────────────────────────────────────────────── +alter table public.msgr_orgs enable row level security; +alter table public.msgr_org_members enable row level security; +alter table public.msgr_org_entitlements enable row level security; +alter table public.msgr_invites enable row level security; +alter table public.msgr_channels enable row level security; +alter table public.msgr_channel_members enable row level security; +alter table public.msgr_crews enable row level security; +alter table public.msgr_messages enable row level security; +alter table public.msgr_attachments enable row level security; +alter table public.msgr_crew_approvals enable row level security; +alter table public.msgr_audit_log enable row level security; + +drop policy if exists msgr_orgs_select on public.msgr_orgs; +-- 소유자 본인 OR 멤버. 소유자 조건이 필요한 이유(드릴 실측): insert … returning은 select 정책도 검사하는데, 생성자를 멤버로 +-- 넣는 AFTER 트리거는 문장 끝에 돌아 RETURNING 시점엔 아직 멤버가 아니다 → 소유자 조건 없이는 조직 생성 자체가 실패한다. +create policy msgr_orgs_select on public.msgr_orgs for select to authenticated + using (owner_user_id = (select auth.uid()) or public.msgr_is_member(id)); +drop policy if exists msgr_orgs_insert on public.msgr_orgs; +create policy msgr_orgs_insert on public.msgr_orgs for insert to authenticated with check (owner_user_id = (select auth.uid()) and deleted_at is null); +drop policy if exists msgr_orgs_update on public.msgr_orgs; +create policy msgr_orgs_update on public.msgr_orgs for update to authenticated + using (public.msgr_is_admin(id)) with check (public.msgr_is_admin(id)); +-- 소유권 이전·삭제 표시는 owner만(정책의 자기 참조 대신 트리거 — old 값을 볼 수 있는 유일한 자리). +create or replace function public.msgr_org_before_update() returns trigger + language plpgsql security definer set search_path = public, pg_temp as $$ +begin + -- 서비스 문맥(auth.uid() null: 서비스 롤·엣지 펑션·운영 도구)은 통과 — 일반 사용자는 RLS(admin)가 먼저 거르고, + -- 이 트리거는 admin 중 owner가 아닌 사람의 소유권 이전·삭제 표시를 막는 두 번째 층이다. + if auth.uid() is not null + and (new.owner_user_id is distinct from old.owner_user_id or new.deleted_at is distinct from old.deleted_at) + and public.msgr_role(old.id) is distinct from 'owner' then + raise exception 'msgr_owner_only'; + end if; + if new.owner_user_id is distinct from old.owner_user_id then + if not exists (select 1 from public.msgr_org_members where org_id = old.id and user_id = new.owner_user_id and removed_at is null) then + raise exception 'msgr_owner_not_member'; + end if; + update public.msgr_org_members set role = 'admin' where org_id = old.id and user_id = old.owner_user_id; + update public.msgr_org_members set role = 'owner' where org_id = old.id and user_id = new.owner_user_id; + perform public.msgr_audit(old.id, 'org.transfer', 'user', new.owner_user_id::text); + end if; + return new; +end $$; +drop trigger if exists msgr_org_before_update on public.msgr_orgs; +create trigger msgr_org_before_update before update on public.msgr_orgs for each row execute function public.msgr_org_before_update(); + +drop policy if exists msgr_members_select on public.msgr_org_members; +create policy msgr_members_select on public.msgr_org_members for select to authenticated using (public.msgr_is_member(org_id)); +drop policy if exists msgr_members_insert on public.msgr_org_members; +create policy msgr_members_insert on public.msgr_org_members for insert to authenticated + with check (public.msgr_is_admin(org_id) and role <> 'owner'); +drop policy if exists msgr_members_update on public.msgr_org_members; +create policy msgr_members_update on public.msgr_org_members for update to authenticated + using (public.msgr_is_admin(org_id) and (role <> 'owner' or public.msgr_role(org_id) = 'owner')) + with check (public.msgr_is_admin(org_id) and (role <> 'owner' or public.msgr_role(org_id) = 'owner')); +drop policy if exists msgr_members_delete on public.msgr_org_members; +create policy msgr_members_delete on public.msgr_org_members for delete to authenticated + using (public.msgr_is_admin(org_id) and role <> 'owner'); + +drop policy if exists msgr_entitlements_select on public.msgr_org_entitlements; +create policy msgr_entitlements_select on public.msgr_org_entitlements for select to authenticated using (public.msgr_is_member(org_id)); + +drop policy if exists msgr_invites_admin on public.msgr_invites; +create policy msgr_invites_admin on public.msgr_invites for all to authenticated + using (public.msgr_is_admin(org_id)) with check (public.msgr_is_admin(org_id) and created_by = (select auth.uid())); + +drop policy if exists msgr_channels_select on public.msgr_channels; +-- 자기 테이블 select 정책은 함수(msgr_can_read_channel) 대신 행 컬럼으로 판정한다(드릴 실측): STABLE 함수는 호출 문장의 +-- 스냅샷을 써서 insert … returning 시점에 방금 넣은 행을 못 본다 → 함수로 쓰면 채널 생성이 RETURNING에서 실패한다. +create policy msgr_channels_select on public.msgr_channels for select to authenticated + using ((kind = 'public' and public.msgr_role(org_id) in ('owner', 'admin', 'member')) + or (created_by = (select auth.uid()) and public.msgr_is_member(org_id)) + or (public.msgr_is_channel_user(id) and public.msgr_is_member(org_id))); +drop policy if exists msgr_channels_insert on public.msgr_channels; +create policy msgr_channels_insert on public.msgr_channels for insert to authenticated + with check (created_by = (select auth.uid()) and public.msgr_role(org_id) in ('owner', 'admin', 'member')); +drop policy if exists msgr_channels_update on public.msgr_channels; +create policy msgr_channels_update on public.msgr_channels for update to authenticated + using (public.msgr_is_admin(org_id) or (created_by = (select auth.uid()) and public.msgr_is_member(org_id))) + with check (public.msgr_is_admin(org_id) or (created_by = (select auth.uid()) and public.msgr_is_member(org_id))); + +drop policy if exists msgr_channel_members_select on public.msgr_channel_members; +create policy msgr_channel_members_select on public.msgr_channel_members for select to authenticated using (public.msgr_can_read_channel(channel_id)); +drop policy if exists msgr_channel_members_write on public.msgr_channel_members; +create policy msgr_channel_members_write on public.msgr_channel_members for all to authenticated + using (exists (select 1 from public.msgr_channels c where c.id = channel_id + and (public.msgr_is_admin(c.org_id) or c.created_by = (select auth.uid()) or (c.kind = 'dm' and public.msgr_can_read_channel(c.id))))) + with check (exists (select 1 from public.msgr_channels c where c.id = channel_id + and (public.msgr_is_admin(c.org_id) or c.created_by = (select auth.uid()) or (c.kind = 'dm' and public.msgr_can_read_channel(c.id))))); + +drop policy if exists msgr_crews_select on public.msgr_crews; +create policy msgr_crews_select on public.msgr_crews for select to authenticated using (public.msgr_is_member(org_id)); +drop policy if exists msgr_crews_insert on public.msgr_crews; +create policy msgr_crews_insert on public.msgr_crews for insert to authenticated + with check (owner_user_id = (select auth.uid()) and public.msgr_role(org_id) in ('owner', 'admin', 'member')); +-- 소유자: 전부 수정 가능(하트비트·커서·허용 범위). admin: 타인 크루의 status(detach)만 — with check에서 다른 컬럼 불변을 강제. +drop policy if exists msgr_crews_update_owner on public.msgr_crews; +create policy msgr_crews_update_owner on public.msgr_crews for update to authenticated + using (owner_user_id = (select auth.uid())) with check (owner_user_id = (select auth.uid())); +drop policy if exists msgr_crews_update_admin on public.msgr_crews; +create policy msgr_crews_update_admin on public.msgr_crews for update to authenticated + using (public.msgr_is_admin(org_id)) + with check (public.msgr_is_admin(org_id) and (owner_user_id, ws_id, slug, allow, allow_users, cursor_msg_id, hosting) = + (select c.owner_user_id, c.ws_id, c.slug, c.allow, c.allow_users, c.cursor_msg_id, c.hosting from public.msgr_crews c where c.id = msgr_crews.id)); +drop policy if exists msgr_crews_delete on public.msgr_crews; +create policy msgr_crews_delete on public.msgr_crews for delete to authenticated + using (owner_user_id = (select auth.uid()) or public.msgr_is_admin(org_id)); + +drop policy if exists msgr_messages_select on public.msgr_messages; +create policy msgr_messages_select on public.msgr_messages for select to authenticated using (public.msgr_can_read_channel(channel_id)); +drop policy if exists msgr_messages_insert on public.msgr_messages; +create policy msgr_messages_insert on public.msgr_messages for insert to authenticated + with check ( + public.msgr_can_write_channel(channel_id) + and ((author_kind = 'user' and author_user_id = (select auth.uid())) + or (author_kind = 'crew' and exists (select 1 from public.msgr_crews c where c.id = crew_id and c.owner_user_id = (select auth.uid()) + and c.status = 'active' and c.org_id = (select org_id from public.msgr_channels ch where ch.id = channel_id)))) + ); +drop policy if exists msgr_messages_update on public.msgr_messages; +create policy msgr_messages_update on public.msgr_messages for update to authenticated + using (author_kind = 'user' and author_user_id = (select auth.uid())) + with check (author_kind = 'user' and author_user_id = (select auth.uid())); + +drop policy if exists msgr_attachments_select on public.msgr_attachments; +create policy msgr_attachments_select on public.msgr_attachments for select to authenticated + using (exists (select 1 from public.msgr_messages m where m.id = message_id and public.msgr_can_read_channel(m.channel_id))); +drop policy if exists msgr_attachments_insert on public.msgr_attachments; +create policy msgr_attachments_insert on public.msgr_attachments for insert to authenticated + with check (exists (select 1 from public.msgr_messages m where m.id = message_id and m.org_id = org_id + and ((m.author_kind = 'user' and m.author_user_id = (select auth.uid())) + or (m.author_kind = 'crew' and exists (select 1 from public.msgr_crews c where c.id = m.crew_id and c.owner_user_id = (select auth.uid())))))); + +drop policy if exists msgr_approvals_select on public.msgr_crew_approvals; +create policy msgr_approvals_select on public.msgr_crew_approvals for select to authenticated using (public.msgr_can_read_channel(channel_id)); +drop policy if exists msgr_approvals_insert on public.msgr_crew_approvals; +create policy msgr_approvals_insert on public.msgr_crew_approvals for insert to authenticated + with check (status = 'pending' and exists (select 1 from public.msgr_crews c where c.id = crew_id and c.owner_user_id = (select auth.uid()) and c.org_id = org_id)); +-- 확정은 크루 소유자만(역할 무관 — BYOK·책임 귀속). pending인 행만, 최종 상태로만. +drop policy if exists msgr_approvals_decide on public.msgr_crew_approvals; +create policy msgr_approvals_decide on public.msgr_crew_approvals for update to authenticated + using (status = 'pending' and exists (select 1 from public.msgr_crews c where c.id = crew_id and c.owner_user_id = (select auth.uid()))) + with check (status in ('approved', 'rejected', 'expired') and decided_by = (select auth.uid()) + and exists (select 1 from public.msgr_crews c where c.id = crew_id and c.owner_user_id = (select auth.uid()))); + +drop policy if exists msgr_audit_select on public.msgr_audit_log; +create policy msgr_audit_select on public.msgr_audit_log for select to authenticated using (public.msgr_is_admin(org_id)); + +-- ── 권한 ──────────────────────────────────────────────────────────────────────── +grant select, insert, update, delete on public.msgr_orgs, public.msgr_org_members, public.msgr_invites, public.msgr_channels, + public.msgr_channel_members, public.msgr_crews, public.msgr_messages, public.msgr_attachments, public.msgr_crew_approvals to authenticated; +grant select on public.msgr_org_entitlements, public.msgr_audit_log to authenticated; +grant all on public.msgr_orgs, public.msgr_org_members, public.msgr_org_entitlements, public.msgr_invites, public.msgr_channels, + public.msgr_channel_members, public.msgr_crews, public.msgr_messages, public.msgr_attachments, public.msgr_crew_approvals, public.msgr_audit_log to service_role; +grant usage, select on all sequences in schema public to authenticated, service_role; + +do $$ declare f text; begin + foreach f in array array['msgr_role(uuid)', 'msgr_is_member(uuid)', 'msgr_is_admin(uuid)', 'msgr_org_plan(uuid)', 'msgr_is_channel_user(uuid)', + 'msgr_can_read_channel(uuid)', 'msgr_can_write_channel(uuid)', 'msgr_accept_invite(text)', + 'msgr_audit(uuid,text,text,text,jsonb)'] loop + execute format('revoke all on function public.%s from public', f); + execute format('revoke execute on function public.%s from anon', f); -- Supabase default privileges 갭(20260723 실측) 방어 + execute format('grant execute on function public.%s to authenticated', f); + end loop; +end $$; +revoke execute on function public.msgr_audit(uuid,text,text,text,jsonb) from authenticated; -- 감사는 트리거·RPC 내부에서만(직접 위조 금지) + +-- ── Realtime 인가(private topic org:) ───────────────────────────────── +-- 수신(select)·송신(insert, 타이핑 표시) 모두 조직 멤버만. payload에 본문이 없으니 채널 비밀은 PostgREST RLS가 지킨다. +drop policy if exists msgr_realtime_recv on realtime.messages; +create policy msgr_realtime_recv on realtime.messages for select to authenticated + using (realtime.messages.extension = 'broadcast' and (select realtime.topic()) like 'org:%' + and public.msgr_is_member(substr((select realtime.topic()), 5)::uuid)); +drop policy if exists msgr_realtime_send on realtime.messages; +create policy msgr_realtime_send on realtime.messages for insert to authenticated + with check (realtime.messages.extension = 'broadcast' and (select realtime.topic()) like 'org:%' + and public.msgr_is_member(substr((select realtime.topic()), 5)::uuid)); + +-- ── Storage 버킷 msgr — name = ///, 1세그먼트 = 조직 멤버십 ──── +-- 1세그먼트가 uuid 형식이 아니면 null → 비멤버 판정(캐스트 예외로 정책 평가 자체가 터지지 않게 — AND 평가 순서는 보장되지 않는다). +create or replace function public.msgr_uuid_or_null(t text) returns uuid + language sql immutable as $$ + select case when t ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' then t::uuid else null end +$$; +drop policy if exists msgr_files_select on storage.objects; +create policy msgr_files_select on storage.objects for select to authenticated + using (bucket_id = 'msgr' and public.msgr_is_member(public.msgr_uuid_or_null((storage.foldername(name))[1]))); +drop policy if exists msgr_files_insert on storage.objects; +create policy msgr_files_insert on storage.objects for insert to authenticated + with check (bucket_id = 'msgr' and public.msgr_is_member(public.msgr_uuid_or_null((storage.foldername(name))[1]))); +drop policy if exists msgr_files_delete on storage.objects; +create policy msgr_files_delete on storage.objects for delete to authenticated + using (bucket_id = 'msgr' and public.msgr_is_admin(public.msgr_uuid_or_null((storage.foldername(name))[1]))); + +-- ── is_pro(): 활성 Team 좌석 보유자도 Pro(좌석 ⊇ 개인 Pro). 개인 entitlements·14일 체험 OR은 20260730050000 그대로. ── +create or replace function public.is_pro() returns boolean + language sql stable security definer set search_path = public, pg_temp as $$ + select coalesce((select plan = 'pro' and (ends_at is null or ends_at > now()) + from public.entitlements where user_id = auth.uid()), false) + or coalesce((select created_at > now() - interval '14 days' from auth.users where id = auth.uid()), false) + or exists (select 1 from public.msgr_org_members m + join public.msgr_org_entitlements e on e.org_id = m.org_id + join public.msgr_orgs o on o.id = m.org_id and o.deleted_at is null + where m.user_id = auth.uid() and m.removed_at is null and m.role <> 'guest' + and e.plan = 'team' and (e.ends_at is null or e.ends_at > now())) +$$; +revoke all on function public.is_pro() from public; +revoke execute on function public.is_pro() from anon; +grant execute on function public.is_pro() to authenticated; diff --git a/test/helpers/msgr-cases.mjs b/test/helpers/msgr-cases.mjs new file mode 100644 index 00000000..51a9fb38 --- /dev/null +++ b/test/helpers/msgr-cases.mjs @@ -0,0 +1,19 @@ +// 팀 메신저 역할 경계표 — 단일 정본. pg 드릴(test/msgr-pg-integration.test.mjs)이 실제 RLS에 대고 돌리고, +// 후속 JS 거울(앱의 권한 표시)이 같은 표를 읽는다. 값을 여기서만 바꾼다. +// 역할: owner·admin·member·guest·removed(제거된 멤버)·outsider(멤버였던 적 없음) +export const ROLES = Object.freeze(['owner', 'admin', 'member', 'guest', 'removed', 'outsider']); + +// 행동별 허용 역할. 'guest'는 초대된 채널(channel_members)만 보므로 공개 채널 행동은 불허. +export const ROLE_MATRIX = Object.freeze({ + readPublicChannel: ['owner', 'admin', 'member'], + postPublicChannel: ['owner', 'admin', 'member'], + createChannel: ['owner', 'admin', 'member'], + inviteMember: ['owner', 'admin'], + removeMember: ['owner', 'admin'], + registerCrew: ['owner', 'admin', 'member'], + readAudit: ['owner', 'admin'], + readInvitedPrivateChannel: ['owner', 'admin', 'member', 'guest'], // 초대된 비공개 채널은 guest도 읽는다 +}); + +export const FREE_SEATS = 3; // 무료 조직 좌석(guest 포함) — 마이그레이션 msgr_member_seat_gate와 동일 +export const FREE_PUBLIC_CHANNELS = 1; // 무료 조직 공개 채널 수 — msgr_channel_gate와 동일 diff --git a/test/msgr-pg-integration.test.mjs b/test/msgr-pg-integration.test.mjs new file mode 100644 index 00000000..d913508b --- /dev/null +++ b/test/msgr-pg-integration.test.mjs @@ -0,0 +1,269 @@ +// 팀 메신저 스키마(20260903120000_msgr.sql) 실행 검증 — SQL·RLS·트리거를 **실제 Postgres**에 적용해 돌린다. +// 경계표는 test/helpers/msgr-cases.mjs(단일 정본). ARGO_PG_TEST_URL 미설정이면 전부 skip(일반 npm test 무영향). +// 실행: `npm run test:pg` (scripts/billing-pg-drill.sh — 파일마다 별도 임시 DB). 사용자 흉내: set role authenticated + +// argo.uid 세션 변수(auth.uid() 스텁이 읽는다) — 슈퍼유저는 RLS를 우회하므로 반드시 역할을 낮춰 실행한다. +import test, { before } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { ROLES, ROLE_MATRIX, FREE_SEATS, FREE_PUBLIC_CHANNELS } from './helpers/msgr-cases.mjs'; + +const DB = process.env.ARGO_PG_TEST_URL; +const skip = !DB && 'ARGO_PG_TEST_URL 미설정 — npm run test:pg로 실행'; +const mig = (f) => fileURLToPath(new URL(`../supabase/migrations/${f}`, import.meta.url)); +const U = { // 고정 uuid — 값은 전부 이 파일의 상수(주입 표면 없음) + owner: '11111111-1111-4111-8111-111111111111', admin: '22222222-2222-4222-8222-222222222222', + member: '33333333-3333-4333-8333-333333333333', guest: '44444444-4444-4444-8444-444444444444', + removed: '55555555-5555-4555-8555-555555555555', outsider: '66666666-6666-4666-8666-666666666666', + svc: '77777777-7777-4777-8777-777777777777', extra: '88888888-8888-4888-8888-888888888888', +}; + +function psqlRaw(args) { return spawnSync('psql', [DB, '-X', '-v', 'ON_ERROR_STOP=1', '-q', ...args], { encoding: 'utf8' }); } +function psql(args) { const r = psqlRaw(args); if (r.status !== 0) throw new Error(`psql 실패: ${r.stderr || r.stdout}`); return r.stdout; } +const sql = (q) => psql(['-A', '-t', '-c', q]).trim(); // 슈퍼유저(RLS 우회) — 시드·관찰 전용 +const asUser = (uid, q) => sql(`set role authenticated; select set_config('argo.uid', '${uid}', false); ${q}`); +const asUserRaw = (uid, q) => psqlRaw(['-A', '-t', '-c', `set role authenticated; select set_config('argo.uid', '${uid}', false); ${q}`]); +const denied = (uid, q, re = /policy|permission denied|msgr_/i) => { const r = asUserRaw(uid, q); assert.notEqual(r.status, 0, `허용됨: ${q.slice(0, 80)}`); assert.match(r.stderr, re); }; +const last = (s) => s.split('\n').filter(Boolean).pop() ?? ''; // set_config 출력 뒤 마지막 결과 줄 + +let ORG, PUB, PRIV, CREW, CREW_SVC; +before(() => { + if (!DB) return; + psql(['-c', ` + do $$ begin + if not exists (select from pg_roles where rolname = 'anon') then create role anon nologin; end if; + if not exists (select from pg_roles where rolname = 'authenticated') then create role authenticated nologin; end if; + if not exists (select from pg_roles where rolname = 'service_role') then create role service_role nologin; end if; + end $$; + grant usage on schema public to anon, authenticated, service_role; + create schema if not exists auth; + grant usage on schema auth to anon, authenticated, service_role; -- 정책 본문의 auth.uid()는 호출 역할로 평가된다(실 Supabase와 동일 권한) + create table if not exists auth.users (id uuid primary key, created_at timestamptz not null default now()); + -- auth.uid() 스텁: 세션 변수 argo.uid — asUser()가 set_config로 사용자를 흉내 낸다 + create or replace function auth.uid() returns uuid language sql stable as $$ select nullif(current_setting('argo.uid', true), '')::uuid $$; + -- storage 스텁(정책 문법·foldername 계약만) — 실 Supabase의 storage.objects와 같은 열 이름 + create schema if not exists storage; + create table if not exists storage.objects (id uuid primary key default gen_random_uuid(), bucket_id text, name text, owner uuid); + create or replace function storage.foldername(name text) returns text[] language sql immutable as $$ select (string_to_array(name, '/'))[1:array_length(string_to_array(name, '/'), 1) - 1] $$; + alter table storage.objects enable row level security; + grant usage on schema storage to authenticated; grant select, insert, delete on storage.objects to authenticated; + -- realtime 스텁: 방송을 realtime.sent에 기록해 payload·topic·private를 단언한다 + create schema if not exists realtime; + create table if not exists realtime.messages (id bigint generated always as identity primary key, topic text, extension text, payload jsonb); + create table if not exists realtime.sent (id bigint generated always as identity primary key, payload jsonb, event text, topic text, private boolean); + create or replace function realtime.topic() returns text language sql stable as $$ select current_setting('realtime.topic', true) $$; + create or replace function realtime.send(payload jsonb, event text, topic text, private boolean default true) returns void + language sql as $$ insert into realtime.sent (payload, event, topic, private) values (payload, event, topic, private) $$; + alter table realtime.messages enable row level security; + grant select, insert on realtime.messages to authenticated; + grant usage on schema realtime to authenticated; + `]); + for (const f of ['20260714150000_entitlements.sql', '20260724000100_trial_14d.sql', '20260728100000_entitlements_ls.sql', + '20260728113000_billing_hardening.sql', '20260728150000_ls_reconcile_cooldown.sql', '20260730050000_is_pro_ends_at.sql', + '20260903120000_msgr.sql']) psql(['-f', mig(f)]); // 배포될 그 파일을 그대로 적용 + for (const id of Object.values(U)) sql(`insert into auth.users (id, created_at) values ('${id}', now() - interval '30 days') on conflict do nothing`); // 체험 창 밖 + // 시드: owner가 조직 생성(트리거가 owner 멤버·free 자격 생성) → admin/member/guest/removed 초대 → 공개·비공개 채널 → 크루 2개 + ORG = last(asUser(U.owner, `insert into public.msgr_orgs (name, slug, owner_user_id) values ('Lean', 'lean', '${U.owner}') returning id`)); + for (const [uid, role] of [[U.admin, 'admin'], [U.member, 'member'], [U.guest, 'guest']]) { + sql(`update public.msgr_org_entitlements set plan = 'team', seats = 10 where org_id = '${ORG}'`); // 시드 동안 좌석 넉넉히(좌석 테스트는 별도) + const code = last(asUser(U.owner, `insert into public.msgr_invites (org_id, role, created_by) values ('${ORG}', '${role}', '${U.owner}') returning code`)); + assert.equal(last(asUser(uid, `select public.msgr_accept_invite('${code}')`)), ORG, `초대 수락 ${role}`); + } + sql(`insert into public.msgr_org_members (org_id, user_id, role, removed_at) values ('${ORG}', '${U.removed}', 'member', now())`); // 제거된 멤버 + sql(`insert into public.msgr_org_members (org_id, user_id, role) values ('${ORG}', '${U.svc}', 'member')`); // 상주 노드 서비스 계정 + PUB = last(asUser(U.owner, `insert into public.msgr_channels (org_id, kind, name, created_by) values ('${ORG}', 'public', 'general', '${U.owner}') returning id`)); + PRIV = last(asUser(U.admin, `insert into public.msgr_channels (org_id, kind, name, created_by) values ('${ORG}', 'private', 'secret', '${U.admin}') returning id`)); + asUser(U.admin, `insert into public.msgr_channel_members (channel_id, member_kind, member_id, added_by) values ('${PRIV}', 'user', '${U.guest}', '${U.admin}')`); + CREW = last(asUser(U.member, `insert into public.msgr_crews (org_id, owner_user_id, ws_id, slug, display_name) values ('${ORG}', '${U.member}', 'lean-ax-abcd', 'seoyun', '서윤') returning id`)); + CREW_SVC = last(asUser(U.svc, `insert into public.msgr_crews (org_id, owner_user_id, ws_id, slug, display_name, hosting) values ('${ORG}', '${U.svc}', 'lean-node', 'node-crew', '노드', 'resident') returning id`)); +}); + +test('조직 생성: 생성자가 owner 멤버로 자동 등록·free 자격 행·감사 행. 비멤버·제거 멤버는 조직이 보이지 않는다', { skip }, () => { + assert.equal(sql(`select role from public.msgr_org_members where org_id = '${ORG}' and user_id = '${U.owner}'`), 'owner'); + assert.equal(sql(`select count(*) from public.msgr_audit_log where org_id = '${ORG}' and action = 'org.create'`), '1'); + for (const uid of [U.outsider, U.removed]) assert.equal(last(asUser(uid, `select count(*) from public.msgr_orgs where id = '${ORG}'`)), '0', uid); + assert.equal(last(asUser(U.member, `select count(*) from public.msgr_orgs where id = '${ORG}'`)), '1'); + denied(U.outsider, `insert into public.msgr_orgs (name, slug, owner_user_id) values ('X', 'x-org', '${U.owner}')`); // 남 명의 조직 생성 불가 +}); + +test('역할 경계표(helpers/msgr-cases): 6역할 × 8행동이 실제 RLS 판정과 일치', { skip }, () => { + const probes = { + readPublicChannel: (u) => last(asUser(u, `select count(*) from public.msgr_channels where id = '${PUB}'`)) === '1', + postPublicChannel: (u) => asUserRaw(u, `insert into public.msgr_messages (org_id, channel_id, author_kind, author_user_id, body) values ('${ORG}', '${PUB}', 'user', '${u}', 'hi')`).status === 0, + createChannel: (u) => asUserRaw(u, `insert into public.msgr_channels (org_id, kind, name, created_by) values ('${ORG}', 'private', 'p-${u.slice(0, 4)}', '${u}')`).status === 0, + inviteMember: (u) => asUserRaw(u, `insert into public.msgr_invites (org_id, created_by) values ('${ORG}', '${u}')`).status === 0, + removeMember: (u) => last(asUser(u, `update public.msgr_org_members set removed_at = now() where org_id = '${ORG}' and user_id = '${U.extra}' returning 1`)) === '1', + registerCrew: (u) => asUserRaw(u, `insert into public.msgr_crews (org_id, owner_user_id, ws_id, slug, display_name) values ('${ORG}', '${u}', 'ws-${u.slice(0, 4)}', 'c', 'c')`).status === 0, + readAudit: (u) => Number(last(asUser(u, `select count(*) from public.msgr_audit_log where org_id = '${ORG}'`))) > 0, + readInvitedPrivateChannel: (u) => last(asUser(u, `select count(*) from public.msgr_channels where id = '${PRIV}'`)) === '1', + }; + for (const action of Object.keys(ROLE_MATRIX)) { + for (const role of ROLES) { + if (action === 'removeMember') sql(`insert into public.msgr_org_members (org_id, user_id, role) values ('${ORG}', '${U.extra}', 'member') on conflict (org_id, user_id) do update set removed_at = null`); + if (action === 'readInvitedPrivateChannel' && ['owner', 'member'].includes(role)) { // owner·member는 초대돼야 읽는다 — 초대 후 판정 + sql(`insert into public.msgr_channel_members (channel_id, member_kind, member_id) values ('${PRIV}', 'user', '${U[role]}') on conflict do nothing`); + } + const got = probes[action](U[role]); + assert.equal(got, ROLE_MATRIX[action].includes(role), `${action} × ${role}`); + } + } + // 프로브 잔재 청소 — 뒤 테스트가 시드만 보게(개수 단언의 격리) + sql(`delete from public.msgr_channel_members where channel_id = '${PRIV}' and member_id in ('${U.owner}', '${U.member}')`); + sql(`delete from public.msgr_org_members where user_id = '${U.extra}'`); + sql(`delete from public.msgr_messages where org_id = '${ORG}' and body = 'hi'`); + sql(`delete from public.msgr_channels where org_id = '${ORG}' and name like 'p-%'`); + sql(`delete from public.msgr_crews where org_id = '${ORG}' and ws_id like 'ws-%'`); + sql(`delete from public.msgr_invites where org_id = '${ORG}' and accepted_at is null`); +}); + +test('초대 흐름: 코드 1회·만료 거부, 수락 후 역할 반영, admin은 owner 행을 못 바꾸고 owner만 소유권 이전', { skip }, () => { + const code = last(asUser(U.admin, `insert into public.msgr_invites (org_id, role, created_by) values ('${ORG}', 'member', '${U.admin}') returning code`)); + assert.equal(last(asUser(U.extra, `select public.msgr_accept_invite('${code}')`)), ORG); + denied(U.outsider, `select public.msgr_accept_invite('${code}')`, /msgr_invite_invalid/); // 재사용 불가 + const expired = last(asUser(U.admin, `insert into public.msgr_invites (org_id, created_by, expires_at) values ('${ORG}', '${U.admin}', now() - interval '1 second') returning code`)); + denied(U.outsider, `select public.msgr_accept_invite('${expired}')`, /msgr_invite_invalid/); + denied(U.outsider, `select public.msgr_accept_invite('deadbeef')`, /msgr_invite_invalid/); + assert.equal(last(asUser(U.admin, `update public.msgr_org_members set role = 'admin' where org_id = '${ORG}' and user_id = '${U.extra}' returning role`)), 'admin'); + assert.equal(last(asUser(U.admin, `update public.msgr_org_members set role = 'member' where org_id = '${ORG}' and user_id = '${U.owner}' returning 1`)), '', 'admin이 owner 행 변경'); + denied(U.admin, `update public.msgr_orgs set owner_user_id = '${U.admin}' where id = '${ORG}'`, /msgr_owner_only/); + denied(U.owner, `update public.msgr_orgs set owner_user_id = '${U.outsider}' where id = '${ORG}'`, /msgr_owner_not_member/); + assert.equal(last(asUser(U.owner, `update public.msgr_orgs set name = 'Lean2' where id = '${ORG}' returning name`)), 'Lean2'); // 일반 수정은 admin도 가능 + assert.equal(last(asUser(U.admin, `update public.msgr_orgs set name = 'Lean' where id = '${ORG}' returning name`)), 'Lean'); + // 오프보딩: removed_at → 즉시 조직·채널·메시지 불가시 + const auditBefore = Number(sql(`select count(*) from public.msgr_audit_log where org_id = '${ORG}' and action = 'member.remove' and target_id = '${U.extra}'`)); + asUser(U.admin, `update public.msgr_org_members set removed_at = now() where org_id = '${ORG}' and user_id = '${U.extra}'`); + assert.equal(last(asUser(U.extra, `select count(*) from public.msgr_channels where org_id = '${ORG}'`)), '0'); + assert.equal(Number(sql(`select count(*) from public.msgr_audit_log where org_id = '${ORG}' and action = 'member.remove' and target_id = '${U.extra}'`)), auditBefore + 1); +}); + +test('좌석·채널 한도: free 3좌석/공개 채널 1, team은 seats·무제한, ends_at 지난 team은 free로 회귀', { skip }, () => { + const org = last(asUser(U.outsider, `insert into public.msgr_orgs (name, slug, owner_user_id) values ('Tiny', 'tiny', '${U.outsider}') returning id`)); + const add = (uid) => sql(`insert into public.msgr_org_members (org_id, user_id, role) values ('${org}', '${uid}', 'member')`); + add(U.extra); add(U.guest); // owner 포함 3명 = FREE_SEATS + assert.equal(sql(`select count(*) from public.msgr_org_members where org_id = '${org}' and removed_at is null`), String(FREE_SEATS)); + assert.throws(() => add(U.member), /msgr_seat_limit/); + assert.equal(sql(`select count(*) from public.msgr_channels where org_id = '${org}' and kind = 'public'`), '0'); + for (let i = 0; i < FREE_PUBLIC_CHANNELS; i++) asUser(U.outsider, `insert into public.msgr_channels (org_id, kind, name, created_by) values ('${org}', 'public', 'g${i}', '${U.outsider}')`); + denied(U.outsider, `insert into public.msgr_channels (org_id, kind, name, created_by) values ('${org}', 'public', 'g-more', '${U.outsider}')`, /msgr_channel_limit/); + asUser(U.outsider, `insert into public.msgr_channels (org_id, kind, name, created_by) values ('${org}', 'private', 'ok', '${U.outsider}')`); // 비공개는 한도 밖 + sql(`update public.msgr_org_entitlements set plan = 'team', seats = 4 where org_id = '${org}'`); + add(U.member); // 4번째 통과 + assert.throws(() => add(U.admin), /msgr_seat_limit/); // seats=4 초과 + asUser(U.outsider, `insert into public.msgr_channels (org_id, kind, name, created_by) values ('${org}', 'public', 'g-team', '${U.outsider}')`); + sql(`update public.msgr_org_entitlements set ends_at = now() - interval '1 day' where org_id = '${org}'`); + assert.equal(sql(`select public.msgr_org_plan('${org}')`), 'free'); + sql(`update public.msgr_org_members set removed_at = now() where org_id = '${org}' and user_id = '${U.member}'`); + assert.throws(() => sql(`update public.msgr_org_members set removed_at = null where org_id = '${org}' and user_id = '${U.member}'`), /msgr_seat_limit/); // 되살림도 게이트 + assert.equal(sql(`update public.msgr_org_members set role = 'admin' where org_id = '${org}' and user_id = '${U.extra}' returning role`), 'admin'); // 활성→활성은 좌석 불변 +}); + +test('is_pro(): 개인 자격 없고 체험 밖이어도 활성 Team 좌석이면 true — 제거·guest·만료 team은 false', { skip }, () => { + const org = last(asUser(U.extra, `insert into public.msgr_orgs (name, slug, owner_user_id) values ('Seat', 'seat', '${U.extra}') returning id`)); + sql(`insert into public.msgr_org_members (org_id, user_id, role) values ('${org}', '${U.removed}', 'guest')`); // 다른 team 조직 멤버가 아닌 사용자 + assert.equal(last(asUser(U.extra, 'select public.is_pro()')), 'f', 'free 조직 owner = 개인 free'); + sql(`update public.msgr_org_entitlements set plan = 'team', seats = 5 where org_id = '${org}'`); + assert.equal(last(asUser(U.extra, 'select public.is_pro()')), 't', 'team 좌석 ⊇ Pro'); + assert.equal(last(asUser(U.removed, 'select public.is_pro()')), 'f', 'guest는 좌석이 아니다'); + sql(`update public.msgr_org_entitlements set ends_at = now() - interval '1 hour' where org_id = '${org}'`); + assert.equal(last(asUser(U.extra, 'select public.is_pro()')), 'f', '만료 team'); + sql(`update public.msgr_org_entitlements set ends_at = null where org_id = '${org}'`); + sql(`update public.msgr_org_members set removed_at = now() where org_id = '${org}' and user_id = '${U.extra}'`); + assert.equal(last(asUser(U.extra, 'select public.is_pro()')), 'f', '제거된 멤버'); + sql(`update public.msgr_org_members set removed_at = null where org_id = '${org}' and user_id = '${U.extra}'`); + sql(`update public.msgr_orgs set deleted_at = now() where id = '${org}'`); + assert.equal(last(asUser(U.extra, 'select public.is_pro()')), 'f', '삭제 표시된 조직'); + sql(`update public.msgr_orgs set deleted_at = null where id = '${org}'`); + // 개인 entitlements 경로 회귀 없음(20260730050000 경계 유지) + sql(`insert into public.entitlements (user_id, plan) values ('${U.outsider}', 'pro')`); + assert.equal(last(asUser(U.outsider, 'select public.is_pro()')), 't'); + sql(`delete from public.entitlements where user_id = '${U.outsider}'`); +}); + +test('메시지: 작성자 위장·타인 크루 명의·detached 크루·보관 채널·타 채널 답글 거부, org_id 위조는 채널 값으로 덮임, client_msg_id 멱등', { skip }, () => { + const ins = (u, cols, vals) => asUser(u, `insert into public.msgr_messages (${cols}) values (${vals}) returning id`); + const m1 = last(ins(U.member, 'org_id, channel_id, author_kind, author_user_id, body', `'${U.outsider}', '${PUB}', 'user', '${U.member}', 'first'`)); // org_id 엉터리 + assert.equal(sql(`select org_id from public.msgr_messages where id = ${m1}`), ORG, '트리거가 채널의 org로 덮는다'); + denied(U.admin, `insert into public.msgr_messages (channel_id, author_kind, author_user_id, body) values ('${PUB}', 'user', '${U.member}', 'as-member')`); // 위장 + denied(U.admin, `insert into public.msgr_messages (channel_id, author_kind, crew_id, body) values ('${PUB}', 'crew', '${CREW}', 'as-crew')`); // 남의 크루 + denied(U.guest, `insert into public.msgr_messages (channel_id, author_kind, author_user_id, body) values ('${PUB}', 'user', '${U.guest}', 'g')`); // guest 공개 채널 + const c1 = last(ins(U.member, 'channel_id, author_kind, crew_id, body, client_msg_id, reply_to', `'${PUB}', 'crew', '${CREW}', '답변', 'reply:${CREW}:${m1}', ${m1}`)); + assert.equal(sql(`select thread_root from public.msgr_messages where id = ${c1}`), m1, 'thread_root는 reply_to로 채움'); + const dup = asUserRaw(U.member, `insert into public.msgr_messages (channel_id, author_kind, crew_id, body, client_msg_id) values ('${PUB}', 'crew', '${CREW}', '답변2', 'reply:${CREW}:${m1}')`); + assert.notEqual(dup.status, 0); assert.match(dup.stderr, /msgr_messages_client_id|duplicate key/); + denied(U.member, `insert into public.msgr_messages (channel_id, author_kind, author_user_id, body, reply_to) values ('${PRIV}', 'user', '${U.member}', 'x', ${m1})`); // member는 PRIV 미초대 + cross + sql(`update public.msgr_crews set status = 'detached' where id = '${CREW}'`); + denied(U.member, `insert into public.msgr_messages (channel_id, author_kind, crew_id, body) values ('${PUB}', 'crew', '${CREW}', 'detached')`); + sql(`update public.msgr_crews set status = 'active' where id = '${CREW}'`); + sql(`update public.msgr_channels set archived_at = now() where id = '${PUB}'`); + let archivedRead; + try { + denied(U.owner, `insert into public.msgr_messages (channel_id, author_kind, author_user_id, body) values ('${PUB}', 'user', '${U.owner}', 'archived')`); + archivedRead = last(asUser(U.owner, `select count(*) from public.msgr_messages where channel_id = '${PUB}'`)); + } finally { + sql(`update public.msgr_channels set archived_at = null where id = '${PUB}'`); // 단언 실패가 뒤 테스트를 연쇄로 깨지 않게 먼저 원복 + } + assert.equal(archivedRead, sql(`select count(*) from public.msgr_messages where channel_id = '${PUB}'`), '보관 채널도 읽기는 유지'); + // 편집은 본인 글만 + assert.equal(last(asUser(U.admin, `update public.msgr_messages set body = 'hack' where id = ${m1} returning 1`)), ''); + assert.equal(last(asUser(U.member, `update public.msgr_messages set edited_at = now(), body = 'first!' where id = ${m1} returning body`)), 'first!'); + // 상주 노드(서비스 계정)도 같은 경로로 자기 크루 명의 발화 + assert.match(last(ins(U.svc, 'channel_id, author_kind, crew_id, body', `'${PUB}', 'crew', '${CREW_SVC}', '노드 답변'`)), /^\d+$/); +}); + +test('Realtime 방송: insert마다 org: private topic으로 본문 없는 payload, 타이핑 송신·수신 정책은 멤버만', { skip }, () => { + sql('delete from realtime.sent'); + const id = last(asUser(U.owner, `insert into public.msgr_messages (channel_id, author_kind, author_user_id, body, mentions) values ('${PUB}', 'user', '${U.owner}', '비밀 본문', '[{"kind":"crew","id":"${CREW}"}]') returning id`)); + const row = sql(`select event || '|' || topic || '|' || private::text || '|' || (payload ? 'body')::text || '|' || (payload->>'id') || '|' || (payload->'mentions'->0->>'id') from realtime.sent`); + assert.equal(row, `message|org:${ORG}|true|false|${id}|${CREW}`); + const canRecv = (u, topic) => last(asUser(u, `select set_config('realtime.topic', '${topic}', false); select count(*) from realtime.messages`)) !== null + && asUserRaw(u, `select set_config('realtime.topic', '${topic}', false); insert into realtime.messages (topic, extension, payload) values ('${topic}', 'broadcast', '{}')`).status === 0; + assert.equal(canRecv(U.member, `org:${ORG}`), true); + assert.equal(canRecv(U.outsider, `org:${ORG}`), false); + assert.equal(canRecv(U.removed, `org:${ORG}`), false); + assert.equal(asUserRaw(U.member, `select set_config('realtime.topic', 'org:${ORG}', false); insert into realtime.messages (topic, extension, payload) values ('org:${ORG}', 'presence', '{}')`).status === 0, false, 'presence는 1차 미허용'); +}); + +test('결재 미러: 브리지(크루 소유자)만 pending 생성, 확정은 소유자만·1회만, 확정 시 방송+감사', { skip }, () => { + denied(U.admin, `insert into public.msgr_crew_approvals (org_id, channel_id, crew_id, approval_id, action) values ('${ORG}', '${PUB}', '${CREW}', 'ap-1', '메일 발송')`); + const ap = last(asUser(U.member, `insert into public.msgr_crew_approvals (org_id, channel_id, crew_id, approval_id, action) values ('${ORG}', '${PUB}', '${CREW}', 'ap-1', '메일 발송') returning id`)); + assert.equal(last(asUser(U.owner, `update public.msgr_crew_approvals set status = 'approved', decided_by = '${U.owner}', decided_at = now() where id = '${ap}' returning 1`)), '', 'owner 역할이라도 남의 크루 결재 불가'); + assert.equal(last(asUser(U.admin, `select status from public.msgr_crew_approvals where id = '${ap}'`)), 'pending', '멤버는 카드가 보인다'); + denied(U.member, `update public.msgr_crew_approvals set status = 'approved', decided_by = '${U.admin}' where id = '${ap}'`); // decided_by 위조 + sql('delete from realtime.sent'); + assert.equal(last(asUser(U.member, `update public.msgr_crew_approvals set status = 'approved', decided_by = '${U.member}', decided_at = now() where id = '${ap}' returning status`)), 'approved'); + assert.equal(sql(`select event || '|' || (payload->>'status') from realtime.sent`), 'approval|approved'); + assert.equal(sql(`select count(*) from public.msgr_audit_log where org_id = '${ORG}' and action = 'approval.approved' and target_id = 'ap-1'`), '1'); + assert.equal(last(asUser(U.member, `update public.msgr_crew_approvals set status = 'rejected', decided_by = '${U.member}' where id = '${ap}' returning 1`)), '', '확정은 1회'); +}); + +test('크루 행: admin은 타인 크루 detach만(허용 범위·커서 불변), 소유자는 커서·하트비트·허용 범위 갱신, 남의 크루 등록 불가', { skip }, () => { + denied(U.admin, `insert into public.msgr_crews (org_id, owner_user_id, ws_id, slug, display_name) values ('${ORG}', '${U.member}', 'lean-ax-abcd', 'x', 'x')`); + assert.equal(last(asUser(U.member, `update public.msgr_crews set cursor_msg_id = 42, last_seen_at = now(), allow = 'list', allow_users = array['${U.owner}'::uuid] where id = '${CREW}' returning cursor_msg_id`)), '42'); + denied(U.admin, `update public.msgr_crews set allow = 'all' where id = '${CREW}'`); + denied(U.admin, `update public.msgr_crews set cursor_msg_id = 0 where id = '${CREW}'`); + assert.equal(last(asUser(U.admin, `update public.msgr_crews set status = 'detached' where id = '${CREW}' returning status`)), 'detached'); + assert.equal(last(asUser(U.member, `update public.msgr_crews set status = 'active' where id = '${CREW}' returning status`)), 'active'); + assert.equal(last(asUser(U.guest, `select count(*) from public.msgr_crews where org_id = '${ORG}'`)), sql(`select count(*) from public.msgr_crews where org_id = '${ORG}'`), 'guest도 크루 목록은 본다(멘션 대상)'); +}); + +test('첨부·Storage 경로: 메시지 작성자만 첨부 행, 버킷 msgr 1세그먼트 조직 멤버십', { skip }, () => { + const m = last(asUser(U.owner, `insert into public.msgr_messages (channel_id, author_kind, author_user_id, body) values ('${PUB}', 'user', '${U.owner}', 'file') returning id`)); + denied(U.admin, `insert into public.msgr_attachments (message_id, org_id, storage_path, name) values (${m}, '${ORG}', '${ORG}/${PUB}/${m}/a.png', 'a.png')`); + asUser(U.owner, `insert into public.msgr_attachments (message_id, org_id, storage_path, name) values (${m}, '${ORG}', '${ORG}/${PUB}/${m}/a.png', 'a.png')`); + assert.equal(last(asUser(U.guest, `select count(*) from public.msgr_attachments where message_id = ${m}`)), '0', 'guest는 공개 채널 첨부 불가시'); + assert.equal(last(asUser(U.member, `select count(*) from public.msgr_attachments where message_id = ${m}`)), '1'); + asUser(U.member, `insert into storage.objects (bucket_id, name) values ('msgr', '${ORG}/${PUB}/${m}/a.png')`); + denied(U.outsider, `insert into storage.objects (bucket_id, name) values ('msgr', '${ORG}/x/y/z.png')`); + assert.equal(last(asUser(U.outsider, `select count(*) from storage.objects where bucket_id = 'msgr'`)), '0'); + assert.equal(last(asUser(U.guest, `select count(*) from storage.objects where bucket_id = 'msgr'`)), '1', '버킷 정책은 조직 단위(채널 비밀은 서명 URL 발급 측이 지킨다)'); + denied(U.member, `insert into storage.objects (bucket_id, name) values ('msgr', 'not-a-uuid/x.png')`, /row-level security/); // 형식 불일치 경로는 캐스트 예외가 아니라 정책 거부 + +}); + +test('권한: anon은 멤버십 함수 실행 불가, authenticated는 감사 직접 기록 불가', { skip }, () => { + const r = psqlRaw(['-c', `set role anon; select public.msgr_is_member('${ORG}')`]); + assert.notEqual(r.status, 0); assert.match(r.stderr, /permission denied/i); + denied(U.owner, `select public.msgr_audit('${ORG}', 'fake', 'x', 'y')`, /permission denied/i); + denied(U.owner, `insert into public.msgr_audit_log (org_id, action) values ('${ORG}', 'fake')`); + denied(U.owner, `update public.msgr_org_entitlements set plan = 'team' where org_id = '${ORG}'`); // 자기 승격 불가 +}); From 4705b331f2a028ab5f412db55db006174dd6f795 Mon Sep 17 00:00:00 2001 From: beyondworks Date: Thu, 3 Sep 2026 14:06:27 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(msgr):=20=EB=B6=84=EB=A6=AC=20=EA=B2=80?= =?UTF-8?q?=EC=88=98=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EB=B6=88=EB=B3=80?= =?UTF-8?q?=20=EC=BB=AC=EB=9F=BC=20=ED=8A=B8=EB=A6=AC=EA=B1=B0(=EC=B1=84?= =?UTF-8?q?=EB=84=90=20org=20=EC=9E=AC=EB=B6=80=EB=AA=A8=ED=99=94=C2=B7?= =?UTF-8?q?=EA=B2=B0=EC=9E=AC=20org/channel=20=EB=B3=80=EC=A1=B0=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8),=20=EC=A2=8C=EC=84=9D=C2=B7=EC=B1=84?= =?UTF-8?q?=EB=84=90=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20advisory=20lock,=20cl?= =?UTF-8?q?ient=5Fmsg=5Fid=20=EC=9E=91=EC=84=B1=EC=9E=90=20=EC=B6=95,=20St?= =?UTF-8?q?orage=20=EC=B1=84=EB=84=90=20=EB=8B=A8=EC=9C=84,=20=EC=B4=88?= =?UTF-8?q?=EB=8C=80=20update=20=EA=B2=BD=EB=A1=9C=20=EC=A0=9C=EA=B1=B0,?= =?UTF-8?q?=20Realtime=20=EA=B4=80=EC=9A=A9=20=EC=BA=90=EC=8A=A4=ED=8A=B8,?= =?UTF-8?q?=20=EC=88=98=EB=9D=BD=20display=5Fname?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CRITICAL-1·2: msgr_lock_cols 트리거(channels org_id/kind/created_by, approvals org/channel/crew/approval_id/action, crews org/owner/ws/slug, members org/user, messages 신원·client_msg_id) — 서비스 문맥은 통과 - HIGH-3: pg_advisory_xact_lock으로 동시 insert 직렬화(좌석·공개 채널 한도), 드릴에 2세션 인터리브 테스트 - HIGH-4: 유니크 인덱스 (channel_id, author_kind, coalesce(crew_id, author_user_id), client_msg_id) — 멤버 선점이 크루 답글을 못 막는다 - HIGH-5: 버킷 msgr select/insert를 2세그먼트(channel_id) 채널 열람·쓰기 판정으로(authenticated 직접 다운로드 경로) - MEDIUM-6·7·8: realtime recv는 심어 둔 행 가시성으로 검증, denied 기본 정규식 좁힘, 초대 update 정책 제거(수락은 RPC만) - LOW: realtime topic 캐스트를 msgr_uuid_or_null로, 수락 시 display_name = 이메일 앞부분 Co-Authored-By: Claude Fable 5.1 --- supabase/migrations/20260903120000_msgr.sql | 69 ++++++++++++++---- test/msgr-pg-integration.test.mjs | 79 ++++++++++++++++++--- 2 files changed, 123 insertions(+), 25 deletions(-) diff --git a/supabase/migrations/20260903120000_msgr.sql b/supabase/migrations/20260903120000_msgr.sql index bd72c129..0aab6338 100644 --- a/supabase/migrations/20260903120000_msgr.sql +++ b/supabase/migrations/20260903120000_msgr.sql @@ -169,7 +169,11 @@ create table if not exists public.msgr_messages ( or (author_kind = 'crew' and crew_id is not null) or (author_kind = 'system')) ); -create unique index if not exists msgr_messages_client_id on public.msgr_messages (channel_id, client_msg_id) where client_msg_id is not null; +-- 멱등 키는 **작성자 축을 포함**한다(분리 검수 HIGH-4): (channel_id, client_msg_id)만이면 일반 멤버가 'reply::'를 +-- 선점해 크루 답글 insert를 유니크 위반으로 막을 수 있었다(DoS 실증). 같은 크루의 재실행(리더 교체 창)은 여전히 한 키다. +drop index if exists public.msgr_messages_client_id; +create unique index if not exists msgr_messages_client_id on public.msgr_messages + (channel_id, author_kind, coalesce(crew_id::text, author_user_id::text, ''), client_msg_id) where client_msg_id is not null; create index if not exists msgr_messages_org_id_id on public.msgr_messages (org_id, id); create index if not exists msgr_messages_channel_id_id on public.msgr_messages (channel_id, id); @@ -223,6 +227,29 @@ create or replace function public.msgr_audit(org uuid, act text, tkind text, tid $$; -- ── 트리거 ────────────────────────────────────────────────────────────────────── +-- 불변 컬럼 잠금(분리 검수 2026-09-03 CRITICAL-1·2): RLS with check는 "새 값"만 보므로 org_id·channel_id 같은 소속 컬럼을 +-- 같은 UPDATE에서 바꾸면 채널 재부모화(타 조직 메시지 노출)·결재 확정과 동시에 org 이동(감사 회피)이 가능했다(exploit 실증). +-- 처방은 old 값을 볼 수 있는 유일한 자리인 트리거. 서비스 문맥(auth.uid() null)은 통과 — 운영 도구·엣지 펑션용. +create or replace function public.msgr_lock_cols() returns trigger + language plpgsql security definer set search_path = public, pg_temp as $$ +declare col text; n jsonb := to_jsonb(new); o jsonb := to_jsonb(old); +begin + if auth.uid() is null then return new; end if; + foreach col in array tg_argv loop + if n->col is distinct from o->col then raise exception 'msgr_immutable_%', col; end if; + end loop; + return new; +end $$; +drop trigger if exists msgr_lock_channels on public.msgr_channels; +create trigger msgr_lock_channels before update on public.msgr_channels for each row execute function public.msgr_lock_cols('org_id', 'kind', 'created_by', 'created_at'); +drop trigger if exists msgr_lock_approvals on public.msgr_crew_approvals; +create trigger msgr_lock_approvals before update on public.msgr_crew_approvals for each row execute function public.msgr_lock_cols('org_id', 'channel_id', 'crew_id', 'approval_id', 'action', 'created_at'); +drop trigger if exists msgr_lock_crews on public.msgr_crews; +create trigger msgr_lock_crews before update on public.msgr_crews for each row execute function public.msgr_lock_cols('org_id', 'owner_user_id', 'ws_id', 'slug', 'registered_at'); +drop trigger if exists msgr_lock_members on public.msgr_org_members; +create trigger msgr_lock_members before update on public.msgr_org_members for each row execute function public.msgr_lock_cols('org_id', 'user_id'); +drop trigger if exists msgr_lock_messages on public.msgr_messages; +create trigger msgr_lock_messages before update on public.msgr_messages for each row execute function public.msgr_lock_cols('org_id', 'channel_id', 'author_kind', 'author_user_id', 'crew_id', 'kind', 'client_msg_id', 'created_at'); -- 조직 생성자는 자동 owner 멤버(security definer — 아직 멤버가 아니라 members insert 정책을 못 지난다). create or replace function public.msgr_org_after_insert() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ @@ -242,6 +269,7 @@ declare lim int; n int; begin if new.removed_at is not null then return new; end if; if tg_op = 'UPDATE' and old.removed_at is null then return new; end if; -- 활성→활성(역할 변경)은 좌석 불변 + perform pg_advisory_xact_lock(hashtext('msgr_seats:' || new.org_id::text)); -- 동시 insert 2건이 각자 스냅샷에서 통과하던 레이스(분리 검수 HIGH-3 실증) 직렬화 select case when public.msgr_org_plan(new.org_id) = 'team' then coalesce(e.seats, 0) else 3 end into lim from public.msgr_org_entitlements e where e.org_id = new.org_id; if lim is null then lim := 3; end if; @@ -269,6 +297,7 @@ create trigger msgr_member_audit after insert or update or delete on public.msgr create or replace function public.msgr_channel_gate() returns trigger language plpgsql security definer set search_path = public, pg_temp as $$ begin + perform pg_advisory_xact_lock(hashtext('msgr_channels:' || new.org_id::text)); -- 좌석 게이트와 같은 레이스 계열 if new.kind = 'public' and public.msgr_org_plan(new.org_id) = 'free' and (select count(*) from public.msgr_channels where org_id = new.org_id and kind = 'public' and archived_at is null) >= 1 then raise exception 'msgr_channel_limit'; @@ -328,8 +357,10 @@ begin if auth.uid() is null then raise exception 'msgr_auth_required'; end if; select * into inv from public.msgr_invites i where i.code = msgr_accept_invite.code and i.accepted_at is null and i.expires_at > now() for update; if inv.id is null then raise exception 'msgr_invite_invalid'; end if; - insert into public.msgr_org_members (org_id, user_id, role) values (inv.org_id, auth.uid(), inv.role) - on conflict (org_id, user_id) do update set role = excluded.role, removed_at = null, joined_at = now(); + insert into public.msgr_org_members (org_id, user_id, role, display_name) + values (inv.org_id, auth.uid(), inv.role, (select split_part(u.email, '@', 1) from auth.users u where u.id = auth.uid())) + on conflict (org_id, user_id) do update set role = excluded.role, removed_at = null, joined_at = now(), + display_name = coalesce(public.msgr_org_members.display_name, excluded.display_name); update public.msgr_invites set accepted_by = auth.uid(), accepted_at = now() where id = inv.id; perform public.msgr_audit(inv.org_id, 'invite.accept', 'invite', inv.id::text); return inv.org_id; @@ -398,9 +429,15 @@ create policy msgr_members_delete on public.msgr_org_members for delete to authe drop policy if exists msgr_entitlements_select on public.msgr_org_entitlements; create policy msgr_entitlements_select on public.msgr_org_entitlements for select to authenticated using (public.msgr_is_member(org_id)); +-- update 정책 없음(분리 검수 MEDIUM-8): 수락 표기는 msgr_accept_invite RPC만 쓴다 — admin이 남의 초대를 "수락됨"으로 위조하던 경로 차단. drop policy if exists msgr_invites_admin on public.msgr_invites; -create policy msgr_invites_admin on public.msgr_invites for all to authenticated - using (public.msgr_is_admin(org_id)) with check (public.msgr_is_admin(org_id) and created_by = (select auth.uid())); +drop policy if exists msgr_invites_select on public.msgr_invites; +create policy msgr_invites_select on public.msgr_invites for select to authenticated using (public.msgr_is_admin(org_id)); +drop policy if exists msgr_invites_insert on public.msgr_invites; +create policy msgr_invites_insert on public.msgr_invites for insert to authenticated + with check (public.msgr_is_admin(org_id) and created_by = (select auth.uid())); +drop policy if exists msgr_invites_delete on public.msgr_invites; +create policy msgr_invites_delete on public.msgr_invites for delete to authenticated using (public.msgr_is_admin(org_id)); drop policy if exists msgr_channels_select on public.msgr_channels; -- 자기 테이블 select 정책은 함수(msgr_can_read_channel) 대신 행 컬럼으로 판정한다(드릴 실측): STABLE 함수는 호출 문장의 @@ -502,29 +539,33 @@ do $$ declare f text; begin end $$; revoke execute on function public.msgr_audit(uuid,text,text,text,jsonb) from authenticated; -- 감사는 트리거·RPC 내부에서만(직접 위조 금지) +-- uuid 형식이 아니면 null → 비멤버 판정(캐스트 예외로 정책 평가 자체가 터지지 않게 — AND 평가 순서는 보장되지 않는다). Realtime·Storage 정책 공용. +create or replace function public.msgr_uuid_or_null(t text) returns uuid + language sql immutable as $$ + select case when t ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' then t::uuid else null end +$$; + -- ── Realtime 인가(private topic org:) ───────────────────────────────── -- 수신(select)·송신(insert, 타이핑 표시) 모두 조직 멤버만. payload에 본문이 없으니 채널 비밀은 PostgREST RLS가 지킨다. drop policy if exists msgr_realtime_recv on realtime.messages; create policy msgr_realtime_recv on realtime.messages for select to authenticated using (realtime.messages.extension = 'broadcast' and (select realtime.topic()) like 'org:%' - and public.msgr_is_member(substr((select realtime.topic()), 5)::uuid)); + and public.msgr_is_member(public.msgr_uuid_or_null(substr((select realtime.topic()), 5)))); drop policy if exists msgr_realtime_send on realtime.messages; create policy msgr_realtime_send on realtime.messages for insert to authenticated with check (realtime.messages.extension = 'broadcast' and (select realtime.topic()) like 'org:%' - and public.msgr_is_member(substr((select realtime.topic()), 5)::uuid)); + and public.msgr_is_member(public.msgr_uuid_or_null(substr((select realtime.topic()), 5)))); -- ── Storage 버킷 msgr — name = ///, 1세그먼트 = 조직 멤버십 ──── --- 1세그먼트가 uuid 형식이 아니면 null → 비멤버 판정(캐스트 예외로 정책 평가 자체가 터지지 않게 — AND 평가 순서는 보장되지 않는다). -create or replace function public.msgr_uuid_or_null(t text) returns uuid - language sql immutable as $$ - select case when t ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' then t::uuid else null end -$$; +-- 채널 단위(분리 검수 HIGH-5): Supabase Storage는 서명 URL 없이도 authenticated 경로로 RLS select만 통과하면 직접 내려받는다 — +-- 조직 단위 정책이면 비공개 채널·DM 첨부가 조직 전체에 공개된다. 2세그먼트(channel_id)로 채널 열람 판정을 건다. drop policy if exists msgr_files_select on storage.objects; create policy msgr_files_select on storage.objects for select to authenticated - using (bucket_id = 'msgr' and public.msgr_is_member(public.msgr_uuid_or_null((storage.foldername(name))[1]))); + using (bucket_id = 'msgr' and public.msgr_can_read_channel(public.msgr_uuid_or_null((storage.foldername(name))[2]))); drop policy if exists msgr_files_insert on storage.objects; create policy msgr_files_insert on storage.objects for insert to authenticated - with check (bucket_id = 'msgr' and public.msgr_is_member(public.msgr_uuid_or_null((storage.foldername(name))[1]))); + with check (bucket_id = 'msgr' and public.msgr_is_member(public.msgr_uuid_or_null((storage.foldername(name))[1])) + and public.msgr_can_write_channel(public.msgr_uuid_or_null((storage.foldername(name))[2]))); drop policy if exists msgr_files_delete on storage.objects; create policy msgr_files_delete on storage.objects for delete to authenticated using (bucket_id = 'msgr' and public.msgr_is_admin(public.msgr_uuid_or_null((storage.foldername(name))[1]))); diff --git a/test/msgr-pg-integration.test.mjs b/test/msgr-pg-integration.test.mjs index d913508b..6d947997 100644 --- a/test/msgr-pg-integration.test.mjs +++ b/test/msgr-pg-integration.test.mjs @@ -4,7 +4,7 @@ // argo.uid 세션 변수(auth.uid() 스텁이 읽는다) — 슈퍼유저는 RLS를 우회하므로 반드시 역할을 낮춰 실행한다. import test, { before } from 'node:test'; import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { ROLES, ROLE_MATRIX, FREE_SEATS, FREE_PUBLIC_CHANNELS } from './helpers/msgr-cases.mjs'; @@ -23,7 +23,8 @@ function psql(args) { const r = psqlRaw(args); if (r.status !== 0) throw new Err const sql = (q) => psql(['-A', '-t', '-c', q]).trim(); // 슈퍼유저(RLS 우회) — 시드·관찰 전용 const asUser = (uid, q) => sql(`set role authenticated; select set_config('argo.uid', '${uid}', false); ${q}`); const asUserRaw = (uid, q) => psqlRaw(['-A', '-t', '-c', `set role authenticated; select set_config('argo.uid', '${uid}', false); ${q}`]); -const denied = (uid, q, re = /policy|permission denied|msgr_/i) => { const r = asUserRaw(uid, q); assert.notEqual(r.status, 0, `허용됨: ${q.slice(0, 80)}`); assert.match(r.stderr, re); }; +// denied 기본 정규식은 RLS·권한만(msgr_ 접두 예외는 명시 정규식으로 — 검수 MEDIUM-7: 이 스키마는 이름이 전부 msgr_라 무의미한 안전망) +const denied = (uid, q, re = /row-level security policy|permission denied for/i) => { const r = asUserRaw(uid, q); assert.notEqual(r.status, 0, `허용됨: ${q.slice(0, 80)}`); assert.match(r.stderr, re); }; const last = (s) => s.split('\n').filter(Boolean).pop() ?? ''; // set_config 출력 뒤 마지막 결과 줄 let ORG, PUB, PRIV, CREW, CREW_SVC; @@ -38,7 +39,7 @@ before(() => { grant usage on schema public to anon, authenticated, service_role; create schema if not exists auth; grant usage on schema auth to anon, authenticated, service_role; -- 정책 본문의 auth.uid()는 호출 역할로 평가된다(실 Supabase와 동일 권한) - create table if not exists auth.users (id uuid primary key, created_at timestamptz not null default now()); + create table if not exists auth.users (id uuid primary key, created_at timestamptz not null default now(), email text); -- auth.uid() 스텁: 세션 변수 argo.uid — asUser()가 set_config로 사용자를 흉내 낸다 create or replace function auth.uid() returns uuid language sql stable as $$ select nullif(current_setting('argo.uid', true), '')::uuid $$; -- storage 스텁(정책 문법·foldername 계약만) — 실 Supabase의 storage.objects와 같은 열 이름 @@ -61,7 +62,7 @@ before(() => { for (const f of ['20260714150000_entitlements.sql', '20260724000100_trial_14d.sql', '20260728100000_entitlements_ls.sql', '20260728113000_billing_hardening.sql', '20260728150000_ls_reconcile_cooldown.sql', '20260730050000_is_pro_ends_at.sql', '20260903120000_msgr.sql']) psql(['-f', mig(f)]); // 배포될 그 파일을 그대로 적용 - for (const id of Object.values(U)) sql(`insert into auth.users (id, created_at) values ('${id}', now() - interval '30 days') on conflict do nothing`); // 체험 창 밖 + for (const [k, id] of Object.entries(U)) sql(`insert into auth.users (id, created_at, email) values ('${id}', now() - interval '30 days', '${k}@example.test') on conflict do nothing`); // 체험 창 밖 // 시드: owner가 조직 생성(트리거가 owner 멤버·free 자격 생성) → admin/member/guest/removed 초대 → 공개·비공개 채널 → 크루 2개 ORG = last(asUser(U.owner, `insert into public.msgr_orgs (name, slug, owner_user_id) values ('Lean', 'lean', '${U.owner}') returning id`)); for (const [uid, role] of [[U.admin, 'admin'], [U.member, 'member'], [U.guest, 'guest']]) { @@ -119,6 +120,9 @@ test('역할 경계표(helpers/msgr-cases): 6역할 × 8행동이 실제 RLS 판 test('초대 흐름: 코드 1회·만료 거부, 수락 후 역할 반영, admin은 owner 행을 못 바꾸고 owner만 소유권 이전', { skip }, () => { const code = last(asUser(U.admin, `insert into public.msgr_invites (org_id, role, created_by) values ('${ORG}', 'member', '${U.admin}') returning code`)); assert.equal(last(asUser(U.extra, `select public.msgr_accept_invite('${code}')`)), ORG); + assert.equal(sql(`select display_name from public.msgr_org_members where org_id = '${ORG}' and user_id = '${U.extra}'`), 'extra', '수락 시 display_name = 이메일 앞부분'); + const inv2 = last(asUser(U.owner, `insert into public.msgr_invites (org_id, created_by) values ('${ORG}', '${U.owner}') returning id`)); + assert.equal(last(asUser(U.admin, `update public.msgr_invites set accepted_by = '${U.outsider}', accepted_at = now(), created_by = '${U.admin}' where id = '${inv2}' returning 1`)), '', 'admin의 초대 위조(update) 경로 없음(검수 MEDIUM-8)'); denied(U.outsider, `select public.msgr_accept_invite('${code}')`, /msgr_invite_invalid/); // 재사용 불가 const expired = last(asUser(U.admin, `insert into public.msgr_invites (org_id, created_by, expires_at) values ('${ORG}', '${U.admin}', now() - interval '1 second') returning code`)); denied(U.outsider, `select public.msgr_accept_invite('${expired}')`, /msgr_invite_invalid/); @@ -190,7 +194,8 @@ test('메시지: 작성자 위장·타인 크루 명의·detached 크루·보관 assert.equal(sql(`select thread_root from public.msgr_messages where id = ${c1}`), m1, 'thread_root는 reply_to로 채움'); const dup = asUserRaw(U.member, `insert into public.msgr_messages (channel_id, author_kind, crew_id, body, client_msg_id) values ('${PUB}', 'crew', '${CREW}', '답변2', 'reply:${CREW}:${m1}')`); assert.notEqual(dup.status, 0); assert.match(dup.stderr, /msgr_messages_client_id|duplicate key/); - denied(U.member, `insert into public.msgr_messages (channel_id, author_kind, author_user_id, body, reply_to) values ('${PRIV}', 'user', '${U.member}', 'x', ${m1})`); // member는 PRIV 미초대 + cross + denied(U.member, `insert into public.msgr_messages (channel_id, author_kind, author_user_id, body, reply_to) values ('${PRIV}', 'user', '${U.member}', 'x', ${m1})`, /msgr_reply_cross_channel|row-level security/); // BEFORE 트리거(타 채널 답글)가 RLS보다 먼저 막는다 + denied(U.owner, `insert into public.msgr_messages (channel_id, author_kind, author_user_id, body, reply_to) values ('${PRIV}', 'user', '${U.owner}', 'x', ${m1})`, /msgr_reply_cross_channel/); // 쓰기 권한이 있어도 타 채널 답글은 트리거가 거부 sql(`update public.msgr_crews set status = 'detached' where id = '${CREW}'`); denied(U.member, `insert into public.msgr_messages (channel_id, author_kind, crew_id, body) values ('${PUB}', 'crew', '${CREW}', 'detached')`); sql(`update public.msgr_crews set status = 'active' where id = '${CREW}'`); @@ -215,11 +220,13 @@ test('Realtime 방송: insert마다 org: private topic으로 본문 없는 p const id = last(asUser(U.owner, `insert into public.msgr_messages (channel_id, author_kind, author_user_id, body, mentions) values ('${PUB}', 'user', '${U.owner}', '비밀 본문', '[{"kind":"crew","id":"${CREW}"}]') returning id`)); const row = sql(`select event || '|' || topic || '|' || private::text || '|' || (payload ? 'body')::text || '|' || (payload->>'id') || '|' || (payload->'mentions'->0->>'id') from realtime.sent`); assert.equal(row, `message|org:${ORG}|true|false|${id}|${CREW}`); - const canRecv = (u, topic) => last(asUser(u, `select set_config('realtime.topic', '${topic}', false); select count(*) from realtime.messages`)) !== null - && asUserRaw(u, `select set_config('realtime.topic', '${topic}', false); insert into realtime.messages (topic, extension, payload) values ('${topic}', 'broadcast', '{}')`).status === 0; - assert.equal(canRecv(U.member, `org:${ORG}`), true); - assert.equal(canRecv(U.outsider, `org:${ORG}`), false); - assert.equal(canRecv(U.removed, `org:${ORG}`), false); + sql(`delete from realtime.messages; insert into realtime.messages (topic, extension, payload) values ('org:${ORG}', 'broadcast', '{}')`); // 수신 게이트는 심어 둔 행의 가시성으로(검수 MEDIUM-6: 0행 필터와 실제 0행을 구분) + const recv = (u, topic) => last(asUser(u, `select set_config('realtime.topic', '${topic}', false); select count(*) from realtime.messages`)); + const canSend = (u, topic) => asUserRaw(u, `select set_config('realtime.topic', '${topic}', false); insert into realtime.messages (topic, extension, payload) values ('${topic}', 'broadcast', '{}')`).status === 0; + assert.equal(recv(U.member, `org:${ORG}`), '1'); assert.equal(canSend(U.member, `org:${ORG}`), true); + assert.equal(recv(U.outsider, `org:${ORG}`), '0'); assert.equal(canSend(U.outsider, `org:${ORG}`), false); + assert.equal(recv(U.removed, `org:${ORG}`), '0'); assert.equal(canSend(U.removed, `org:${ORG}`), false); + assert.equal(recv(U.member, 'org:not-a-uuid'), '0', '형식 불일치 topic은 예외가 아니라 0행'); assert.equal(asUserRaw(U.member, `select set_config('realtime.topic', 'org:${ORG}', false); insert into realtime.messages (topic, extension, payload) values ('org:${ORG}', 'presence', '{}')`).status === 0, false, 'presence는 1차 미허용'); }); @@ -255,7 +262,12 @@ test('첨부·Storage 경로: 메시지 작성자만 첨부 행, 버킷 msgr 1 asUser(U.member, `insert into storage.objects (bucket_id, name) values ('msgr', '${ORG}/${PUB}/${m}/a.png')`); denied(U.outsider, `insert into storage.objects (bucket_id, name) values ('msgr', '${ORG}/x/y/z.png')`); assert.equal(last(asUser(U.outsider, `select count(*) from storage.objects where bucket_id = 'msgr'`)), '0'); - assert.equal(last(asUser(U.guest, `select count(*) from storage.objects where bucket_id = 'msgr'`)), '1', '버킷 정책은 조직 단위(채널 비밀은 서명 URL 발급 측이 지킨다)'); + assert.equal(last(asUser(U.guest, `select count(*) from storage.objects where bucket_id = 'msgr'`)), '0', '공개 채널 첨부는 guest 불가시 — 버킷 정책은 채널 단위(검수 HIGH-5: authenticated 직접 다운로드 경로)'); + assert.equal(last(asUser(U.member, `select count(*) from storage.objects where bucket_id = 'msgr'`)), '1'); + asUser(U.admin, `insert into storage.objects (bucket_id, name) values ('msgr', '${ORG}/${PRIV}/1/secret.pdf')`); + assert.equal(last(asUser(U.guest, `select count(*) from storage.objects where name like '%secret.pdf'`)), '1', '초대된 비공개 채널 첨부는 guest도'); + assert.equal(last(asUser(U.member, `select count(*) from storage.objects where name like '%secret.pdf'`)), '0', '비공개 채널 밖 멤버는 불가시'); + denied(U.member, `insert into storage.objects (bucket_id, name) values ('msgr', '${ORG}/${PRIV}/2/x.pdf')`); denied(U.member, `insert into storage.objects (bucket_id, name) values ('msgr', 'not-a-uuid/x.png')`, /row-level security/); // 형식 불일치 경로는 캐스트 예외가 아니라 정책 거부 }); @@ -267,3 +279,48 @@ test('권한: anon은 멤버십 함수 실행 불가, authenticated는 감사 denied(U.owner, `insert into public.msgr_audit_log (org_id, action) values ('${ORG}', 'fake')`); denied(U.owner, `update public.msgr_org_entitlements set plan = 'team' where org_id = '${ORG}'`); // 자기 승격 불가 }); + +test('검수 CRITICAL: 채널 org 재부모화·결재 확정 시 org/channel 변조·크루 소유 이전·멤버 재소속 전부 거부(불변 컬럼 트리거)', { skip }, () => { + const org2 = last(asUser(U.member, `insert into public.msgr_orgs (name, slug, owner_user_id) values ('Two', 'two', '${U.member}') returning id`)); + const mine = last(asUser(U.member, `insert into public.msgr_channels (org_id, kind, name, created_by) values ('${ORG}', 'private', 'mine', '${U.member}') returning id`)); + denied(U.member, `update public.msgr_channels set org_id = '${org2}' where id = '${mine}'`, /msgr_immutable_org_id/); + denied(U.member, `update public.msgr_channels set kind = 'public' where id = '${mine}'`, /msgr_immutable_kind/); + assert.equal(last(asUser(U.member, `update public.msgr_channels set topic = 't' where id = '${mine}' returning topic`)), 't', '일반 컬럼은 수정 가능'); + const ap = last(asUser(U.member, `insert into public.msgr_crew_approvals (org_id, channel_id, crew_id, approval_id, action) values ('${ORG}', '${PUB}', '${CREW}', 'ap-lock', '송금') returning id`)); + const decoyCh = last(asUser(U.member, `insert into public.msgr_channels (org_id, kind, name, created_by) values ('${org2}', 'private', 'decoy', '${U.member}') returning id`)); + denied(U.member, `update public.msgr_crew_approvals set status = 'approved', decided_by = '${U.member}', decided_at = now(), org_id = '${org2}', channel_id = '${decoyCh}' where id = '${ap}'`, /msgr_immutable_org_id/); + denied(U.member, `update public.msgr_crew_approvals set status = 'approved', decided_by = '${U.member}', decided_at = now(), channel_id = '${decoyCh}' where id = '${ap}'`, /msgr_immutable_channel_id/); + assert.equal(sql(`select status || '|' || org_id from public.msgr_crew_approvals where id = '${ap}'`), `pending|${ORG}`, '변조 시도 후 원 조직에 pending 그대로'); + assert.equal(last(asUser(U.admin, `select count(*) from public.msgr_crew_approvals where id = '${ap}'`)), '1', '원 조직 admin이 계속 본다'); + denied(U.member, `update public.msgr_crews set org_id = '${org2}' where id = '${CREW}'`, /msgr_immutable_org_id/); + denied(U.member, `update public.msgr_crews set slug = 'other' where id = '${CREW}'`, /msgr_immutable_slug/); + denied(U.admin, `update public.msgr_org_members set org_id = '${org2}' where org_id = '${ORG}' and user_id = '${U.guest}'`, /msgr_immutable_org_id/); + const m = last(asUser(U.member, `insert into public.msgr_messages (channel_id, author_kind, author_user_id, body, client_msg_id) values ('${PUB}', 'user', '${U.member}', 'lock', 'c-lock') returning id`)); + denied(U.member, `update public.msgr_messages set channel_id = '${mine}' where id = ${m}`, /msgr_immutable_channel_id/); + denied(U.member, `update public.msgr_messages set author_user_id = '${U.owner}' where id = ${m}`, /msgr_immutable_author_user_id/); + assert.equal(sql(`update public.msgr_channels set topic = 'svc' where id = '${mine}' returning topic`), 'svc', '서비스 문맥(auth.uid null)은 트리거 통과'); +}); + +test('검수 HIGH-4: client_msg_id는 작성자 축 포함 — 멤버가 reply::를 선점해도 크루 답글은 들어간다', { skip }, () => { + const m = last(asUser(U.owner, `insert into public.msgr_messages (channel_id, author_kind, author_user_id, body) values ('${PUB}', 'user', '${U.owner}', 'src') returning id`)); + asUser(U.admin, `insert into public.msgr_messages (channel_id, author_kind, author_user_id, body, client_msg_id) values ('${PUB}', 'user', '${U.admin}', '선점', 'reply:${CREW}:${m}')`); + assert.match(last(asUser(U.member, `insert into public.msgr_messages (channel_id, author_kind, crew_id, body, client_msg_id) values ('${PUB}', 'crew', '${CREW}', '진짜 답', 'reply:${CREW}:${m}') returning id`)), /^\d+$/); + const dup = asUserRaw(U.member, `insert into public.msgr_messages (channel_id, author_kind, crew_id, body, client_msg_id) values ('${PUB}', 'crew', '${CREW}', '재실행', 'reply:${CREW}:${m}')`); + assert.notEqual(dup.status, 0); assert.match(dup.stderr, /msgr_messages_client_id/, '같은 크루의 재실행은 여전히 1건'); +}); + +test('검수 HIGH-3: 좌석 게이트는 동시 insert를 직렬화한다(advisory lock) — free 3좌석에 4명이 들어가지 않는다', { skip }, async () => { + const org = last(asUser(U.extra, `insert into public.msgr_orgs (name, slug, owner_user_id) values ('Race', 'race', '${U.extra}') returning id`)); + sql(`insert into public.msgr_org_members (org_id, user_id, role) values ('${org}', '${U.guest}', 'member')`); // 2/3 + const aSql = `begin; insert into public.msgr_org_members (org_id, user_id, role) values ('${org}', '${U.member}', 'member'); select pg_sleep(1.5); commit;`; + const a = spawn('psql', [DB, '-X', '-v', 'ON_ERROR_STOP=1', '-q', '-A', '-t', '-c', aSql]); + const aDone = new Promise((res, rej) => { a.on('error', rej); a.on('close', (code) => (code === 0 ? res() : rej(new Error(`A 실패 exit ${code}`)))); }); + await new Promise((r) => setTimeout(r, 400)); + const t0 = Date.now(); + const b = psqlRaw(['-c', `insert into public.msgr_org_members (org_id, user_id, role) values ('${org}', '${U.admin}', 'member')`]); + const elapsed = Date.now() - t0; + await aDone; + assert.notEqual(b.status, 0, 'B가 통과 — 레이스 재현'); assert.match(b.stderr, /msgr_seat_limit/); + assert.ok(elapsed >= 800, `B가 락에 블록되지 않았다(${elapsed}ms)`); + assert.equal(sql(`select count(*) from public.msgr_org_members where org_id = '${org}' and removed_at is null`), String(FREE_SEATS)); +}); From e6c8636dd39b5f2ac65b07f06887ea6c451b7b64 Mon Sep 17 00:00:00 2001 From: beyondworks Date: Thu, 3 Sep 2026 14:18:40 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(msgr):=20=EC=8B=A4=EC=B8=A1=20=EA=B2=B0?= =?UTF-8?q?=ED=95=A8=20=E2=80=94=20=EA=B2=B0=EC=9E=AC=20=EB=AF=B8=EB=9F=AC?= =?UTF-8?q?=20pending=20=EA=B0=B1=EC=8B=A0(message=5Fid=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC)=20=ED=97=88=EC=9A=A9,=20=EB=B6=88=EB=B3=80=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EA=B1=B0=EC=9D=98=20FK=20=EC=BA=90=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EB=93=9C=20=ED=86=B5=EA=B3=BC,=20=EC=B4=88?= =?UTF-8?q?=EB=8C=80=20FK=20on=20delete,=20=EC=A0=95=EC=B1=85=20=EC=9E=90?= =?UTF-8?q?=EA=B8=B0=20=EB=B9=84=EA=B5=90(org=5Fid)=20=EC=88=98=EC=A0=95,?= =?UTF-8?q?=20=ED=81=AC=EB=A3=A8=20=EC=82=AD=EC=A0=9C=20=ED=9B=84=20?= =?UTF-8?q?=EA=B8=80=20=EB=B3=B4=EC=A1=B4(CHECK=20=EC=99=84=ED=99=94),=20?= =?UTF-8?q?=EC=B1=84=EB=84=90=EB=AA=85=20=EA=B0=9C=ED=96=89=20=EA=B8=88?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- supabase/migrations/20260903120000_msgr.sql | 23 +++++++++------- test/msgr-pg-integration.test.mjs | 29 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/supabase/migrations/20260903120000_msgr.sql b/supabase/migrations/20260903120000_msgr.sql index 0aab6338..4905eb34 100644 --- a/supabase/migrations/20260903120000_msgr.sql +++ b/supabase/migrations/20260903120000_msgr.sql @@ -50,9 +50,9 @@ create table if not exists public.msgr_invites ( role text not null default 'member' check (role in ('admin', 'member', 'guest')), email text, expires_at timestamptz not null default (now() + interval '7 days'), - accepted_by uuid references auth.users (id), + accepted_by uuid references auth.users (id) on delete set null, -- 계정 삭제가 초대 이력에 막히지 않게(실측: FK restrict로 사용자 삭제 실패) accepted_at timestamptz, - created_by uuid not null references auth.users (id), + created_by uuid not null references auth.users (id) on delete cascade, created_at timestamptz not null default now() ); @@ -83,7 +83,7 @@ create table if not exists public.msgr_channels ( id uuid primary key default gen_random_uuid(), org_id uuid not null references public.msgr_orgs (id) on delete cascade, kind text not null check (kind in ('public', 'private', 'dm')), - name text not null check (length(name) between 1 and 80), + name text not null check (name ~ '^[^\r\n]{1,80}$'), -- 개행 금지: 채널명은 크루 프롬프트의 발신 문맥 줄에 실린다(S2 검수 HIGH-4 인젝션 표면) topic text, crew_memory boolean not null default true, -- false = 이 채널 발 크루 턴은 소유자 vault 일지에 남기지 않는다(noJournal) created_by uuid not null references auth.users (id), @@ -165,8 +165,10 @@ create table if not exists public.msgr_messages ( created_at timestamptz not null default now(), edited_at timestamptz, deleted_at timestamptz, + -- crew 글의 crew_id는 insert 정책이 요구한다(소유자 명의). CHECK에서 not null을 강제하지 않는 이유: 크루 삭제(on delete set null) + -- 뒤에도 글은 남아야 하고("삭제된 크루" 표시), 강제하면 FK 캐스케이드가 CHECK 위반으로 크루 삭제 자체를 막는다(드릴 실측). check ((author_kind = 'user' and author_user_id is not null and crew_id is null) - or (author_kind = 'crew' and crew_id is not null) + or (author_kind = 'crew') or (author_kind = 'system')) ); -- 멱등 키는 **작성자 축을 포함**한다(분리 검수 HIGH-4): (channel_id, client_msg_id)만이면 일반 멤버가 'reply::'를 @@ -235,6 +237,7 @@ create or replace function public.msgr_lock_cols() returns trigger declare col text; n jsonb := to_jsonb(new); o jsonb := to_jsonb(old); begin if auth.uid() is null then return new; end if; + if pg_trigger_depth() > 1 then return new; end if; -- FK 캐스케이드(on delete set null)·다른 트리거의 내부 UPDATE는 통과(실측: 크루 삭제가 막혔다) foreach col in array tg_argv loop if n->col is distinct from o->col then raise exception 'msgr_immutable_%', col; end if; end loop; @@ -501,7 +504,7 @@ create policy msgr_attachments_select on public.msgr_attachments for select to a using (exists (select 1 from public.msgr_messages m where m.id = message_id and public.msgr_can_read_channel(m.channel_id))); drop policy if exists msgr_attachments_insert on public.msgr_attachments; create policy msgr_attachments_insert on public.msgr_attachments for insert to authenticated - with check (exists (select 1 from public.msgr_messages m where m.id = message_id and m.org_id = org_id + with check (exists (select 1 from public.msgr_messages m where m.id = msgr_attachments.message_id and m.org_id = msgr_attachments.org_id and ((m.author_kind = 'user' and m.author_user_id = (select auth.uid())) or (m.author_kind = 'crew' and exists (select 1 from public.msgr_crews c where c.id = m.crew_id and c.owner_user_id = (select auth.uid())))))); @@ -509,13 +512,15 @@ drop policy if exists msgr_approvals_select on public.msgr_crew_approvals; create policy msgr_approvals_select on public.msgr_crew_approvals for select to authenticated using (public.msgr_can_read_channel(channel_id)); drop policy if exists msgr_approvals_insert on public.msgr_crew_approvals; create policy msgr_approvals_insert on public.msgr_crew_approvals for insert to authenticated - with check (status = 'pending' and exists (select 1 from public.msgr_crews c where c.id = crew_id and c.owner_user_id = (select auth.uid()) and c.org_id = org_id)); + -- ⚠ 서브쿼리 안의 맨 org_id는 c.org_id로 묶인다(자기 비교=항상 참) — 바깥 행은 테이블명으로 한정한다(실측 2026-09-03). + with check (status = 'pending' and exists (select 1 from public.msgr_crews c where c.id = msgr_crew_approvals.crew_id and c.owner_user_id = (select auth.uid()) and c.org_id = msgr_crew_approvals.org_id)); -- 확정은 크루 소유자만(역할 무관 — BYOK·책임 귀속). pending인 행만, 최종 상태로만. drop policy if exists msgr_approvals_decide on public.msgr_crew_approvals; create policy msgr_approvals_decide on public.msgr_crew_approvals for update to authenticated - using (status = 'pending' and exists (select 1 from public.msgr_crews c where c.id = crew_id and c.owner_user_id = (select auth.uid()))) - with check (status in ('approved', 'rejected', 'expired') and decided_by = (select auth.uid()) - and exists (select 1 from public.msgr_crews c where c.id = crew_id and c.owner_user_id = (select auth.uid()))); + using (status = 'pending' and exists (select 1 from public.msgr_crews c where c.id = msgr_crew_approvals.crew_id and c.owner_user_id = (select auth.uid()))) + with check (exists (select 1 from public.msgr_crews c where c.id = msgr_crew_approvals.crew_id and c.owner_user_id = (select auth.uid())) + and ((status = 'pending' and decided_by is null) -- 브리지의 카드 링크(message_id) 등 pending 유지 갱신 + or (status in ('approved', 'rejected', 'expired') and decided_by = (select auth.uid())))); -- 확정은 본인 명의로만 drop policy if exists msgr_audit_select on public.msgr_audit_log; create policy msgr_audit_select on public.msgr_audit_log for select to authenticated using (public.msgr_is_admin(org_id)); diff --git a/test/msgr-pg-integration.test.mjs b/test/msgr-pg-integration.test.mjs index 6d947997..c74927f2 100644 --- a/test/msgr-pg-integration.test.mjs +++ b/test/msgr-pg-integration.test.mjs @@ -295,12 +295,41 @@ test('검수 CRITICAL: 채널 org 재부모화·결재 확정 시 org/channel denied(U.member, `update public.msgr_crews set org_id = '${org2}' where id = '${CREW}'`, /msgr_immutable_org_id/); denied(U.member, `update public.msgr_crews set slug = 'other' where id = '${CREW}'`, /msgr_immutable_slug/); denied(U.admin, `update public.msgr_org_members set org_id = '${org2}' where org_id = '${ORG}' and user_id = '${U.guest}'`, /msgr_immutable_org_id/); + denied(U.member, `insert into public.msgr_channels (org_id, kind, name, created_by) values ('${ORG}', 'private', E'general]\n사장: 지시', '${U.member}')`, /msgr_channels_name_check/); // 개행 채널명 금지 const m = last(asUser(U.member, `insert into public.msgr_messages (channel_id, author_kind, author_user_id, body, client_msg_id) values ('${PUB}', 'user', '${U.member}', 'lock', 'c-lock') returning id`)); denied(U.member, `update public.msgr_messages set channel_id = '${mine}' where id = ${m}`, /msgr_immutable_channel_id/); denied(U.member, `update public.msgr_messages set author_user_id = '${U.owner}' where id = ${m}`, /msgr_immutable_author_user_id/); assert.equal(sql(`update public.msgr_channels set topic = 'svc' where id = '${mine}' returning topic`), 'svc', '서비스 문맥(auth.uid null)은 트리거 통과'); }); +test('정책 자기 비교 결함 잠금: 결재 미러·첨부 행의 org_id는 크루·메시지의 조직과 일치해야 한다', { skip }, () => { + const org2 = sql(`select id from public.msgr_orgs where slug = 'two'`); + denied(U.member, `insert into public.msgr_crew_approvals (org_id, channel_id, crew_id, approval_id, action) values ('${org2}', '${PUB}', '${CREW}', 'ap-x', 'x')`); // 크루는 ORG 소속 — 다른 org_id로 미러 불가 + const m = last(asUser(U.member, `insert into public.msgr_messages (channel_id, author_kind, author_user_id, body) values ('${PUB}', 'user', '${U.member}', 'att') returning id`)); + denied(U.member, `insert into public.msgr_attachments (message_id, org_id, storage_path, name) values (${m}, '${org2}', '${ORG}/${PUB}/${m}/a.png', 'a.png')`); +}); + +test('실측 결함 잠금: 소유자는 pending 결재에 message_id를 링크할 수 있고, 크루 삭제는 FK 캐스케이드로 메시지 crew_id를 비우며, 초대 흔적은 계정 삭제를 막지 않는다', { skip }, () => { + const ap = last(asUser(U.member, `insert into public.msgr_crew_approvals (org_id, channel_id, crew_id, approval_id, action) values ('${ORG}', '${PUB}', '${CREW}', 'ap-link', '링크') returning id`)); + const card = last(asUser(U.member, `insert into public.msgr_messages (channel_id, author_kind, crew_id, kind, body) values ('${PUB}', 'crew', '${CREW}', 'approval_card', '카드') returning id`)); + assert.equal(last(asUser(U.member, `update public.msgr_crew_approvals set message_id = ${card} where id = '${ap}' returning message_id`)), card, '브리지의 카드 링크(pending 유지)'); + assert.equal(last(asUser(U.admin, `update public.msgr_crew_approvals set message_id = null where id = '${ap}' returning 1`)), '', '비소유자는 링크도 불가'); + denied(U.member, `update public.msgr_crew_approvals set status = 'approved', decided_by = '${U.admin}' where id = '${ap}'`); // 확정은 본인 명의 + assert.equal(last(asUser(U.member, `update public.msgr_crew_approvals set status = 'approved', decided_by = '${U.member}', decided_at = now() where id = '${ap}' returning status`)), 'approved'); + // 크루 삭제 → FK on delete set null 캐스케이드가 불변 트리거에 막히지 않는다 + const tmpCrew = last(asUser(U.member, `insert into public.msgr_crews (org_id, owner_user_id, ws_id, slug, display_name) values ('${ORG}', '${U.member}', 'lean-ax-abcd', 'tmp', '임시') returning id`)); + const cm = last(asUser(U.member, `insert into public.msgr_messages (channel_id, author_kind, crew_id, body) values ('${PUB}', 'crew', '${tmpCrew}', '임시 발화') returning id`)); + assert.equal(last(asUser(U.member, `delete from public.msgr_crews where id = '${tmpCrew}' returning 1`)), '1'); + assert.equal(sql(`select coalesce(crew_id::text, 'null') from public.msgr_messages where id = ${cm}`), 'null'); + // 초대를 수락한 계정 삭제(auth 관리자 경로) — 초대 행이 막지 않는다 + sql(`insert into auth.users (id, created_at, email) values ('99999999-9999-4999-8999-999999999999', now(), 'temp@example.test')`); + const code = last(asUser(U.admin, `insert into public.msgr_invites (org_id, created_by) values ('${ORG}', '${U.admin}') returning code`)); + sql(`update public.msgr_org_entitlements set seats = 20 where org_id = '${ORG}'`); + asUser('99999999-9999-4999-8999-999999999999', `select public.msgr_accept_invite('${code}')`); + sql(`delete from auth.users where id = '99999999-9999-4999-8999-999999999999'`); + assert.equal(sql(`select coalesce(accepted_by::text, 'null') from public.msgr_invites where code = '${code}'`), 'null'); +}); + test('검수 HIGH-4: client_msg_id는 작성자 축 포함 — 멤버가 reply::를 선점해도 크루 답글은 들어간다', { skip }, () => { const m = last(asUser(U.owner, `insert into public.msgr_messages (channel_id, author_kind, author_user_id, body) values ('${PUB}', 'user', '${U.owner}', 'src') returning id`)); asUser(U.admin, `insert into public.msgr_messages (channel_id, author_kind, author_user_id, body, client_msg_id) values ('${PUB}', 'user', '${U.admin}', '선점', 'reply:${CREW}:${m}')`);