17. Compliance, Audit Log & DSAR
Alex's client asks for proof that their data stays in the EU and a trail of who changed what — and a user emails LexVault asking "what do you have on me?" Both answered without leaving the console.
The Compliance page has three tabs: DPA Report, Audit Log, and Data Export. Together they cover the three things a GDPR auditor (or your customer's legal team) actually asks for: the Article 30 records-of-processing report, the tamper-evident trail of who changed what, and one-click DSAR exports for both the "tell me everything about me" (Article 15) and the "give me my data so I can leave" (Article 20) shapes.
What is a DSAR?
A Data Subject Access Request is the legal mechanism a user or a regulator uses to ask "what personal data do you hold on me?" (Article 15) or "give me my data in a portable format so I can move to a competitor" (Article 20). The controller (you) has a 30-day deadline to comply. Most teams build the export pipeline ad-hoc the first time one lands — Eurobase ships it as a one-click console flow + an API endpoint so you don't write the SQL each time.
DPA Report
Generates a Data Processing Agreement (Article 30) report showing your sub-processors, data flow, encryption status, and whether any CLOUD Act exposure exists. Download it as JSON for your compliance records.
Audit Log
Every sensitive action on your project is automatically recorded in the audit log with a timestamp, actor email, IP address, and metadata. Tracked actions include:
- Auth config changes — updating login providers, OAuth settings, session duration
- API key regeneration — who rotated keys and when
- Project deletion — logged before the data is removed
- Schema DDL — creating, dropping, or renaming tables and columns
- RLS policy changes — toggling row-level security, applying presets, creating or dropping policies
- Index changes — creating or dropping indexes and constraints
- OAuth secrets — setting or rotating provider client secrets (the secret itself is never logged, only the event)
Filtering
Use the action filter dropdown to narrow the log to a specific action type — for example, show only schema.drop_table events to trace who deleted a table and when.
Audit log entries cannot be edited or deleted. They are append-only and stored in the platform database, separate from your project's tenant schema.
Data Export — one-click DSAR
The Data Export tab handles both DSAR shapes regulators and end-users actually ask for: Article 15 (single user, "tell me everything you have on me") and Article 20 (full project, "give me the data so I can leave"). Both run in the background and produce a downloadable zip; the download link expires after 7 days, and all the bytes stay on EU infrastructure (Scaleway fr-par) the whole way. Nothing to wire up, no SQL to write — the deadline is statutory, the tooling shouldn't be the bottleneck.
Full Project Export — Article 20 (data portability)
Exports every table in your tenant schema (up to 100,000 rows per table) including the auth users table, plus the project audit log (latest 10,000 entries) and a _metadata.json describing the run, as a single zip. The schema/ folder holds what you need to rebuild the project: schema.sql (tables, constraints incl. foreign-key actions, indexes, row-level security and every policy, functions, triggers, views and comments, as standard pg_dump output taken from the same snapshot as the rows), sequences.json (current sequence values), auth_manifest.json (auth settings and email templates — no secrets; re-enter OAuth client secrets after a restore) and storage_manifest.json (every stored object with its size, checksum and content type). Uploaded file contents are not in the archive — fetch those through the storage API. Pick JSON for round-trippable structure or CSV for spreadsheets. The one-click button in this tab is Pro and above; on Free the same export is available through the API (next section). Use this when a customer hands you "we're leaving, give us our data" or when a regulator asks for a full snapshot.
A consistent copy for a data migration. All tables in one export are read in a single read-only REPEATABLE READ transaction, so every table comes from the same moment and foreign keys between them stay consistent, even while your app keeps writing. That makes the full export the way to take a consistent copy of several related tables. To also stop writes during the export and the cutover, make the tables read-only first (see Migrations). For a few small tables, a single SQL statement also reads one snapshot, e.g. SELECT (SELECT json_agg(a) FROM orders a) AS orders, (SELECT json_agg(b) FROM order_items b) AS order_items.
Single-User Export — Article 15 (subject access request)
Search by email or paste a user UUID; the export contains only that user's auth record and every row in your tenant tables that references their user_id. Rate-limited to one export per data subject per 24 hours (so any admin calling for that user is gated by the same window) — this stops a runaway script or a hostile actor with a leaked admin token from exfiltrating the user table one row at a time.
Scripting exports — nightly backups from the API
Both exports are plain platform-API calls, so a cron job on your side can run them on every tier, including Free. The project's public and service keys cannot call them: they are platform endpoints on api.eurobase.app, authenticated as you. Create a Personal Access Token (Account → Personal Access Tokens) for the project with access Admin and send it as a Bearer token. You must be an admin of the project yourself, and so must the token: requesting an export, listing exports and checking an export's status all need Admin, so a Read-only or Developer token gets 403 {"error":"insufficient role"} even when you own the project. A token created for a different project gets 404: each project token works only in its own project.
# 1. request a full export (json or csv)
curl -X POST https://api.eurobase.app/platform/projects/$PROJECT_ID/compliance/export \
-H "Authorization: Bearer $EUROBASE_PAT" -H "Content-Type: application/json" \
-d '{"format":"json"}'
# → 202 {"id":"<exportId>","status":"pending",...}
# 2. poll until status is "completed", then download
curl https://api.eurobase.app/platform/projects/$PROJECT_ID/compliance/exports/$EXPORT_ID \
-H "Authorization: Bearer $EUROBASE_PAT"
# → {"status":"completed","download_url":"https://...","expires_at":"..."}download_urlis a presigned link valid for 1 hour; request the status again for a fresh one. The archive itself is kept for 7 days, then deleted.- Check
complete. Every table in your schema is exported in full, whatever its row-level security policies say, and empty tables come out as empty files. If something can't be exported in full (a table over 100,000 rows, or one the export can't read), the export still finishes, but with"complete": falseand awarningslist saying what is missing._metadata.jsonin the archive lists every table with its row count and status. - Rate limit: one full-project export per hour per project (single-user exports: one per data subject per 24 h). A nightly job fits comfortably.
- Export calls go through the platform API, so they do not count as project activity for the Free-tier idle pause (see chapter 2). A paused project still exports fine.
- Every request, completion and failure lands in the Audit Log with the token owner's email and IP.
Downloading the archive safely
download_url is a pre-signed Scaleway Object Storage link. Its host is always s3.fr-par.scw.cloud over HTTPS, on every plan: the bucket is in the path, never in the hostname. If your client allowlists download hosts, that one entry is all you need.
https://s3.fr-par.scw.cloud/<project-bucket>/exports/<project-id>/<export-id>.zip?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=…&X-Amz-Date=…&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&response-content-disposition=…&X-Amz-Signature=…
- The link carries its own signature in the query string. Fetch it without an
Authorizationheader: your token only ever goes toapi.eurobase.app, and S3 refuses a request that has both. - A successful download is a direct
200with the zip, no redirect. download_urlappears oncestatusiscompleted. Each status request signs a new link (valid 1 hour). Afterexpires_at(7 days) the archive and its record are deleted and the status request returns404: request a new export.
Archive format
A full project export with "format": "json" (with "csv", the table files are .csv with a header row):
eurobase-<slug>-project-<YYYY-MM-DD>.zip
├── _metadata.json what was exported, with row counts and statuses
├── _audit_log.json project audit log (latest 10,000 entries)
├── _retention_holds.json legal retention holds (usually [])
├── tables/
│ ├── <table>.json one file per table, empty tables too
│ └── … (platform tables such as users included)
└── schema/
├── schema.sql pg_dump --schema-only, same snapshot as the rows
├── sequences.json current sequence values
├── auth_manifest.json auth settings and email templates, no secrets
└── storage_manifest.json stored objects: key, size, ETag, content type_metadata.json describes the run (shortened example):
{
"format_version": 2,
"export_id": "2f0c…",
"project_id": "8a1b…",
"format": "json",
"exported_at": "2026-11-08T10:15:02.123Z",
"table_count": 11,
"total_rows": 48210,
"complete": true,
"tables": [
{ "table": "orders", "file": "tables/orders.json", "rows": 1200, "status": "exported" },
{ "table": "order_items", "file": "tables/order_items.json", "rows": 0, "status": "exported" },
{ "table": "users", "file": "tables/users.json", "rows": 340, "status": "exported",
"redacted_columns": ["password_hash"] },
{ "table": "order_summary", "rows": 0, "status": "skipped",
"reason": "view: derived data, definition in schema/schema.sql" }
],
"row_limit_per_table": 100000,
"sections": [
{ "section": "schema", "file": "schema/schema.sql", "status": "exported" },
{ "section": "sequences", "file": "schema/sequences.json", "status": "exported", "count": 2 },
{ "section": "auth_manifest", "file": "schema/auth_manifest.json", "status": "exported" },
{ "section": "storage_manifest", "file": "schema/storage_manifest.json", "status": "exported", "count": 0 }
],
"retention_hold_count": 0
}- Each table's
statusisexported,truncated(more than 100,000 rows) orfailed(with anerror). Views, materialized views and foreign tables are listed asskippedwith areason; their definitions are inschema/schema.sql. - Credential columns of platform tables (password hashes, token hashes, vault secrets) are left out and listed in
redacted_columns. - Use an archive for a restore or migration only when
completeistrue; otherwisewarningssays what is missing.
A table file is one JSON array with an object per row, keyed by column name. An empty table is [].
[
{
"id": "6f1d0c2e-…",
"customer_id": "a91b…",
"total": 42.5,
"payload": "q83v…==",
"created_at": "2026-11-07T18:02:11.482913Z",
"meta": { "source": "web" }
}
]uuidvalues are strings;jsonbis nested JSON;timestamptzis RFC 3339.byteavalues (e.g. encrypted payloads) are base64 strings: decode them before writing them back.
Tables with FORCE ROW LEVEL SECURITY
Both export types read each table as its owner, with row-level security bypassed, so it gets every row. A table with FORCE ROW LEVEL SECURITY applies its policies to the owner too. The export then leaves that table out rather than export only part of it: it is failed in _metadata.json, complete is false, and a full project export's warning reads:
table "orders" was not exported: query would be affected by row-level security policy for table "orders"
To export such tables, switch FORCE off for the export and back on afterwards, in the SQL editor:
-- 1. before the export, for each table named in the warnings ALTER TABLE orders NO FORCE ROW LEVEL SECURITY; -- 2. run the export and check that "complete" is true -- 3. afterwards ALTER TABLE orders FORCE ROW LEVEL SECURITY;
Row-level security stays enabled the whole time, so requests from your app are still checked against your policies. FORCE only decides whether the policies also apply to the table owner, which is the role behind the SQL editor, migrations and the export. On Team, that includes connections using your database's owner URL while FORCE is off. An archive with failed tables can't be completed afterwards; request a new export.
Your Data Processing Agreement (Art. 28 GDPR)
The DPA report above is a description of processing (Article 30). The contract that makes Eurobase your processor is the Data Processing Agreement v2, which you accepted click-through when you created your account — that is a valid written form under Art. 28(9), and it applies on every tier, including Free. The full text is at /legal/dpa: it names the contracting entity (Eurobase OÜ, Ahtri 12, Tallinn 15551, Estonia, registry code 17557586), sets out the technical and organisational measures, and carries the sub-processor annex. Print it or save it as PDF for your records; the sub-processors actually engaged by your project's enabled features are listed live in the DPA Report tab.
Need a countersigned copy on Eurobase letterhead with the sub-processor list attached, for example because your auditor or Verein board wants a wet-ink or PDF-signed document? That is a paid-tier service: available on Pro and above on request via dpo@eurobase.app. On Free, the click-through DPA at /legal/dpa is your contract.
What the audit log records
Every export request, completion, and failure is recorded in the Audit Log with the actor's email + IP. So the trail of who exported what, when, and why is always there for the next compliance review — even after the export bytes themselves expire.