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