Concept demo

Sample / fictional data throughout. This is a concept demo of a heli-ski / heli-charter operations platform — not a certified or currently-in-use operational safety system, and not affiliated with or representing any specific real operator. Every aircraft, person, and guest shown is invented for demonstration.

CLEARSPAR
← Blog
2026-07-09

The One Table I Took Out of Row-Level Security

Clearspar Heli is a concept prototype of a day-of-operations system for a heli-ski/heli-charter operation — not a real operator, not real flights, not real guests. Every aircraft and person in the demo is invented sample data, and it is not a certified operational safety system. So the leak in this post never exposed a real anyone. The reason it's worth writing up is the shape of the mistake: a genuine cross-tenant information disclosure that grew out of two decisions that were each, on their own, correct. It was caught by a deliberate adversarial pass over an already-live app, before it could matter, and the way it hid is the interesting part.

The backdrop is the multi-tenant isolation model an earlier post covered: every tenant's data is separated by Postgres Row-Level Security, keyed on a per-request session variable (app.current_org_id), not by application-side WHERE org_id filtering. The whole appeal of that approach is that you can't forget the filter on a new endpoint, because the filter isn't yours to skip — the database refuses to hand back rows that don't belong to the current org. When RLS is the mechanism everywhere, the reflex becomes “RLS has us covered.” That reflex is exactly what this bug fed on.

One table is deliberately not under RLS: error_events, the native error-tracking log. The reason is real. An error can be captured before any org context resolves — a crash on the login screen, a failure in a pre-auth code path — and if that table were RLS-scoped, the most important errors to record would be the ones RLS silently dropped to zero rows. So it's global on purpose. The catch is what a global infra table ends up holding: org_id, user_id, request_path, message, and stack for errors from every tenant, all in one place with no per-tenant boundary underneath it.

The read route for that table, GET /api/error-events, was gated to ManagerOnly — the same tier as the audit log, meaning any org's ops_manager. Because the table sits outside RLS, that role gate wasn't one of two defenses; it was quietly the only one. A regular ops_manager at organization A could call the endpoint and read organization B's error rows: another tenant's request paths, user ids, and stack traces. The tell was a comment I'd left right above the route asserting that “the tight role gate — not per-tenant RLS — is what protects it.” That comment was reasoning about the right question — what protects this table, given it's off RLS — and had landed on the wrong answer. A role gate answers “is this person a manager?” It does not answer “is this person allowed to see this org's rows?” Those are different questions, and the table needed the second one.

The fix was to stop trusting the gate and enforce isolation at the read boundary instead. The state-layer query gained an org_id filter, and the route computes the scope explicitly: a regular ops_manager is restricted to their own org's rows, and only a platform admin — is_platform_admin, the pre-existing cross-org superuser capability that already gates org creation at POST /api/admin/orgs — gets the full global view including the pre-auth, NULL-org rows. No new role was invented; the fix reused the one cross-org capability the app already had. In the same pass I looked at the other global, non-RLS table — the poller-health telemetry — and deliberately left it alone, because it carries only process-wide poller status and no per-org request context, so there's nothing cross-tenant in it to leak. Scoping the fix by what the data actually contained mattered as much as making it.

Verification was the point of the exercise, so it got concrete. The smoke suite gained state-layer assertions using two synthetic org ids (90001 and 90002): a request scoped to one org returns only that org's error rows and never the other tenant's, while the platform-admin view (org_id unset) sees both plus the NULL-org pre-auth rows. Those ran green as part of the full smoke suite, alongside the concurrent multi-org RLS load test at 20 of 20 over real Postgres. The demo only seeds a single org, which is why the cross-org case is proven at the state layer rather than staged over HTTP — but the negative case (org A never sees org B) and the positive case (the admin does) both get asserted, not assumed.

The lesson generalizes past this one table. A security audit that stops at “does the isolation mechanism hold” is asking the wrong version of the question. RLS held perfectly the whole time — it just wasn't the thing guarding this table, because I'd taken this table out of it for a good reason and hadn't fully carried the consequence forward. The question that actually catches this is “does every data path participate in the isolation mechanism, and if one is exempted, what replaced the guarantee it gave up?” The table you carved out for a legitimate reason is precisely where a role check can quietly become the only thing between two tenants — and a role check looks a lot like a boundary right up until you ask it which org's rows it's willing to return.