Skip to content

Data Model

All tables are prefixed auth_, use UUID v7 string primary keys, and carry created_at / updated_at (UTC) unless noted. Entities live in src/Entity/ and are Cycle-annotated (#[Entity], #[Column]); repositories implement Altair\Persistence\Contracts\RepositoryInterface. Migrations live in database/migrations/ and are generated via bin/altair db:migration-plan.

Why UUID v7, not the skeleton’s primary int? Sequential ids leak tenant size and enable enumeration. v7 is time-ordered, so it indexes nearly as well as an auto-increment while staying opaque. Generated with symfony/uid.


┌───────────────────┐
│ auth_users │
│ (identity) │
└─────────┬─────────┘
┌───────────────┬───────┼────────────┬──────────────┬───────────────┐
│ │ │ │ │ │
auth_memberships auth_refresh_ auth_mfa_ auth_otp_ auth_recovery_ auth_password_
(user⇄org) tokens factors challenges codes resets / email_
│ (sessions) verifications
auth_membership_roles ── auth_roles ── auth_role_permissions ── auth_permissions
│ │
auth_organizations ─────────┘ (org-scoped roles; org_id NULL = system role)
auth_invitations (pending members)
auth_audit_log (append-only security event trail)

Relationships:

  • A User has many Memberships; a Membership binds one user to one Organization (unique(user_id, organization_id)).
  • A Membership has many Roles (via auth_membership_roles); a Role has many Permissions (via auth_role_permissions).
  • A Role belongs to an Organization, or is a system role when organization_id IS NULL (e.g. global superadmin).
  • A User has many RefreshTokens (sessions/devices), MfaFactors, OtpChallenges, and RecoveryCodes.

ColumnTypeNotes
iduuid (pk)v7
emailstring(320)unique, stored lowercased/normalized
email_verified_atdatetime, nullablenull until verified
password_hashstring(255), nullableArgon2id; nullable to allow passwordless-only users later
display_namestring(120), nullable
statusenumactive | disabled | locked
mfa_enforcedboolper-user override of the global enforce policy
failed_login_countintreset on success
locked_untildatetime, nullableset by lockout policy
last_login_atdatetime, nullable
created_at/updated_atdatetime

Indexes: unique(email), index(status).

Identity-provider contract: CycleIdentityProvider::findOneBy() returns the row as an array including email and password_hash, so the framework’s RepositoryIdentityValidator (configured username→email, hash→password_hash) can password_verify() against it unchanged.

ColumnTypeNotes
iduuid (pk)
namestring(160)
slugstring(160)unique, URL-safe
statusenumactive | suspended
created_byuuid (fk users)becomes owner
created_at/updated_atdatetime

Indexes: unique(slug), index(status).

ColumnTypeNotes
iduuid (pk)
user_iduuid (fk users)ON DELETE CASCADE
organization_iduuid (fk orgs)ON DELETE CASCADE
statusenuminvited | active | suspended
invited_byuuid, nullable
joined_atdatetime, nullable
created_at/updated_atdatetime

Indexes: unique(user_id, organization_id), index(organization_id, status).

ColumnTypeNotes
iduuid (pk)
organization_iduuid, nullableNULL ⇒ system/global role
namestring(80)
slugstring(80)unique within org (unique(org_id, slug))
descriptionstring(255), null
is_systemboolsystem roles cannot be deleted/edited by tenants

Seeded roles (see rbac.md): owner, admin, member (per-org templates) and superadmin (system, organization_id NULL).

ColumnTypeNotes
iduuid (pk)
keystring(120)unique, e.g. members.invite
descriptionstring(255)

The permission catalog is seeded from a code-defined registry (single source of truth) on migrate; see rbac.md.

role_id (fk), permission_id (fk); pk(role_id, permission_id); ON DELETE CASCADE both sides.

membership_id (fk), role_id (fk); pk(membership_id, role_id); ON DELETE CASCADE. This is what binds a user’s roles within a specific org.

The heart of the rotating-refresh strategy.

ColumnTypeNotes
iduuid (pk)
user_iduuid (fk users)ON DELETE CASCADE
organization_iduuid, nullableactive-org context the access token is scoped to
family_iduuidrotation lineage; shared across the whole rotation chain
parent_iduuid, nullablethe token this one rotated from
token_hashstring(64)HMAC-SHA256 of the opaque secret (hex); never plaintext
user_agentstring(255), nullabledevice fingerprinting for the sessions list
ipstring(45), nullable
expires_atdatetimeabsolute expiry (e.g. now + 30d)
last_used_atdatetime, nullableupdated on each rotation
revoked_atdatetime, nullableset on rotation, logout, or reuse-detection
revoked_reasonenum, nullablerotated | logout | reuse_detected | admin | password_change
created_atdatetime

Indexes: unique(token_hash), index(user_id, revoked_at), index(family_id), index(expires_at) (for the cleanup sweep).

Reuse detection: a refresh request presents a token; we hash and look it up. If found but revoked_at is set (already rotated), an attacker is replaying a stolen token → revoke the entire family_id and emit auth.refresh_reuse_detected. See flows.md.

ColumnTypeNotes
iduuid (pk)
user_iduuid (fk users)ON DELETE CASCADE
typeenumtotp | sms | email
labelstring(80), nullablee.g. “iPhone Authenticator”
secret_encryptedtext, nullableTOTP shared secret, encrypted (Encrypter)
phone_e164string(20), nullablefor sms factors (E.164)
emailstring(320), nullablefor email factors (defaults to user email)
is_defaultboolpreferred factor when multiple exist
confirmed_atdatetime, nullableunconfirmed factors can’t satisfy MFA
last_used_atdatetime, nullable
created_at/updated_atdatetime

Indexes: index(user_id, type), index(user_id, confirmed_at).

auth_otp_challenges: transient SMS/email OTP + MFA tickets

Section titled “auth_otp_challenges: transient SMS/email OTP + MFA tickets”
ColumnTypeNotes
iduuid (pk)
user_iduuid (fk users)
factor_iduuid, nullablethe factor being challenged (sms/email)
purposeenumlogin_mfa | enroll | password_reset | email_verify | step_up
channelenumsms | email | totp
code_hashstring(64), nullableHMAC-SHA256 of the numeric code (null for totp, verified live)
destinationstring(320), nullablemasked in responses; phone/email actually sent to
attemptsintincremented per verify try
max_attemptsintdefault 5
expires_atdatetimee.g. now + 5m
consumed_atdatetime, nullablesingle-use
ipstring(45), nullable
created_atdatetime

Indexes: index(user_id, purpose, consumed_at), index(expires_at).

The short-lived mfa_token returned by /auth/login when MFA is required is itself a signed, single-purpose JWT (purpose=login_mfa, ~5 min), not a DB row, that references the user; the OTP challenge row holds the actual code.

id, user_id (fk), code_hash (HMAC-SHA256), used_at (nullable), created_at. Index index(user_id, used_at). Generated in batches of 10; each is single-use; regenerating invalidates the prior batch.

auth_email_verifications & auth_password_resets

Section titled “auth_email_verifications & auth_password_resets”

Identical shape (single-use, hashed token):

ColumnTypeNotes
iduuid (pk)
user_iduuid (fk users)
emailstring(320)the address being verified/reset
token_hashstring(64)HMAC-SHA256 of the emailed token
expires_atdatetimeverify: 24h, reset: 1h
consumed_atdatetime, nullablesingle-use
ipstring(45), null
created_atdatetime

Email verification and password reset support both a click link (opaque token) and a 6-digit OTP code path, sharing this table; the OTP path reuses auth_otp_challenges with purpose=email_verify/password_reset. Hosts pick one via config (flows.email_verification.style: link | otp).

ColumnTypeNotes
iduuid (pk)
organization_iduuid (fk orgs)
emailstring(320)invitee (may not yet be a user)
role_idsjsonroles to grant on acceptance
token_hashstring(64)HMAC-SHA256 of the emailed invite token
invited_byuuid (fk users)
expires_atdatetimedefault 7d
accepted_atdatetime, nullable
created_atdatetime

Index: index(organization_id), index(email), unique(token_hash).

auth_audit_log: append-only security trail

Section titled “auth_audit_log: append-only security trail”
ColumnTypeNotes
iduuid (pk)
actor_user_iduuid, nullablewho triggered it (null for anonymous attempts)
organization_iduuid, nullableorg context if any
eventstring(80)mirrors the PSR-14 event name (events.md)
ipstring(45), null
user_agentstring(255), null
metadatajsonevent-specific, never secrets
created_atdatetime

Indexes: index(actor_user_id, created_at), index(event, created_at), index(organization_id, created_at). Written by a PSR-14 listener subscribing to the domain events (not inline in domain services) so auditing stays a cross-cutting concern.


A scheduled command (bin/altair job, or host cron) prunes expired transient rows so the tables stay small:

  • auth_otp_challenges: delete consumed_at IS NOT NULL OR expires_at < now().
  • auth_refresh_tokens: delete expires_at < now() - grace (keep recently revoked for reuse-detection forensics, e.g. 7-day grace).
  • auth_email_verifications / auth_password_resets: delete consumed/expired.
  • auth_audit_log: retained per host policy (default 1 year), then archived.

A Univeros\Polaris\Domain\Maintenance\PruneExpiredService exposes this; the host wires it to its scheduler. (Polaris does not assume a scheduler exists.)


One migration per table (generated, never hand-written; see the Altair skill). Ordering respects FKs:

0001_create_auth_users
0002_create_auth_organizations
0003_create_auth_memberships
0004_create_auth_roles
0005_create_auth_permissions
0006_create_auth_role_permissions
0007_create_auth_membership_roles
0008_create_auth_refresh_tokens
0009_create_auth_mfa_factors
0010_create_auth_otp_challenges
0011_create_auth_recovery_codes
0012_create_auth_email_verifications
0013_create_auth_password_resets
0014_create_auth_invitations
0015_create_auth_audit_log
0016_seed_permissions_and_system_roles (data migration from the code registry)

Generate with bin/altair db:migration-plan (runs NOT-NULL / FK / unique safety checks against the live DB), then bin/altair db:migrate.