Skip to main content

Domain Model

:::info Source Sourced from services/identity-service/DOMAIN_MODEL.md in the documentation repo. :::

Companion: 02 DDD · 12 Data Models · SERVICE_OVERVIEW

1. Aggregate Map

┌───────────────────────────────────────────────────────────────┐
│ Identity Bounded Context │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ <<Aggregate Root>> User │ │
│ │ ├── Credential (entity, 1..*) │ │
│ │ ├── MFAFactor (entity, 0..*) │ │
│ │ └── ExternalIdentity (entity, 0..*) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ <<Aggregate Root>> │ │ <<Aggregate Root>> │ │
│ │ Session │ │ Device │ │
│ └──────────────────────┘ └──────────────────────┘ │
│ │
│ ┌──────────────────────┐ │
│ │ <<Aggregate Root>> │ │
│ │ APIKey │ │
│ └──────────────────────┘ │
└───────────────────────────────────────────────────────────────┘

2. Value Objects

All value objects are immutable and validated at construction. Those marked with (F03) are frozen at end of M0.

Value ObjectTypeValidationNotes
UserId (F03)Branded<string, 'UserId'>ULID format, prefix usr_Generated once at registration
SessionId (F03)Branded<string, 'SessionId'>ULID format, prefix ses_Generated per login
DeviceId (F03)Branded<string, 'DeviceId'>ULID format, prefix dev_Generated per device registration
APIKeyId (F03)Branded<string, 'APIKeyId'>ULID format, prefix apk_Generated per API key issuance
TenantId (F03)Branded<string, 'TenantId'>ULID format, prefix ten_Shared kernel with tenant-service
EmailstringRFC 5322, normalized lowercase, max 254 charsPrimary identifier for lookup
PasswordHashstringargon2id output formatNever exposed outside infrastructure
RefreshTokenHashstringSHA-256 of the opaque refresh tokenStored hashed; token itself sent to client
DeviceFingerprintstringSHA-256 of (publicKey + userAgent)Used for uniqueness constraint
WebAuthnPublicKeyobjectCBOR-decoded COSE keyStored as JSON in credential
ISODatestringRFC 3339 UTCAll timestamps UTC
ScopestringDot-separated resource:actione.g., catalog:read, enrollment:write
IPAddressstringIPv4 or IPv6Logged for audit; not used as identity
ExternalProviderstring (closed enum)Known issuerSee §3.7; includes generic OIDC/SAML and named vendors (keycloak, okta, cognito, firebase_auth, etc.)

3. Aggregates — Detailed Specification

3.1 User Aggregate (Root)

The User aggregate is the primary identity construct. It contains credentials, MFA factors, and external identities as child entities.

interface User {
// Identity
id: UserId;
primaryEmail: Email;
emailVerified: boolean;
status: UserStatus; // 'active' | 'locked' | 'disabled' | 'pending_verification'
homeTenantId?: TenantId;
createdAt: ISODate;
updatedAt: ISODate;
version: number; // optimistic concurrency

// Child entities (loaded with aggregate)
credentials: Credential[];
mfaFactors: MFAFactor[];
externalIdentities: ExternalIdentity[];
}

Invariants

IDInvariantEnforcement
U-INV-1primaryEmail is globally unique across all usersUnique index on users.primary_email; domain rejects duplicate at construction
U-INV-2A user must have at least one credential (password, webauthn, or magic_link) OR at least one external identityEnforced at registration; checked before credential removal
U-INV-3A locked user cannot authenticateLogin use case checks status before credential verification
U-INV-4A disabled user cannot authenticate or create sessionsHarder than locked; requires admin to re-enable
U-INV-5A pending_verification user cannot access tenant resources (can only verify email)JWT issued with restricted scope until verified
U-INV-6emailVerified transitions from false to true only; never backDomain method is one-way
U-INV-7Maximum 5 MFA factors per userEnforced at enrollment
U-INV-8Maximum 10 external identities per userPrevents abuse via SSO linking
U-INV-9Recovery codes are single-use; consumed codes are marked, not deletedMFAFactor tracks consumed codes in metadata

State Transitions

┌────────────────────┐
│ pending_verification│
└─────────┬──────────┘
│ verify_email

┌────────────────────┐
┌──────│ active │◄──────┐
│ └─────────┬──────────┘ │
│ │ │
│ lock │ disable unlock
│ (failed │ (admin) (admin/
│ attempts │ auto-unlock)
│ or admin) │ │
▼ ▼ │
┌────────────┐ ┌────────────────┐ │
│ locked │ │ disabled │ │
│ │───┘ │ │
└─────┬──────┘ └────────────────┘ │
│ │
└──────────────────────────────────────┘
(auto-unlock after lockedUntil)

3.2 Credential Entity

interface Credential {
userId: UserId;
kind: CredentialKind; // 'password' | 'webauthn' | 'magic_link'
hash?: PasswordHash; // password only, argon2id
webauthn?: WebAuthnPublicKey; // webauthn only
rotatedAt: ISODate;
failedAttempts: number;
lockedUntil?: ISODate;
createdAt: ISODate;
}

Invariants

IDInvariantEnforcement
C-INV-1A user may have at most one password credentialDomain rejects second password credential
C-INV-2failedAttempts increments on wrong password; resets on successful loginDomain method
C-INV-3After 5 consecutive failures, lockedUntil is set to now + 15min (progressive: 15, 30, 60, 120 min)Lockout policy in domain
C-INV-4Password hash uses argon2id with m=64MiB, t=3, p=1Infrastructure adapter; domain declares port
C-INV-5Password must meet complexity: min 12 chars, not in breach listValidated before hashing
C-INV-6rotatedAt updates on every password changeDomain method

3.3 Session Aggregate (Root)

interface Session {
id: SessionId;
userId: UserId;
deviceId: DeviceId;
tenantId: TenantId; // active tenant for this session
issuedAt: ISODate;
expiresAt: ISODate;
refreshTokenHash: RefreshTokenHash;
ip: IPAddress;
ua: string;
revokedAt?: ISODate;
amr: string[]; // authentication methods reference
}

Invariants

IDInvariantEnforcement
S-INV-1A session is either active (no revokedAt) or revoked (has revokedAt)Immutable transition
S-INV-2Revoked sessions cannot be refreshedCheck before token rotation
S-INV-3expiresAt is always issuedAt + 30 days for refresh; access JWT is 15 minSet at creation
S-INV-4Refresh token rotation: old hash replaced with new hash; if old hash is reused, entire session family is revokedDetects token theft
S-INV-5A user may have at most 10 active sessions (configurable per tenant)Oldest session revoked on overflow
S-INV-6Session must reference a valid, non-disabled userChecked at creation and refresh

3.4 Device Aggregate (Root)

interface Device {
id: DeviceId;
userId: UserId;
fingerprint: DeviceFingerprint;
publicKey: string; // PEM-encoded public key for offline binding
trustedAt?: ISODate;
lastSeenAt: ISODate;
offlineCertificate?: {
cert: string; // X.509 certificate signed by identity-service
expiresAt: ISODate;
issuedAt: ISODate;
};
revokedAt?: ISODate;
createdAt: ISODate;
}

Invariants

IDInvariantEnforcement
D-INV-1(userId, fingerprint) is uniqueUnique index
D-INV-2A user may register at most 5 devicesDomain enforces at registration
D-INV-3Only trusted devices (trustedAt set) can request offline bindingDomain check
D-INV-4Offline certificate expires within 90 days of issuanceSet at binding; not renewable without re-bind
D-INV-5Revoked devices cannot be used for sessions or offline bindingDomain check
D-INV-6Device revocation cascades: all sessions from this device are revokedHandled in application service

3.5 MFAFactor Entity (child of User)

interface MFAFactor {
id: string; // ULID
userId: UserId;
kind: MFAKind; // 'totp' | 'sms' | 'webauthn' | 'recovery_codes'
metadata: JSONValue; // kind-specific: TOTP secret, phone number, webauthn credential ID, code list
enrolledAt: ISODate;
lastUsedAt?: ISODate;
verified: boolean; // TOTP requires initial verification
}

Invariants

IDInvariantEnforcement
M-INV-1TOTP factor must be verified (initial code confirmed) before it becomes activeverified flag
M-INV-2Recovery codes are generated as a set of 10; each is single-usemetadata.codes[].used: boolean
M-INV-3At most one TOTP factor, one SMS factor per userDomain check at enrollment
M-INV-4SMS factor is deprecated for sensitive scopesPolicy layer rejects SMS for scope:admin
M-INV-5Removing the last MFA factor is allowed only if MFA is not required by tenant policyCross-checked via tenant-service policy

3.6 APIKey Aggregate (Root)

interface APIKey {
id: APIKeyId;
tenantId: TenantId;
ownerUserId?: UserId; // null for service-account keys
name: string; // human-readable label
scopes: Scope[];
hash: string; // SHA-256 of the raw key (raw key shown once at creation)
prefix: string; // first 8 chars of raw key for identification
createdAt: ISODate;
expiresAt?: ISODate;
lastUsedAt?: ISODate;
revokedAt?: ISODate;
}

Invariants

IDInvariantEnforcement
A-INV-1Raw API key is shown exactly once at creation; only hash is storedApplication service returns raw key, persists hash
A-INV-2Scopes cannot exceed the owner's granted scopesValidated at creation against user's roles
A-INV-3Maximum 20 API keys per tenantDomain enforces
A-INV-4Revoked keys cannot authenticateChecked at validation
A-INV-5Expired keys (expiresAt < now) cannot authenticateChecked at validation

3.7 ExternalIdentity Entity (child of User)

type ExternalProvider =
| 'oidc'
| 'saml'
| 'google'
| 'microsoft'
| 'keycloak'
| 'okta'
| 'cognito'
| 'firebase_auth'
| 'generic_oidc'
| 'generic_saml';

interface ExternalIdentity {
userId: UserId;
provider: ExternalProvider;
subject: string; // provider's unique subject ID
issuer: string; // provider's issuer URL (OIDC iss or SAML entity)
metadata: JSONValue; // provider-specific claims (normalized; PII minimized)
linkedAt: ISODate;
lastLoginAt?: ISODate;
}

Invariants

IDInvariantEnforcement
E-INV-1(provider, subject, issuer) is globally uniqueUnique index
E-INV-2Unlinking the last external identity is blocked if user has no password credentialChecked in domain
E-INV-3Provider metadata is scrubbed of PII beyond what is needed for matchingACL adapter strips unnecessary fields

4. Domain Events

All domain events are published via the transactional outbox pattern. Each event is wrapped in the platform EventEnvelope (frozen at F01).

4.1 Events Published

EventTriggerKey Payload Fields
identity.user.registered.v1New user created (any method)userId, primaryEmail, homeTenantId, registrationSource
identity.user.email_verified.v1Email verification completeduserId, verifiedAt
identity.user.logged_in.v1Successful authenticationuserId, tenantId, deviceId, amr[], ip, ua
identity.user.locked.v1Account locked (failed attempts or admin)userId, reason, lockedUntil
identity.session.revoked.v1Session revokedsessionId, userId, reason (logout, rotation_reuse, admin_wipe, password_reset, security_incident)
identity.device.bound_for_offline.v1Device offline certificate issueduserId, tenantId, deviceId, fingerprint, certExpiresAt
identity.api_key.issued.v1API key createdapiKeyId, tenantId, ownerUserId, scopes
identity.api_key.revoked.v1API key revokedapiKeyId, tenantId, reason
identity.password.reset_requested.v1Password reset initiateduserId, ip, ua

4.2 Events Consumed

EventProducerHandler
tenant.org.user_invited.v1tenant-serviceCreate shadow user with status: pending_verification if no user exists for the invited email
gdpr.subject_request.received.v1platform cross-cuttingAnonymize or delete all identity data for the subject; emit gdpr.subject_request.acknowledged.v1

5. Domain Services

5.1 PasswordPolicyService

Encapsulates password complexity rules:

  • Minimum 12 characters
  • At least one uppercase, one lowercase, one digit, one special character
  • Not in HaveIBeenPwned breach list (checked via k-anonymity API)
  • Not equal to email or common variations
  • Not in the last 5 password hashes (rotation history)

5.2 LockoutPolicyService

Progressive lockout after consecutive failures:

  • 5 failures: lock 15 minutes
  • 10 failures: lock 30 minutes
  • 15 failures: lock 60 minutes
  • 20+ failures: lock 120 minutes, emit identity.user.locked.v1

Auto-unlock after lockedUntil expires. Manual unlock by admin at any time.

5.3 AdaptiveMFAService

Determines whether to step up to MFA challenge based on risk signals:

  • New device (never-seen fingerprint)
  • New IP geolocation (> 500km from last login)
  • Impossible travel (two logins from distant locations within short time)
  • High failed-attempt history
  • Risk score from AI classifier (via ai-gateway-service)

Returns: { required: boolean; factors: MFAKind[]; riskScore: number; riskReasons: string[] }

6. Ports (Interfaces)

6.1 Outbound Ports

PortPurposeAdapter
PasswordHasherHash and verify passwordsArgon2idHasherAdapter
TokenSignerSign JWTs (EdDSA Ed25519)JoseTokenSignerAdapter (KMS-backed)
OIDCClientOIDC discovery + token exchangeOpenIdClientAdapter
SAMLClientSAML metadata + assertion parsingSamlifyAdapter
EventPublisherPublish domain events to NATSNATSEventPublisherAdapter (via outbox)
EmailSenderRequest email deliveryDelegates to notification-service via event
AIRiskClassifierGet risk score for login attemptAIGatewayClientAdapter
BreachListCheckerCheck password against breach databasesHaveIBeenPwnedAdapter (k-anonymity)

6.2 Inbound Ports

PortPurposeAdapter
IdentityHttpControllerREST API surfaceNestJS controllers
IdentityEventHandlerNATS event consumerNestJS NATS subscriber