Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/guides/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
18 changes: 16 additions & 2 deletions supabase/functions/_e2e/functions_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}});
32 changes: 32 additions & 0 deletions supabase/functions/_shared/user-delete.ts
Original file line number Diff line number Diff line change
@@ -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<typeof userDeleteMessageSchema>;

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;
}
52 changes: 52 additions & 0 deletions supabase/functions/platform-moderation/deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 33 additions & 4 deletions supabase/functions/platform-moderation/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -49,7 +51,8 @@ export async function handle(request: Request): Promise<Response> {
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.
Expand All @@ -59,10 +62,36 @@ export async function handle(request: Request): Promise<Response> {
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<void> {
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;
}
}
20 changes: 16 additions & 4 deletions supabase/schemas/moderation/functions/apply_event.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion supabase/schemas/moderation/schema.sql
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
13 changes: 10 additions & 3 deletions supabase/schemas/moderation/tables/user_states.sql
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
-- 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;

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.';
Loading
Loading