Auth Service — Domain Model
Status: populated Owner: Platform Engineering + Security Last updated: 2026-04-19
Change log
- v1.2 (2026-04-19) — Rebaselined for multi-IdP. Introduced
TenantIdentityProviderandExternalIdentityaggregates;User.firebaseUidremoved in favour of a 1..N collection ofExternalIdentitylinks. MFA aggregate applies only to native / Keycloak-managed users; tenant-federated users inherit MFA from their IdP.
1. Aggregates
User
| Field | Type | Notes |
|---|---|---|
userId | UUIDv4 | Identity |
accountId | UUIDv4 | Parent account |
email | Email VO | Unique per account |
externalIdentities | ExternalIdentity[] | 0..N federation links (see below) |
passwordHash | string | null | argon2id; null unless the user uses the native fallback provider |
primaryProviderId | string | e.g. keycloak, tenant-oidc:acme, firebase-legacy, native |
mfa | MfaConfig | TOTP secret + backup codes (native / Keycloak-managed only) |
status | enum | active, locked, suspended, deleted |
roles | RoleAssignment[] | Account-scoped RBAC |
lastLoginAt | Instant | null |
TenantIdentityProvider
| Field | Type | Notes |
|---|---|---|
providerId | string | e.g. keycloak, tenant-oidc:acme, tenant-saml:acme, firebase-legacy |
tenantId | UUIDv4 | Tenant binding |
kind | enum | oidc | saml | firebase |
status | enum | active, disabled, failing |
discoveryUrl / metadataRef | string | null | OIDC discovery URL or SAML metadata ref |
keycloakIdpAlias | string | null | Alias in the Keycloak realm when brokered |
attributeMappers | JSON | email / groups / roles mappers |
isDefault | boolean | Exactly one default per tenant (keycloak unless overridden) |
createdAt, lastValidatedAt | Instant |
ExternalIdentity
| Field | Type | Notes |
|---|---|---|
userId | UUIDv4 | Platform user |
providerId | string | FK → TenantIdentityProvider |
externalSubject | string | IdP sub / SAML NameID |
linkedAt | Instant | — |
lastAuthenticatedAt | Instant | null | — |
claims | JSON (trimmed) | Last-seen normalised claims for audit |
Account
| Field | Type | Notes |
|---|---|---|
accountId | UUIDv4 | Identity |
tenantId | UUIDv4 | Platform tenant |
name | string | Display name |
planId | string | Billing plan reference |
status | enum | active, suspended, deleted |
quotaTier | enum | Used by Kong rate-limit routing |
ApiKey
| Field | Type | Notes |
|---|---|---|
keyId | ULID key_* | Identity (shown to user) |
accountId | UUIDv4 | Owner |
keyHash | string | sha256(raw); raw shown once at creation |
scopes | string[] | sms:send, sms:read, billing:read, ... |
status | enum | active, revoked, expired |
lastUsedAt | Instant | null | |
expiresAt | Instant | null | Optional |
Session
Short-lived; access token stateless (JWT), refresh token stored for revocation.
JwkKey
| Field | Notes |
|---|---|
kid | Key id |
alg | RS256 |
status | active, next, retiring |
activatedAt, retiresAt | rotation schedule |
2. Value Objects
| VO | Invariant |
|---|---|
Email | RFC-5322-subset regex; normalized (lowercase) |
PasswordHash | argon2id m=64MB, t=3, p=4 |
ApiKeyScope | Validated against scope registry |
3. Domain Events
| Event | Trigger |
|---|---|
auth.user.registered.v1 | New user created |
auth.user.logged_in.v1 | Successful sign-in (payload includes providerId) |
auth.user.locked.v1 | Repeated failures |
auth.user.erased.v1 | GDPR erasure |
auth.api_key.issued.v1 | New key created (includes keyId, NOT raw key) |
auth.api_key.revoked.v1 | Key revoked |
auth.jwks.rotated.v1 | New key promoted to active |
auth.role.assigned.v1 | Role granted |
auth.idp.configured.v1 | Tenant registers a TenantIdentityProvider |
auth.idp.disabled.v1 | Tenant disables an IdP (emergency / manual) |
auth.idp.removed.v1 | Tenant deregisters |
auth.external_identity.linked.v1 | ExternalIdentity created |
auth.external_identity.unlinked.v1 | ExternalIdentity removed |
auth.sso.session.started.v1 | Successful federated login |
auth.sso.session.failed.v1 | Federation failure (signature, issuer, reserved domain, etc.) |
4. Invariants
emailunique peraccountId.- Raw API key never persisted; only
keyHash. JwkKeywith statusactiveis always ≥ 1 and ≤ 1 at any time.User.rolesscoped to the user'saccountId; cross-account assignment rejected.- Every
tenantIdhas exactly oneTenantIdentityProviderwithisDefault = true(Keycloak unless explicitly overridden). (providerId, externalSubject)is globally unique; an external subject cannot be linked to more than one platformuserId.- A user with
primaryProviderId != 'native'cannot have apasswordHashset. - Reserved email domains (e.g.
*@ghasi.io) cannot be claimed by any non-keycloakprovider; the broker rejects such assertions at mapper time.