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 Object | Type | Validation | Notes |
|---|---|---|---|
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 |
Email | string | RFC 5322, normalized lowercase, max 254 chars | Primary identifier for lookup |
PasswordHash | string | argon2id output format | Never exposed outside infrastructure |
RefreshTokenHash | string | SHA-256 of the opaque refresh token | Stored hashed; token itself sent to client |
DeviceFingerprint | string | SHA-256 of (publicKey + userAgent) | Used for uniqueness constraint |
WebAuthnPublicKey | object | CBOR-decoded COSE key | Stored as JSON in credential |
ISODate | string | RFC 3339 UTC | All timestamps UTC |
Scope | string | Dot-separated resource:action | e.g., catalog:read, enrollment:write |
IPAddress | string | IPv4 or IPv6 | Logged for audit; not used as identity |
ExternalProvider | string (closed enum) | Known issuer | See §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
| ID | Invariant | Enforcement |
|---|---|---|
| U-INV-1 | primaryEmail is globally unique across all users | Unique index on users.primary_email; domain rejects duplicate at construction |
| U-INV-2 | A user must have at least one credential (password, webauthn, or magic_link) OR at least one external identity | Enforced at registration; checked before credential removal |
| U-INV-3 | A locked user cannot authenticate | Login use case checks status before credential verification |
| U-INV-4 | A disabled user cannot authenticate or create sessions | Harder than locked; requires admin to re-enable |
| U-INV-5 | A pending_verification user cannot access tenant resources (can only verify email) | JWT issued with restricted scope until verified |
| U-INV-6 | emailVerified transitions from false to true only; never back | Domain method is one-way |
| U-INV-7 | Maximum 5 MFA factors per user | Enforced at enrollment |
| U-INV-8 | Maximum 10 external identities per user | Prevents abuse via SSO linking |
| U-INV-9 | Recovery codes are single-use; consumed codes are marked, not deleted | MFAFactor 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
| ID | Invariant | Enforcement |
|---|---|---|
| C-INV-1 | A user may have at most one password credential | Domain rejects second password credential |
| C-INV-2 | failedAttempts increments on wrong password; resets on successful login | Domain method |
| C-INV-3 | After 5 consecutive failures, lockedUntil is set to now + 15min (progressive: 15, 30, 60, 120 min) | Lockout policy in domain |
| C-INV-4 | Password hash uses argon2id with m=64MiB, t=3, p=1 | Infrastructure adapter; domain declares port |
| C-INV-5 | Password must meet complexity: min 12 chars, not in breach list | Validated before hashing |
| C-INV-6 | rotatedAt updates on every password change | Domain 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
| ID | Invariant | Enforcement |
|---|---|---|
| S-INV-1 | A session is either active (no revokedAt) or revoked (has revokedAt) | Immutable transition |
| S-INV-2 | Revoked sessions cannot be refreshed | Check before token rotation |
| S-INV-3 | expiresAt is always issuedAt + 30 days for refresh; access JWT is 15 min | Set at creation |
| S-INV-4 | Refresh token rotation: old hash replaced with new hash; if old hash is reused, entire session family is revoked | Detects token theft |
| S-INV-5 | A user may have at most 10 active sessions (configurable per tenant) | Oldest session revoked on overflow |
| S-INV-6 | Session must reference a valid, non-disabled user | Checked 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
| ID | Invariant | Enforcement |
|---|---|---|
| D-INV-1 | (userId, fingerprint) is unique | Unique index |
| D-INV-2 | A user may register at most 5 devices | Domain enforces at registration |
| D-INV-3 | Only trusted devices (trustedAt set) can request offline binding | Domain check |
| D-INV-4 | Offline certificate expires within 90 days of issuance | Set at binding; not renewable without re-bind |
| D-INV-5 | Revoked devices cannot be used for sessions or offline binding | Domain check |
| D-INV-6 | Device revocation cascades: all sessions from this device are revoked | Handled 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
| ID | Invariant | Enforcement |
|---|---|---|
| M-INV-1 | TOTP factor must be verified (initial code confirmed) before it becomes active | verified flag |
| M-INV-2 | Recovery codes are generated as a set of 10; each is single-use | metadata.codes[].used: boolean |
| M-INV-3 | At most one TOTP factor, one SMS factor per user | Domain check at enrollment |
| M-INV-4 | SMS factor is deprecated for sensitive scopes | Policy layer rejects SMS for scope:admin |
| M-INV-5 | Removing the last MFA factor is allowed only if MFA is not required by tenant policy | Cross-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
| ID | Invariant | Enforcement |
|---|---|---|
| A-INV-1 | Raw API key is shown exactly once at creation; only hash is stored | Application service returns raw key, persists hash |
| A-INV-2 | Scopes cannot exceed the owner's granted scopes | Validated at creation against user's roles |
| A-INV-3 | Maximum 20 API keys per tenant | Domain enforces |
| A-INV-4 | Revoked keys cannot authenticate | Checked at validation |
| A-INV-5 | Expired keys (expiresAt < now) cannot authenticate | Checked 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
| ID | Invariant | Enforcement |
|---|---|---|
| E-INV-1 | (provider, subject, issuer) is globally unique | Unique index |
| E-INV-2 | Unlinking the last external identity is blocked if user has no password credential | Checked in domain |
| E-INV-3 | Provider metadata is scrubbed of PII beyond what is needed for matching | ACL 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
| Event | Trigger | Key Payload Fields |
|---|---|---|
identity.user.registered.v1 | New user created (any method) | userId, primaryEmail, homeTenantId, registrationSource |
identity.user.email_verified.v1 | Email verification completed | userId, verifiedAt |
identity.user.logged_in.v1 | Successful authentication | userId, tenantId, deviceId, amr[], ip, ua |
identity.user.locked.v1 | Account locked (failed attempts or admin) | userId, reason, lockedUntil |
identity.session.revoked.v1 | Session revoked | sessionId, userId, reason (logout, rotation_reuse, admin_wipe, password_reset, security_incident) |
identity.device.bound_for_offline.v1 | Device offline certificate issued | userId, tenantId, deviceId, fingerprint, certExpiresAt |
identity.api_key.issued.v1 | API key created | apiKeyId, tenantId, ownerUserId, scopes |
identity.api_key.revoked.v1 | API key revoked | apiKeyId, tenantId, reason |
identity.password.reset_requested.v1 | Password reset initiated | userId, ip, ua |
4.2 Events Consumed
| Event | Producer | Handler |
|---|---|---|
tenant.org.user_invited.v1 | tenant-service | Create shadow user with status: pending_verification if no user exists for the invited email |
gdpr.subject_request.received.v1 | platform cross-cutting | Anonymize 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
| Port | Purpose | Adapter |
|---|---|---|
PasswordHasher | Hash and verify passwords | Argon2idHasherAdapter |
TokenSigner | Sign JWTs (EdDSA Ed25519) | JoseTokenSignerAdapter (KMS-backed) |
OIDCClient | OIDC discovery + token exchange | OpenIdClientAdapter |
SAMLClient | SAML metadata + assertion parsing | SamlifyAdapter |
EventPublisher | Publish domain events to NATS | NATSEventPublisherAdapter (via outbox) |
EmailSender | Request email delivery | Delegates to notification-service via event |
AIRiskClassifier | Get risk score for login attempt | AIGatewayClientAdapter |
BreachListChecker | Check password against breach databases | HaveIBeenPwnedAdapter (k-anonymity) |
6.2 Inbound Ports
| Port | Purpose | Adapter |
|---|---|---|
IdentityHttpController | REST API surface | NestJS controllers |
IdentityEventHandler | NATS event consumer | NestJS NATS subscriber |