From 9a41e1f6f50c62947d04c804b0cb242c41d7045c Mon Sep 17 00:00:00 2001 From: MrChengLen Date: Tue, 29 Sep 2026 09:13:04 +0200 Subject: [PATCH 1/3] docs(compliance): DPA, TOM, RoPA and questionnaire describe what the code does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compliance templates that DPOs and procurement read claimed more than the code does in places. Corrected, each against the code: - Audit log: actors are account IDs (email hashes only for failed logins, duplicate registrations, reset requests and contact messages); the payload is stored as JSON, not a digest; API-key management, admin changes, batch jobs and the /pdf tools are not recorded. There is no AUDIT_RETENTION_DAYS setting: the log has no built-in retention period, and the operator states theirs. - Upload check: a magic-byte deny-list, with the converter chosen by file extension, not detected from content. - app/ee holds only PII redaction, switched on by AI_OPERATIONS_ENABLED; there is no licence key. veraPDF runs in CI, not per request. - A leftover temp directory can last about 70 minutes; X-Output-SHA256 comes only from single-file /convert and /compress; uvicorn's access log is on, and the TLS-terminating edge proxy sees request bodies; SMTP also carries contact-form messages but no receipts. - Account deletion: any account with a Stripe customer id takes the tax-retained path (the id exists from the first checkout); audit events lose their actor ID only on a hard delete. - Locations (Hetzner: a data centre in the EU; Zoho: Amsterdam and Dublin), support response times agreed per contract, email-only vulnerability reports, data-subject requests to privacy@filemorph.io, v1.1.0 as the only release so far and what its SBOM lacks. - The TOM annex and questionnaire gain the per-route rate limits and failed-key budget, the two-job release workflow with its hash-pinned SBOM generator and .dockerignore; the questionnaire covers error messages that no longer echo library internals; the RoPA gains the contact form. - Agreement template: no VAT only while §19 UStG applies; app/ee is excluded from the AGPL in §3.2 and after termination; the AGPL guide says the same. Full suite 1471 passed / 72 skipped on main 289aa07; gitleaks and scope-guard clean; security-auditor PASS, code-reviewer findings addressed. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 35 ++ docs/agpl-fuer-behoerden.md | 21 +- docs/commercial-license-agreement-template.md | 33 +- docs/dpa-template.md | 41 ++- docs/dpa-tom-annex.md | 80 +++-- docs/gdpr-account-deletion-design.md | 96 +++--- docs/gdpr-privacy-analysis.md | 25 +- docs/records-of-processing-template.md | 52 +-- docs/sub-processors.md | 14 +- docs/support-sla.md | 16 +- docs/vendor-security-questionnaire.md | 301 +++++++++++------- 11 files changed, 462 insertions(+), 252 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a510c34..12cacd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,41 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed — compliance templates describe what the code does + +The DPA template and its TOM annex, the records-of-processing template, the +vendor security questionnaire, the sub-processor list, the support framework, +both GDPR documents, the AGPL guide for public bodies and the commercial +licence agreement template claimed more than the code does in places. They +now say that the audit log names actors by account ID (an email hash only for +failed logins, duplicate registrations, reset requests and contact-form +messages), stores its payload as JSON rather than a digest, and does not record +API-key management, admin changes, batch jobs or the `/pdf/*` tools; that +`X-Output-SHA256` comes only from single-file `/convert` and `/compress`; that +the upload check is a magic-byte deny-list and the converter is chosen by file +extension, not from content; that `app/ee/` holds only PII redaction, switched +on by `AI_OPERATIONS_ENABLED` rather than a licence key; that veraPDF runs in +CI, not per request; that a +leftover temp directory can last about 70 minutes, not 10; that uvicorn's +access log is on and a TLS-terminating edge proxy sees uploads; and that the +SMTP relay also carries contact-form messages but no receipts. Hosting and +email locations, the Python version, CSP, CORS, disclosure targets and code +anchors are updated, and vulnerability reports go to `security@filemorph.io` +only (PGP key on request); v1.1.0 is named as the only release so far, along +with what its SBOM lacks, and support response times as set per agreement. The +documents no longer name an `AUDIT_RETENTION_DAYS` setting, which never +existed: the audit log has no built-in retention period, and the operator +states theirs. Data-subject requests go to `privacy@filemorph.io`, as in the +privacy policy. The TOM annex and the questionnaire gain the rate limits and +failed-key budget, the two-job release workflow with its hash-pinned SBOM +generator and `.dockerignore`; the questionnaire also covers error messages +that no longer echo library internals, and the records of processing gain the +contact form. The account-deletion design and the questionnaire note that the +code takes the paid path for any account with a Stripe customer id; audit +events lose their actor ID only on a hard delete. In the agreement template, +no VAT is charged only while §19 UStG applies; it and the AGPL guide exclude +`app/ee/` from the AGPL. + ### Security — the Docker image is built without a restored build cache `docker.yml` restored BuildKit's GitHub Actions cache (`cache-from: type=gha`) diff --git a/docs/agpl-fuer-behoerden.md b/docs/agpl-fuer-behoerden.md index 0118555..d332e47 100644 --- a/docs/agpl-fuer-behoerden.md +++ b/docs/agpl-fuer-behoerden.md @@ -1,7 +1,9 @@ # AGPLv3 für Behörden, Krankenhäuser und Kanzleien FileMorph steht unter der **GNU Affero General Public License v3** (AGPLv3). -Diese Lizenz wird in Beschaffungsabteilungen gelegentlich als +Ausgenommen sind die Module unter `app/ee/` (PII-Schwärzung); sie sind +nur kommerziell lizenziert. +Die AGPLv3 wird in Beschaffungsabteilungen gelegentlich als "problematisch" wahrgenommen, weil das Wort *Affero* den Eindruck einer Veröffentlichungspflicht erweckt. Dieses Dokument räumt das auf und erklärt, was die AGPLv3 für eine deutsche Verwaltungs-, Kranken- oder @@ -104,12 +106,13 @@ Die Compliance-Edition (kommerzielle Lizenz) lohnt sich, wenn … ausstatten, die unter eigener Lizenz bleiben sollen, - Sie **vertraglich abgesicherte Support-SLAs** und einen festen Ansprechpartner für sicherheitskritische Updates benötigen, -- Sie eine **Air-Gap- oder KRITIS-Variante** mit garantierten - Reaktionszeiten und Patch-Backports einsetzen wollen. +- Sie eine **Air-Gap- oder KRITIS-Variante** mit individuell + vereinbarten Reaktionszeiten und Patch-Backports einsetzen wollen. Für die rein interne Verwaltungs- oder Klinik-Nutzung ist dagegen die -**AGPLv3-Edition kostenfrei und vollumfänglich nutzbar**. Die meisten -unserer Behörden-Deployments laufen unter AGPLv3. +**AGPLv3-Edition kostenfrei und vollumfänglich nutzbar**. Ausgenommen +sind die Module unter `app/ee/` (PII-Schwärzung); sie sind nur +kommerziell lizenziert. ## Was im EVB-IT-Vertragswerk zu beachten ist @@ -122,7 +125,13 @@ Vertragsabschluss ein **Software Bill of Materials (SBOM)** verlangt werden — ein fehlendes oder unvollständiges SBOM kann künftig einen Mangel darstellen. FileMorph liefert dieses Artefakt im CycloneDX-Format mit jedem Release als `filemorph-{version}.cdx.json` -(siehe [`patch-policy.md`](./patch-policy.md)). +(siehe [`patch-policy.md`](./patch-policy.md)). Es erfasst die +Python-Abhängigkeiten des Images, nicht dessen Betriebssystem-Pakete +wie FFmpeg oder Ghostscript. Das SBOM zum bisher einzigen Release, +v1.1.0 vom 1. Juni 2026, entstand noch mit dem früheren Verfahren: Es +führt zusätzlich die Pakete des SBOM-Generators auf, und Komponenten, +die ihre Lizenz nur als `License-Expression` angeben, stehen darin +ohne Lizenzangabe. > **Wichtig für die Vertragswahl:** Die Reform betraf 8 der 11 > EVB-IT-Vertragstypen. **EVB-IT Cloud** und **EVB-IT Überlassung diff --git a/docs/commercial-license-agreement-template.md b/docs/commercial-license-agreement-template.md index a48771c..1114a85 100644 --- a/docs/commercial-license-agreement-template.md +++ b/docs/commercial-license-agreement-template.md @@ -1,7 +1,7 @@ # Commercial License Agreement — Template **Status:** Skeleton template — **not a binding contract as it stands.** -**Last reviewed:** 2026-05-12 +**Last reviewed:** 2026-09-28 This document is the starting point for the **Commercial License Agreement** between a FileMorph Compliance-Edition customer (*Licensee*) @@ -46,7 +46,10 @@ Read that first; this document is the contractual form of it. `https://github.com/MrChengLen/FileMorph`, at the version line stated in Schedule A, together with its Documentation. - **"AGPL"** — the GNU Affero General Public License v3.0 under which the - Software is also published (see `LICENSE` in the repository). + Software, except the modules under `app/ee/`, is also published (see + `LICENSE` in the repository). The modules under `app/ee/` are + published source-available under the commercial licence only (see + `COMMERCIAL-LICENSE.md`). - **"Licensed Scope"** — the deployment scope licensed under this Agreement: the tier, number of servers, and employee band stated in Schedule A. @@ -78,7 +81,9 @@ Scope. the band in Schedule A, or use by a different legal entity — AGPL-3.0 governs unless and until the Licensed Scope is extended by a written amendment (a "true-up", typically a move to a higher tier per -[`COMMERCIAL-LICENSE.md`](../COMMERCIAL-LICENSE.md)). +[`COMMERCIAL-LICENSE.md`](../COMMERCIAL-LICENSE.md)). This does not +apply to the modules under `app/ee/`: they are not available under +AGPL-3.0, so they may be used only within the Licensed Scope. 3.3 This Agreement does not remove AGPL-3.0 from the public repository and does not affect any other party's rights under AGPL-3.0. The @@ -114,9 +119,11 @@ Licensee relies on it for the Licensee's own compliance. invoiced annually in advance, due within thirty (30) days of the invoice date. -6.2 Fees are exclusive of VAT. For cross-border supplies within the EU -to a VAT-registered business, the reverse-charge mechanism applies and -the Licensee provides a valid VAT-ID. +6.2 As long as the Licensor applies the small-business scheme under +§19 UStG (Kleinunternehmerregelung), no VAT is charged on the Fees. For +cross-border supplies within the EU to a VAT-registered business, the +reverse-charge mechanism applies and the Licensee provides a valid +VAT-ID. 6.3 Overdue amounts bear interest at the statutory rate (§288 BGB) from the due date. @@ -253,7 +260,8 @@ disclosure, is independently developed without use of the disclosing party's information, or must be disclosed by law or court order (with prior notice to the other party where lawful). -15.3 The Software itself is published under AGPL-3.0 and is not +15.3 The Software's source code is public — under AGPL-3.0, and +source-available for the modules under `app/ee/` — and is not confidential. 15.4 This §15 survives termination for three (3) years. @@ -262,9 +270,12 @@ confidential. 16.1 On termination or expiry of this Agreement, the licence in §3 (and §4, if applicable) ends. The Licensee's continued use of the Software is -thereafter governed by AGPL-3.0. +thereafter governed by AGPL-3.0, except for the modules under `app/ee/`: +they are not available under AGPL-3.0, so the right to use them ends +with this Agreement. -16.2 Existing installations may continue to run; the Licensor does not +16.2 Existing installations may continue to run, with the modules under +`app/ee/` disabled (§16.1); the Licensor does not disable or force-update deployed instances. Continued updates after termination require a current commercial licence or compliance with AGPL-3.0; the Documentation as a contractual deliverable, the Support @@ -314,7 +325,7 @@ Germany, to the extent permitted by law. | Tier | `[Starter / Standard / Enterprise / KRITIS–air-gap / OEM]` | | Number of servers licensed | `[N]` | | Employee band | `[≤ 50 / ≤ 2 000 / unlimited / as agreed]` | -| Annual Fee (excl. VAT) | `[€ … — per COMMERCIAL-LICENSE.md, or as negotiated for Enterprise / KRITIS / OEM]` | +| Annual Fee (no VAT charged while the Licensor applies §19 UStG — see §6.2) | `[€ … — per COMMERCIAL-LICENSE.md, or as negotiated for Enterprise / KRITIS / OEM]` | | Multi-year discount, if any | `[…]` | | OEM redistribution terms (OEM tier only) | `[…]` | | Licensed version line | `[e.g. v1.x — see docs/patch-policy.md]` | @@ -327,7 +338,7 @@ filled at finalisation: | Item | Value | |---|---| -| Severity response windows (P1 / P2 / P3 / P4) | `[per the tier — see docs/support-sla.md]` | +| Severity response windows (P1 / P2 / P3 / P4) | `[as agreed for this Agreement — docs/support-sla.md gives the framework, not figures]` | | Coverage hours | `[business hours Europe/Berlin / extended / 24×7 — as agreed]` | | Named support contact | `[…]` | | Escalation path | `[…]` | diff --git a/docs/dpa-template.md b/docs/dpa-template.md index 854b552..f8ea6b5 100644 --- a/docs/dpa-template.md +++ b/docs/dpa-template.md @@ -1,7 +1,7 @@ # Data Processing Agreement (DPA) — Template **Status:** Skeleton template, finalised individually in pilot conversations. -**Last reviewed:** 2026-05-08 +**Last reviewed:** 2026-09-28 This document is the starting point for a Data Processing Agreement (DPA) under Article 28 GDPR between a FileMorph Compliance-Edition customer @@ -58,8 +58,11 @@ users. Processing operations include: - Returning the converted output and a SHA-256 integrity header - Writing structured logs (no file content; only metadata: tier, format pair, byte counts, duration, success flag) -- Recording audit events for actions affecting accounts or entitlements - (registration, login, key creation, deletion, billing changes) +- Recording audit events for account and billing actions (registration, + login, email verification, password reset, account deletion, + subscription changes) and for single-file conversions and + compressions — not for API-key management, admin changes in the + cockpit, batch jobs, or the `/pdf/*` tools The Service does **not** perform any analytics, profiling, advertising, or data sale. @@ -78,8 +81,10 @@ or data sale. identifiers - File contents during processing — deleted from memory and disk immediately after the converted output is returned (typical - retention: seconds; absolute upper bound: 10 minutes via startup - sweep, see `app/main.py`) + retention: seconds). A temp directory left behind, e.g. by a crashed + worker, is removed by the startup sweep or the hourly background + sweep once it is older than 10 minutes — with the default settings + within about 70 minutes (see `app/main.py`) - Audit-event records (see §5 below) — retained per the controller's configured retention policy @@ -89,7 +94,11 @@ Every Compliance-Edition deployment writes a tamper-evident audit log (SHA-256 hash chain, see `app/core/audit.py` and Migration 005). Each entry contains: -- Event type, timestamp, actor identifier, actor IP, payload digest +- Event type, timestamp, actor identifier (the account ID, where there + is one), actor IP, and the event payload as canonical JSON (operation + metadata such as format pair, byte counts and output SHA-256; a + SHA-256 hash of the email address for events such as a failed login; + never file content) - Hash of the previous event (chain integrity) The audit log is a tamper-evident record of processing *operations* on @@ -97,11 +106,15 @@ the controller's behalf — useful evidence for, but distinct from, the controller's Article 30 *Verzeichnis von Verarbeitungstätigkeiten* (Records of Processing Activities), for which see [`docs/records-of-processing-template.md`](records-of-processing-template.md). -The audit-log retention period defaults to `[RETENTION DAYS]` and is -configurable via the `AUDIT_RETENTION_DAYS` environment variable. +The application has no built-in retention period for the audit log: rows +are append-only and are not pruned automatically. Pruning them takes a +privileged database role that bypasses the append-only trigger; the +retention period and procedure are `[RETENTION PERIOD + PROCEDURE]`. -Each converted output carries an `X-Output-SHA256` response header so -the controller can independently verify integrity. +Each output of the single-file `/convert` and `/compress` endpoints +carries an `X-Output-SHA256` response header so the controller can +independently verify integrity; batch ZIPs and the `/pdf/*` tools do +not. ## 6. Sub-processors @@ -128,8 +141,9 @@ The processor implements the measures documented in: - [`docs/release-signing.md`](release-signing.md) These cover: encryption in transit (TLS 1.2+, HSTS), at-rest scope (no -persistent file storage by design), access control (timing-safe API key -validation, JWT-bound roles, admin role with database recheck per +persistent file storage by design), access control (hashed API keys, +compared in constant time for the key file and looked up by hash for +per-user keys; JWT-bound roles; admin role with database recheck per request), key management, software-supply-chain hardening (cosign-signed images, signed Git tags, CycloneDX SBOM), and incident-response timelines — structured along the Article 32 GDPR categories @@ -202,7 +216,8 @@ Place of jurisdiction is Hamburg, Germany. 1. Review the bracketed placeholders in §1 and §2 and fill them with the deployment context. -2. Replace `[RETENTION DAYS]` in §5 with the configured value. +2. Replace `[RETENTION PERIOD + PROCEDURE]` in §5 with the agreed + audit-log retention period and the pruning procedure. 3. Start from the [`docs/dpa-tom-annex.md`](dpa-tom-annex.md) template, fill its `[operator: …]` placeholders with the measures specific to the deployment (instance location, network segmentation, on-call, diff --git a/docs/dpa-tom-annex.md b/docs/dpa-tom-annex.md index 3612106..2fffb50 100644 --- a/docs/dpa-tom-annex.md +++ b/docs/dpa-tom-annex.md @@ -12,8 +12,11 @@ the binding one — this file is the starting point. > **Two layers.** The measures below split into: > > - **Application-level measures** — implemented *by the FileMorph -> software itself*, identical in every deployment, verifiable in the -> source. Stated here as facts, with the code anchor. +> software itself*, identical in every deployment of the same version, +> verifiable in the source. Stated here as facts, with the code anchor, +> for the current code on `main`; the only tagged release so far, +> v1.1.0 (2026-06-01), predates some of them — see +> [`CHANGELOG.md`](../CHANGELOG.md). > - **Deployment-level measures** — implemented *by whoever operates the > deployment* (hosting, network, backups, on-call). Shown as > `[operator: …]` placeholders: a self-hoster fills them with their own @@ -34,7 +37,7 @@ the binding one — this file is the starting point. ### Physical access control (Zutrittskontrolle) `[operator: physical security of the hosting facility — e.g. "Hetzner -Online GmbH datacentre, Falkenstein / Frankfurt, ISO 27001-certified, +Online GmbH — data centre in the EU, ISO 27001-certified, 24/7 access control, CCTV, mantrap"; or the customer's own datacentre measures for an on-prem deployment]`. The FileMorph software holds no physical assets of its own. @@ -48,14 +51,14 @@ physical assets of its own. - Password authentication (Cloud features): bcrypt with an adaptive cost factor — `app/core/auth.py`. - Session tokens: short-lived JWTs, 15-minute access / 30-day refresh — - `app/core/auth.py`. + `app/core/tokens.py`. - Administrative interface (`/cockpit`): requires a valid JWT *and* `role='admin'`, re-checked against the database on every request — a stale token cannot escalate after a role change. -- Upload pipeline: a magic-byte allow-list (`BLOCKED_MAGIC` in - `app/core/processing.py`) rejects PE / ELF / shell / PHP payloads - before any decoder runs; format is determined from content, not from - the client-declared type. +- Upload pipeline: a magic-byte deny-list (`BLOCKED_MAGIC` in + `app/core/processing.py`) rejects files that start with a PE, ELF, + shell-script or PHP signature before any decoder runs; the converter + is chosen from the file extension, not detected from content. - `[operator: OS-level access — SSH key-only login, no password auth, restricted sudo, host firewall]`. @@ -88,8 +91,13 @@ physical assets of its own. — UUID stems only; the original name survives only in the `Content-Disposition` response header, filtered through `safe_download_name()` — `app/core/utils.py`. -- The audit log records actors as hashed-email identifiers, not raw email - addresses — `app/core/audit.py`. +- The audit log identifies an actor by account ID (a random UUID), never + by email address. A failed login, a duplicate registration, a + password-reset request and a contact-form message also store a + SHA-256 hash of the lower-cased email address — a pseudonym, not an + anonymisation: a known address can be hashed and matched — + `app/core/audit.py`, `app/api/routes/auth.py`, + `app/api/routes/contact.py`. - API keys are stored only as SHA-256 hashes; raw keys are shown once at creation and never logged. - Image conversions and compressions strip EXIF / XMP / IPTC metadata @@ -121,19 +129,30 @@ physical assets of its own. - The FileMorph application transmits no file content, file names, or file hashes to any sub-processor — see [`sub-processors.md`](sub-processors.md); the only outbound calls are to the configured database, the SMTP relay - (authentication / billing mail only), and Stripe (Checkout session - creation + webhook). -- Output integrity: every converted file carries an `X-Output-SHA256` - response header (streaming SHA-256 of the delivered bytes); the same - hash lands in the audit-log payload, so the controller can verify a - file matches the attestation made at conversion time. + (account and billing mail, and contact-form messages to the operator), + and Stripe (creating the customer and the Checkout / Billing-Portal + sessions, and cancelling subscriptions when an account is deleted; + Stripe's webhook calls come in the other direction and are + signature-checked). +- Output integrity: every file returned by the single-file `/convert` + and `/compress` endpoints carries an `X-Output-SHA256` response header + (streaming SHA-256 of the delivered bytes); the same hash lands in the + audit-log payload, so the controller can verify a file matches the + attestation made at conversion time. Batch ZIPs, the `/pdf/*` tools + and PII redaction return no such header. ### 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 - ISO 27001 A.12.4.1 / BORA §50 / BeurkG §39a. + 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 + checkout; single-file conversions and compressions; contact-form + messages; and PII redactions. It does not record API-key creation or + revocation, admin changes in the cockpit, batch jobs, or the `/pdf/*` + tools. - Structured logs record operation metadata (operation, format pair, byte counts, duration, success flag, tier, data classification) and no file content — regression guard `tests/test_observability_logs.py`. @@ -152,10 +171,18 @@ physical assets of its own. `app/core/concurrency.py`. Every synchronous C-binding call (FFmpeg, WeasyPrint, Pillow, pypdf) runs in a worker thread, never on the event loop. -- Rate limiting: per-endpoint slowapi limits — `app/core/rate_limit.py` - (in-memory; effective for a single instance — a multi-instance - deployment needs an external store, noted in `security-overview.md` - § Known Limitations). +- Rate limiting: explicit slowapi limits per API endpoint, from 1 to + 120 requests per minute (the contact form: 5 per hour), counted per + client IP — per signed-in account on the account endpoints (API keys, + billing, email language); every limit, and the four endpoints exempt + on purpose, are listed in + [`api-reference.md` § Rate Limiting](api-reference.md#rate-limiting). + Rejected `X-API-Key` attempts have their own budget of 30 per minute + per IP, then get `429`; a valid key is never refused. The limiter's + own log lines, which carry the client IP or account ID, are + suppressed — `app/core/rate_limit.py` (in-memory; effective for a + single instance — a multi-instance deployment needs an external store, + noted in `security-overview.md` § Known Limitations). - Readiness probe `/api/v1/ready` reports database + tempdir health so an orchestrator can gate traffic correctly; `/api/v1/health` is a cheap liveness probe that exposes only `{"status":"ok"}`. @@ -222,6 +249,17 @@ physical assets of its own. (Critical 7 days / High 30 days / Medium-Low next regular release); third-party-license posture in [`third-party-licenses.md`](third-party-licenses.md). +- Release workflow in two jobs: `sbom` installs the image's dependency + set and the SBOM generator with read-only repository access; + `verify-and-publish` checks the tag's GPG signature and publishes with + write access, installs nothing, and receives the SBOM as an artifact. + Neither job restores a cache or keeps the checkout's credentials. The + generator is installed hash-pinned and wheels-only from + `requirements-sbom.lock` — `.github/workflows/release.yml`; regression + guard `tests/test_supply_chain_hygiene.py`. +- `.dockerignore` keeps secrets and local state (`.env*`, the API-key + file in `data/`, `.git`, Python environments) out of the image build + context — regression guard `tests/test_dockerignore.py`. - `[operator: dependency-update cadence on the running deployment; how SBOM diffs are reviewed before deploying]`. diff --git a/docs/gdpr-account-deletion-design.md b/docs/gdpr-account-deletion-design.md index 7a4b04b..979109b 100644 --- a/docs/gdpr-account-deletion-design.md +++ b/docs/gdpr-account-deletion-design.md @@ -2,8 +2,9 @@ **Status:** Slice c.1 (free path) shipped 2026-05-06; slice c.2 (paid path with HGB §257 / AO §147 tax retention) shipped 2026-05-12. Both -paths are live — free / never-paid accounts are hard-deleted; accounts -linked to Stripe are kept in the restricted state described in § 5.B +paths are live — accounts not linked to Stripe are hard-deleted; +accounts linked to Stripe, even by a checkout that was never paid, are +kept in the restricted state described in § 5.B (only ``email`` / Stripe customer id / ``tier`` / ``created_at`` retained, ``deleted_at`` stamped, everything else nulled or sentinelled). The endpoint, the three-field gate, the last-admin @@ -22,14 +23,16 @@ pass), ``alembic/versions/010_account_deletion_paid_path.py`` ``deletion_mode == "tax_retained"`` paragraph), ``app/templates/dashboard.html`` + ``app/static/js/dashboard.js`` (the "Danger zone" flow), ``app/templates/account_deleted.html`` (the -landing page). The remaining items called out in §§ 5.B + 12 — the -10-year purge cron, the AVV/DPA template, and a cockpit guard that -refuses admin mutations on a row with ``deleted_at IS NOT NULL`` — -stay tracked as separate follow-ups. +landing page). The DPA template has since shipped +(``docs/dpa-template.md``); the other items called out in §§ 5.B + +12 — the 10-year purge cron and a cockpit guard that refuses admin +mutations on a row with ``deleted_at IS NOT NULL`` — stay tracked as +separate follow-ups. **Audience:** Self-hosters, contributors, and the FileMorph cloud operator. -**Last updated:** 2026-05-12 (status row above; body preserved as -the design trail). +**Last updated:** 2026-09-28 (status row above; §§ 4, 5, 7, 11 and +12 corrected where they no longer matched the code; otherwise the +body is preserved as the design trail). --- @@ -176,7 +179,7 @@ cascades. The relevant `ON DELETE` clauses live in | `User` | (target row) | Hard delete | **Retained**, fields nulled selectively | Subject of erasure (free) / tax-restricted (paid). | | `ApiKey.user_id` | `ON DELETE CASCADE` (line 96) | Auto-deleted | Application-level hard delete (no tax relevance) | API keys are 1:1 with the user and not invoice-relevant. | | `FileJob.user_id` | `ON DELETE SET NULL` (line 123) | Anonymized; row retained | Anonymized; row retained | Aggregate analytics value; without `user_id`, the row is not personal data. | -| `UsageRecord.user_id` | `ON DELETE SET NULL` (line 153) | Anonymized; row retained | Anonymized; row retained | Tier-metric continuity. Billing is flat-rate (`Pro €7/mo`, `Business €19/mo`) — usage rows do not feed invoice line items, so anonymization is permissible even on paid accounts. | +| `UsageRecord.user_id` | `ON DELETE SET NULL` (line 153) | Anonymized; row retained | Anonymized; row retained | Tier-metric continuity. Billing is a flat subscription fee per plan (prices on `/pricing`) — usage rows do not feed invoice line items, so anonymization is permissible even on paid accounts. | | `UsageRecord.api_key_id` | `ON DELETE SET NULL` (line 158) | Anonymized via the `api_keys` cascade | Anonymized via the application-level ApiKey delete | Same reasoning, applied transitively. | | Stripe customer record | (external) | Subscription cancelled; customer record retained by Stripe | Subscription cancelled; customer record retained by Stripe | Stripe's tax-retention obligation; already disclosed in `privacy.html` § 3a. | @@ -216,8 +219,10 @@ Stripe subscription so it stops billing (§ 5.A), and retaining the FileMorph-side records that German tax law obliges us to keep for ten years (§ 5.B). They are independent: § 5.A also runs for never-paid accounts that happen to have a stale -`stripe_customer_id` (e.g. abandoned checkout); § 5.B only -triggers when at least one payment has actually completed. +`stripe_customer_id` (e.g. abandoned checkout); in the original +design § 5.B only triggers when at least one payment has actually +completed. As implemented, any `stripe_customer_id` triggers it +(see "Trigger condition" in § 5.B). ### 5.A Subscription cancellation (cancel-first pattern) @@ -287,8 +292,14 @@ corresponds to which payment. #### Trigger condition -The paid path activates when **either** of these is true at the -moment of deletion: +**As implemented:** the code uses a more conservative trigger than the +design below — any account with a Stripe customer id takes the paid +path, including one that started a checkout but never paid +(`deletion_mode_for()` in `app/core/account_deletion.py`). Deciding the +finer condition would need a Stripe round-trip at deletion time. + +The original design: the paid path activates when **either** of these +is true at the moment of deletion: 1. `User.stripe_customer_id IS NOT NULL` **and** at least one `Subscription` for that customer has ever transitioned to @@ -354,8 +365,7 @@ year of the 10-year deadline (HGB §257 Abs. 4 starts the clock "with the end of the calendar year" of the last accounting entry; the buffer absorbs the year-end alignment plus any in-flight tax audit). The exact buffer is operator-tunable; the -implementing sprint picks a value and documents it in -`docs-internal/filemorph-io-runbook.md`. +implementing sprint picks a value and documents it. The purge job is **out of scope** for this design (its own sprint), but the schema and the `deleted_at` marker are added @@ -363,9 +373,9 @@ here so the purge job has something to act on. #### What is *not* retained on the paid path -- File content — already deleted under the existing retention - policy (24h / 7d / 30d by tier) long before the account is - deleted. +- File content — nothing to delete here: uploads are processed + ephemerally and not stored (`RETENTION_HOURS=0`); their temp files + are removed after each request. - `password_hash` — replaced with a sentinel as described. - `api_keys` — fully removed. - `FileJob.user_id` and `UsageRecord.user_id` — `SET NULL`. The @@ -459,11 +469,14 @@ no custom modal library. ## 7. Audit log -Account-deletion events are logged to standard output via the -application's structured logger, **not** to a dedicated database -table. This matches the existing pattern in -`app/api/routes/auth.py:307` (password-reset email dispatch) and -keeps the design footprint small. +The design foresaw one line through the application's structured +logger. As shipped, account deletion writes that line and, in +addition, two events to the hash-chained audit log +(`app/core/audit.py`, Migration 005), which was added separately: +`auth.account_deletion.requested` before the account changes (actor: +the account ID; payload: email domain, `had_subscription`, +`deletion_mode`) and `auth.account_deletion.completed` after it +(payload: email domain, `deletion_mode`). ### Event format @@ -471,10 +484,10 @@ keeps the design footprint small. logger.info( "account_deletion", extra={ - "user_id": str(user.id), - "tier": user.tier.value, - "email_domain": email.split("@", 1)[1], - "had_subscription": bool(user.stripe_customer_id), + "user_id": str(user_id), + "email_domain": email_domain, + "had_subscription": had_subscription, + "deletion_mode": mode, }, ) ``` @@ -486,19 +499,11 @@ kept for debug purposes." ### Retention -Log retention follows the hosting infrastructure's defaults. On +The log line's retention follows the hosting infrastructure's defaults. On production deployments behind Caddy, logs are typically retained for around thirty days, which lines up with the EDPB's one-month recommendation for handling erasure requests. -### Future work (out of scope here) - -A dedicated `audit_log` table — -`(actor_id, action, target_id, timestamp, metadata_json)` — -would make admin-action review easier and survive log rotation. -That is a separate sprint and a separate design document; this -design intentionally does not introduce it. - --- ## 8. Confirmation email @@ -785,14 +790,12 @@ directly. `is_active=False` and `deleted_at IS NOT NULL` guards already block login one layer up.) -These tests run under the same fixtures as the rest of the auth -test suite — the in-memory SQLite engine from -`tests/conftest.py` and the `disable_rate_limiting` session -fixture. Tests 13 + 15 require Alembic to have applied the -`deleted_at` column and the partial unique index; tests skip -under SQLite if the partial index is not supported there -(SQLite supports partial indexes since 3.8.0; should be fine on -the in-memory engine). +These tests (`tests/test_account_deletion.py`) run against an +in-memory SQLite engine of their own, whose schema comes from the +ORM models (`Base.metadata.create_all` — including `deleted_at` and +the partial unique index, which SQLite supports), with rate +limiting switched off for the whole suite by `RATELIMIT_ENABLED=0` +in `tests/conftest.py`. --- @@ -826,8 +829,9 @@ them: - **CLI subcommand** (e.g. `filemorph delete-account` over an API key). Web UI only. The API key being deleted cannot authenticate its own deletion. -- **`audit_log` database table.** Logged via stdout for now; - the dedicated table is its own sprint. +- **`audit_log` database table.** Not part of this design; the + hash-chained audit log that was added separately records the + deletion (§ 7). - **Admin bulk-delete.** The admin cockpit keeps its existing per-user "Deactivate" flow. Bulk deletion is a different UX problem. diff --git a/docs/gdpr-privacy-analysis.md b/docs/gdpr-privacy-analysis.md index 7cbc1cb..9849029 100644 --- a/docs/gdpr-privacy-analysis.md +++ b/docs/gdpr-privacy-analysis.md @@ -1,7 +1,7 @@ # FileMorph — Data Protection & GDPR Analysis **Original analysis date:** 2026-04-20 -**Last refreshed:** 2026-09-09 +**Last refreshed:** 2026-09-28 **Scope:** Community Edition (current `main` branch), Cloud Edition (live), planned Compliance Edition. **Analyst:** Automated compliance review **Reviewer note:** This document is a technical privacy analysis intended for engineering and legal review. It does not constitute legal advice. Engage a qualified data protection lawyer before launching any paid SaaS tier in the EU. @@ -24,28 +24,31 @@ historical reasoning trail. | `allow_origins=["*"]` with `allow_credentials=True` | Closed (PT-003) — strict allow-list, credentials only when origins set | `app/main.py` CORS middleware | | No privacy policy | Closed — `/privacy` route + `app/templates/privacy.html` (last revised 2026-04-23) | `app/main.py::privacy` | | No security headers | Closed — `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Content-Security-Policy`, `Permissions-Policy`, plus HSTS over HTTPS | `app/main.py::security_headers` | -| No DPA / sub-processor list | Template shipped — `docs/sub-processors.md` (Cloud) + `legal/avv-template-*.md` (Compliance Edition, in flight) | repo | +| No DPA / sub-processor list | Templates shipped — `docs/sub-processors.md`, `docs/dpa-template.md` with its TOM annex `docs/dpa-tom-annex.md`, and `docs/records-of-processing-template.md` | repo | | No security disclosure surface | RFC 9116 `/.well-known/security.txt` + `/security` page + `SECURITY.md` | `app/api/routes/seo.py`, `app/templates/security.html` | | pip-audit "non-blocking" | Now blocking on every CI build | `.github/workflows/ci.yml` | | Tamper-evident audit log | Closed — SHA-256 hash chain (Migration 005, Postgres append-only trigger) | `app/core/audit.py` | | `X-Output-SHA256` response header | Closed — chunk-streamed SHA-256 over the bytes the client receives | `app/api/routes/convert.py`, `app/api/routes/compress.py` | | `RETENTION_HOURS` toggle + periodic sweep | Closed — env-var driven, `TEMP_SWEEP_INTERVAL_MINUTES` periodic sweep | `app/core/config.py`, `app/main.py::lifespan` | | Email-verification at registration | Closed — fire-and-forget verify email at `/register`, JWT bound to email-at-issuance (`eat` claim, 7-day TTL); `users.email_verified_at` records state | `app/api/routes/auth.py` (verify_email + resend_verification routes), Migration 006 | -| Self-service account deletion (free path) | Closed (slice c.1) — `DELETE /api/v1/auth/account` with three-field re-confirmation, last-admin guard, hybrid cascade, confirmation email; Stripe-touched accounts return 409 directing to operator support contact | `app/api/routes/auth.py::delete_account` | +| Self-service account deletion | Closed (slices c.1 + c.2) — `DELETE /api/v1/auth/account` with three-field re-confirmation, last-admin guard, hybrid cascade, confirmation email; accounts linked to a Stripe customer are kept in a restricted, tax-retained state (HGB §257 / AO §147) instead of being hard-deleted — see `docs/gdpr-account-deletion-design.md` | `app/api/routes/auth.py::delete_account`, `app/core/account_deletion.py` | | Default-on EXIF strip for image conversions | Closed — `app/converters/_metadata.py` strips EXIF/XMP/IPTC; ICC preserved | `app/converters/image.py`, `app/compressors/image.py` | | PDF/A-2b conversion target | Closed — pikepdf markup pass + optional ghostscript re-render; veraPDF CI gate validates fixture conformance per PR | `app/converters/pdfa.py`, `.github/workflows/verapdf.yml` | | `X-Data-Classification` propagation | Closed — BSI-style taxonomy validated in middleware; echoed on responses; recorded in audit-log | `app/core/data_classification.py` | | Concurrency limiter (NEU-D.1) | Closed — global semaphore + per-actor tier-bound semaphore; 503 vs 429 with `Retry-After` | `app/core/concurrency.py` | -| Cosign-signed images + GPG-signed tags | Closed — keyless OIDC sign on image push; `.github/workflows/release.yml` GPG-signs annotated tags | `.github/workflows/docker.yml`, `release.yml` | +| Cosign-signed images + GPG-signed tags | Closed — keyless OIDC sign on image push; the maintainer GPG-signs release tags locally, and `.github/workflows/release.yml` verifies the signature before it publishes a release | `.github/workflows/docker.yml`, `release.yml` | | localStorage keys written without disclosure (ePrivacy Dir. / § 25 TDDDG — the "Cookie notice ⚠️" row in the compliance matrix below, and T-8) | Closed — `/privacy` §6 now enumerates every key the app writes (`fm_access_token`, `fm_refresh_token`, `filemorph_api_key`, `fm_cookie_notice_dismissed`) with purpose and legal basis, and an informational bar deep-links there on first visit. Deliberately **not** a consent dialog: no cookies are set, no third-party resources load, and every key is strictly necessary, so § 25 Abs. 2 Nr. 2 TDDDG exempts them — a fake Accept/Reject choice would misrepresent that. `test_notice_is_not_a_consent_dialog` pins it | `app/templates/privacy.html` §6, `app/templates/partials/cookie_notice.html`, `app/static/js/cookie-notice.js`, `tests/test_cookie_notice.py` | -What is still in flight for the Compliance-Edition push: paid-path -account-deletion (slice c.2 — `users.deleted_at` partial-unique-index -+ tax-retention column under HGB §257 / AO §147), `invoice.payment_*` -Stripe webhook coverage, login per-user rate-limit lockout, and the -Prometheus/Grafana monitoring (#139). The status table in -`CLAUDE.md` "Cloud Edition — Status" is the authoritative running -inventory. +The items once listed here as in flight have since landed or +narrowed: paid-path account deletion (slice c.2 — `users.deleted_at`, +the partial unique index, and the tax-retained path under HGB §257 / +AO §147) is live; the Stripe webhook handles `invoice.payment_failed` +(dunning email + audit event), and a recovered payment arrives as +`customer.subscription.updated`; the application exposes Prometheus +metrics at `/api/v1/metrics`, while dashboards and alerting are not +set up yet (`docs/security-overview.md` § Known Limitations). There +is no per-account login lockout; `/api/v1/auth/login` is limited to +5 requests per minute per IP. --- diff --git a/docs/records-of-processing-template.md b/docs/records-of-processing-template.md index 55d67d7..471c6c0 100644 --- a/docs/records-of-processing-template.md +++ b/docs/records-of-processing-template.md @@ -21,8 +21,8 @@ repository so that: > and must be filled by whoever runs the instance. Prune the activities > that do not apply (a Community-Edition install with no user accounts, > no database, no SMTP relay, and no Stripe key processes personal data -> under A1 and A6 only — A2–A5 are then not applicable). Have a DPO or -> counsel confirm the result reflects your actual processing. Companion +> under A1 and A6 only — A2–A5 and A7 are then not applicable). Have a +> DPO or counsel confirm the result reflects your actual processing. Companion > documents: [`gdpr-privacy-analysis.md`](gdpr-privacy-analysis.md) > (data-flow analysis), [`sub-processors.md`](sub-processors.md) (the > recipient list), [`dpa-tom-annex.md`](dpa-tom-annex.md) (the Article 32 @@ -52,9 +52,9 @@ repository so that: | Purpose | Provide the file-conversion / compression service requested by the user | | Data subjects | Users of the service; any natural persons whose data appears in uploaded files (categories not known to the operator in advance) | | Personal data | Uploaded file content while being processed (held in memory; if a temp path is needed, a UUID-named scratch file); request-log IP address; session JWT / API-key identifiers | -| Recipients | None for the conversion itself — the application transmits no file content, names, or hashes to any sub-processor (see [`sub-processors.md`](sub-processors.md)). OS-level access logs reach the hosting provider (covered by A6). | -| Third-country transfers | None | -| Retention / erasure | File content: ephemeral — deleted from memory and disk immediately after the output is returned (typically seconds; absolute upper bound a ~10-minute startup/background sweep). `RETENTION_HOURS` defaults to `0`. | +| Recipients | None for the conversion itself — the application transmits no file content, names, or hashes to any sub-processor (see [`sub-processors.md`](sub-processors.md)). An edge proxy in front of the service, if one is used (e.g. Cloudflare), terminates TLS and so carries uploads and results in transit. The server, and its access logs (A6), sit with the hosting provider. | +| Third-country transfers | None from the application. `[operator: if an edge proxy outside the EU carries the traffic (e.g. Cloudflare Inc., US), name it and the transfer safeguard]` | +| Retention / erasure | File content: ephemeral — deleted from memory and disk immediately after the output is returned (typically seconds). A temp directory left behind, e.g. by a crashed worker, is removed by the startup sweep or the hourly background sweep once it is older than 10 minutes — with the defaults (`TEMP_SWEEP_INTERVAL_MINUTES`, `TEMP_SWEEP_MAX_AGE_MINUTES`) within about 70 minutes. `RETENTION_HOURS` defaults to `0`. | | TOMs | See [`dpa-tom-annex.md`](dpa-tom-annex.md) | ### A1b — PII redaction (Cloud-Edition add-on; omit if `AI_OPERATIONS_ENABLED` is unset) @@ -75,10 +75,10 @@ repository so that: |---|---| | Purpose | Authenticate users; issue and manage API keys; enforce per-tier quotas; administer the service via the admin cockpit | | Data subjects | Registered users; administrators | -| Personal data | Email address; bcrypt password hash; API-key SHA-256 hashes; tier; `stripe_customer_id` (if a paid subscription exists); admin-role flag; usage records (operation type, byte counts, timestamp — no file content) | -| Recipients | Hosting provider (server access logs only); no others | -| Third-country transfers | None — the database is hosted in the EU | -| Retention / erasure | Until the user deletes the account (`DELETE /api/v1/auth/account`, Art. 17 — actor identifiers in `file_jobs` / `usage_records` / audit events are nulled, `api_keys` rows removed), subject to statutory retention of tax-relevant records (HGB §257 / AO §147 — typically 10 years) for accounts that have had a billing relationship | +| Personal data | Email address; bcrypt password hash; API-key SHA-256 hashes; tier; `stripe_customer_id` (created when the account first starts a Stripe checkout); admin-role flag; usage records (operation type, byte counts, timestamp — no file content) | +| Recipients | Hosting provider (the server and database run there); an edge proxy in front of the service, if one is used, in transit (see A1). Payment and email: A3, A4 | +| Third-country transfers | None — the database is hosted in the EU. `[operator: an edge proxy outside the EU — as in A1]` | +| Retention / erasure | Until the user deletes the account (`DELETE /api/v1/auth/account`, Art. 17 — actor identifiers in `file_jobs` / `usage` are nulled, and in audit events on a hard delete (accounts without a Stripe customer id); `api_keys` rows removed), subject to statutory retention of tax-relevant records (HGB §257 / AO §147 — typically 10 years) for accounts that have had a billing relationship | | TOMs | See [`dpa-tom-annex.md`](dpa-tom-annex.md) | ### A3 — Subscription billing (Cloud, paid tiers) @@ -97,11 +97,11 @@ repository so that: | Field | | |---|---| -| Purpose | Deliver authentication / account / billing emails: email verification, password reset, billing receipts, dunning notices, account-deletion confirmation | +| Purpose | Deliver authentication / account / billing emails: email verification, password reset, payment-failure (dunning) notices, account-deletion confirmation. FileMorph sends no payment receipts | | Data subjects | Registered users | -| Personal data | Recipient email address; email body (e.g. the reset link, the receipt) | +| Personal data | Recipient email address; email body (e.g. the reset link) | | Recipients | Zoho Corporation B.V. (SMTP relay) | -| Third-country transfers | None — Zoho EU, hosted in Frankfurt, Germany | +| Third-country transfers | None — Zoho EU, data centres in Amsterdam (NL) and Dublin (IE) | | Retention / erasure | Not persisted by FileMorph — emails are sent fire-and-forget; the relay's own retention is governed by its terms | | TOMs | See [`dpa-tom-annex.md`](dpa-tom-annex.md) | @@ -109,12 +109,12 @@ repository so that: | Field | | |---|---| -| Purpose | Maintain a tamper-evident record of actions affecting accounts and entitlements (registration, login, API-key creation, account deletion, billing changes) and of conversion / compression operations, for security and compliance evidence | +| Purpose | Maintain a tamper-evident record of account and billing events (registration, login, email verification, password reset, account deletion, subscription and payment changes), of single-file conversion / compression operations, of contact-form messages and of PII redactions, for security and compliance evidence. API-key management, admin changes in the cockpit, batch jobs and the `/pdf/*` tools are not recorded | | Data subjects | Registered users | -| Personal data | Hashed-email actor identifier (no raw email stored); actor IP address; event type; payload digest; timestamp; hash of the previous event (chain integrity) | +| Personal data | Account ID of the actor, where there is one (no email address stored); a SHA-256 hash of the email address for failed logins, duplicate registrations, password-reset requests and contact-form messages; the email domain for account deletions; actor IP address; event type; event payload (operation metadata such as format pair, byte counts, output hash — no file content); timestamp; hash of the previous event (chain integrity) | | Recipients | None | | Third-country transfers | None | -| Retention / erasure | `[operator: the value of AUDIT_RETENTION_DAYS — set it to the value your privacy notice declares. On account deletion the actor identifier is nulled while the event type and payload digest survive.]` | +| Retention / erasure | `[operator: the audit log has no built-in retention period — rows are append-only and are not pruned automatically. State the period your privacy notice declares and how you prune (a privileged database role that bypasses the append-only trigger). On a hard delete (accounts without a Stripe customer id) the actor identifier is nulled while the event type and payload survive.]` | | TOMs | See [`dpa-tom-annex.md`](dpa-tom-annex.md) | ### A6 — Server / access logging @@ -123,16 +123,28 @@ repository so that: |---|---| | Purpose | Operate, troubleshoot, and secure the service | | Data subjects | Visitors to the service | -| Personal data | IP address; request timestamp; requested URL; HTTP status; response size — written by the OS-level web server / reverse proxy, not by the FileMorph application | +| Personal data | IP address; request timestamp; requested URL; HTTP status; response size — written by the reverse proxy, and by the application server: uvicorn's per-request access line (client address, method, path, status) is on in the shipped container (`entrypoint.sh`) | | Recipients | Hosting provider | | Third-country transfers | `[operator: none for an EU host such as Hetzner; state otherwise if your host is elsewhere]` | | Retention / erasure | `[operator: your log-rotation period — e.g. rotated within 30 days]` | | TOMs | See [`dpa-tom-annex.md`](dpa-tom-annex.md) | +### A7 — Contact form + +| Field | | +|---|---| +| Purpose | Answer messages sent through the public contact form (`/contact`) | +| Data subjects | People who send a message through the form | +| Personal data | Name (optional); email address; subject (optional); message; page language. An audit event `contact.message.received` records a SHA-256 hash of the email address and the language (see A5). If the form's spam trap is triggered, the application log records the client IP and nothing is sent. The rate limiter (5 messages per hour per IP) holds the client IP in memory only | +| Recipients | The SMTP relay (A4), which delivers the message to the operator's mailbox with the sender's address as `Reply-To` | +| Third-country transfers | As A4 for the relay; an edge proxy as in A1 | +| Retention / erasure | Not stored by FileMorph — the message exists only as the delivered email. `[operator: how long contact messages are kept in the mailbox]` | +| TOMs | See [`dpa-tom-annex.md`](dpa-tom-annex.md) | + > A Community-Edition deployment that runs anonymous conversions only and > configures no database, no SMTP relay, and no Stripe key processes -> personal data under **A1 and A6 only** — A2–A5 are then not applicable -> and should be removed from your register. +> personal data under **A1 and A6 only** — A2–A5 and A7 are then not +> applicable and should be removed from your register. --- @@ -143,11 +155,11 @@ repository so that: | Field | | |---|---| | Controller | `[operator: the customer — legal name and contact, per the customer's DPA §1]` | -| Categories of processing performed | Receiving uploaded files via HTTPS; running format conversion / compression in transient memory and ephemeral filesystem locations; returning the converted output and a SHA-256 integrity header; writing structured logs (operation metadata only, no file content); recording audit events for actions affecting accounts or entitlements | +| Categories of processing performed | Receiving uploaded files via HTTPS; running format conversion / compression in transient memory and ephemeral filesystem locations; returning the converted output and a SHA-256 integrity header; writing structured logs (operation metadata only, no file content); recording audit events for account and billing actions (registration, login, email verification, password reset, account deletion, subscription changes) and for single-file conversions and compressions — not for API-key management, admin changes in the cockpit, batch jobs, or the `/pdf/*` tools | | Categories of personal data | As specified in the customer's DPA §4 — not determined by the processor in advance | | Recipients / sub-processors | As listed in [`sub-processors.md`](sub-processors.md), or the reduced set agreed in the customer's DPA §6 | | Third-country transfers | None — except, where the customer's own subscription is billed through Stripe, the transfer described in A3 (US; Stripe DPA + SCCs) | -| Retention / erasure | Per the customer's DPA — file content ephemeral; audit log per the customer's configured `AUDIT_RETENTION_DAYS`; deletion / return at end of provision per DPA §10 | +| Retention / erasure | Per the customer's DPA — file content ephemeral; audit log per the retention period agreed with the customer (no built-in pruning); deletion / return at end of provision per DPA §10 | | TOMs | See [`dpa-tom-annex.md`](dpa-tom-annex.md), finalised for the deployment in the customer's DPA Annex II | (One B1 entry per Compliance-Edition customer — see [`dpa-template.md`](dpa-template.md).) diff --git a/docs/sub-processors.md b/docs/sub-processors.md index e0ddb24..caff0f4 100644 --- a/docs/sub-processors.md +++ b/docs/sub-processors.md @@ -21,10 +21,10 @@ a starting template, not as a binding statement about your deployment. | Service | Purpose | Data category | Region | Toggle | |---|---|---|---|---| -| **Hetzner Online GmbH** | Server hosting | Server access logs (IP, request time, URL, status, size) — written by the OS-level web server, not by the FileMorph application. | Frankfurt / Falkenstein, Germany (EU) | Inherent to the deployment; switch hosting provider to opt out. | -| **Cloudflare Inc.** | DDoS protection, edge caching, optional R2 storage | TLS-terminated request metadata; no request bodies. | Distributed (operator may set a regional preference). | Optional; remove the proxy and serve the origin directly. | +| **Hetzner Online GmbH** | Server hosting | Whatever the server holds: uploads while they are processed, the database if configured (accounts, usage records, audit log), and the logs — access logs with client IPs are written by the reverse proxy and by the application's uvicorn server. | Data centre in the EU. | Inherent to the deployment; switch hosting provider to opt out. | +| **Cloudflare Inc.** | DNS, DDoS protection, edge caching | All traffic to the service: the edge terminates TLS, so it handles request metadata (client IP, URL, headers) and, in transit, request and response bodies — uploaded and converted files included. FileMorph uses no Cloudflare storage. | Distributed (operator may set a regional preference). | Optional; remove the proxy and serve the origin directly. | | **Stripe Inc.** | Payment processing (Cloud Edition only) | Customer email and an internal user identifier; card data is collected by Stripe directly and never reaches FileMorph. | United States (EU SCCs apply via Stripe DPA). | Disabled when `STRIPE_SECRET_KEY` is empty. | -| **Zoho Corporation B.V.** | Transactional email (password-reset, billing receipts) | Recipient address and the email body (reset link or receipt). | Frankfurt, Germany (EU). | Disabled when `SMTP_HOST` is empty. | +| **Zoho Corporation B.V.** | Transactional email (email verification, password reset, payment-failure notices, account-deletion confirmation) and delivery of contact-form messages to the operator | Recipient address and the email body (e.g. a reset link); for a contact-form message, the sender's name, email address, subject and message. | EU data centres in Amsterdam (NL) and Dublin (IE). | Disabled when `SMTP_HOST` is empty. | | **GitHub Inc.** | Source distribution and issue tracking | Public repository metadata only; not in the request path of any deployment. | United States. | Inherent to the open-source distribution model. | ## What FileMorph itself does NOT send out @@ -34,9 +34,11 @@ files, file contents, or filenames to any sub-processor. The only outbound calls in the application code are: - PostgreSQL queries to the configured database (Cloud Edition). -- SMTP submissions to the configured relay for password-reset and - billing emails (Cloud Edition). -- Stripe Checkout-Session creation and webhook responses (Cloud Edition, +- SMTP submissions to the configured relay: account and billing emails + (Cloud Edition) and contact-form messages to the operator. +- Stripe API calls — creating the customer and the Checkout and + Billing-Portal sessions, cancelling subscriptions when an account is + deleted — and responses to Stripe's signed webhooks (Cloud Edition, paid tiers). There is no analytics beacon, no telemetry endpoint, no "phone home" call, diff --git a/docs/support-sla.md b/docs/support-sla.md index 7ab18aa..c1623fb 100644 --- a/docs/support-sla.md +++ b/docs/support-sla.md @@ -67,8 +67,9 @@ After an issue is triaged and confirmed (severity by CVSS v3.x base score): | Medium | 4.0 – 6.9 | next regular release | | Low | 0.1 – 3.9 | next regular release | -A *regular release* historically lands every 1–4 weeks. Self-hosters who pin to -a `vX.Y` line and cannot take the latest `main` tag can request a backport of a +So far there has been one tagged release, v1.1.0 (2026-06-01); there is no +established release cadence yet. Self-hosters who pin to a `vX.Y` line and +cannot take the latest `main` tag can request a backport of a Critical/High fix onto that line — contact `security@filemorph.io` with the version; see [`patch-policy.md`](./patch-policy.md) for the release-line model. (For Enterprise / KRITIS agreements, backports onto a fixed version line plus @@ -127,9 +128,8 @@ agreement specifies: - **Email:** `support@filemorph.io` — for any licensed customer. - **Dedicated contact:** named in the agreement for Standard and above; a named escalation path for Enterprise / KRITIS. -- **Security incidents** always *also* go through `security@filemorph.io` or a - private [GitHub Security Advisory](https://github.com/MrChengLen/FileMorph/security/advisories/new), - per [`SECURITY.md`](../SECURITY.md) — the security-disclosure process runs in +- **Security incidents** always *also* go to `security@filemorph.io`, per + [`SECURITY.md`](../SECURITY.md) — the security-disclosure process runs in parallel with, not instead of, any support arrangement. ## What support does not cover @@ -150,9 +150,9 @@ agreement specifies: ## How to raise something For a **suspected vulnerability** — anyone, paid or not — use the disclosure -channel in [`SECURITY.md`](../SECURITY.md): email `security@filemorph.io` or -open a private GitHub Security Advisory. That routes it correctly and starts the -security-fix clock. +channel in [`SECURITY.md`](../SECURITY.md): email `security@filemorph.io` (for +encrypted mail, the PGP key is available on request at the same address). That +routes it correctly and starts the security-fix clock. For a **support request** under a commercial agreement, email `support@filemorph.io` (or your dedicated contact) with: diff --git a/docs/vendor-security-questionnaire.md b/docs/vendor-security-questionnaire.md index 7b8d636..870ad7e 100644 --- a/docs/vendor-security-questionnaire.md +++ b/docs/vendor-security-questionnaire.md @@ -24,6 +24,10 @@ to a questionnaire instead of re-deriving the answers each time. > the code they are running; deployment-level questions (hosting, > network, on-call, backup) they answer for themselves. The same split > appears in [`dpa-tom-annex.md`](./dpa-tom-annex.md). +> +> Application-level answers describe the current code on `main`. The +> only tagged release so far, v1.1.0 (2026-06-01), predates several of +> them — see the "Unreleased" section of [`CHANGELOG.md`](../CHANGELOG.md). --- @@ -39,7 +43,7 @@ to a questionnaire instead of re-deriving the answers each time. | Licensing contact | `licensing@filemorph.io` ([`COMMERCIAL-LICENSE.md`](../COMMERCIAL-LICENSE.md)) | | Support contact | `support@filemorph.io` ([`support-sla.md`](./support-sla.md)) | | Public source code | [`github.com/MrChengLen/FileMorph`](https://github.com/MrChengLen/FileMorph) | -| Product editions | **Community Edition** (AGPL-3.0, self-host, anonymous conversions) and **Compliance Edition** (per-server commercial licence, `app/ee/`-features unlocked via licence key) — see [`COMMERCIAL-LICENSE.md`](../COMMERCIAL-LICENSE.md) | +| Product editions | **Community Edition** (AGPL-3.0, self-host, anonymous conversions) and **Compliance Edition** (the same code under a per-server commercial licence, with DPA, support agreement and onboarding) — see [`COMMERCIAL-LICENSE.md`](../COMMERCIAL-LICENSE.md) | ## 1. Product overview @@ -54,11 +58,11 @@ the application after the response is returned. ### 1.2 Where does it run? -The product is a single Linux container — Python 3.11 + FastAPI on +The product is a single Linux container — Python 3.14 + FastAPI on Uvicorn. It runs behind any reverse proxy (the deployment templates use Caddy with automatic HTTPS) on any host the customer chooses. The -public SaaS at filemorph.io runs on Hetzner Online GmbH in Frankfurt, -Germany. Self-hosted deployments place themselves wherever the +public SaaS at filemorph.io runs on Hetzner Online GmbH — data centre in +the EU. Self-hosted deployments place themselves wherever the customer requires — on-premises, sovereign cloud, air-gapped. ### 1.3 What deployment models are supported? @@ -68,10 +72,13 @@ customer requires — on-premises, sovereign cloud, air-gapped. - **Self-hosted Community Edition** under AGPL-3.0 — operator runs the unmodified container; no licence cost. - **Self-hosted Compliance Edition** under the per-server commercial - licence — adds the `app/ee/` feature set (audit-chain hard-mode, - PDF/A-2b validation gate, signed release-tag verification, dedicated - support, offline-update tooling); details in - [`COMMERCIAL-LICENSE.md`](../COMMERCIAL-LICENSE.md). + licence — the same code under a commercial licence instead of the + AGPL, plus the contract chain (DPA, support agreement, onboarding) + and, for Enterprise / KRITIS, backports and offline-update tooling as + agreed; details in [`COMMERCIAL-LICENSE.md`](../COMMERCIAL-LICENSE.md). + The audit-log fail-closed mode (`AUDIT_FAIL_CLOSED`) and PDF/A-2b + conversion are part of the AGPL code and available to every + deployment, and every release tag is GPG-signed (§8.3). ### 1.4 Who is the typical customer? @@ -92,8 +99,8 @@ segment. ### 2.1 Where is data hosted? -For the SaaS at filemorph.io: Hetzner Online GmbH, Frankfurt / Falkenstein, -Germany (EU). Hetzner's datacentres are ISO 27001-certified. For a +For the SaaS at filemorph.io: Hetzner Online GmbH — data centre in the +EU. Hetzner's datacentres are ISO 27001-certified. For a self-hosted deployment: wherever the customer runs the container — their answer governs. @@ -104,9 +111,12 @@ content, file names, or file hashes to any third party. The only outbound calls in the application code are: - PostgreSQL queries to the configured database (Cloud features). -- SMTP submissions to the configured relay for authentication / billing - emails — never for file content (Cloud features). -- Stripe Checkout-Session creation and webhook responses (paid tiers +- SMTP submissions to the configured relay: account and billing emails + (Cloud features) and contact-form messages to the operator — never + file content. +- Stripe API calls — creating the customer and the Checkout and + Billing-Portal sessions, cancelling subscriptions when an account is + deleted — and responses to Stripe's signed webhooks (paid tiers only). There is no analytics beacon, no telemetry endpoint, no "phone home" @@ -122,20 +132,25 @@ For the SaaS: the United States, covered by the Stripe DPA and EU Standard Contractual Clauses (SCCs). Card data is collected by Stripe directly and never reaches FileMorph. -- Transactional email transits **Zoho Corporation B.V.**, hosted in - Frankfurt, Germany — no third-country transfer. -- Server hosting and DNS sit on Hetzner Online GmbH — no third-country - transfer. +- Transactional email transits **Zoho Corporation B.V.**, EU data + centres in Amsterdam (NL) and Dublin (IE) — no third-country transfer. +- Server hosting sits on Hetzner Online GmbH, data centre in the EU — + no third-country transfer. +- DNS and the edge proxy are **Cloudflare Inc.** (United States). The + edge terminates TLS, so every request — uploaded files included — and + every response passes through Cloudflare's network, which is global; + covered by the Cloudflare DPA and EU Standard Contractual Clauses. For a self-hoster: their own configuration governs. The application -defaults emit no third-country traffic unless the operator wires Stripe -or a non-EU SMTP relay. +defaults emit no third-country traffic unless the operator wires Stripe, +a non-EU SMTP relay, or a non-EU edge proxy. ### 2.4 Sub-processors Default sub-processors for an operator that enables every Cloud feature: -Hetzner (hosting), Cloudflare (optional edge), Stripe (payments), Zoho -(transactional email), GitHub (source distribution and issue tracking). +Hetzner (hosting), Cloudflare (optional edge proxy and DNS), Stripe +(payments), Zoho (transactional email and contact-form delivery), GitHub +(source distribution and issue tracking). Each is listed with data category, region, and the toggle that disables it in [`docs/sub-processors.md`](./sub-processors.md). A Community-Edition self-host with no database, no SMTP relay, and no Stripe key contacts @@ -205,15 +220,18 @@ refers to and attaches to the counter-signed contract. ### 3.5 How are data-subject rights handled? -- **Access (Art. 15):** by request to `hallo@filemorph.io`. The data - set is small — email, tier, usage records, audit-event payload - digests — and can be exported as JSON. -- **Rectification (Art. 16):** users update email and password from the - dashboard; tier changes happen via the Stripe customer portal. +- **Access (Art. 15):** by request to `privacy@filemorph.io`. The data + set is small — email, tier, usage records, audit events — and can be + exported as JSON. +- **Rectification (Art. 16):** by request to `privacy@filemorph.io` — the + dashboard has no email or password change; a password is changed + through the reset email. Tier changes happen via the Stripe customer + portal. - **Erasure (Art. 17):** self-service at `DELETE /api/v1/auth/account`. Two modes: - - **Free / never-paid accounts** → hard delete (cascades). - - **Paid accounts** → restricted delete (HGB §257 / AO §147, + - **Accounts without a Stripe customer id** → hard delete (cascades). + - **Accounts with a Stripe customer id** (paid, or a checkout started + but never paid) → restricted delete (HGB §257 / AO §147, Art. 17(3)(b)): `email`, `stripe_customer_id`, `tier`, `created_at` retained for tax purposes; the rest is nulled, `password_hash` is replaced with a sentinel, `deleted_at` is set; ApiKeys deleted, @@ -223,7 +241,7 @@ refers to and attaches to the counter-signed contract. - **Portability (Art. 20):** the export above is JSON; nothing is held in a proprietary format. - **Restriction / objection (Art. 18 / 21):** by request to - `hallo@filemorph.io`. + `privacy@filemorph.io`. ### 3.6 What is the retention regime? @@ -237,10 +255,12 @@ refers to and attaches to the counter-signed contract. - **Billing records:** statutory retention under HGB §257 / AO §147 (typically 10 years from the end of the calendar year of the last transaction). -- **Audit log:** governed by `AUDIT_RETENTION_DAYS`, set by the - operator to match the privacy notice. On account deletion the - actor identifier is nulled; the event type and payload digest - survive. +- **Audit log:** no built-in retention period — rows are append-only + and are not pruned automatically; pruning takes a privileged + database role that bypasses the append-only trigger. The operator + sets the period to match the privacy notice. On a hard delete + (accounts without a Stripe customer id) the actor identifier is + nulled; the event type and payload survive. - **Server access logs:** operator-side, per the operator's log-rotation policy. @@ -310,7 +330,7 @@ and renews certificates automatically. | API-key comparison | Key file: `hmac.compare_digest` (constant-time); per-user keys: lookup by SHA-256 hash | `app/core/security.py::validate_api_key`, `::find_active_api_key` | | Password hashing | bcrypt, adaptive cost | `app/core/auth.py::hash_password` | | Password verification | bcrypt `checkpw` | `app/core/auth.py::verify_password` | -| Session tokens | JWT HS256, 15-min access + 30-day refresh | `app/core/auth.py::create_access_token` | +| Session tokens | JWT HS256, 15-min access + 30-day refresh | `app/core/tokens.py::create_access_token` | | Audit-log integrity | SHA-256 hash chain | `app/core/audit.py` | | Output integrity | Streaming SHA-256, returned as `X-Output-SHA256` header and recorded in the audit-log payload | `app/core/audit.py`, `app/api/routes/convert.py`, `compress.py` | | Release signing | GPG (OpenPGP) | [`docs/release-signing.md`](./release-signing.md) | @@ -392,12 +412,13 @@ are not in the AGPL or default-Compliance build today. Every upload passes through: -1. **Magic-byte allow-list** — `BLOCKED_MAGIC = [b"MZ", b"\x7fELF", - b"#!/", b" Date: Tue, 29 Sep 2026 09:18:25 +0200 Subject: [PATCH 2/3] =?UTF-8?q?chore(changelog):=20take=20the=20entry=20ou?= =?UTF-8?q?t=20to=20merge=20main=20in=20=E2=80=94=20back=20in=20two=20comm?= =?UTF-8?q?its?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main gained the website-texts entry (#178) after this branch was cut, and every PR adds its entry at the same place. Removing this PR's entry lets GitHub merge main in without a conflict; the next commit puts it back on top. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12cacd9..a510c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,41 +9,6 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] -### Fixed — compliance templates describe what the code does - -The DPA template and its TOM annex, the records-of-processing template, the -vendor security questionnaire, the sub-processor list, the support framework, -both GDPR documents, the AGPL guide for public bodies and the commercial -licence agreement template claimed more than the code does in places. They -now say that the audit log names actors by account ID (an email hash only for -failed logins, duplicate registrations, reset requests and contact-form -messages), stores its payload as JSON rather than a digest, and does not record -API-key management, admin changes, batch jobs or the `/pdf/*` tools; that -`X-Output-SHA256` comes only from single-file `/convert` and `/compress`; that -the upload check is a magic-byte deny-list and the converter is chosen by file -extension, not from content; that `app/ee/` holds only PII redaction, switched -on by `AI_OPERATIONS_ENABLED` rather than a licence key; that veraPDF runs in -CI, not per request; that a -leftover temp directory can last about 70 minutes, not 10; that uvicorn's -access log is on and a TLS-terminating edge proxy sees uploads; and that the -SMTP relay also carries contact-form messages but no receipts. Hosting and -email locations, the Python version, CSP, CORS, disclosure targets and code -anchors are updated, and vulnerability reports go to `security@filemorph.io` -only (PGP key on request); v1.1.0 is named as the only release so far, along -with what its SBOM lacks, and support response times as set per agreement. The -documents no longer name an `AUDIT_RETENTION_DAYS` setting, which never -existed: the audit log has no built-in retention period, and the operator -states theirs. Data-subject requests go to `privacy@filemorph.io`, as in the -privacy policy. The TOM annex and the questionnaire gain the rate limits and -failed-key budget, the two-job release workflow with its hash-pinned SBOM -generator and `.dockerignore`; the questionnaire also covers error messages -that no longer echo library internals, and the records of processing gain the -contact form. The account-deletion design and the questionnaire note that the -code takes the paid path for any account with a Stripe customer id; audit -events lose their actor ID only on a hard delete. In the agreement template, -no VAT is charged only while §19 UStG applies; it and the AGPL guide exclude -`app/ee/` from the AGPL. - ### Security — the Docker image is built without a restored build cache `docker.yml` restored BuildKit's GitHub Actions cache (`cache-from: type=gha`) From 9f3351804acff762b33b034cd2088bd97c631746 Mon Sep 17 00:00:00 2001 From: MrChengLen Date: Tue, 29 Sep 2026 09:18:47 +0200 Subject: [PATCH 3/3] docs(changelog): compliance entry back on top of the merged changelog Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7998f19..32dd320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,41 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed — compliance templates describe what the code does + +The DPA template and its TOM annex, the records-of-processing template, the +vendor security questionnaire, the sub-processor list, the support framework, +both GDPR documents, the AGPL guide for public bodies and the commercial +licence agreement template claimed more than the code does in places. They +now say that the audit log names actors by account ID (an email hash only for +failed logins, duplicate registrations, reset requests and contact-form +messages), stores its payload as JSON rather than a digest, and does not record +API-key management, admin changes, batch jobs or the `/pdf/*` tools; that +`X-Output-SHA256` comes only from single-file `/convert` and `/compress`; that +the upload check is a magic-byte deny-list and the converter is chosen by file +extension, not from content; that `app/ee/` holds only PII redaction, switched +on by `AI_OPERATIONS_ENABLED` rather than a licence key; that veraPDF runs in +CI, not per request; that a +leftover temp directory can last about 70 minutes, not 10; that uvicorn's +access log is on and a TLS-terminating edge proxy sees uploads; and that the +SMTP relay also carries contact-form messages but no receipts. Hosting and +email locations, the Python version, CSP, CORS, disclosure targets and code +anchors are updated, and vulnerability reports go to `security@filemorph.io` +only (PGP key on request); v1.1.0 is named as the only release so far, along +with what its SBOM lacks, and support response times as set per agreement. The +documents no longer name an `AUDIT_RETENTION_DAYS` setting, which never +existed: the audit log has no built-in retention period, and the operator +states theirs. Data-subject requests go to `privacy@filemorph.io`, as in the +privacy policy. The TOM annex and the questionnaire gain the rate limits and +failed-key budget, the two-job release workflow with its hash-pinned SBOM +generator and `.dockerignore`; the questionnaire also covers error messages +that no longer echo library internals, and the records of processing gain the +contact form. The account-deletion design and the questionnaire note that the +code takes the paid path for any account with a Stripe customer id; audit +events lose their actor ID only on a hard delete. In the agreement template, +no VAT is charged only while §19 UStG applies; it and the AGPL guide exclude +`app/ee/` from the AGPL. + ### Fixed — website texts match what the service does The public pages were checked claim by claim against the code: