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.
Which module do I implement against?
Section titled “Which module do I implement against?”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.
IamJwtClaimsEnricher
Section titled “IamJwtClaimsEnricher”Add your own claims to issued access tokens:
@Componentclass 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.
IamUserLifecycleListener
Section titled “IamUserLifecycleListener”React to user events (created, activated, deactivated, login) — sync to a CRM, send welcome
mail, feed analytics. Receives read-only views (IamUserView, IamTenantView).
IamAuthenticationProvider
Section titled “IamAuthenticationProvider”Plug a custom credential check (LDAP, legacy user store) into the login flow while IAM still issues its tokens and resolves its permissions.
IamScopeResolver
Section titled “IamScopeResolver”Resolve the active scope from the request when it isn’t a path parameter — e.g. derive the branch from a header or session.
IamAuditEnricher
Section titled “IamAuditEnricher”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?).
IamAuditSink
Section titled “IamAuditSink”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")@Componentclass 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) carrynull; supply your own fallback if your column is required.oldValue/newValue/metadataare JSON strings, notMaps — 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 inboundX-Correlation-Idor a generated UUID string) — parse leniently, neverUUID.fromStringblindly.
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.
IamTenantHostnameSource
Section titled “IamTenantHostnameSource”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:
@Beanfun 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.
IamSecurityCustomizer
Section titled “IamSecurityCustomizer”Adjust IAM’s HttpSecurity configuration without replacing the whole chain.
User resolution SPI
Section titled “User resolution SPI”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.