Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
27fbe8a
test(audit): run the audit trigger on Postgres in CI — account deleti…
MrChengLen Sep 30, 2026
dd1190c
fix(db): a fresh Postgres database can be migrated — baseline creates…
MrChengLen Oct 1, 2026
04a7408
chore(changelog): take the entry out to merge main in — back in two c…
MrChengLen Oct 1, 2026
b5671cd
Merge branch 'main' into pr-account-deletion-postgres
MrChengLen Oct 1, 2026
49a0af0
docs(changelog): fresh-install entry back on top of the merged changelog
MrChengLen Oct 1, 2026
e103452
chore(changelog): take the entry out to merge main in — back in two c…
MrChengLen Oct 1, 2026
9136881
Merge branch 'main' into pr-account-deletion-postgres
MrChengLen Oct 1, 2026
284261f
docs(changelog): fresh-install entry back on top of the merged changelog
MrChengLen Oct 1, 2026
a5ddd79
fix(auth): deleting a free account works on Postgres — audit trigger …
MrChengLen Oct 1, 2026
e5e26c3
chore(changelog): take the entries out to merge main in — back in two…
MrChengLen Oct 1, 2026
c13d3ce
Merge branch 'main' into pr-account-deletion-postgres
MrChengLen Oct 1, 2026
d584688
docs(changelog): both entries back on top of the merged changelog
MrChengLen Oct 1, 2026
6804361
chore(changelog): take the entries out to merge main in — back in two…
MrChengLen Oct 1, 2026
d3513fd
Merge branch 'main' into pr-account-deletion-postgres
MrChengLen Oct 1, 2026
f018bf6
docs(changelog): both entries back on top of the merged changelog
MrChengLen Oct 1, 2026
d7e0a27
chore(changelog): take the entries out to merge main in — back in two…
MrChengLen Oct 1, 2026
3ba3d97
Merge branch 'main' into pr-account-deletion-postgres
MrChengLen Oct 1, 2026
af42349
docs(changelog): both entries back on top of the merged changelog
MrChengLen Oct 1, 2026
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
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,30 @@ jobs:
lint-and-test:
runs-on: ubuntu-latest

# A real PostgreSQL for tests/test_audit_postgres.py. The rest of the suite
# builds its schema from the models on in-memory SQLite and never runs the
# migrations. That is how migration 005's append-only trigger on
# audit_events could refuse the ON DELETE SET NULL cascade of every free
# account deletion (fixed in migration 012), and migration 001 could fail on
# an empty database, without a failing test. The major version is the one
# docker-compose.cloud.yml runs (the test checks it). Pinned by digest
# like the veraPDF image: to move it, take the digest from
# `docker buildx imagetools inspect postgres:<tag>`. Trust auth is fine for
# a throwaway database that only this job's steps can reach.
services:
postgres:
image: postgres:16.15-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
env:
POSTGRES_DB: filemorph_test
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -h localhost -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Expand Down Expand Up @@ -182,6 +206,8 @@ jobs:
run: pip-audit -r requirements.lock --ignore-vuln PYSEC-2026-1325 --ignore-vuln CVE-2026-55073

- name: Run tests
env:
FILEMORPH_TEST_POSTGRES_URL: postgresql+asyncpg://postgres@localhost:5432/filemorph_test
run: pytest tests/ -v --tb=short

# requirements.lock is what the image installs, so it has to keep
Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/deps-latest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ jobs:
test-latest:
runs-on: ubuntu-latest

# The same PostgreSQL service as ci.yml's lint-and-test; see the comment there.
services:
postgres:
image: postgres:16.15-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea
env:
POSTGRES_DB: filemorph_test
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -h localhost -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Expand All @@ -57,4 +72,6 @@ jobs:
run: pip install -r requirements-dev.txt

- name: Run tests
env:
FILEMORPH_TEST_POSTGRES_URL: postgresql+asyncpg://postgres@localhost:5432/filemorph_test
run: pytest tests/ -v --tb=short
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,43 @@ Versions follow [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Fixed — deleting a free account works on PostgreSQL

`DELETE /api/v1/auth/account` failed with a 500 on PostgreSQL for every
account without a Stripe customer id, and nothing was deleted. Deleting the
`users` row makes Postgres null `audit_events.actor_user_id` through the
foreign key's `ON DELETE SET NULL`, which it carries out as an UPDATE, and
the audit log's append-only trigger (migration 005) refused every UPDATE, so
the whole deletion rolled back. Every account has audit events naming it —
at the latest the deletion request itself — so every such deletion failed.
Migration 012 lets exactly that change through: `actor_user_id` goes from a
value to NULL, nothing else in the row changes, and the account it named no
longer exists. Every other UPDATE or DELETE of an audit event is still
refused, including a direct UPDATE that nulls a live account's ID, and the
hash chain still verifies. Accounts with a Stripe customer id were not
affected: their `users` row is kept for the tax-retention period, so nothing
cascades.

The test suite runs on SQLite, which has no such trigger, so no test noticed.
The `lint-and-test` job and the weekly `deps-latest` run now start a
PostgreSQL 16 service, and `tests/test_audit_postgres.py` migrates an empty
database with Alembic (which surfaced the migration 001 failure below),
deletes an account through the API, and checks that its audit events are
kept without the account ID, the chain verifies, and every other change to an
event is refused. A guard fails if either workflow stops providing the
database. The TOM annex and the vendor security questionnaire name migration
012 next to 005.

### Fixed — a fresh Cloud Edition database can be migrated

On an empty PostgreSQL database, `alembic upgrade head`, which the container
entrypoint runs on every Cloud Edition start, failed in the first migration
with `type "tier_enum" already exists`: migration 001 created its two enum
types itself and then again with their tables. A fresh Cloud Edition install
therefore never started; the container kept restarting. Migration 001 now
leaves creating the types to the tables. A database that already has the
schema is not affected, since Alembic does not run migration 001 again.

### Added — online cancellation without login: "Cancel contracts here" (§ 312k BGB)

German consumer law (§ 312k BGB) requires a subscription sold online to be
Expand Down
7 changes: 4 additions & 3 deletions alembic/versions/001_baseline.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ def _uuid_col(name: str, *args, **kwargs) -> sa.Column:


def upgrade() -> None:
tier_enum.create(op.get_bind(), checkfirst=True)
job_status_enum.create(op.get_bind(), checkfirst=True)

# No explicit tier_enum/job_status_enum.create() here: op.create_table
# creates each enum type with its first table, without checkfirst, so a
# type created beforehand made a fresh Postgres fail with "type already
# exists".
op.create_table(
"users",
_uuid_col("id", primary_key=True),
Expand Down
89 changes: 89 additions & 0 deletions alembic/versions/012_audit_actor_erasure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Let an account deletion null ``audit_events.actor_user_id``.

Revision ID: 012_audit_actor_erasure
Revises: 011_ai_usage
Create Date: 2026-09-29

Migration 005 gave ``audit_events.actor_user_id`` an ``ON DELETE SET NULL``
foreign key, so that deleting an account (GDPR Art. 17) keeps its audit rows
but drops their link to the person. The same migration made the table
append-only with a trigger that raises on every UPDATE. Postgres carries out
SET NULL as an UPDATE of the referencing rows, so the trigger refused it and
the whole ``DELETE FROM users`` failed. Every registered account has audit
rows naming it (registration, login), so self-service deletion of a free
account failed with a 500 and deleted nothing.

The trigger function now lets exactly that change through: ``actor_user_id``
goes from a value to NULL, nothing else in the row changes, and the ``users``
row it named no longer exists. Only deleting that account produces this
combination. A direct UPDATE of a live account's rows, and every other
UPDATE or DELETE, is still refused. The rest of the row is
compared as a whole (``to_jsonb(row) - 'actor_user_id'``) rather than column
by column, so a column added later is covered too and dropping one cannot
break the function. ``actor_user_id`` is not an input to ``record_hash``, so
``verify_chain`` still passes.

Postgres only, like the trigger itself; SQLite (the test harness) never had
it. ``tests/test_audit_postgres.py`` runs this against a real Postgres in CI.
"""

from __future__ import annotations

from typing import Sequence, Union

from alembic import op

revision: str = "012_audit_actor_erasure"
down_revision: Union[str, None] = "011_ai_usage"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None

# CREATE OR REPLACE keeps the function's identity, so migration 005's two
# triggers (audit_events_no_update, audit_events_no_delete) stay bound to it;
# a later exception has to re-issue the whole function. NEW is read only for
# an UPDATE; a DELETE trigger has none. The lookup names public.users because
# a session's temporary tables come first in the search path, so a temporary
# "users" could answer it otherwise. The migrations create the tables in the
# connecting role's current schema, public unless configured otherwise; where
# they live elsewhere, the lookup fails and the deletion is refused.
_ALLOW_ACTOR_ERASURE = """
CREATE OR REPLACE FUNCTION audit_events_block_modification()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'UPDATE' THEN
IF OLD.actor_user_id IS NOT NULL
AND NEW.actor_user_id IS NULL
AND (to_jsonb(NEW) - 'actor_user_id') = (to_jsonb(OLD) - 'actor_user_id')
AND NOT EXISTS (SELECT 1 FROM public.users WHERE id = OLD.actor_user_id) THEN
RETURN NEW;
END IF;
END IF;
RAISE EXCEPTION
'audit_events is append-only; % is not permitted',
TG_OP;
END;
$$ LANGUAGE plpgsql;
"""

# Migration 005's function, verbatim.
_BLOCK_EVERYTHING = """
CREATE OR REPLACE FUNCTION audit_events_block_modification()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION
'audit_events is append-only; % is not permitted',
TG_OP;
END;
$$ LANGUAGE plpgsql;
"""


def upgrade() -> None:
if op.get_bind().dialect.name == "postgresql":
op.execute(_ALLOW_ACTOR_ERASURE)


def downgrade() -> None:
if op.get_bind().dialect.name == "postgresql":
op.execute(_BLOCK_EVERYTHING)
4 changes: 3 additions & 1 deletion app/core/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
Three properties hold:

1. **Append-only at the database layer.** Migration 005 installs a
Postgres trigger that raises on UPDATE / DELETE. SQLite (test
Postgres trigger that raises on UPDATE / DELETE; the one exception
(migration 012) is the ``ON DELETE SET NULL`` cascade that clears
``actor_user_id`` when that account is deleted. SQLite (test
harness only) skips the trigger; we cover the SQLite path with a
"verify rejects tampering" test instead.
2. **Forward chain.** ``record_hash[i] = SHA256(record_hash[i-1] ||
Expand Down
8 changes: 7 additions & 1 deletion app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,9 @@ class AuditEvent(Base):

Append-only enforcement at the database layer (Postgres trigger in
migration 005) means even a compromised application credential
cannot UPDATE or DELETE rows. SQLite (test harness only) skips the
cannot UPDATE or DELETE rows. The one exception (migration 012) is
the ``ON DELETE SET NULL`` cascade that clears ``actor_user_id``
when the account it names is deleted. SQLite (test harness only) skips the
trigger — the migration scopes the trigger to ``dialect.name ==
'postgresql'``.

Expand Down Expand Up @@ -307,6 +309,10 @@ class AuditEvent(Base):
DateTime(timezone=True), nullable=False, server_default=func.now()
)
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
# Nulled by the database when the user is deleted (see the class
# docstring). A ``User.audit_events`` relationship would need
# ``passive_deletes="all"``: otherwise the ORM nulls the rows itself
# while the user still exists, and the trigger refuses the deletion.
actor_user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
Expand Down
6 changes: 4 additions & 2 deletions docs/dpa-tom-annex.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,10 @@ physical assets of its own.
### Input control (Eingabekontrolle)

- Tamper-evident audit log: SHA-256 hash chain, Postgres append-only
trigger — `app/core/audit.py`, Migration 005; the `verify_chain`
helper detects retroactive edits from a SQL dump alone. Compatible with
trigger — `app/core/audit.py`, Migrations 005 and 012 (the one change
the trigger allows: a hard account deletion nulls the account ID on
that account's events); the `verify_chain` helper detects retroactive
edits from a SQL dump alone. Compatible with
ISO 27001 A.12.4.1 / BORA §50 / BeurkG §39a. It records registration,
login, email verification, password reset and account deletion;
subscription and payment events, including the withdrawal waiver at
Expand Down
9 changes: 6 additions & 3 deletions docs/vendor-security-questionnaire.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,8 +304,8 @@ and renews certificates automatically.
- **API keys:** SHA-256 hashes only — `app/core/security.py`. Raw key
is shown once at creation and never logged.
- **Audit log:** plain Postgres rows protected by an append-only
trigger and a SHA-256 hash chain — `app/core/audit.py`, Migration
005. Backups protect the integrity of the at-rest copy; the chain
trigger and a SHA-256 hash chain — `app/core/audit.py`, Migrations
005 and 012. Backups protect the integrity of the at-rest copy; the chain
detects retroactive edits from a SQL dump alone.
- **Database backups:** operator-side. The Compliance Edition deployment
encrypts backups at rest and stores them off-site; the AGPL operator
Expand Down Expand Up @@ -573,7 +573,10 @@ classification. **No file content** is logged. The regression guard is

Yes. The audit log is a SHA-256 hash chain over append-only Postgres
rows protected by a database trigger
(`app/core/audit.py`, Migration 005). The `verify_chain` helper
(`app/core/audit.py`, Migrations 005 and 012). The one change the
trigger allows is the database nulling the account ID on an account's
events when that account is hard-deleted (Art. 17 GDPR); the chain
still verifies afterwards. The `verify_chain` helper
detects retroactive edits from a SQL dump alone — compatible with
ISO 27001 A.12.4.1, BORA §50, and BeurkG §39a expectations.

Expand Down
Loading
Loading