Skip to main content

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 RootChildren (entities / VOs)Tenant-scoped?
UserCredential, MFAFactor[], ExternalIdentity[]Yes (nullable for platform.super_admin).
Session(none — refresh-token chain is value object inside)Yes (via tenantId claim).
DeviceOfflineBinding?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

IDInvariant
U-INV-1primaryEmail is unique within (tenantId, userType) (and globally for platform_admin).
U-INV-2A User must have at least one of: credential OR a non-empty externalIdentities.
U-INV-3status='locked' implies lockedUntil > now() OR lockedReason='admin'.
U-INV-4Login operations on status ∈ {locked, disabled, erased, pending_verification} are rejected.
U-INV-5userType='guest'mfaFactors.length === 0 AND apikeys === 0 AND no Device.offlineBinding.
U-INV-6emailVerifiedAt is monotonic (cannot be unset once set).
U-INV-7mfaFactors.length ≤ 5.
U-INV-8userType='platform_admin'tenantId IS NULL AND at least one MFA factor enrolled before activation.
U-INV-9State 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

IDInvariant
C-INV-1One Credential per User. (Multi-credential = future).
C-INV-2failedAttempts increments on failure; resets on success.
C-INV-3Progressive lockout: 5 → 15 min, 10 → 30 min, 15 → 60 min, 20 → 120 min.
C-INV-4previousHashes.length ≤ 5; new password must not match any.
C-INV-5Password must satisfy PasswordPolicyService (length ≥ 12, classes ≥ 3, no email substring, no breach hit).
C-INV-6rotatedAt updated on every change.
C-INV-7If 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

IDInvariant
S-INV-1expiresAt > issuedAt; refresh chain TTL: online sessions 30 d, offline-bound 7 d.
S-INV-2A revoked=true session can never be refreshed.
S-INV-3Refresh-token rotation: new hash replaces old, previous-N kept for reuse detection.
S-INV-4Reuse detection: if a hash from previousTokenHashes is presented, the entire family is revoked (reason='rotation_reuse') and a security event is emitted.
S-INV-5Maximum 10 active sessions per (userId, tenantId). Oldest is revoked on overflow (reason='family_overflow').
S-INV-6If deviceId !== null, the Device must be trusted=true; revoking the device cascades.
S-INV-7amr 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

IDInvariant
D-INV-1UNIQUE(userId, fingerprint.value).
D-INV-2offlineBinding is only issued when platform='electron-desktop' AND trusted=true.
D-INV-3offlineBinding.expiresAt ≤ issuedAt + 7d (configurable per tenant; max 7 d).
D-INV-4Revoking a Device cascades: all Session.deviceId === this.id are revoked (reason='device_revoked').
D-INV-5Maximum 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

IDInvariant
M-INV-1TOTP secret stored as ciphertext (envelope-encrypted via KMS).
M-INV-2WebAuthn signCount must be monotonically increasing; clone detection on regression.
M-INV-3Recovery codes are single-use; regenerating bundle invalidates all prior.
M-INV-4sms type deprecated for high-trust scopes; allowed for guest accounts only.
M-INV-5A 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

IDInvariant
K-INV-1Raw key material returned once at issuance, never again.
K-INV-2hash is argon2id (m=64MB, t=3, p=1).
K-INV-3userType='guest' ⇒ no API keys.
K-INV-4scopes must be a non-empty subset of the issuer's own scopes.
K-INV-5Key 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

IDInvariant
E-INV-1UNIQUE(provider, subject).
E-INV-2A user can link multiple providers; each provider once.
E-INV-3First 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.

EventAggregateTrigger
melmastoon.iam.user.registered.v1UserAfter User.register succeeds.
melmastoon.iam.user.email_verified.v1UserFirst successful email verification.
melmastoon.iam.user.login_succeeded.v1UserAny successful authentication.
melmastoon.iam.user.login_failed.v1UserAny login failure (with reason).
melmastoon.iam.user.locked.v1UserLockout / admin lock / breach lock.
melmastoon.iam.user.unlocked.v1UserLockout expiry / admin unlock.
melmastoon.iam.user.mfa_enrolled.v1UserNew verified factor.
melmastoon.iam.user.mfa_removed.v1UserFactor removed.
melmastoon.iam.user.erased.v1UserGDPR erasure complete.
melmastoon.iam.session.refreshed.v1SessionRefresh-token rotation.
melmastoon.iam.session.revoked.v1SessionAny revoke (with reason).
melmastoon.iam.password.reset_requested.v1CredentialReset initiated.
melmastoon.iam.password.reset_completed.v1CredentialReset completed.
melmastoon.iam.password.changed.v1CredentialSelf-service change.
melmastoon.iam.device.registered.v1DeviceNew device added.
melmastoon.iam.device.trusted.v1DeviceDevice trust enabled.
melmastoon.iam.device.bound_for_offline.v1DeviceOffline cert issued.
melmastoon.iam.device.revoked.v1DeviceDevice revoked.
melmastoon.iam.apikey.issued.v1APIKeyKey created.
melmastoon.iam.apikey.revoked.v1APIKeyKey revoked.
melmastoon.iam.external_identity.linked.v1UserOIDC/SAML link.
melmastoon.iam.external_identity.unlinked.v1UserUnlink.

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

TermDefinition
PrincipalAn authenticated identity, human or machine.
CredentialA secret used to prove identity (currently password only; webauthn lives on MFA).
AMRAuthentication Methods References (RFC 8176) — values like pwd, totp, webauthn.
Refresh familyA lineage of refresh tokens originating from a single login event; reuse of any prior token revokes the entire family.
Device bindingCryptographic association of a Device to a User via Ed25519 keypair, enabling offline cert.
Offline certificateX.509 cert (≤ 7 d) signed by tenant CA; embedded in Electron app to allow refresh while offline.
JIT provisioningJust-in-time creation of a User on first successful federated login.
Step-up MFAAdaptive challenge inserted into a flow when risk score warrants, even if the session already has amr=pwd.
Magic linkOne-time URL containing a server-side single-use nonce; redeemed in a single GET, never replayable.
Tenant CAPer-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 detectionServer-side check that an incoming refresh token is the current head of its family; otherwise → family revoke.