Skip to content

fix(hostnames): read tenant hostnames through the BYPASSRLS role - #46

Merged
rafiki270 merged 1 commit into
mainfrom
claude/hostname-rls-fix
Sep 6, 2026
Merged

fix(hostnames): read tenant hostnames through the BYPASSRLS role#46
rafiki270 merged 1 commit into
mainfrom
claude/hostname-rls-fix

Conversation

@rafiki270

Copy link
Copy Markdown
Contributor

Summary

Every tenant-hostname lookup has been returning nothing in production since it shipped.

organisations and teams carry FORCE ROW LEVEL SECURITY, and team-hostname.service.ts reached for the tenant-scoped client — the one uoa_app connects as. On a /domain/* route there is no tenant context to satisfy the policies with: no app.org_id, no app.user_id, and the domain branch of organisations_select additionally wants an org_members row for that absent user. No branch can match.

It does not error. It returns zero rows, and every caller read that as an answer:

  • /domain/organisations/resolve and /domain/teams/resolve answered 404 for tenants that plainly existnessie-works and kilomayo are both on this domain and neither resolved.
  • /domain/slug-available answered available: true for every slug, taken ones included. A create dialog would have offered two tenants the same address; only the unique constraint stood behind it.

The fix is the pattern already in this codebase for exactly this case: team-avatar.service.ts is a /domain/* route reading teams, and it goes through getAdminPrisma() — the BYPASSRLS role DATABASE_ADMIN_URL names in production, which test-db.ts documents as the role domain-hash auth runs on.

That leaves the domain predicate as the only boundary, so the file now says so where someone editing it will read it: every query filters on the caller's own domain, teams through their organisation's, and anything added here has to keep doing that.

Testing

  • The existing unit tests could not have caught this — they inject deps.prisma, so they exercised query shapes and never the client that runs them.
  • New API/tests/integration/team-hostname-rls.test.ts uses the real roles: uoa_admin finds the tenant; uoa_app finds nothing and reports a taken slug as available — the shipped failure, reproduced. It also pins the cross-domain refusals, which are now carried by the predicate rather than by the policy.
  • DB-less suite: 1883 passed, 374 skipped.
  • Integration suite: 396 passed, 35 skipped.
  • Lint clean.

🤖 Generated with Claude Code

Every hostname lookup has been returning nothing in production since it
shipped. `organisations` and `teams` carry FORCE ROW LEVEL SECURITY, and this
service reached for the tenant-scoped client — the one `uoa_app` connects as.
On a `/domain/*` route there is no tenant context to satisfy the policies with:
no `app.org_id`, no `app.user_id`, and the domain branch of
`organisations_select` additionally requires an `org_members` row for that
absent user. So no branch can ever match.

It does not error. It returns zero rows, and every caller read that as an
answer:

  * /domain/organisations/resolve and /domain/teams/resolve answered 404 for
    tenants that plainly exist — `nessie-works` and `kilomayo` are both present
    on this domain and neither could be resolved.
  * /domain/slug-available answered `available: true` for every slug, including
    taken ones. A create dialog would have told two tenants the same address was
    free; only the unique constraint stood behind it.

The fix is the pattern this codebase already uses for exactly this case:
`team-avatar.service.ts` is a `/domain/*` route reading `teams` and it goes
through `getAdminPrisma()` — the BYPASSRLS role that `DATABASE_ADMIN_URL` names
in production, which `test-db.ts` documents as the one domain-hash auth runs on.

That makes the domain predicate the only remaining boundary, so the file now
says so where someone editing it will read it: every query filters on the
caller's own domain, teams through their organisation's, and anything added
here must keep that.

The unit tests could not have caught this — they inject `deps.prisma`, so they
exercised the query shapes and never the client that runs them. The new
integration test uses the real roles: `uoa_admin` finds the tenant, `uoa_app`
finds nothing and reports a taken slug as available, which is the failure
exactly. It also pins the cross-domain refusals, since those are now carried by
the predicate rather than by the policy.

DB-less 1883 passed; integration 396 passed; lint clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
@rafiki270
rafiki270 merged commit 759b4f6 into main Sep 6, 2026
2 checks passed
@rafiki270
rafiki270 deleted the claude/hostname-rls-fix branch September 6, 2026 06:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant