← Back to blog

A Week in Closed Beta: Streaming DSAR, RLS-Aware Realtime, and the CVE Crawl

Stefan Gimeson··6 min read
A Week in Closed Beta: Streaming DSAR, RLS-Aware Realtime, and the CVE Crawl

Twelve PRs landed on main in the last three days. Most of them you would not notice as a customer — but each one closes a class of problem we would have hit later, with more users and worse timing. This is what a closed-beta week looks like when the testers are paying attention.

Self-serve "Download my data" — the one customers will notice

GDPR Article 15 and 20 give every end-user the right to a complete copy of their data, in a machine-readable format, within one month. Industry research puts the per-request fulfilment cost at around $1,500 — most of it engineering time spent stitching together exports from a CSV here, the auth table there, the support tool over there.

The SDK now ships eb.auth.exportMyData('json' | 'csv') and eb.auth.getMyExport(id). A signed-in user hits a button in your app and gets back a presigned zip of every row that references them, plus their auth record, plus a metadata file. Rate-limited to one export per user per 24 hours, audited, no support ticket. A $1,500 cost centre becomes a function call.

The console got the admin equivalent in the same week: open Compliance → Data Export, pick a user, get the same zip. Same pipeline, admin-initiated.

Streaming the export pipeline so it doesn't OOM on real tenants

The first version of DSAR export held the entire archive in a Go bytes.Buffer and accumulated every row of every table into a []map[string]interface{} before zipping. Fine for the demo tenant. For an actual tenant with millions of rows across a hundred tables, it would OOM the worker pod twice over — once in the row buffer, once in the zip.

The pipeline is now rows.Next() → CSV/JSON encoder → zip.Writer → os.CreateTemp → S3. Memory is bounded by one row plus the zip compression buffer, regardless of table size. The JSON output format is preserved on the wire so existing consumers don't see a change.

Belt-and-braces: the rate limiter used to count failed exports against the 24-hour budget. If a worker crashed, the user was locked out for a day. Now the SQL filters status <> 'failed' so a system error doesn't punish the user. The audit log also emits a compliance.export.failed entry for every failed stage (resolve, build, upload), so the Compliance → Audit Log tab tells you exactly what broke without a backend log dive.

Realtime now respects row ownership

This was the worst correctness gap we shipped to beta. The old behaviour: an SDK INSERT on a table broadcast the full row payload to every WebSocket subscriber on the project, regardless of the RLS policy. End-user Alice received Bob's private rows even though SELECT through REST correctly filtered them. Classic Supabase-style RLS-protects-reads-but-not-realtime gap.

The new behaviour: if your table has a user_id, owner_id, created_by, or uploaded_by column, a signed-in end-user only receives realtime events for rows where that column equals their JWT subject. eb_sk_* secret keys and platform JWTs (the console) keep full visibility — those are the service-role paths. Anonymous eb_pk_* subscriptions on owner-scoped tables get nothing. Tables without an owner column still broadcast to all (lookup tables, public feeds — those weren't the bug).

Full RLS policy evaluation per (event × subscriber) is a v1.1 follow-up; the owner-column filter is the same logic the owner_access RLS preset already applies at table creation, so realtime and REST agree on which column scopes a row.

auth.email() in RLS policies now reads live from the database

If you used auth.email() in an RLS policy, you were getting whatever email the JWT was issued with — which could be stale for up to the full access-token TTL. Rename a user, and their RLS-evaluated identity wouldn't update until they signed in again.

The function now does an indexed PK lookup on the tenant's users table. One extra row read per policy evaluation, ~0.1 ms on an indexed PK, versus silent staleness for an hour. Correct trade-off.

TEM batching past Scaleway's per-message recipient quota

A small ops one, but the kind of paper cut that turns into a daily annoyance. Scaleway TEM caps recipients per message at 10 (counting the visible To slot). The admin "Email" button on the closed-beta allowlist sent a single TEM request with all selected addresses in BCC, so picking 10+ recipients returned 403. The workaround was selecting 9, sending, deselecting, picking the next 9, etc.

The bulk sender now chunks the recipient list, issues one TEM POST per chunk, and continues on per-chunk errors. If chunk 2 fails (transient 5xx, quota, network), chunks 3 and 4 still go out and the console shows "Partial: 12 sent, 3 failed" with the failed addresses kept selected for one-click retry. Configurable via TEM_MAX_RECIPIENTS_PER_MESSAGE so a raised Scaleway quota becomes an env-var flip, not a redeploy.

The CVE crawl

Three Linux LPE advisories landed in the past three weeks: Copy Fail, Dirty Frag, and ssh-keysign-pwn. All let a process inside a pod escalate to root on the host or escape the container.

Functions runner pods execute untrusted tenant JS on shared Kapsule nodes — so the LPE-to-escape path is in scope. The proper fix is a patched-kernel Kapsule pool upgrade. The bridge is three DaemonSets that blacklist the affected modules (algif_aead, esp4, esp6, rxrpc) and set kernel.yama.ptrace_scope=3 on every node. None of these modules are on any Eurobase hot path (we terminate TLS at the LB, not IPsec; nothing here uses ptrace) so the only operational cost is that you can't strace a node process during an incident without temporarily flipping the sysctl back. Worth it.

What didn't make it this week

The PR that added per-tenant Postgres encryption keys, AAD binding, and key versioning for the vault (#51) is the next pressing security item but it's a chunkier piece of work — three independent fixes inside one issue. Closed-beta is the right window for it; expect it on the blog next week with the same kind of write-up.

What closed beta is for

The pattern from last week's "Six Security Fixes" piece holds: the platform gets more correct in ways customers won't directly notice, but each one is a class of problem that costs ten times as much to fix once you have a hundred customers instead of fifteen. A platform that ships a clean release in week 12 is either being shielded from real testers or isn't being looked at hard enough. I'd rather know now.

If you're on the closed beta and you've spotted something that surprised you: every email we send carries an "Open an issue" link. The issue tracker is public; security-sensitive reports can use GitHub's private advisory flow.

We're building this in the open. The boring weeks are part of it.