Salta ai contenuti

0017. Use Better Auth for identity and organizations, and keep workspaces, access and the audit log in GuestCraft

Questi contenuti non sono ancora disponibili nella tua lingua.

  • Status: accepted
  • Date: 2026-10-26

Chapter 8 of the analysis defines tenancy in three levels: the instance, the organization (the customer) and the workspace (the unit of isolation). Chapter 7 chooses Better Auth (MIT) for identity. Its organization is GuestCraft’s organization, because members, invitations, SSO, SCIM and verified domains live there. Workspaces, with their own memberships and roles, are GuestCraft’s tables, because Better Auth’s teams have no roles. Chapter 18 asks for:

  • revocable sessions;
  • protection against brute force;
  • API keys stored as hashes;
  • an append-only audit log that the application role cannot change.

What shapes the code:

  • Better Auth expects its own tables. It checks the schema at startup (validateSchema). Its CLI generates the tables from the plugins in use, and an upgrade may add columns.
  • Better Auth’s adapter knows nothing about row-level security. It never sets app.workspace_ids or app.organization_ids.
  • Better Auth does not wrap its organization routes in a transaction. Creating an organization inserts the organization, then the member; hooks run after each step.
  • Better Auth reads the environment. It reads BETTER_AUTH_SECRET, BETTER_AUTH_URL, BETTER_AUTH_TELEMETRY and NODE_ENV. With NODE_ENV=test it turns off its origin and CSRF checks by default.
  • Rules of the repository. Only server reads process.env (ADR 0010). db owns every table, under one migration journal (ADR 0010, ADR 0011). Tenant data is reached only through the unit of work (ADR 0009).

Who owns what.

Better Auth owns GuestCraft owns
users, credentials, sessions, rate-limit counters, verification tokens workspaces and workspace memberships, with their roles
organizations, members and their roles (owner, admin, member), invitations the guarded handler, principals, the tenant scope of each unit of work
sign-up, sign-in, sign-out, password changes, session revocation the audit log; API keys (ADR 0040)

Better Auth’s tables.

  • They are in their own schema, guestcraft_auth, written by migration 0002_identity.sql with Better Auth 1.7’s own names: singular tables, camelCase columns. Better Auth’s schema check passes, and an upgrade compares directly with what its CLI generates. These are the only tables outside the snake_case, plural convention.
  • They are global, as chapter 8 says: no row-level security. Only the app role may use the schema; the worker may not. Grants come from AUTH_TABLES in the inventory, like the others, and the catalog test checks them.
  • Identifiers are UUIDv7 generated by GuestCraft (advanced.database.generateId), in uuid columns. GuestCraft speaks TypeIDs (usr_, org_, inv_) and converts at the boundary.
  • One addition to Better Auth’s schema: member is unique per organization and user, so that workspace memberships can reference it.

Organizations.

  • A Better Auth organization is a GuestCraft organization with the same identifier. A trigger on guestcraft_auth.organization creates the row of guestcraft.organizations, and keeps its name in step, in the same transaction.
  • The trigger runs as the app role. It sets the organization context only for its own insert, then restores the previous one.
  • Deleting organizations is disabled. Names longer than 200 characters are refused, as in every GuestCraft table.

Workspaces and memberships.

  • guestcraft.workspaces (migration 0001) and guestcraft.workspace_memberships hold them. A membership is (workspace_id, user_id, organization_id, role), with two composite foreign keys:
    • to workspaces (id, organization_id), so the workspace belongs to the organization;
    • to guestcraft_auth.member (organizationId, userId), with on delete cascade, so only a member of the organization has a role in its workspaces, and leaving the organization removes them all.
  • Workspace roles are admin, revenue_manager, front_desk, housekeeping, accounting and viewer (core/tenancy, and a check constraint). Custom roles and the resource:action permissions of chapter 8 come with the first use case that needs them.
  • Owners and admins of the organization act as admin in every workspace of it. A member reaches only the workspaces where they have a role. Unknown roles grant nothing.
  • Administrators of the organization create workspaces. Administrators of the organization or of the workspace add and remove members. Their routes will be GuestCraft’s own, in api; Better Auth’s team routes are blocked.

Principals and the tenant scope.

  • principalOf(request) reads the session cookie through Better Auth, without refreshing the session. It returns the user, the active organization of the session with the user’s roles, and the workspaces the user may reach there. Without a valid session there is no principal.
  • Reading a user’s access crosses tenants. It reads member first, which needs no context. Only if the user is a member does it read the organization’s workspaces and memberships, in a transaction scoped to that organization.
  • workspaceScope(principal, workspace, roles?) returns the TenantScope that the unit of work sets as app.workspace_ids and app.organization_ids. It returns access.denied when the principal cannot reach the workspace with one of the roles. Only administrators of the organization get the organization in the scope; everyone else sees their workspace only. organizationScope(principal) is for administrators: the organization and all its workspaces.
  • How the API picks the workspace of a request (the GuestCraft-Workspace header, or a field of the session) is decided with the API: the membership proves the right, whatever chooses the workspace.

Sessions and cookies.

  • Sessions are rows of session, so they can be revoked. They last 7 days and are extended once a day, Better Auth’s defaults, until organization policies arrive. There is no cookie cache, so a revocation takes effect immediately.
  • The cookie guestcraft.session_token is HTTP-only, SameSite=Lax, host-only and Path=/. Over HTTPS it is Secure with the __Secure- prefix.
  • The origin and CSRF checks are set explicitly to on, so NODE_ENV cannot turn them off. Trusted origins are the base URL and those configured.
  • Sign-in and sign-up are limited per client: 3 attempts in 10 seconds, Better Auth’s rule. The counters are stored in the database, so every instance on Vercel shares them, as chapter 7 asks. The client’s address comes from the headers configured for the proxy in front of GuestCraft.
  • Secrets are versioned, newest first (BETTER_AUTH_SECRETS), at least 32 characters each. The newest signs the cookies, so rotating it signs everybody out; older versions still decrypt stored values.
  • Passwords have at least 12 characters. Implicit account linking by email is off (chapter 7).
  • Accounts are created on invitation only by default (signUp: "invitation"). The check reads the pending, unexpired invitations through Better Auth’s adapter, on the connection of the transaction that creates the user. A second connection from the same pool could wait forever for the first one, behind a small pool or a transaction pooler. Better Auth accepts an invitation only from a verified address, so invitations work once the email adapter sends verification emails.

The guarded handler.

  • handler serves /api/auth/** through an allowlist of method and path: accounts and sessions, organizations, members and invitations. Everything else answers 404 with an RFC 9457 problem before Better Auth sees it. This includes deleting users or organizations, teams, dynamic roles, password reset (until email exists) and API keys.
  • Adding a Better Auth route to GuestCraft means adding it to the list, in review.

API keys: deferred, with the design fixed. API keys arrive with the API that uses them, as chapter 8 describes:

  • they belong to the organization, so a person leaving breaks nothing;
  • the workspace, environment and permissions of a key are kept by GuestCraft, never in metadata that Better Auth’s routes can change;
  • they are stored only as hashes, with a visible prefix (gc_live_, gc_test_, gc_org_), expiry, last use and rotation with two valid keys.

The principal will gain a second kind, api_key. (ADR 0040 instead keeps the user kind and records the key in apiKeyId.) Better Auth’s API key routes stay blocked by the guard.

The audit log.

  • guestcraft.audit_log records who did what, when and from where. Each entry has:
    • the organization, and the workspace when there is one;
    • the actor: a user, or the system;
    • a dotted action;
    • a subject;
    • small data, never personal data beyond identifiers;
    • the IP address.
  • The app and worker roles may only insert and read it. A trigger refuses UPDATE, DELETE and TRUNCATE, the owner’s too. The owner can still drop the trigger: as chapter 18 says, tamper evidence needs hash chains anchored outside the database, an option for later.
  • Row-level security shows an entry to its organization, and to its workspace when it has one.
  • GuestCraft’s changes, such as creating a workspace or adding a member, record their entry in the same transaction. The unit of work gives use cases the same audit capability (core/audit).
  • Better Auth’s changes to organizations, members and invitations are recorded by an after-hook, in a transaction of their own. Better Auth commits each step before its hooks run, so this cannot be atomic. If the entry fails, the request fails with an error, but the change stays.
  • Sign-ins belong to no organization: the session rows keep their IP address and user agent.

Configuration and dependencies.

  • createAuth({ config, pool, clock, random, logger }) receives everything and reads no environment variable. It sets explicitly every option Better Auth would otherwise read from the environment: the base URL, the secrets, secure cookies, the rate limit and the origin checks. Telemetry is off. The runtime logger receives only a validated SQLSTATE when a sign-in lock audit fails.
  • Better Auth and Kysely are MIT, and nothing paid is used.
  • The dependency matrix does not change: auth imports core and db/auth.
  • The integration tests run on PostgreSQL 17, through PgBouncer in transaction mode, as the app role. They cover:
    • sign-up, organizations and workspaces;
    • invitations and memberships;
    • row-level security between two organizations and two workspaces, both ways;
    • the audit log refusing changes from every role;
    • the guard;
    • sign-up by invitation;
    • cookie attributes, cross-site requests and shared rate limits.
  • The catalog test covers guestcraft_auth: the tables of the inventory, their grants, no row-level security, and the schema used by the app role only. The RLS matrix covers workspace_memberships and audit_log for both runtime roles.
  • An upgrade of Better Auth must keep its schema check and these tests green. A new column is a new migration, written from what its CLI generates. An audited route whose answer changes fails loudly, and the unit tests pin the shapes read.
  • Coverage of packages/auth/src must stay at 95/90/95/95.
  • Until the email adapter exists, nobody can verify an address or reset a password. Invitations are accepted through a copied link (ADR 0030).
  • Open: server/config must not set BETTER_AUTH_TELEMETRY, which would turn telemetry on despite the option. It should refuse to start when it is set.
  • Better Auth’s teams as workspaces. Teams have no roles (chapter 7).
  • Better Auth’s tables in the guestcraft schema, under row-level security. Its adapter never sets the tenant context, and identities are global by nature.
  • Snake_case names for Better Auth’s tables, through its field mapping or a Kysely plugin. This follows the convention, but every upgrade would need a translation of what its CLI generates, and a mapping that drifts silently.
  • Creating GuestCraft’s organization in an afterCreateOrganization hook. It is not atomic: a failure after Better Auth’s commit would leave an organization without its GuestCraft row. The trigger is atomic.
  • Better Auth’s API key plugin now. Its routes authorize at the organization level and keep scopes in metadata that they can change (chapter 8). It would need the same guard and a service of our own, for a feature no route uses yet.
  • A cookie cache or stateless sessions. They are faster, but a revoked session would stay valid until the cache expires.

Trusted origins are exact HTTP or HTTPS origins. Wildcards, credentials, paths, trailing slashes, queries and fragments are refused at startup.

A custom client-IP header requires explicit trusted proxy addresses or CIDRs. The HTTP runtime must accept traffic only through those proxies, which append or replace the forwarded chain. Better Auth strips trusted hops from the right and uses the first untrusted address; a caller cannot change the login bucket by prepending addresses. An installation on Vercel may use its overwritten default forwarded header. A directly exposed Docker deployment must not trust forwarded headers from arbitrary clients; its runtime must supply a trusted ingress.

Organization names are checked at their stored length, including whitespace, so the HTTP validation agrees with the database constraint.

The HTTP runtime requires the client-IP header and exact trusted proxy addresses together whenever other machines reach the instance: production, or a base URL that uses https: or a host other than the loopback, whatever GUESTCRAFT_ENV says. Vercel defaults to its platform-set x-vercel-forwarded-for header.

The sign-in handler reserves an attempt in a per-account lockout before checking credentials: five attempts in a fixed 15-minute window across all runtimes and source IPs, cleared on success. The reservation runs in a GuestCraft plugin’s onRequest, which Better Auth calls only once its per-client limiter has admitted the request: a request that limiter refuses writes no lockout row, so one client address adds at most three rows per 10 seconds. Each reservation also deletes at most 100 rows whose window has ended. The lockout’s key is an HMAC of the folded email under a random 32-byte key that migration 0020 generates once from PostgreSQL’s strong random source and stores in guestcraft_auth.sign_in_lockout_key, which only the app role reads. The table holds no address, and rotating the auth secret keeps every lock, which an earlier key derived from AUTH_SECRET reset at each rotation. The lockout has its own table, guestcraft_auth.sign_in_lockouts (migration 0020): it first shared Better Auth’s rateLimit table, whose cleanup deletes every row older than the longest per-client window (one minute), so another client’s sign-in could erase a 15-minute lock. Its rows go only when their own window has ended, by the application clock: the instances’ clocks must be synchronized.

The fifth failed attempt, which locks the account, is audited as team.sign_in_locked by the system in every workspace whose team lists the person (their own workspaces, and all those of an organization they own or administer), with locked_until and neither the e-mail address nor the client’s address. An address without an account records nothing. There are no e-mail alerts and no progressive delays.

Better Auth’s per-client limiter keeps the rateLimit table through GuestCraft’s storage (rateLimit.customStorage), atomic in one statement and on the injected clock. It reads the bigint timestamps as numbers: node-postgres returns them as text, and Better Auth 1.7.10 concatenated the window to that text, announcing a retry in millions of years. Both 429 answers carry Retry-After and X-Retry-After in seconds.

A target account can still be locked by repeated bad attempts. The lock ends on its own, and a team manager lifts it with DELETE /v1/members/{member_id}/sign_in_lock and the strong If-Match of its GET (Users and roles in the web app), audited as team.sign_in_unlocked in the transaction that clears it; the lock of an organization owner or administrator needs an organization owner or administrator. A missing If-Match answers 400 request.invalid, as on every other operation that requires one, until a round of the API conventions moves them all to 428. A manager may lift their own lock from a session opened before it: that session belongs to the legitimate user, and the unlock is audited. When every administrator is locked out, the locks end on their own within their window, and nothing needs a database edit. Operators still need a proxy that overwrites the forwarding headers and is the only ingress.

ADR 0053 adds Better Auth’s two-factor and passkey plugins to the guarded handler, sessions that record how they were signed in, organizations’ security policies (which replace “until organization policies arrive” above for the session age), and password reset without e-mail: a one-time link that an organization administrator or an instance administrator issues, GuestCraft’s own capability rather than Better Auth’s reset routes, which stay closed.