Skip to main content

Identity Service — Application Logic

Status: populated Owner: TBD Last updated: 2026-04-17 Companion: Service Template · 05 API · 04 Events

1. Use case map

1.1 Commands

Use caseCommand DTOEmitsNotes
RegisterUserRegisterUserCommandidentity.user.registered.v1Creates shadow user in Keycloak when backend != in_house; triggers email verification
VerifyEmailVerifyEmailCommandidentity.user.email_verified.v1Activates user, completes registration
LoginWithPasswordLoginPasswordCommandidentity.session.created.v1, identity.user.logged_in.v1argon2id verify; MFA challenge if required
LoginWithOIDCLoginOIDCCommandidentity.external_identity.linked.v1 (JIT), identity.session.created.v1PKCE; state/nonce validation; JIT link or create
LoginWithSAMLLoginSAMLCommandidentity.external_identity.linked.v1 (JIT), identity.session.created.v1Encrypted assertion; entity ID allowlist
LoginWithKeycloakBrokerLoginKeycloakCommandsame as OIDCToken exchange (RFC 8693); re-mint Ghasi JWT
RefreshSessionRefreshSessionCommandidentity.session.created.v1 (new), identity.session.revoked.v1 (old)Refresh rotation one-time-use
Logout / LogoutAllDevicesLogoutCommandidentity.session.revoked.v1Revoke one or all sessions
EnrollTOTPEnrollTOTPCommandidentity.user.mfa_enrolled.v1QR code issued; verify before enabling
EnrollWebAuthnEnrollWebAuthnCommandidentity.user.mfa_enrolled.v1Passkey / platform authenticator
VerifyMFAVerifyMFACommandidentity.user.mfa_challenge_failed.v1 (on fail)Consumed during login flow
ChangePasswordChangePasswordCommandidentity.user.password_changed.v1Breach-list check; invalidates other sessions
RequestPasswordResetRequestPasswordResetCommanddomain notice (consumed by communication-service)Signed token email
SuspendUser / ReactivateUser / DeactivateUseridentity.user.{suspended,reactivated,deactivated}.v1Admin scope
RegisterDeviceRegisterDeviceCommandidentity.device.registered.v1Returns device ID + challenge
IssueOfflineBindingCertBindDeviceCommandidentity.device.bound_for_offline.v1Consumed by content/chart services
RevokeDeviceRevokeDeviceCommandidentity.device.revoked.v1Cascades to revoke sessions bound to device
CreateAPIKeyCreateAPIKeyCommandidentity.api_key.issued.v1Returns secret once
RotateAPIKeyRotateAPIKeyCommandidentity.api_key.issued.v1, identity.api_key.revoked.v1Overlap window configurable
RevokeAPIKeyRevokeAPIKeyCommandidentity.api_key.revoked.v1
CreateServiceAccountCreateServiceAccountCommandidentity.service_account.created.v1Keycloak confidential client
RevokeServiceAccountRevokeServiceAccountCommandidentity.service_account.revoked.v1
CreateModule / UpdateModule (licensing)identity.license.module.{created,updated}.v1Super Admin only
AssignLicenseAssignLicenseCommandidentity.license.assignment.created.v1Validates dependencies + min_node_types
ChangeLicenseStatusChangeLicenseStatusCommandidentity.license.assignment.status_changed.v1Enforces always-on immutability
UpdateLicenseConstraintsUpdateLicenseConstraintsCommandidentity.license.assignment.constraints_updated.v1
ApplyModuleBundleApplyBundleCommandN × identity.license.assignment.created.v1Transactional

1.2 Queries

QueryReturns
GetMeOwn User profile
GetMeAccessContextAggregated { roles[], memberships[], effectiveModules[] } for the caller
GetUserByIdSingle User scoped by tenant
ListUsersByTenantPaginated user list with filters
GetDevice / ListMyDevicesUser-owned devices with trust + binding status
GetAPIKey / ListAPIKeysTenant API keys (metadata only, never secret)
ListServiceAccountsSuper Admin / Tenant Admin
JWKSPublic keys for JWT verification
GetModuleCatalogue / GetModuleModule definitions + dependency graph
GetNodeAssignmentsDirect licenses at a node (no inheritance)
GetEffectiveLicensesComputed effective module set for (tenantId, providerId, nodeId)
GetLicenseHistoryAppend-only change log for an assignment

2. Orchestration flows

2.1 Login with MFA + context

2.2 Effective license resolution

2.3 License assignment change → cache invalidation

3. Application ports

3.1 Authentication / sessions

PortPurposeDefault adapter
PasswordHasherargon2id hash & verifyArgon2idAdapter
TokenSignerSign & verify Ghasi JWTsKMSTokenSignerAdapter
RefreshStorePersist refresh hashesPostgresRefreshStoreAdapter
SessionRepositoryRead/write sessionsPostgres
UserRepositoryUser aggregate persistencePostgres
MFAChallengeStoreChallenge tokens (short-lived)Redis

3.2 Identity provider abstraction

PortPurposeAdapters
IdentityAuthenticationProviderHigh-level authenticate(credentials)InHouseAuth, KeycloakBrokerAuth, OIDCClientAuth, SAMLSpAuth
CredentialStoreCredential CRUDPostgresCredentialStore (in_house), KeycloakCredentialStore
ExternalIdentityLinkerLink OIDC/SAML subject ↔ UserPostgresExternalIdentityAdapter
DeviceRepositoryDevice + BindingCertificatePostgres
CertificateIssuerSign short-lived binding certsKMSCertificateIssuer

3.3 Licensing

PortPurposeAdapter
ModuleRepositoryModule cataloguePostgres
LicenseAssignmentRepositoryAssignments + historyPostgres
HierarchyAncestryClientFetch ancestor chainHTTP → tenant-service /tenant/internal/hierarchy/nodes/:id/ancestors
EffectiveLicenseCacheRedis cacheRedis
LicenseBreachNotifierEmit seat/usage breach eventsNATS

3.4 Cross-cutting

PortPurpose
EventPublisherOutbox-relayed domain events
AuditWriterLow-retention audit tap (primary record via audit-service event consumer)
AccessContextAssemblerCombine tenant-service roles/memberships + local effective licenses
BreachPasswordDenyListCheck pwned-passwords hash prefix

4. Saga / outbox patterns

PatternWhereNotes
Outbox relayAll commands that emit eventsoutbox table + relay worker; exactly-once per subject+key with NATS JetStream
Inbox dedupeConsumers of tenant.activated, tenant.suspended, tenant.terminatedinbox table keyed on event id
Saga — registrationRegisterUser → async email → VerifyEmailNo compensating tx; expiry at 24h re-issues token
Saga — refresh rotationRefreshSession uses SERIALIZABLE tx to invalidate old hash + mint newReplay detection emits SessionRevoked for entire chain
Saga — device revocation on suspendtenant.suspended → revoke all devices → publish device.revoked eventsIdempotent per device id

5. Error handling

LayerStrategy
ControllersMap domain errors → HTTP via ErrorToHttp interceptor. Standard envelope {error, message, requestId, timestamp}
Use casesThrow typed DomainError subclasses (CrossTenantError, UserSuspendedError, DependencyNotLicensedError, ...). No raw throws.
AdaptersTranslate infrastructure exceptions to transient vs permanent. Retries at adapter level only for transient network classes.
OutboxFailed publish kept in table; relay retries with exponential backoff + jitter; alert after 15 min continuous failure
External IdPsCircuit breaker per (issuer, tenantId); half-open after 60s; breaker-open returns IDENT_FEDERATION_UNAVAILABLE

6. Input validation

SurfaceValidator
Public RESTZod schemas in presentation/dto — fail fast with 400 + field-level errors
Internal RESTSame Zod schemas; plus ip-allowlist guard
Events (inbox)Ajv against registered schema in @ghasi/event-envelope
JWTjose with KMS-backed JWKS; enforced audience, issuer, algorithm

7. Ports → adapters wiring

// infrastructure/app.module.ts
providers: [
{ provide: PasswordHasher, useClass: Argon2idAdapter },
{ provide: TokenSigner, useClass: KMSTokenSignerAdapter },
{ provide: IdentityAuthenticationProvider, useFactory: authProviderFactory, inject: [Config] },
{ provide: HierarchyAncestryClient, useClass: HttpHierarchyClient },
{ provide: EffectiveLicenseCache, useClass: RedisEffectiveLicenseCache },
{ provide: EventPublisher, useClass: OutboxEventPublisher },
]

8. Open questions

  • Should ApplyBundle support partial failure (continue on dependency error) with compensating notes, or strictly all-or-nothing? Current design: strictly transactional.
  • Adaptive MFA scoring engine (in S4): in-house or behind RiskScorer port for vendor swap?