iam-service — Domain Model
Catalog summary:
docs/03-microservices/iam-service.md· SERVICE_OVERVIEW · Standards · NAMING · 06 Data Models
The domain layer is pure TypeScript — no NestJS, no I/O, no persistence concerns. Every type below lives in src/domain/ and is testable with zero infrastructure.
1. Branded Identifiers
Every aggregate / entity ID is a branded primitive — distinct at compile time, identical at runtime.
export type Brand<T, B> = T & { readonly __brand: B };
export type UserId = Brand<string, 'UserId'>; // usr_<ulid>
export type CredentialId = Brand<string, 'CredentialId'>; // crd_<ulid>
export type SessionId = Brand<string, 'SessionId'>; // ses_<ulid>
export type DeviceId = Brand<string, 'DeviceId'>; // dev_<ulid>
export type MFAFactorId = Brand<string, 'MFAFactorId'>; // mfa_<ulid>
export type APIKeyId = Brand<string, 'APIKeyId'>; // key_<ulid>
export type ExternalIdentityId = Brand<string, 'ExternalIdentityId'>; // ext_<ulid>
export type TenantId = Brand<string, 'TenantId'>; // ten_<ulid>
export type RefreshTokenId = Brand<string, 'RefreshTokenId'>; // rft_<ulid>
export type ISODate = Brand<string, 'ISODate'>; // RFC 3339
export type Email = Brand<string, 'Email'>; // RFC 5322 normalised lowercase
export type IPMasked = Brand<string, 'IPMasked'>; // /24 v4 or /48 v6
ID prefixes are canonical — see NAMING §11.
2. Aggregate Map
| Aggregate Root | Children (entities / VOs) | Tenant-scoped? |
|---|---|---|
User | Credential, MFAFactor[], ExternalIdentity[] | Yes (nullable for platform.super_admin). |
Session | (none — refresh-token chain is value object inside) | Yes (via tenantId claim). |
Device | OfflineBinding? | Yes. |
APIKey | (none) | Yes. |
Session, Device, APIKey reference User by userId, but transactions never cross more than one aggregate root in a single command.
3. Value Objects
export interface PasswordHash {
readonly algo: 'argon2id';
readonly memoryKb: number; // ≥ 65536
readonly iterations: number; // ≥ 3
readonly parallelism: number; // ≥ 1
readonly salt: string; // base64
readonly hash: string; // base64
readonly version: number; // bump when params change
}
export interface DeviceFingerprint {
readonly value: string; // HMAC-SHA256(tenantSecret, attributes)
readonly attributes: {
osFamily: 'windows' | 'macos' | 'linux' | 'ios' | 'android';
osVersion: string;
appVersion: string;
cpuArch: 'x64' | 'arm64';
machineIdHash: string; // OS-level machine GUID, hashed
};
}
export interface OfflineBinding {
readonly certificatePem: string; // X.509 PEM, signed by tenant CA
readonly publicKeyJwk: JsonWebKey; // Ed25519 JWK
readonly issuedAt: ISODate;
readonly expiresAt: ISODate; // ≤ issuedAt + 7d
readonly serial: string; // hex
readonly issuingKid: string; // tenant CA kid
}
export interface RefreshTokenChain {
readonly familyId: string; // ULID; identifies a session lineage
readonly currentTokenHash: string; // sha256(refreshToken) base64
readonly previousTokenHashes: string[]; // sliding window of last N (default 5)
readonly generation: number; // monotonic per family
readonly rotatedAt: ISODate;
}
4. Aggregate: User
export type UserType = 'staff' | 'guest' | 'platform_admin';
export type UserStatus = 'pending_verification' | 'active' | 'locked' | 'disabled' | 'erased';
export interface User {
readonly id: UserId;
readonly tenantId: TenantId | null; // null only when userType='platform_admin'
readonly userType: UserType;
readonly primaryEmail: Email;
readonly emailVerifiedAt: ISODate | null;
readonly status: UserStatus;
readonly lockedUntil: ISODate | null;
readonly lockedReason: string | null; // 'lockout' | 'admin' | 'breached_credential' | 'compromised_session'
readonly createdAt: ISODate;
readonly updatedAt: ISODate;
readonly version: number; // optimistic concurrency
// children (loaded lazily by repo, never mutated outside aggregate methods)
readonly credential: Credential | null;
readonly mfaFactors: ReadonlyArray<MFAFactor>;
readonly externalIdentities: ReadonlyArray<ExternalIdentity>;
}
4.1 Invariants
| ID | Invariant |
|---|---|
| U-INV-1 | primaryEmail is unique within (tenantId, userType) (and globally for platform_admin). |
| U-INV-2 | A User must have at least one of: credential OR a non-empty externalIdentities. |
| U-INV-3 | status='locked' implies lockedUntil > now() OR lockedReason='admin'. |
| U-INV-4 | Login operations on status ∈ {locked, disabled, erased, pending_verification} are rejected. |
| U-INV-5 | userType='guest' ⇒ mfaFactors.length === 0 AND apikeys === 0 AND no Device.offlineBinding. |
| U-INV-6 | emailVerifiedAt is monotonic (cannot be unset once set). |
| U-INV-7 | mfaFactors.length ≤ 5. |
| U-INV-8 | userType='platform_admin' ⇒ tenantId IS NULL AND at least one MFA factor enrolled before activation. |
| U-INV-9 | State transitions: pending_verification → active, active ↔ locked, * → disabled, * → erased (terminal). |
4.2 Methods (excerpt)
declare class UserAggregate {
static register(input: RegisterInput): { user: User; events: DomainEvent[] };
changePassword(newHash: PasswordHash, by: 'self' | 'admin' | 'reset'): DomainEvent[];
recordLoginSuccess(at: ISODate, factor: AuthMethod[]): DomainEvent[];
recordLoginFailure(at: ISODate, reason: LoginFailureReason): DomainEvent[];
lock(reason: LockReason, until: ISODate | 'permanent'): DomainEvent[];
unlock(by: UserId | 'system'): DomainEvent[];
enrollMFA(factor: MFAFactor): DomainEvent[];
removeMFA(factorId: MFAFactorId): DomainEvent[];
linkExternal(identity: ExternalIdentity): DomainEvent[];
erase(): DomainEvent[]; // GDPR
}
5. Entity: Credential
export interface Credential {
readonly id: CredentialId;
readonly userId: UserId;
readonly hash: PasswordHash;
readonly rotatedAt: ISODate;
readonly previousHashes: ReadonlyArray<PasswordHash>; // last 5 (history)
readonly failedAttempts: number;
readonly lastFailedAt: ISODate | null;
readonly forceChangeOnNextLogin: boolean;
readonly breachedAt: ISODate | null; // last time HIBP flagged this hash
}
5.1 Invariants
| ID | Invariant |
|---|---|
| C-INV-1 | One Credential per User. (Multi-credential = future). |
| C-INV-2 | failedAttempts increments on failure; resets on success. |
| C-INV-3 | Progressive lockout: 5 → 15 min, 10 → 30 min, 15 → 60 min, 20 → 120 min. |
| C-INV-4 | previousHashes.length ≤ 5; new password must not match any. |
| C-INV-5 | Password must satisfy PasswordPolicyService (length ≥ 12, classes ≥ 3, no email substring, no breach hit). |
| C-INV-6 | rotatedAt updated on every change. |
| C-INV-7 | If breachedAt !== null, forceChangeOnNextLogin=true. |
6. Aggregate: Session
export type AuthMethod = 'pwd' | 'totp' | 'webauthn' | 'magic_link' | 'oidc' | 'saml' | 'apikey' | 'recovery_code';
export interface Session {
readonly id: SessionId;
readonly userId: UserId;
readonly tenantId: TenantId | null; // null only for platform_admin
readonly deviceId: DeviceId | null;
readonly amr: ReadonlyArray<AuthMethod>;
readonly issuedAt: ISODate;
readonly expiresAt: ISODate; // refresh-family expiry
readonly lastRefreshAt: ISODate;
readonly refreshChain: RefreshTokenChain;
readonly ipMasked: IPMasked;
readonly userAgentHash: string; // sha256
readonly revoked: boolean;
readonly revokedAt: ISODate | null;
readonly revokedReason: SessionRevokeReason | null;
readonly version: number;
}
export type SessionRevokeReason =
| 'logout'
| 'rotation_reuse'
| 'admin_revoke'
| 'tenant_deleted'
| 'user_locked'
| 'device_revoked'
| 'password_changed'
| 'idle_timeout'
| 'family_overflow';
6.1 Invariants
| ID | Invariant |
|---|---|
| S-INV-1 | expiresAt > issuedAt; refresh chain TTL: online sessions 30 d, offline-bound 7 d. |
| S-INV-2 | A revoked=true session can never be refreshed. |
| S-INV-3 | Refresh-token rotation: new hash replaces old, previous-N kept for reuse detection. |
| S-INV-4 | Reuse detection: if a hash from previousTokenHashes is presented, the entire family is revoked (reason='rotation_reuse') and a security event is emitted. |
| S-INV-5 | Maximum 10 active sessions per (userId, tenantId). Oldest is revoked on overflow (reason='family_overflow'). |
| S-INV-6 | If deviceId !== null, the Device must be trusted=true; revoking the device cascades. |
| S-INV-7 | amr must include the strongest factor used; tenant policy may require ≥ 2 factors for staff. |
7. Aggregate: Device
export type DevicePlatform = 'electron-desktop' | 'mobile-android' | 'mobile-ios' | 'web';
export interface Device {
readonly id: DeviceId;
readonly userId: UserId;
readonly tenantId: TenantId;
readonly platform: DevicePlatform;
readonly fingerprint: DeviceFingerprint;
readonly publicKeyJwk: JsonWebKey | null; // Ed25519, present after binding
readonly trusted: boolean;
readonly trustedAt: ISODate | null;
readonly offlineBinding: OfflineBinding | null;
readonly lastSeenAt: ISODate;
readonly registeredAt: ISODate;
readonly revoked: boolean;
readonly revokedAt: ISODate | null;
readonly revokedReason: string | null;
readonly version: number;
}
7.1 Invariants
| ID | Invariant |
|---|---|
| D-INV-1 | UNIQUE(userId, fingerprint.value). |
| D-INV-2 | offlineBinding is only issued when platform='electron-desktop' AND trusted=true. |
| D-INV-3 | offlineBinding.expiresAt ≤ issuedAt + 7d (configurable per tenant; max 7 d). |
| D-INV-4 | Revoking a Device cascades: all Session.deviceId === this.id are revoked (reason='device_revoked'). |
| D-INV-5 | Maximum 5 active devices per user (configurable per tenant). |
8. Entity: MFAFactor
export type MFAFactorType = 'totp' | 'webauthn' | 'recovery_codes' | 'sms';
export interface MFAFactor {
readonly id: MFAFactorId;
readonly userId: UserId;
readonly type: MFAFactorType;
readonly label: string; // user-supplied, e.g. "iPhone 15"
readonly secret: TOTPSecret | WebAuthnCredential | RecoveryCodeBundle | SMSDestination;
readonly verified: boolean;
readonly verifiedAt: ISODate | null;
readonly lastUsedAt: ISODate | null;
readonly createdAt: ISODate;
}
export interface TOTPSecret { algo: 'sha1' | 'sha256'; digits: 6 | 8; period: 30; secretCipher: string; }
export interface WebAuthnCredential { credentialId: string; publicKey: string; signCount: number; aaguid: string; }
export interface RecoveryCodeBundle { codeHashes: ReadonlyArray<string>; usedFlags: ReadonlyArray<boolean>; }
export interface SMSDestination { e164: string; }
8.1 Invariants
| ID | Invariant |
|---|---|
| M-INV-1 | TOTP secret stored as ciphertext (envelope-encrypted via KMS). |
| M-INV-2 | WebAuthn signCount must be monotonically increasing; clone detection on regression. |
| M-INV-3 | Recovery codes are single-use; regenerating bundle invalidates all prior. |
| M-INV-4 | sms type deprecated for high-trust scopes; allowed for guest accounts only. |
| M-INV-5 | A factor must be verified=true before it can satisfy a challenge. |
9. Aggregate: APIKey
export type APIKeyScope = 'read:bookings' | 'write:bookings' | 'read:guests' | 'lock:dispense' | 'admin:*';
export interface APIKey {
readonly id: APIKeyId;
readonly userId: UserId;
readonly tenantId: TenantId;
readonly label: string;
readonly hash: string; // argon2id of full key
readonly prefix: string; // first 8 chars (for log search)
readonly scopes: ReadonlyArray<APIKeyScope>;
readonly propertyIds: ReadonlyArray<string>; // optional property scoping
readonly issuedAt: ISODate;
readonly expiresAt: ISODate | null; // null = no expiry
readonly lastUsedAt: ISODate | null;
readonly revoked: boolean;
readonly revokedAt: ISODate | null;
readonly revokedReason: string | null;
}
9.1 Invariants
| ID | Invariant |
|---|---|
| K-INV-1 | Raw key material returned once at issuance, never again. |
| K-INV-2 | hash is argon2id (m=64MB, t=3, p=1). |
| K-INV-3 | userType='guest' ⇒ no API keys. |
| K-INV-4 | scopes must be a non-empty subset of the issuer's own scopes. |
| K-INV-5 | Key is rejected if expiresAt && expiresAt < now() OR revoked=true. |
10. Entity: ExternalIdentity
export type ExternalProvider = 'google' | 'microsoft' | 'apple' | 'okta' | 'azure-ad' | 'keycloak' | 'saml-custom';
export interface ExternalIdentity {
readonly id: ExternalIdentityId;
readonly userId: UserId;
readonly provider: ExternalProvider;
readonly subject: string; // IdP's stable user ID
readonly emailAtLink: Email;
readonly issuerUrl: string;
readonly linkedAt: ISODate;
readonly lastUsedAt: ISODate | null;
readonly attributesSnapshot: Record<string, string>; // last seen claims
}
10.1 Invariants
| ID | Invariant |
|---|---|
| E-INV-1 | UNIQUE(provider, subject). |
| E-INV-2 | A user can link multiple providers; each provider once. |
| E-INV-3 | First successful SSO without local credential ⇒ JIT-provisioned User. |
11. Domain Events
All events are immutable, past-tense, prefixed melmastoon.iam.<aggregate>.<verb>.v<n>. Full JSON Schemas in EVENT_SCHEMAS.md.
| Event | Aggregate | Trigger |
|---|---|---|
melmastoon.iam.user.registered.v1 | User | After User.register succeeds. |
melmastoon.iam.user.email_verified.v1 | User | First successful email verification. |
melmastoon.iam.user.login_succeeded.v1 | User | Any successful authentication. |
melmastoon.iam.user.login_failed.v1 | User | Any login failure (with reason). |
melmastoon.iam.user.locked.v1 | User | Lockout / admin lock / breach lock. |
melmastoon.iam.user.unlocked.v1 | User | Lockout expiry / admin unlock. |
melmastoon.iam.user.mfa_enrolled.v1 | User | New verified factor. |
melmastoon.iam.user.mfa_removed.v1 | User | Factor removed. |
melmastoon.iam.user.erased.v1 | User | GDPR erasure complete. |
melmastoon.iam.session.refreshed.v1 | Session | Refresh-token rotation. |
melmastoon.iam.session.revoked.v1 | Session | Any revoke (with reason). |
melmastoon.iam.password.reset_requested.v1 | Credential | Reset initiated. |
melmastoon.iam.password.reset_completed.v1 | Credential | Reset completed. |
melmastoon.iam.password.changed.v1 | Credential | Self-service change. |
melmastoon.iam.device.registered.v1 | Device | New device added. |
melmastoon.iam.device.trusted.v1 | Device | Device trust enabled. |
melmastoon.iam.device.bound_for_offline.v1 | Device | Offline cert issued. |
melmastoon.iam.device.revoked.v1 | Device | Device revoked. |
melmastoon.iam.apikey.issued.v1 | APIKey | Key created. |
melmastoon.iam.apikey.revoked.v1 | APIKey | Key revoked. |
melmastoon.iam.external_identity.linked.v1 | User | OIDC/SAML link. |
melmastoon.iam.external_identity.unlinked.v1 | User | Unlink. |
12. Domain Services
export interface PasswordPolicyService {
validate(input: { plaintext: string; userEmail: Email; previousHashes: PasswordHash[] }): PolicyResult;
}
export interface LockoutPolicyService {
computeLockout(failedAttempts: number, history: LockoutHistory[]): { until: ISODate; reason: 'lockout' };
}
export interface AdaptiveMFAService {
evaluate(ctx: LoginContext): Promise<{ challenge: 'none' | 'totp' | 'webauthn' | 'recovery'; reasons: string[] }>;
}
export interface DeviceTrustService {
shouldAutoTrust(ctx: { user: User; device: Device; loginHistory: LoginRecord[] }): boolean;
}
export interface RefreshTokenFamilyService {
rotate(session: Session, presented: string): { newToken: string; updated: Session } | { reuseDetected: true };
}
13. Ubiquitous Language Glossary
| Term | Definition |
|---|---|
| Principal | An authenticated identity, human or machine. |
| Credential | A secret used to prove identity (currently password only; webauthn lives on MFA). |
| AMR | Authentication Methods References (RFC 8176) — values like pwd, totp, webauthn. |
| Refresh family | A lineage of refresh tokens originating from a single login event; reuse of any prior token revokes the entire family. |
| Device binding | Cryptographic association of a Device to a User via Ed25519 keypair, enabling offline cert. |
| Offline certificate | X.509 cert (≤ 7 d) signed by tenant CA; embedded in Electron app to allow refresh while offline. |
| JIT provisioning | Just-in-time creation of a User on first successful federated login. |
| Step-up MFA | Adaptive challenge inserted into a flow when risk score warrants, even if the session already has amr=pwd. |
| Magic link | One-time URL containing a server-side single-use nonce; redeemed in a single GET, never replayable. |
| Tenant CA | Per-tenant intermediate CA in Cloud KMS; signs that tenant's device certificates. |
Home tenant (tid) | The active tenant context of a session; for chain operators with tids[], switchable via /auth/switch-tenant. |
| Reuse detection | Server-side check that an incoming refresh token is the current head of its family; otherwise → family revoke. |