diff --git a/docs/guides/backend.md b/docs/guides/backend.md index 5b8fb15..b7e8099 100644 --- a/docs/guides/backend.md +++ b/docs/guides/backend.md @@ -43,6 +43,19 @@ Rules worth knowing, each asserted by a pgTAP file under `supabase/tests/`: - A `content.hide` sets `quarantinedBy` on the session or profile; RLS then shows the row to its author only. `user.suspend` and `user.ban` make every policy treat the user as restricted. `content.restore` and `user.restore` undo them. +- `user.delete` (DevDogs account deletion): `apply_event` locks the account out and + records the state as `deleted` (no reason kept); the function then deletes the + linked auth user through the admin API (`auth.admin.deleteUser`). Every DogPack + table references `auth.users` with `on delete cascade`, so the profile, + enrollments, hosted sessions with their rosters, memberships, blocks in both + directions and the onboarding marker all go; nothing blocks it. Repeats and + unknown users answer 2xx, and a retry after a failed admin call finishes the + deletion; a `stale` outcome (an older message than the last decision) still deletes, since the + account is gone either way. Earlier `user.ban`/`user.suspend` payloads for the same person stay + in `moderation.events` with their reasons (bans survive "delete my data"; best effort per the + decision). `moderation.user_states` keeps only the platform user id with state + `deleted`. Until `_shared/moderation` is re-vendored from a commit that carries + the type (DevDogsUGA #215), `_shared/user-delete.ts` accepts it locally. - `_shared/moderation/` is a vendored copy of `@devdogsuga/sdk/moderation`; see its README for why and how it is kept in step. diff --git a/supabase/functions/_e2e/functions_test.ts b/supabase/functions/_e2e/functions_test.ts index b74b7fc..1bccfa3 100644 --- a/supabase/functions/_e2e/functions_test.ts +++ b/supabase/functions/_e2e/functions_test.ts @@ -232,13 +232,27 @@ Deno.test({ name: "platform-moderation: signed, idempotent, order-safe, and a hi assertEquals((await bob.client.schema("dogpack").from("sessions").select("id").eq("id", sessionId)).data!.length, 1); }}); +const unknownDeleted = crypto.randomUUID(); +Deno.test({ name: "platform-moderation: user.delete removes the local user, idempotently", ...loose, fn: async () => { + const del = { type: "user.delete", timestamp: new Date(Date.now() + 120_000).toISOString(), data: { userId: cara.platformId, reason: null, reportId: null } }; + assertEquals((await deliver(del, { id: `msg_del_${run}` })).json.outcome, "applied"); + assertNotEquals((await admin.auth.admin.getUserById(cara.id)).error, null, "the auth user is gone"); + assertEquals((await sql`select 1 from dogpack.profiles where "userId" = ${cara.id}`).length, 0); + const again = await deliver(del, { id: `msg_del_${run}` }); + assertEquals(again.status, 200); + assertEquals(again.json.outcome, "duplicate"); + // A different message for an id DogPack never had. + const unknown = { ...del, data: { ...del.data, userId: unknownDeleted } }; + assertEquals((await deliver(unknown, { id: `msg_del_unknown_${run}` })).status, 200); +}}); + Deno.test({ name: "cleanup", ...loose, fn: async () => { for (const m of [alice, bob, cara]) { await admin.auth.admin.deleteUser(m.id); } await sql`delete from moderation.events where id like ${"%" + run}`; - await sql`delete from moderation.user_states where "platformUserId" in ${sql([alice.platformId, bob.platformId, cara.platformId])}`; - await sql`delete from moderation.decisions where "itemKey" like 'content:session:%' or "itemKey" like ${"user:" + alice.platformId}`; + await sql`delete from moderation.user_states where "platformUserId" in ${sql([alice.platformId, bob.platformId, cara.platformId, unknownDeleted])}`; + await sql`delete from moderation.decisions where "itemKey" like 'content:session:%' or "itemKey" in ${sql(["user:" + alice.platformId, "user:" + cara.platformId, "user:" + unknownDeleted])}`; await sql.end(); await platform.close(); }}); diff --git a/supabase/functions/_shared/user-delete.ts b/supabase/functions/_shared/user-delete.ts new file mode 100644 index 0000000..7ab215a --- /dev/null +++ b/supabase/functions/_shared/user-delete.ts @@ -0,0 +1,32 @@ +/** + * The `user.delete` moderation message, accepted locally. + * + * The vendored `messages.ts` is pinned to a commit that predates it, and the + * published @devdogsuga/sdk does not carry it yet (DevDogsUGA PR #215 adds it + * to USER_MESSAGE_TYPES, the receiver and sdk-spec). Same wire shape as the + * other user messages. Delete this file and use `parseMessage` once the vendored + * copy is re-vendored from a commit that includes #215. + */ +import { z } from "zod"; + +export const userDeleteMessageSchema = z.object({ + type: z.literal("user.delete"), + timestamp: z.iso.datetime(), + data: z.object({ + userId: z.uuid(), + reason: z.string().max(1000).nullable(), + reportId: z.uuid().nullable(), + }), +}); +export type UserDeleteMessage = z.infer; + +export function parseUserDelete(body: string): UserDeleteMessage | null { + let json: unknown; + try { + json = JSON.parse(body); + } catch { + return null; + } + const parsed = userDeleteMessageSchema.safeParse(json); + return parsed.success ? parsed.data : null; +} diff --git a/supabase/functions/platform-moderation/deno.lock b/supabase/functions/platform-moderation/deno.lock index ecc96d0..e6569a8 100644 --- a/supabase/functions/platform-moderation/deno.lock +++ b/supabase/functions/platform-moderation/deno.lock @@ -1,13 +1,65 @@ { "version": "5", "specifiers": { + "npm:@supabase/supabase-js@2": "2.117.3", "npm:postgres@3.4.9": "3.4.9", "npm:zod@^4.1.0": "4.3.6" }, "npm": { + "@supabase/auth-js@2.117.3": { + "integrity": "sha512-XOZV+jllcU2Hzij8SwgM78zl0lOEr6BTjzi2MYkBb39nOAQzb5AelGpMUvVj7lwGSB11JmpAs/FEQroAyFzWEA==", + "dependencies": [ + "tslib" + ] + }, + "@supabase/functions-js@2.117.3": { + "integrity": "sha512-6GuizbP4N5xWaxEVUsI+UQpJdmMmn0t3sYNpObo9gCAldvM22gFjnYzRVmkHxp5ylU21azEYmiWVwk9iDgCMkQ==", + "dependencies": [ + "tslib" + ] + }, + "@supabase/phoenix@0.4.5": { + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==" + }, + "@supabase/postgrest-js@2.117.3": { + "integrity": "sha512-NpyTgullu6tw11ojYKTdZg9HannZ4mu4FUlhc9V5zDXOVllUJzEmuyLM+WWoYF0BurfrRvarimpm3SR8P0Gzfw==", + "dependencies": [ + "tslib" + ] + }, + "@supabase/realtime-js@2.117.3": { + "integrity": "sha512-4W4ISbreRygNXbhwqxeqyuRul8RPx+SF+Hp2IT8wctebQEHSVCULfKatiRiEhmasfoXvYm2AHV4XFsOxhoousA==", + "dependencies": [ + "@supabase/phoenix", + "tslib" + ] + }, + "@supabase/storage-js@2.117.3": { + "integrity": "sha512-QhUxFC33Crlw6WDeA4hLADdi1PzjI3c4UJs7ivWHHEnU7ulmCWEZSDaRCuIfmsjUwbHYaIfRNFQNsm+IsGYS6w==", + "dependencies": [ + "iceberg-js", + "tslib" + ] + }, + "@supabase/supabase-js@2.117.3": { + "integrity": "sha512-K+f8PimXDunPWQ63CKRXaF5pbVPJxmfw5ryAnh0s9S9PkwcuqBjiSpIaqpF7KLDljeYwXIL85ST8QxaO0+XuvA==", + "dependencies": [ + "@supabase/auth-js", + "@supabase/functions-js", + "@supabase/postgrest-js", + "@supabase/realtime-js", + "@supabase/storage-js" + ] + }, + "iceberg-js@0.8.1": { + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==" + }, "postgres@3.4.9": { "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==" }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "zod@4.3.6": { "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==" } diff --git a/supabase/functions/platform-moderation/handler.ts b/supabase/functions/platform-moderation/handler.ts index 796d4d1..96e006f 100644 --- a/supabase/functions/platform-moderation/handler.ts +++ b/supabase/functions/platform-moderation/handler.ts @@ -11,12 +11,14 @@ * 2. idempotent moderation.apply_event (the webhook id is recorded) * 3. order-safe moderation.apply_event (older than the last decision: stale) * Everything that is not an application still answers 2xx so the platform stops - * retrying; only a database failure answers 5xx, which it retries with the + * retrying; only a database or admin API failure answers 5xx, which it retries with the * message intact, because apply_event records the id and the change together. */ import postgres from "npm:postgres@3.4.9"; import { parseMessage } from "../_shared/moderation/messages.ts"; import { verifyMessage } from "../_shared/moderation/signing.ts"; +import { parseUserDelete } from "../_shared/user-delete.ts"; +import { serviceClient } from "../_shared/auth.ts"; import { json } from "../_shared/http.ts"; /** One secret, or several (comma separated) while a rotation is in flight. */ @@ -49,7 +51,8 @@ export async function handle(request: Request): Promise { const verified = await verifyMessage(keys, request.headers, body); if (!verified.ok) return json(401, { status: 401, error: verified.reason }); - const message = parseMessage(body); + const deletion = parseUserDelete(body); + const message = deletion ?? parseMessage(body); if (!message) { // Signed but not a message this version understands (a newer schema, say). // Answer 2xx like every other non-application: a retry cannot fix it. @@ -59,10 +62,36 @@ export async function handle(request: Request): Promise { try { const [row] = await database()` select moderation.apply_event(${verified.id}, ${body}::text::jsonb) as outcome`; - return json(200, { status: 200, outcome: row.outcome as string }); + const outcome = row.outcome as string; + // user.delete: apply_event locked the account out and recorded the id; the + // auth user is removed here, through the admin API, and DogPack's foreign + // keys cascade the rest. It runs on 'duplicate' too, so a retry after a + // failed admin call finishes the job. Deletion is final, so it runs + // whatever the ordering outcome (even 'stale'). An unknown user, or one + // already deleted, is a success. + if (deletion) { + await deleteLocalUser(deletion.data.userId); + } + return json(200, { status: 200, outcome }); } catch (error) { - console.error("moderation.apply_event failed", verified.id, error); + console.error("platform-moderation failed", verified.id, error); return json(500, { status: 500, error: "apply_failed" }); } } +/** + * Delete the DogPack auth user linked to a DevDogs identity, if there is one. + * Their identity row is what links them, so once the user is gone a repeat + * finds nothing and returns quietly. + */ +async function deleteLocalUser(platformUserId: string): Promise { + const rows = await database()` + select i.user_id as id + from auth.identities i + where i.provider = 'custom:devdogsuga' and i.provider_id = ${platformUserId}`; + for (const { id } of rows) { + const { error } = await serviceClient().auth.admin.deleteUser(id as string); + // Deleted between the lookup and the call: that is what we wanted. + if (error && error.status !== 404 && error.code !== "user_not_found") throw error; + } +} diff --git a/supabase/schemas/moderation/functions/apply_event.sql b/supabase/schemas/moderation/functions/apply_event.sql index 66b5e9b..be1c202 100644 --- a/supabase/schemas/moderation/functions/apply_event.sql +++ b/supabase/schemas/moderation/functions/apply_event.sql @@ -18,6 +18,14 @@ -- user.suspend / user.ban the user cannot sign in and every policy -- treats them as restricted -- user.restore lifts both +-- user.delete the platform account is gone (DevDogs +-- account deletion). Here: the same lockout +-- as a ban, the state recorded as 'deleted' +-- with the reason dropped. The auth user +-- itself is deleted by the Edge Function +-- through the admin API right after this +-- commits, and DogPack's own foreign keys +-- (all on delete cascade) remove the rest. -- -- A message about content or a user DogPack has no row for is recorded and -- answers 'applied': there is nothing to hide, and a later row would not be @@ -45,7 +53,7 @@ begin raise exception 'missing message id'; end if; if v_type not in ('content.hide', 'content.remove', 'content.restore', - 'user.suspend', 'user.ban', 'user.restore') then + 'user.suspend', 'user.ban', 'user.restore', 'user.delete') then raise exception 'unknown message type %', v_type; end if; if v_decided is null then @@ -62,7 +70,9 @@ begin -- 1. Idempotent. The id is recorded before the ordering check, so a stale -- message that is replayed is a duplicate, not stale twice. insert into moderation.events ("id", "type", "itemKey", "decidedAt", "payload") - values (p_id, v_type, v_key, v_decided, p_message) + values (p_id, v_type, v_key, v_decided, + -- A deleted account keeps no free text about the person, here either. + case when v_type = 'user.delete' then jsonb_set(p_message, '{data,reason}', 'null'::jsonb) else p_message end) on conflict ("id") do nothing; get diagnostics v_inserted = row_count; if v_inserted = 0 then @@ -103,8 +113,10 @@ begin insert into moderation.user_states ("platformUserId", "state", "reason", "decidedAt") values ( v_platform_user, - case v_type when 'user.suspend' then 'suspended' when 'user.ban' then 'banned' else 'active' end, - v_data ->> 'reason', + case v_type when 'user.suspend' then 'suspended' when 'user.ban' then 'banned' + when 'user.delete' then 'deleted' else 'active' end, + -- A deleted account keeps no free text about the person. + case when v_type = 'user.delete' then null else v_data ->> 'reason' end, v_decided ) on conflict ("platformUserId") do update diff --git a/supabase/schemas/moderation/schema.sql b/supabase/schemas/moderation/schema.sql index 31b37dd..5f924f1 100644 --- a/supabase/schemas/moderation/schema.sql +++ b/supabase/schemas/moderation/schema.sql @@ -1,5 +1,5 @@ -- The moderation receiver's state: which platform messages were applied, the --- last decision per item, and which platform users are suspended or banned. +-- last decision per item, and which platform users are suspended, banned or deleted. -- Not exposed over the API. Only `moderation.apply_event` writes it, and only -- the `platform-moderation` Edge Function calls that. create schema if not exists moderation; diff --git a/supabase/schemas/moderation/tables/user_states.sql b/supabase/schemas/moderation/tables/user_states.sql index 3f5241d..7cff844 100644 --- a/supabase/schemas/moderation/tables/user_states.sql +++ b/supabase/schemas/moderation/tables/user_states.sql @@ -1,15 +1,22 @@ --- Platform users who are suspended or banned, keyed by their DEVDOGS user id +-- Platform users who are suspended, banned or deleted, keyed by their DEVDOGS user id -- (the subject of their custom:devdogsuga identity), not by DogPack's own -- auth id. A ban can arrive before the person ever signs in to DogPack, and -- must still bite when they do; `private.is_restricted` joins the two ids at -- check time. The row survives "delete my data" on purpose: deleting your -- data does not lift a ban. +-- +-- Account deletion (`user.delete`): the row stays with state 'deleted' and no +-- reason. It holds only the platform's own user id, which the platform has +-- already deleted, and it keeps a late or replayed message about that id from +-- being treated as a new person. The local auth user and everything DogPack +-- holds on it is removed by the platform-moderation function. Earlier ban and +-- suspend payloads in moderation.events keep their reasons (best effort). create table moderation.user_states ( "platformUserId" uuid primary key, "state" text not null, "reason" text, "decidedAt" timestamptz not null, - constraint "user_states_state_valid" check ("state" in ('active', 'suspended', 'banned')) + constraint "user_states_state_valid" check ("state" in ('active', 'suspended', 'banned', 'deleted')) ); alter table moderation.user_states enable row level security; @@ -17,4 +24,4 @@ alter table moderation.user_states enable row level security; revoke all on table moderation.user_states from public, anon, authenticated; grant select, insert, update on table moderation.user_states to service_role; -comment on table moderation.user_states is 'Suspended and banned platform users, keyed by the DevDogs user id.'; +comment on table moderation.user_states is 'Suspended, banned and deleted platform users, keyed by the DevDogs user id.'; diff --git a/supabase/tests/03_moderation.test.sql b/supabase/tests/03_moderation.test.sql index 7aff26f..bd7e620 100644 --- a/supabase/tests/03_moderation.test.sql +++ b/supabase/tests/03_moderation.test.sql @@ -31,7 +31,7 @@ insert into dogpack.enrollments ("userId", "term", "course") values insert into dogpack.sessions ("id", "hostId", "term", "course", "title", "location", "startsAt", "endsAt", "capacity") values ('33333333-3333-4333-8333-333333333333', '00000000-0000-4000-8000-0000000000f1', 202808, 'CSCI 1301', 'Loops review', 'MLC', now() + interval '1 day', now() + interval '1 day 2 hours', 4); -select plan(44); +select plan(59); -- The function is for the service role only. select tests.as_user('00000000-0000-4000-8000-0000000000f2'); @@ -148,6 +148,47 @@ insert into auth.identities (provider_id, user_id, identity_data, provider) valu ('aaaaaaaa-0000-4000-8000-000000000003', '00000000-0000-4000-8000-0000000000f3', '{}', 'custom:devdogsuga'); select is(private.is_restricted('00000000-0000-4000-8000-0000000000f3'), true, 'and they are restricted from their first session'); +-- Account deletion (user.delete): lock the account out, record 'deleted', and let +-- the auth user's deletion (the Edge Function's admin API call, simulated here +-- by a delete on auth.users) cascade through every DogPack table. +insert into auth.users (id, email) values ('00000000-0000-4000-8000-0000000000f4', 'leaving@dogpack.devdogsuga.test'); +insert into auth.identities (provider_id, user_id, identity_data, provider) values + ('aaaaaaaa-0000-4000-8000-000000000004', '00000000-0000-4000-8000-0000000000f4', '{}', 'custom:devdogsuga'); +insert into dogpack.profiles ("userId", "displayName", "classYear", "major", "onboardedAt") + values ('00000000-0000-4000-8000-0000000000f4', 'Leaving', 'senior', 'CS', now()); +insert into dogpack.enrollments ("userId", "term", "course") values ('00000000-0000-4000-8000-0000000000f4', 202808, 'CSCI 1301'); +insert into dogpack.sessions ("id", "hostId", "term", "course", "title", "location", "startsAt", "endsAt", "capacity") values + ('66666666-6666-4666-8666-666666666666', '00000000-0000-4000-8000-0000000000f4', 202808, 'CSCI 1301', 'Leaving host', 'MLC', now() + interval '2 days', now() + interval '2 days 2 hours', 4); +insert into dogpack.session_members ("sessionId", "userId") values ('66666666-6666-4666-8666-666666666666', '00000000-0000-4000-8000-0000000000f2'); +insert into dogpack.blocks ("blockerId", "blockedId") values + ('00000000-0000-4000-8000-0000000000f2', '00000000-0000-4000-8000-0000000000f4'), + ('00000000-0000-4000-8000-0000000000f4', '00000000-0000-4000-8000-0000000000f2'); + +select is( + moderation.apply_event('msg_12', '{"type":"user.delete","timestamp":"2027-01-07T09:00:00Z","data":{"userId":"aaaaaaaa-0000-4000-8000-000000000004","reason":"gone","reportId":null}}'::jsonb), + 'applied', 'a user.delete is applied'); +select is((select "payload" -> 'data' -> 'reason' from moderation.events where "id" = 'msg_12'), 'null'::jsonb, 'and the stored message keeps no reason either'); +select is((select state from moderation.user_states where "platformUserId" = 'aaaaaaaa-0000-4000-8000-000000000004'), 'deleted', 'the state is recorded as deleted'); +select is((select reason from moderation.user_states where "platformUserId" = 'aaaaaaaa-0000-4000-8000-000000000004'), null, 'with no free-text reason kept'); +select isnt((select banned_until from auth.users where id = '00000000-0000-4000-8000-0000000000f4'), null, 'and the account is locked out until the auth user is removed'); + +-- The admin API's deleteUser. Nothing in DogPack may block it. +delete from auth.users where id = '00000000-0000-4000-8000-0000000000f4'; +select is((select count(*)::int from dogpack.profiles where "userId" = '00000000-0000-4000-8000-0000000000f4'), 0, 'deleting the auth user removes the profile'); +select is((select count(*)::int from dogpack.enrollments where "userId" = '00000000-0000-4000-8000-0000000000f4'), 0, 'the enrollments'); +select is((select count(*)::int from dogpack.sessions where "hostId" = '00000000-0000-4000-8000-0000000000f4'), 0, 'the sessions they hosted'); +select is((select count(*)::int from dogpack.session_members where "sessionId" = '66666666-6666-4666-8666-666666666666'), 0, 'with their rosters'); +select is((select count(*)::int from dogpack.blocks where "blockerId" = '00000000-0000-4000-8000-0000000000f4' or "blockedId" = '00000000-0000-4000-8000-0000000000f4'), 0, 'blocks to and from them'); +select is((select count(*)::int from private.onboarded_users where "userId" = '00000000-0000-4000-8000-0000000000f4'), 0, 'and the onboarding marker'); +select is((select count(*)::int from auth.identities where user_id = '00000000-0000-4000-8000-0000000000f4'), 0, 'and their DevDogs identity'); +select is((select "displayName" from dogpack.profiles where "userId" = '00000000-0000-4000-8000-0000000000f2'), 'Back again', 'other members'' data is untouched'); +select is( + moderation.apply_event('msg_12', '{"type":"user.delete","timestamp":"2027-01-07T09:00:00Z","data":{"userId":"aaaaaaaa-0000-4000-8000-000000000004","reason":"gone","reportId":null}}'::jsonb), + 'duplicate', 'a replayed user.delete is a duplicate'); +select is( + moderation.apply_event('msg_13', '{"type":"user.delete","timestamp":"2027-01-07T09:05:00Z","data":{"userId":"aaaaaaaa-0000-4000-8000-000000000099","reason":null,"reportId":null}}'::jsonb), + 'applied', 'a user.delete for a platform user DogPack never had is acknowledged'); + -- Malformed messages are refused, and leave no trace. select throws_ok($$select moderation.apply_event('msg_11', '{"type":"content.explode","timestamp":"2027-01-06T09:00:00Z","data":{}}'::jsonb)$$, 'P0001', null, 'an unknown message type raises'); select is((select count(*)::int from moderation.events where "id" = 'msg_11'), 0, 'and is not recorded');