ADR-0006: Go backend as read-only consumer of Better Auth via shared PostgreSQL

Status

Accepted

Tags

authentication, better-auth, postgresql, multi-tenancy, permissions, redis, nats

Decision

Go microservices skip a synchronization layer and query the Better Auth PostgreSQL tables directly using read-only SELECT operations. Go models must exactly match the schema generated by the Better Auth plugins. Cache invalidation is handled in real-time via NATS events.

Why

Adding a NATS sync layer to replicate Better Auth identity data into Go-owned tables introduces synchronization lag, dual-write complexity, and a second source of truth. Direct queries to a single shared schema are simpler, strictly consistent, and lower latency.

Table ownership

Go models must use the exact column names generated by Better Auth plugins (plural table names, snake_case).

Permission resolution (multi-tier)

  1. JWT claims (orgRole, organizationId) — fast, no IO, used for coarse checks
  2. Redis cache (perm:<userId>:<orgId>, 5m TTL) — mid-tier, avoids repeated DB hits
  3. PostgreSQL (members table) — authoritative, queried only on cache miss

Consistency model

Role/membership changes in Bun trigger NATS events (member.role.changed, member.removed). The Go MemberRoleChangeConsumer immediately deletes the affected Redis cache key. The next request re-queries PostgreSQL. Sub-second consistency without synchronization overhead.

Required indexes

Rules for agents

  • Go models that map to Better Auth tables must use exact plural table names: users, members, organizations
  • Never add write operations to Better Auth table repositories — INSERT, UPDATE, DELETE belong in the Bun backend only
  • Role changes must go through the Better Auth API on Bun, never direct SQL from Go — except the single carve-out recorded under Exceptions (E1); do not widen it
  • Maintain all three required indexes; do not drop them
  • When adding a new permission check, follow the three-tier lookup: JWT claim → Redis → PostgreSQL

Exceptions

Exactly one write exception is granted. Anything not listed here is still forbidden.

E1 — Rental landlord self-promotion (users.role)

The rental module (orgless B2C, ADR-0055) promotes a signed-up account to the global landlord role with one conditional UPDATE on users.role from Go, in internal/modules/rental/adapter/outbound/persistence/postgresql/user_repository.go. Scope of the exception — all of these hold, or the exception does not apply:
  • One meaningful column, users.role, on the session user’s own row. No other user’s row may be written.
  • The same statement also sets that row’s updated_at. This is deliberate and is part of the exception: updated_at records when the row last changed, so a write that skipped it would leave every Better Auth consumer reading a timestamp that denies a change which did happen. It carries no authorization meaning of its own. No other Better Auth column may be written.
  • The write is guarded in SQL, not in Go: rows already holding admin or superadmin are excluded by the WHERE clause, so a staff role can never be overwritten.
  • Only the landlord value may be assigned to role. Granting or revoking any staff role from Go stays forbidden.
  • Organization roles (members.role) are not covered. They keep going through the Better Auth API on Bun, with the NATS cache invalidation described above.
Why not the Better Auth API on Bun: slice 1 has no Bun endpoint for setting a global platform role, and adding one — plus a Go HTTP client, service credentials, and a failure/retry path across two runtimes — is a larger and less reviewable surface than one guarded single-column UPDATE. This is a recorded trade-off, not an oversight. Lifecycle consequence the caller must handle: platform roles reach Go through JWT claims (buildRoles), which Bun mints at token-issue time. A promotion written directly in Postgres does not retroactively change a token already in the caller’s hands, and there is no session invalidation. The account therefore holds landlord in the database but not in its current token until a new token is issued. The promotion endpoint reports this to the client (token_refresh_required), and landlord endpoints keep rejecting the stale token until it is refreshed — that refusal is correct behaviour, not a bug. Revisit when: Bun grows a platform-role API, or a second module needs to write a Better Auth column. Either makes this exception a pattern, which it must not become.

Bad pattern (do not generate)

Good pattern