Skip to content

SPI Hooks

Beyond bean overrides, IAM exposes a small SPI for hooking into its flows. Implement the interface as a Spring bean; IAM discovers it.

The contract SPIs live in package dev.mpofusindie.iam.spi in the Spring-free iam-api jar. The servlet/Spring-coupled SPIs live in package dev.mpofusindie.iam.spi.server in iam-core. Your import follows the package, and the compile dependency follows the module:

SPI Package Module Why
IamAuditSink (+ IamAuditEventView) dev.mpofusindie.iam.spi iam-api (contract) Framework-free — implement it with the Spring-free contract dependency only.
IamJwtClaimsEnricher dev.mpofusindie.iam.spi iam-api Speaks MutableMap + the read-only IamUserView/IamTenantView.
IamUserLifecycleListener dev.mpofusindie.iam.spi iam-api Read-only views only.
IamUserProvider (+ IamUserView) dev.mpofusindie.iam.spi iam-api Plain identity view — no Spring, no servlet.
IamTenantProvider (+ IamTenantView) dev.mpofusindie.iam.spi iam-api Plain tenant view.
IamAuthenticationProvider (+ AuthenticationRequest) dev.mpofusindie.iam.spi iam-api Views in, views out — no persistence or servlet types.
IamNotificationSender (+ IamNotification) dev.mpofusindie.iam.spi iam-api Plain notification value type.
IamScopeResolver (+ ScopeContext) dev.mpofusindie.iam.spi.server iam-core Reads HttpServletRequest.
IamAuditEnricher dev.mpofusindie.iam.spi.server iam-core Reads HttpServletRequest.
IamSecurityCustomizer dev.mpofusindie.iam.spi.server iam-core Configures Spring HttpSecurity.

The servlet/Spring-coupled trio lives in dev.mpofusindie.iam.spi.server (iam-core) by design — implementing them already means you have the engine on the classpath, and keeping them in their own package leaves dev.mpofusindie.iam.spi as an iam-api-only contract package (no split package). Everything else is in the contract jar (Wave A / A-1) so a business module can implement it without pulling in the implementation.

Add your own claims to issued access tokens:

@Component
class OrgClaimsEnricher(private val orgService: OrgService) : IamJwtClaimsEnricher {
override fun enrich(claims: MutableMap<String, Any>, user: IamUserView, tenant: IamTenantView) {
claims["org_unit"] = orgService.unitFor(user.id)
}
}

Reserved claims (sub, tid, roles, type, exp, iat, jti) can’t be overwritten, and a throwing enricher is caught and logged rather than breaking token generation.

React to user events (created, activated, deactivated, login) — sync to a CRM, send welcome mail, feed analytics. Receives read-only views (IamUserView, IamTenantView).

Plug a custom credential check (LDAP, legacy user store) into the login flow while IAM still issues its tokens and resolves its permissions.

Resolve the active scope from the request when it isn’t a path parameter — e.g. derive the branch from a header or session.

Attach host context (deployment region, feature flags) to every audit record’s metadata JSON before it’s written — enrich(auditContext: MutableMap<String, Any>, request: HttpServletRequest?).

Stream every committed audit event to an external system (Kafka, ELK, an SIEM, a webhook) in addition to the durable iam_audit_logs table — registering a sink changes nothing about the default behaviour. It’s a fun interface: one method, one bean.

IamAuditSink and IamAuditEventView live in the contract module iam-api (package dev.mpofusindie.iam.spi), so a host can implement a sink with the Spring-free, contract-only dependency — no iam-core on the compile classpath:

// build.gradle.kts (a business module that only implements the sink)
implementation("dev.mpofusindie:iam-api")
@Component
class KafkaAuditSink(private val kafka: KafkaTemplate<String, String>) : IamAuditSink {
override fun onAuditEvent(event: IamAuditEventView) {
kafka.send("iam-audit", event.tenantId.toString(), serialize(event))
}
}

Sinks receive IamAuditEventView — a flat, framework-free projection (never IAM’s persistence types). Know the shapes it actually delivers (they are honest on purpose, not conveniences):

  • entityId: UUID? is nullable — non-entity events (e.g. a login) carry null; supply your own fallback if your column is required.
  • oldValue / newValue / metadata are JSON strings, not Maps — parse them if you want structure; assigning the string into a JSON column double-encodes.
  • correlationId: String? is a free-form token, not a UUID (the inbound X-Correlation-Id or a generated UUID string) — parse leniently, never UUID.fromString blindly.

Delivery is after-commit (a rolled-back transaction is never delivered), best-effort and per-sink isolated (a throwing sink can’t break the request or starve other sinks), and at-most-once, in-process (no retries, no crash recovery — poll iam_audit_logs as your outbox if you need guaranteed delivery). Because delivery runs after the business commit, a sink runs with no ambient transaction and is not atomic with the business change — a DB-writing sink must open its own unit of work, and a failed sink loses only the mirror row (the durable table still holds it), so never treat a sink mirror as system-of-record without reconciling against iam_audit_logs. See the Stream Audit Events recipe for the full contract and the Observability guide for how the correlation id ties events to logs.

Map a full request hostname to a tenant for the host resolution strategy — the custom-domain story. The built-in IamHostTenantResolver consults this source first (a lookup, not a parse) and only falls back to subdomain-of-base-domain parsing when it returns null:

@Bean
fun customDomains(repo: CustomDomainRepository) =
IamTenantHostnameSource { hostname -> repo.findVerified(hostname)?.tenantId }

Return null for “not mine” (the base-domain fallback then decides) — it’s not a rejection. Called per request before authentication, so keep it cache-friendly. See Multi-Tenancy & Isolation.

Adjust IAM’s HttpSecurity configuration without replacing the whole chain.

For apps that keep users elsewhere entirely, the resolver SPI lets IAM look up users/tenants through your implementation — IAM manages authorization while identity lives in your system of record.