Identity Service — Domain Model
Status: populated Owner: TBD Last updated: 2026-04-17 Companion: Service Template · 02 DDD · 04 Events
1. Aggregates
| Aggregate | Root entity | Primary ID | Invariants |
|---|---|---|---|
| User | User | UserId (usr_) | Email unique per tenant; status transitions constrained; keycloakUserId present when backend != in_house |
| Session | Session | SessionId (ses_) | Refresh rotation replaces refreshHash; absolute expiry ≤ 8h; idle expiry ≤ tenant config; revoked_at or expires_at set on terminal state |
| Credential | Credential | CredentialId (crd_) | Exactly one active argon2id password hash per user OR ≥1 WebAuthn credential when MFA required; breach-list check passes for new hashes |
| MFAFactor | MFAFactor | MFAFactorId (mfa_) | One TOTP seed, ≤ 5 WebAuthn credentials, ≤ 10 recovery codes; each MFAFactor has verifiedAt when enrolled |
| Device | Device | DeviceId (dev_) | Public-key unique per user; trust level in {untrusted, trusted, offline_bound}; offline binding certificate expiry ≤ 30 days |
| APIKey | APIKey | APIKeyId (apk_) | Tenant-scoped; secret is HMAC-stored, returned once; scopes drawn from platform vocabulary; revoke is terminal |
| ServiceAccount | ServiceAccount | ServiceAccountId (svc_) | clientId globally unique; tenantId nullable for platform-level; status terminal on revoke |
| ExternalIdentity | ExternalIdentity | ExternalIdentityId (eid_) | (issuer, subject) unique; links to exactly one User within one tenant |
| Module | Module | ModuleId (mod_) | code globally unique, immutable (BR-LIC-001); is_always_on cannot be deleted |
| LicenseAssignment | LicenseAssignment | LicenseAssignmentId (lic_) | (module_id, node_id) unique; terminated terminal; trials must have effective_to; dependencies licensed at same/ancestor node |
| LicenseAssignmentHistory | n/a (child) | lih_ | Append-only; one row per change with before/after JSONB |
2. State machines
2.1 User
2.2 Session
2.3 LicenseAssignment
2.4 Device
3. Entities (non-root)
| Entity | Parent aggregate | Purpose |
|---|---|---|
| RefreshToken | Session | Rotating refresh token with one-time-use semantics |
| RecoveryCode | MFAFactor | Single-use break-out code, hashed |
| DeviceFingerprint | Device | UA string, screen, timezone hash used for risk scoring |
| BindingCertificate | Device | Short-lived X.509-style cert for offline content encryption |
| AllowedRedirectUri | ExternalIdentity | Whitelisted callback URIs per tenant/IdP |
| LicenseConstraint | LicenseAssignment | Structured { seats, expiresAt, usageCap } read from JSONB |
4. Value objects
| Value object | Shape | Notes |
|---|---|---|
UserId | branded ULID usr_<ulid> | Frozen F03 |
TenantId | branded ULID ten_<ulid> | Inherited from tenant-service |
EmailAddress | RFC 5322 lower-cased, length ≤ 320 | Normalized at boundary |
PasswordHash | argon2id$v=19$m=65536,t=3,p=1$... | Opaque outside domain |
DeviceFingerprint | SHA-256 hex over canonical UA/screen/timezone | Only stored hashed |
JWTClaims | { sub, tid, tids, jti, amr, context_node_id?, exp, iat, iss, aud } | Frozen F01 |
ModuleCode | dotted string ehr.core, diag.laboratory | Immutable (F05) |
LicenseScope | enum exact | inherit-down | — |
LicenseStatus | enum trial | active | suspended | expired | terminated | — |
ProviderKind | enum in_house | keycloak_broker | oidc | saml | Selects adapter |
PermissionScope (API key) | tenant:{resource}:{action} | Tenant-bound |
5. Domain events
All events use platform envelope; subject format identity.{aggregate}.{event}.v1.
5.1 User & session
| Event | Subject | Fired by |
|---|---|---|
| UserRegistered | identity.user.registered.v1 | RegisterUser use case |
| UserEmailVerified | identity.user.email_verified.v1 | VerifyEmail |
| UserSuspended | identity.user.suspended.v1 | SuspendUser |
| UserReactivated | identity.user.reactivated.v1 | ReactivateUser |
| UserDeactivated | identity.user.deactivated.v1 | DeactivateUser / GDPR |
| UserLoggedIn | identity.user.logged_in.v1 | Login* handlers (local/OIDC/SAML) |
| UserPasswordChanged | identity.user.password_changed.v1 | ChangePassword |
| UserMFAEnrolled | identity.user.mfa_enrolled.v1 | EnrollMFA |
| UserMFAChallengeFailed | identity.user.mfa_challenge_failed.v1 | VerifyMFA |
| SessionCreated | identity.session.created.v1 | Login* |
| SessionRevoked | identity.session.revoked.v1 | Logout / admin / suspension |
5.2 Device, key, federation
| Event | Subject | Purpose |
|---|---|---|
| DeviceRegistered | identity.device.registered.v1 | New device enrolled |
| DeviceBoundForOffline | identity.device.bound_for_offline.v1 | Offline cert issued (consumed by content/chart) |
| DeviceRevoked | identity.device.revoked.v1 | Cert revocation |
| APIKeyIssued | identity.api_key.issued.v1 | New tenant API key created |
| APIKeyRevoked | identity.api_key.revoked.v1 | — |
| ServiceAccountCreated | identity.service_account.created.v1 | — |
| ServiceAccountRevoked | identity.service_account.revoked.v1 | Consumers drop tokens |
| ExternalIdentityLinked | identity.external_identity.linked.v1 | OIDC/SAML JIT |
5.3 Licensing (merged module)
| Event | Subject | Purpose |
|---|---|---|
| ModuleCreated | identity.license.module.created.v1 | Super Admin adds module |
| ModuleUpdated | identity.license.module.updated.v1 | Description / dependency change |
| LicenseAssigned | identity.license.assignment.created.v1 | New license attached to node |
| LicenseStatusChanged | identity.license.assignment.status_changed.v1 | active → suspended etc. |
| LicenseConstraintsUpdated | identity.license.assignment.constraints_updated.v1 | Seats/expiry change |
| LicenseAssignmentExpired | identity.license.assignment.expired.v1 | Scheduled job fires |
6. Ubiquitous language
| Term | Meaning |
|---|---|
| Principal | Any authenticated actor (User or ServiceAccount) |
| Provider (backend) | An identity adapter: in_house, keycloak_broker, oidc, saml |
| Provider (clinical) | A licensed clinical user (physician, nurse, ...). Profile data lives in tenant-service |
| Session | Access+refresh pair bound to a User and Device |
| Binding | A Device's offline capability, proved by a signed BindingCertificate |
| Module | A licensable capability (ehr.core, clinical.immunizations, diag.radiology) |
| License assignment | A (module, node) grant with scope, status, constraints |
| Effective license set | Runtime-computed set of modules active for a (provider, node) after ancestor inheritance |
| Access context | Aggregated view combining roles (tenant), memberships (tenant), and effective licenses (identity) |
| JWKS | Public keys published at /.well-known/jwks.json for JWT verification |
| amr | OIDC amr claim = methods used to authenticate (pwd, mfa, webauthn, fed) |
| Break-glass | Emergency override login with tenant-signed audit trail (S4) |
7. Aggregate relationships
8. Invariants enforced in domain layer
| # | Invariant |
|---|---|
| INV-01 | email unique per tenantId |
| INV-02 | A suspended or deactivated user cannot open new sessions |
| INV-03 | Refresh rotation is one-time-use; replay produces SessionRevoked and user security-incident event |
| INV-04 | MFA required when tenant config mfa_required=true OR role set requires it OR adaptive score > threshold |
| INV-05 | Offline binding certificates expire within 30 days; issuance requires device.status == trusted |
| INV-06 | Module dependencies must be licensed at same or ancestor node before assignment |
| INV-07 | is_always_on modules cannot transition to suspended or terminated |
| INV-08 | terminated license is terminal; new assignment must be created to re-license |
| INV-09 | Trial licenses MUST have effective_to |
| INV-10 | Cross-tenant license assignment prohibited; node's tenant must match assignment tenant |
| INV-11 | JWT amr reflects all factors used; mfa claim present iff a second factor was verified in session |
| INV-12 | ExternalIdentity.(issuer, subject) unique and bound to exactly one User within a tenant |
9. Open questions
- Should WebAuthn platform authenticators replace TOTP as default second factor for clinical roles by M2? Align with HIPAA guidance.
- Do we need per-module usage caps enforced at runtime (vs billing-only) for S1?