Skip to content

fix(db): free-account deletion and fresh Cloud installs work on Postgres — CI now tests on Postgres - #185

Draft
MrChengLen wants to merge 1 commit into
mainfrom
pr-account-deletion-postgres
Draft

MrChengLen wants to merge 1 commit into
mainfrom
pr-account-deletion-postgres

Conversation

@MrChengLen

Copy link
Copy Markdown
Owner

What

Two PostgreSQL-only bugs that the SQLite test suite could not see, and a CI job that runs the migrations and the audit trigger on a real PostgreSQL from now on.

1. Deleting a free account failed on PostgreSQL (GDPR Art. 17). DELETE /api/v1/auth/account returned 500, and nothing was deleted. audit_events.actor_user_id references users with ON DELETE SET NULL. Postgres carries out that SET NULL as an UPDATE of the audit rows, and the append-only trigger from migration 005 refused every UPDATE, so the whole DELETE FROM users rolled back. Every account has at least one audit event naming it (at the latest the deletion request itself), so every free-path deletion failed. Accounts with a Stripe customer id take the tax-retained path, which keeps the users row, so nothing cascades there.

Migration 012_audit_actor_erasure replaces the trigger function so that exactly that change passes:

  • actor_user_id goes from a value to NULL;
  • nothing else in the row changes (to_jsonb(NEW) - 'actor_user_id' = to_jsonb(OLD) - 'actor_user_id', so a column added or dropped later cannot break it);
  • the account it named no longer exists in public.users. The lookup is schema-qualified because a temporary table named users would otherwise shadow the real one.

A direct UPDATE that nulls a live account's id, every other UPDATE and every DELETE are still refused. actor_user_id is not an input to record_hash, so verify_chain still passes. The downgrade restores migration 005's function.

2. A fresh Cloud Edition database could not be migrated. Migration 001 created tier_enum and job_status_enum itself and then again through op.create_table, which creates a column's enum type without checkfirst. On an empty Postgres, alembic upgrade head therefore stopped with type "tier_enum" already exists. The container entrypoint runs that command on every Cloud Edition start, so a fresh install never came up. The two explicit create() calls are gone. A database that already has the schema is not affected, since Alembic does not run 001 again.

CI

lint-and-test and the weekly deps-latest run now start a PostgreSQL 16 service (digest-pinned; trust auth on a throwaway database). tests/test_audit_postgres.py:

  • migrates an empty database with Alembic;
  • deletes an account through the API and checks that the account is gone, its audit events remain without the account id, and the chain verifies;
  • checks that every other change is refused: payload update, nulling a live account's id, reassigning the actor, deleting the row, a temporary users table standing in, and a cascade that changes anything besides the actor.

A guard test fails if either workflow stops providing the database, so the Postgres tests cannot skip unnoticed.

Reproduction, commit by commit

  1. Tests + CI only: REPRO1_RUN — the Postgres tests stop at the migration (type "tier_enum" already exists).
    • migration 001 fix: REPRO2_RUN — only the deletion test fails (audit_events is append-only; UPDATE is not permitted).
    • migration 012: FIX_RUN — green.

Docs

The TOM annex and the vendor security questionnaire name migration 012 next to 005. The RoPA, DPA and questionnaire already say that a hard delete nulls the actor id; with this PR that is what happens. Two CHANGELOG entries.

Rejected alternatives

  • Dropping the foreign key or making it NO ACTION: the deleted person's id would stay in the log.
  • Deleting the person's audit events: breaks the chain and the append-only guarantee.
  • Comparing the columns one by one: a later migration that drops a column would make the function fail at run time.
  • pg_trigger_depth() > 1: admits an UPDATE from any trigger, not just the deletion cascade.

🤖 Generated with Claude Code

…on fails there

The suite only ever ran on in-memory SQLite, where migration 005's
append-only trigger on audit_events does not exist. On Postgres the
trigger also refuses the ON DELETE SET NULL cascade from users, so
DELETE /api/v1/auth/account aborts for every account without a Stripe
customer id and deletes nothing.

lint-and-test and the weekly deps-latest run now start a PostgreSQL 16
service (pinned by digest; trust auth on a throwaway database), and
tests/test_audit_postgres.py migrates it with Alembic, deletes an
account through the API and checks that every other change to an audit
event is still refused. A guard fails if either workflow stops
providing the database, so these tests cannot skip unnoticed in CI.

This commit adds the tests only, and they are expected to fail: the
first run on an empty Postgres stops in migration 001 ("type tier_enum
already exists"), and once that is fixed,
test_deleting_a_free_account_nulls_its_audit_actor fails with "audit_events
is append-only; UPDATE is not permitted". Both fixes follow.

Local suite 1490 passed, 79 skipped (the Postgres tests skip without a
database); ruff clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant