Schemas
The tables go-auth creates, the differences between drivers, and the three ways to generate or apply the schema.
Schemas
go-auth embeds one complete desired auth schema and a versioned migration catalog
per driver. Auth migrations run separately from application and ORM migrations.
New never executes DDL. Use the matching CLI before starting auth services.
Version 0.5.2 targets revision 3 on all drivers. The additive upgrade preserves
existing token rows and adds empty workflow context/payload values; PostgreSQL
also extends the token-type check for optional 2FA setting confirmations.
The column tables below describe the PostgreSQL schema. Names are fixed on
SQLite and MySQL; types, collations, constraints and indexes differ (see
Driver differences). The journal table goauth_migrations
is reserved in addition to the fifteen auth tables documented here.
Applying and exporting the schema
Run these commands from the application's Go module, where go-auth is already
pinned in go.mod. From this repository's source checkout, use
go run ./cmd/goauth instead.
go run github.com/nazimdjebloun/go-auth/cmd/goauth migrate \
--driver postgres --dsn "$DATABASE_URL"An unused auth namespace receives the published baseline. A tracked installation receives pending migrations in numeric order. Completed SQL is not replayed; history and the previous schema are checked before continuing. An untracked installation must use explicit adoption.
All schema and migration commands accept postgres/pg, sqlite/sqlite3,
and mysql. The CLI registers all three drivers. The migration catalog begins
at revision 000001; adoption currently supports only its complete schema,
not arbitrary earlier release layouts. See Migrations for
history, recovery, ownership, timeouts and deployment procedures.
PostgreSQL requires pgcrypto and pg_trgm. The baseline issues
CREATE EXTENSION IF NOT EXISTS for both; provision them beforehand if deployment
credentials cannot create extensions. They may be shared with the application
and are never treated as auth-only tables.
The complete schema includes application authorization tables (app_roles,
app_permissions, app_role_permissions, app_authorization_state) even when
the optional service is disabled. The app_permissions_namespace_owner CHECK
requires is_system = (substr(permission_key, 1, 11) = 'goauth.app.').
Migration execution creates no roles or permission records; initialize them
through the application permission workflow.
Organization authorization tables
The canonical schema includes these tables even when org permissions are disabled. Migration revision 2 creates their structure without inserting roles or grants.
| Table | Purpose |
|---|---|
org_authorization_state | One revision row per initialized organization; references organizations with cascade deletion. |
org_roles | Org-local IDs/slugs and protected owner, admin, member identities; enabled state and optimistic revision. |
org_permissions | Org-local exact keys, metadata, enabled state, system ownership and revision. |
org_role_permissions | Explicit grants; both role and permission references include org_id. |
org_member_roles | At most one explicit role per (org_id,user_id) membership, plus assignment revision. |
org_invite_roles | Proposed role and observed role revision per (org_id,invite_id) invitation. |
Role and permission candidate keys (org_id,id) enforce same-org links. A unique
organization_invites(org_id,id) index supports its composite invitation foreign
key. Assignment links restrict role deletion; services reject roles referenced by
members or invitations. Membership and invitation deletion cascade their links.
Organization deletion through the service removes assignment links before its
existing cascades. Role/permission deletion cascades grant links at SQL level;
services refuse referenced business-definition deletion and explicitly clean up
library grants during owner-authorized removal.
org_permissions_namespace_owner requires
is_system = (substr(permission_key, 1, 11) = 'goauth.org.'). Built-in role system
keys are constrained to owner, admin, member, unique per organization.
Keys/slugs use binary collation on MySQL. See the complete embedded canonical SQL
and the org permission workflow.
The tables
Twenty-one tables. Most foreign keys to users are ON DELETE CASCADE, so deleting a
user takes their sessions, tokens, linked OAuth accounts, and org memberships
with it. sessions.active_org_id and organizations.created_by use
ON DELETE SET NULL: deleting an org clears it from sessions pointing at it,
and an org outlives its creator's account. invites.created_by declares no
delete action. app_role_permissions.granted_by uses ON DELETE SET NULL.
users.app_role_id and app-role grant links to permission definitions use
ON DELETE RESTRICT, so referenced roles and definitions cannot be deleted.
recovery_requests has no account foreign key: the same row is written for
known and unknown addresses, and the worker discards it if no eligible account
exists when processing. Audit records and delivery obligations also remain
independent of account deletion.
Passwords are salted KDF hashes (bcrypt by default, or the configured hasher),
optionally peppered before hashing. High-entropy session, refresh, reset, OAuth
state, and invite tokens use SHA-256 hashes. User-entered verification, 2FA,
set-password, and deletion codes use HMAC-SHA256 with the OTP pepper. Raw tokens
and codes are delivered once through the relevant transport or email. The OAuth
PKCE code_verifier is stored in the short-lived state row for the later token
exchange; provider access and refresh tokens are encrypted rather than hashed.
The summaries describe PostgreSQL types and constraints. Each table includes the exact DDL the library applies on each driver, indexes included. Table sections follow PostgreSQL dependency order; apply its extension statements first, as described above.
recovery_requests
Durable public password-reset and verification-resend requests. The public request writes only the supplied normalized address and operation, before any account lookup. No raw reset token or verification code is stored here.
| Column | Type | Notes |
|---|---|---|
id | TEXT | Application-generated request ID |
kind / email | TEXT | password_reset or verification, and the normalized address |
attempts | INTEGER | Incremented on each claim, including claims interrupted by a crash |
available_at | BIGINT | Next eligible time, UTC Unix seconds |
lease_until / claim_owner | BIGINT / TEXT | Two-minute lease and unique owner; stale workers cannot acknowledge a reclaimed job |
dead_lettered | INTEGER | 1 when processing fails after the five-attempt budget is exhausted |
created_at | BIGINT | UTC Unix seconds; basis for eligibility and retention |
Successful processing deletes the row. Failures retry after 10, 20, 40, and 80 seconds; delivery is at least once, so a crash after sending can cause another email and credential regeneration. Requests become ineligible after 24 hours. The minute-based cleanup deletes expired pending requests and dead letters older than seven days from creation, while preserving active leases.
CREATE TABLE IF NOT EXISTS recovery_requests (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
email TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
available_at BIGINT NOT NULL,
lease_until BIGINT NOT NULL DEFAULT 0,
claim_owner TEXT NOT NULL DEFAULT '',
dead_lettered INTEGER NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_recovery_requests_claim ON recovery_requests(dead_lettered, available_at, lease_until);
CREATE INDEX IF NOT EXISTS idx_recovery_requests_created ON recovery_requests(created_at);app_roles
One application role per account. Protected identity uses the unique nullable
system_key; display names do not confer administrator access. See
App authorization tables for column constraints.
CREATE TABLE IF NOT EXISTS app_roles (
id UUID PRIMARY KEY,
slug VARCHAR(80) NOT NULL UNIQUE,
name VARCHAR(120) NOT NULL,
description TEXT NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT true,
system_key VARCHAR(40) UNIQUE,
revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CHECK (system_key IS NULL OR system_key IN ('platform_admin', 'user'))
);app_permissions
Installed business and library definitions. The namespace ownership CHECK
binds is_system to the exact lowercase goauth.app. prefix; library-owned
definitions must remain enabled. Creating a definition grants nobody access.
CREATE TABLE IF NOT EXISTS app_permissions (
id UUID PRIMARY KEY,
permission_key VARCHAR(160) NOT NULL UNIQUE,
name VARCHAR(120) NOT NULL,
description TEXT NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT true,
is_system BOOLEAN NOT NULL DEFAULT false,
revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CHECK (NOT is_system OR is_enabled),
CONSTRAINT app_permissions_namespace_owner
CHECK (is_system = (substr(permission_key, 1, 11) = 'goauth.app.'))
);app_authorization_state
A single row (id=1) records initialization and the global authorization
revision. Applying DDL creates no state row; trusted initialization does.
CREATE TABLE IF NOT EXISTS app_authorization_state (
id SMALLINT PRIMARY KEY CHECK (id = 1),
revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0),
created_at TIMESTAMPTZ NOT NULL
);users
Accounts. One row per person.
| Column | Type | Notes |
|---|---|---|
id | UUID | Primary key, defaults to gen_random_uuid() |
email | TEXT | Unique, not null |
password_hash | TEXT | Nullable: OAuth-only accounts have no password |
password_pepper_version | BIGINT | Nullable: NULL is unpeppered; a positive uint32 value selects one configured password-pepper key |
name | TEXT | Defaults to '' |
app_role_id | UUID | Nullable role FK; deletion is restricted while assigned |
app_role_assignment_revision | BIGINT | Nonnegative, defaults to 0 |
role | TEXT | CHECK (role IN ('user', 'admin')), defaults to user |
is_verified / verified_at | BOOLEAN / TIMESTAMPTZ | Email verification state |
is_banned / banned_at | BOOLEAN / TIMESTAMPTZ | Ban state |
two_factor_enabled | BOOLEAN | Defaults to false |
org_owner_count | INT | Denormalized counter, kept in sync by the org service |
last_login_at | TIMESTAMPTZ | |
created_at / updated_at | TIMESTAMPTZ |
roleis only constrained by aCHECKon PostgreSQL: see Driver differences.
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
password_hash TEXT,
app_role_id UUID,
app_role_assignment_revision BIGINT NOT NULL DEFAULT 0 CHECK (app_role_assignment_revision >= 0),
password_pepper_version BIGINT CHECK (password_pepper_version > 0 AND password_pepper_version <= 4294967295),
name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
is_verified BOOLEAN NOT NULL DEFAULT false,
verified_at TIMESTAMPTZ,
is_banned BOOLEAN NOT NULL DEFAULT false,
banned_at TIMESTAMPTZ,
two_factor_enabled BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
org_owner_count INT NOT NULL DEFAULT 0,
last_login_at TIMESTAMPTZ,
FOREIGN KEY (app_role_id) REFERENCES app_roles(id) ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS idx_users_password_pepper_version ON users(password_pepper_version);
CREATE INDEX IF NOT EXISTS idx_users_app_role ON users(app_role_id, id);
CREATE INDEX IF NOT EXISTS idx_users_role_created_at ON users (role, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_users_updated_at ON users (updated_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_users_last_login_at ON users (last_login_at);
CREATE INDEX IF NOT EXISTS idx_users_banned ON users (created_at DESC) WHERE is_banned;
CREATE INDEX IF NOT EXISTS idx_users_unverified ON users (created_at DESC) WHERE NOT is_verified;
CREATE INDEX IF NOT EXISTS idx_users_two_factor ON users (created_at DESC) WHERE two_factor_enabled;
CREATE INDEX IF NOT EXISTS idx_users_email_trgm ON users USING gin (email gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_users_name_trgm ON users USING gin (name gin_trgm_ops);app_role_permissions
Explicit role grants. The composite primary key prevents duplicate links.
Role deletion cascades its links; permission deletion is restricted while
links exist. Deleting the granting account clears granted_by without
removing the grant.
CREATE TABLE IF NOT EXISTS app_role_permissions (
role_id UUID NOT NULL,
permission_id UUID NOT NULL,
granted_by UUID,
created_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (role_id, permission_id),
FOREIGN KEY (role_id) REFERENCES app_roles(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES app_permissions(id) ON DELETE RESTRICT,
FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_app_role_permissions_permission ON app_role_permissions(permission_id, role_id);organizations
Tenants.
| Column | Type | Notes |
|---|---|---|
id | UUID | Primary key |
name | VARCHAR(255) | |
slug | VARCHAR(255) | Unique |
created_by | UUID | → users(id) ON DELETE SET NULL: the org outlives its creator's account |
owner_count / member_count | INT | Denormalized counters |
metadata | JSONB | Free-form, defaults to {} |
created_at / updated_at | TIMESTAMPTZ |
CREATE TABLE IF NOT EXISTS organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
owner_count INT NOT NULL DEFAULT 0,
member_count INT NOT NULL DEFAULT 0,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);organization_members
The join table. Membership is the pair. There is no surrogate key.
| Column | Type | Notes |
|---|---|---|
org_id | UUID | → organizations(id) ON DELETE CASCADE |
user_id | UUID | → users(id) ON DELETE CASCADE |
role | VARCHAR(50) | CHECK on owner, admin, member |
joined_at | TIMESTAMPTZ |
- Composite primary key
(org_id, user_id), so a user holds exactly one role per org. roleis enforced byCHECKon PostgreSQL only.
CREATE TABLE IF NOT EXISTS organization_members (
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (org_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_org_members_user ON organization_members(user_id);
CREATE INDEX IF NOT EXISTS idx_org_members_org_role ON organization_members(org_id, role);
CREATE INDEX IF NOT EXISTS idx_org_members_org_joined ON organization_members(org_id, joined_at);organization_invites
Pending invitations to join an org.
| Column | Type | Notes |
|---|---|---|
id | UUID | Primary key |
org_id | UUID | → organizations(id) ON DELETE CASCADE |
email | TEXT | |
role | VARCHAR(50) | The role they get. CHECK on owner, admin, member |
code_hash | TEXT | Unique. The raw code goes out by email only |
invited_by | UUID | → users(id) ON DELETE CASCADE |
expires_at / created_at | TIMESTAMPTZ |
CREATE TABLE IF NOT EXISTS organization_invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role VARCHAR(50) NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
code_hash TEXT UNIQUE NOT NULL,
invited_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_org_invites_org ON organization_invites(org_id);
CREATE INDEX IF NOT EXISTS idx_org_invites_email ON organization_invites(email);sessions
One row per active login: both tokens, the device fingerprint, and the active-org pointer.
| Column | Type | Notes |
|---|---|---|
id | UUID | Primary key |
user_id | UUID | → users(id) ON DELETE CASCADE |
token_hash | TEXT | Unique. SHA-256 of the session token |
refresh_token_hash | TEXT | SHA-256 of the current refresh token |
prev_refresh_token_hash | TEXT | The previous one, kept so the grace window can accept it briefly after rotation |
refresh_rotated_at | TIMESTAMPTZ | When the last rotation happened. The grace window is measured from here |
ip_address / user_agent | TEXT | As seen at creation |
is_revoked / revoked_at | BOOLEAN / TIMESTAMPTZ | |
created_at | TIMESTAMPTZ | |
expires_at / refresh_expires_at | TIMESTAMPTZ | Absolute expiry for each token |
last_active_at | TIMESTAMPTZ | Updated on authenticated requests, subject to TouchDebounce |
active_org_id | UUID | → organizations(id) ON DELETE SET NULL |
active_org_role | VARCHAR(50) | |
two_factor_verified_at | TIMESTAMPTZ | Nullable; set only for a session created after successful 2FA. Preserved during refresh. |
- On PostgreSQL a table-level
CHECKforcesactive_org_idandactive_org_roleto be both set or both null. A session cannot be scoped to an org without a role in it.
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT UNIQUE NOT NULL,
refresh_token_hash TEXT NOT NULL DEFAULT '',
prev_refresh_token_hash TEXT NOT NULL DEFAULT '',
ip_address TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
is_revoked BOOLEAN NOT NULL DEFAULT false,
expires_at TIMESTAMPTZ NOT NULL,
refresh_expires_at TIMESTAMPTZ,
refresh_rotated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ,
last_active_at TIMESTAMPTZ NOT NULL DEFAULT now(),
active_org_id UUID REFERENCES organizations(id) ON DELETE SET NULL,
active_org_role VARCHAR(50),
two_factor_verified_at TIMESTAMPTZ,
CHECK ((active_org_id IS NULL AND active_org_role IS NULL) OR (active_org_id IS NOT NULL AND active_org_role IS NOT NULL))
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_refresh_token_hash ON sessions(refresh_token_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_prev_refresh_token_hash ON sessions(prev_refresh_token_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_user_active_org ON sessions(user_id, active_org_id);
CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_last_active_at ON sessions (last_active_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions (expires_at);
CREATE INDEX IF NOT EXISTS idx_sessions_ip_address ON sessions (ip_address);verification_tokens
One table behind nine short-lived token flows, discriminated by type.
| Column | Type | Notes |
|---|---|---|
id | UUID | Primary key |
user_id | UUID | → users(id) ON DELETE CASCADE. Nullable: an invite-verify token can exist before the account does |
email | TEXT | |
token_hash | TEXT | Unique |
type | TEXT | CHECK on verify_email, reset_password, set_password, invite_verify, oauth_state, delete_account, 2fa_login, 2fa_enable, 2fa_disable |
expires_at / used_at | TIMESTAMPTZ | used_at makes tokens single-use |
code_verifier | TEXT | The PKCE verifier, only populated for oauth_state rows |
attempts / resend_count | INT | Wrong-guess and resend counters for code challenges |
created_at | TIMESTAMPTZ | |
context_hash | VARCHAR(64) | Selected provider-name hash for new OAuth state; invitation code hash for pending invitation proof; first-factor method hash for login 2FA; initiating session hash for an optional 2FA setting confirmation. Empty on older tokens. SQLite uses TEXT. |
encrypted_payload | TEXT | Encrypted invitation code hash on invited OAuth state, or encrypted pending profile before invitation email confirmation; empty otherwise |
- The nine
typevalues are enforced byCHECKon PostgreSQL only; on SQLite and MySQL the column is unconstrained.
CREATE TABLE IF NOT EXISTS verification_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
token_hash TEXT UNIQUE NOT NULL,
type TEXT NOT NULL CHECK (type IN ('verify_email', 'reset_password', 'set_password', 'invite_verify', 'oauth_state', 'delete_account', '2fa_login', '2fa_enable', '2fa_disable')),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
code_verifier TEXT,
attempts INT NOT NULL DEFAULT 0,
resend_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
context_hash VARCHAR(64) NOT NULL DEFAULT '',
encrypted_payload TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_verification_tokens_token_hash ON verification_tokens(token_hash);provider_accounts
Linked OAuth identities. A user may have several. Provider names and subject IDs
match case-sensitively on all drivers. MySQL uses utf8mb4_0900_bin for both
columns so trailing spaces and distinct Unicode sequences also remain distinct.
| Column | Type | Notes |
|---|---|---|
id | UUID | Primary key |
user_id | UUID | → users(id) ON DELETE CASCADE |
provider / provider_user_id | TEXT | UNIQUE(provider, provider_user_id): one identity links to one account |
provider_email / provider_name / avatar_url | TEXT | Profile snapshot from the provider |
access_token / refresh_token | TEXT | Encrypted at rest; never returned by any endpoint |
token_expires_at | TIMESTAMPTZ | |
created_at / updated_at | TIMESTAMPTZ |
CREATE TABLE IF NOT EXISTS provider_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL,
provider_email TEXT NOT NULL DEFAULT '',
provider_name TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
access_token TEXT,
refresh_token TEXT,
token_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(provider, provider_user_id)
);
CREATE INDEX IF NOT EXISTS idx_provider_accounts_user_id ON provider_accounts(user_id);
CREATE INDEX IF NOT EXISTS idx_provider_accounts_provider ON provider_accounts(provider, provider_user_id);invites
Signup invites, for invite-only registration.
| Column | Type | Notes |
|---|---|---|
id | UUID | Primary key |
email | TEXT | Who it was issued to |
code | TEXT | Unique. Holds the SHA-256 hash despite the name. The raw code only goes out by email |
created_by | UUID | → users(id): the admin who issued it |
status | TEXT | CHECK on pending, accepted, revoked, expired |
expires_at / accepted_at / created_at | TIMESTAMPTZ |
statusis enforced byCHECKon PostgreSQL only.- Contrast
organization_invites.code_hash, which names the same thing honestly.
CREATE TABLE IF NOT EXISTS invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
code TEXT UNIQUE NOT NULL,
created_by UUID NOT NULL REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
expires_at TIMESTAMPTZ NOT NULL,
accepted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_invites_email ON invites(email);
CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code);
CREATE INDEX IF NOT EXISTS idx_invites_status_created_at ON invites (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_invites_created_at ON invites (created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_invites_expires_at ON invites (expires_at);
CREATE INDEX IF NOT EXISTS idx_invites_pending_expires ON invites (expires_at) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_invites_email_trgm ON invites USING gin (email gin_trgm_ops);audit_log
Written in the caller's transaction by audit.Service.Record when audit is enabled — a successfully written record commits or rolls back with the state change it describes. Default fail-open recording can allow a change to commit without a record when insertion fails. Rows are insert-only and never updated. The table name is singular audit_log. The admin route that reads it is /admin/audit-logs.
| Column | Type | Notes |
|---|---|---|
id | UUID | Primary key |
event_type | TEXT | e.g. user.login, admin.ban |
severity | TEXT | Defaults to info |
success | BOOLEAN | Defaults to true |
actor_id / target_id / session_id / org_id | UUID | All nullable and deliberately not foreign keys. Audit rows must survive the deletion of what they describe |
ip / user_agent / parsed_ua | TEXT / JSONB | |
request_id / correlation_id | TEXT | For stitching events to your own request tracing |
metadata | JSONB | Event-specific payload |
created_at | TIMESTAMPTZ |
- PostgreSQL additionally indexes
metadatawith a GIN index for containment queries. - Pre-resolution email events keep
actor_idnull and store the address inmetadata.email; email text is never written to PostgreSQL's UUID column.
CREATE TABLE IF NOT EXISTS audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'info',
success BOOLEAN NOT NULL DEFAULT true,
actor_id UUID,
target_id UUID,
session_id UUID,
org_id UUID,
ip TEXT,
user_agent TEXT NOT NULL DEFAULT '',
parsed_ua JSONB,
request_id TEXT NOT NULL DEFAULT '',
correlation_id TEXT NOT NULL DEFAULT '',
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_log_event_type ON audit_log(event_type);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor_id ON audit_log(actor_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_target_id ON audit_log(target_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_session_id ON audit_log(session_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_org_id ON audit_log(org_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_event_type_created_at ON audit_log(event_type, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_metadata ON audit_log USING GIN (metadata jsonb_path_ops);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor_created_at ON audit_log (actor_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_target_created_at ON audit_log (target_id, created_at DESC);audit_outbox
The durable-delivery queue, created alongside audit_log when the embedded schema is applied. A row exists only while its event still must be delivered to an external sink: written in the same transaction as the record, deleted on successful delivery. Every retry UPDATE, claim, dead-letter, and eviction lands on this table — never on audit_log, which stays append-only and partition-friendly. audit_log_id deliberately has no foreign key (a check on the credential path would slow every login, and partition drops must not cascade); orphaned obligations are found by the janitor's periodic scan.
| Column | Notes |
|---|---|
audit_log_id | PK; the event's id, same dialect type as audit_log.id |
org_id | Tenant attribution, for per-org delivery routing |
priority | Dispatch tier: 0 critical ships first, 1 best-effort (attacker-floodable failure events like login.failed) |
attempts | Delivery attempts so far; at MaxAttempts the row is marked dead-lettered, not deleted |
next_attempt_at | Exponential-backoff schedule (10s base, capped at 1h) |
claimed_at / claim_owner | Claim lease for multi-instance dispatch; a stale claim is stealable (a benign duplicate under at-least-once delivery) |
last_error | Last failure, for operators |
dead_lettered_at | Terminal-failure timestamp; retained for DeadLetterTTL as failure evidence |
created_at | TTL basis for the OutboxMaxAge sweep |
CREATE TABLE IF NOT EXISTS audit_outbox (
audit_log_id UUID PRIMARY KEY,
org_id UUID,
priority INT NOT NULL DEFAULT 0,
attempts INT NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
claimed_at TIMESTAMPTZ,
claim_owner TEXT NOT NULL DEFAULT '',
last_error TEXT NOT NULL DEFAULT '',
dead_lettered_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_outbox_claim ON audit_outbox(priority, next_attempt_at);
CREATE INDEX IF NOT EXISTS idx_audit_outbox_created_at ON audit_outbox(created_at);App authorization tables
DDL includes these tables on all three drivers, even when the runtime feature is disabled. Applying the schema creates no records. Initialization is explicit.
| Table | Columns and constraints |
|---|---|
app_roles | id PK, unique immutable slug, name, description, is_enabled, nullable unique system_key, positive revision, created_at, updated_at. Protected identity is platform_admin or user. |
app_permissions | id PK, unique immutable permission_key, name, description, is_enabled, is_system, positive revision, timestamps. app_permissions_namespace_owner requires is_system exactly for the lowercase goauth.app. prefix. Library definitions must remain enabled. |
app_role_permissions | Composite PK (role_id,permission_id), role FK CASCADE, permission FK RESTRICT, nullable granted_by user FK SET NULL, created_at. Reverse index on (permission_id,role_id). |
app_authorization_state | Single row with PK id=1, positive revision, created_at. Serializes authorization writes after the account guard. |
users additions | Nullable app_role_id role FK RESTRICT, nonnegative app_role_assignment_revision default 0; index (app_role_id,id). |
Initial admin/user roles start at revision 1. Assignment stores one role ID on the account, rather than a many-to-many user-role join. Grant replacement increments role revision; assignment increments account assignment revision. Definitions have their own metadata revision. The app revision marks management changes; it is not used as a session permission cache.
MySQL permission keys use binary collation. Role/permission names are at most
120 characters, slugs 80 and keys 160; services validate canonical identifiers
before queries. IDs use UUID/TEXT/VARCHAR(36) according to the existing driver
conventions. Authorization timestamps use TIMESTAMPTZ, DATETIME and
DATETIME(6) on PostgreSQL, SQLite and MySQL respectively.
Driver differences
Three files living in internal/schema/ and embedded
by //go:embed. The table and column names are identical across all three.
Types, defaults, collations, constraints and indexes differ.
| PostgreSQL | SQLite | MySQL | |
|---|---|---|---|
| IDs | UUID, defaulted by gen_random_uuid() | TEXT, generated by the application | VARCHAR(36), generated by the application |
| Timestamps | TIMESTAMPTZ | DATETIME | DATETIME, DATETIME(6) on the org tables |
| Recovery queue times | BIGINT UTC Unix seconds | BIGINT UTC Unix seconds | BIGINT UTC Unix seconds |
| Booleans | BOOLEAN | INTEGER (0/1) | BOOLEAN |
| JSON columns | JSONB | TEXT | JSON for metadata and parsed_ua |
CHECK constraints | Existing domain checks plus app identity/revision/ownership checks | Pepper version plus app identity/revision/ownership checks | App identity/revision/ownership checks |
| App indexes | User-role and reverse grant indexes | User-role and reverse grant indexes | User-role and reverse grant indexes |
Re-running goauth migrate | Completed IDs are skipped; schema is verified | Completed IDs are skipped; schema is verified | Completed IDs are skipped; incomplete DDL requires verified recovery |
| Foreign keys | inline REFERENCES | inline REFERENCES | named CONSTRAINT ... FOREIGN KEY |
| Timestamp precision | TIMESTAMPTZ | DATETIME | DATETIME(6) (microseconds) on the org tables |
| Placeholders | $1, $2, …: left as-is for PostgreSQL, rewritten to ? by sqlstore.DB.Rebind | ? | ? |
Queries are written once with $N placeholders and rewritten per driver at
runtime, so none of this leaks into application code.
Constraints are not portable
Fixed account-role/status constraints vary by driver. Application authorization
identity/revision/ownership checks exist in all three fresh-install schemas;
MySQL requires a version that enforces CHECK constraints. Service validation
remains authoritative on every driver. Database access can bypass service
policies such as protected metadata and last-admin invariants.
Next
- Configuration: every option, field by field
- Architecture: how the storage layer fits together
email_change_requests
Revision 4 adds an account slot keyed by user_id, with a cascading user FK.
It stores request ID, initiating session/scope, old/new addresses, HMAC code
hashes, an HMAC credential-state fingerprint, status and abuse counters.
created_at, expires_at, budget_started_at, current_sent_at and
new_sent_at are UTC Unix microseconds. There are no raw confirmation codes or
password verifiers in this table. PostgreSQL uses a UUID user key; MySQL uses
VARCHAR(36); SQLite uses TEXT.
Replacement uses a fresh request ID while preserving the hourly start budget.
Used/cancelled slots clear their code hashes. Slots remain until replacement or
account deletion; redemption enforces expiry. Revoking a session does not remove
its account's budget. The additive migration preserves existing account data.
Completion enqueues two email_changed items in recovery_requests, each with
only its destination and fixed kind, and no raw credential.
Redis cache versions
sessions.cache_version and verification_tokens.cache_version are VARCHAR(36) NOT NULL DEFAULT 'legacy', placed last in each column list. Migration revision 5 preserves existing credential data and backfills the sentinel. Legacy rows bypass caching; library creation/mutation assigns fresh opaque UUID versions atomically. The metadata is internal and not exposed in public JSON.