Identity Resolution
Identity resolution maps an external identifier to an internal identity ID. It answers the question: "given this external identifier (subject ID, email, DID), which internal identity does it belong to?"
The system uses a pluggable resolver chain, multiple IdentityResolver implementations are registered via multibinding and tried in priority order until one succeeds. This allows combining different resolution strategies (identity matching store, LDAP, OIDC introspection, directory service) without hard-coding the resolution logic.
IdentityResolver Interface
interface IdentityResolver {
val resolverId: String // e.g., "identity-matching", "ldap"
val priority: Int // Higher = tried first
suspend fun supports(tenantId: String, resolverConfig: ResolverConfig?): Boolean
suspend fun resolve(
identifier: String,
tenantId: String,
resolverConfig: ResolverConfig?
): IdkResult<IdentityResolutionResult, IdkError>
}
Each resolver:
- Self-determines whether it can handle the request via
supports() - Returns
resolved=truewith the internal identity ID on success - Returns
resolved=falseto indicate it can't resolve, the chain moves to the next resolver - Returns
Errfor hard failures that should stop the chain
Resolution Result
data class IdentityResolutionResult(
val resolved: Boolean = false,
val internalIdentityId: String?,
val resolverId: String?,
val identifierType: IdentifierType?,
val metadata: Map<String, String> = emptyMap(),
)
Built-In Resolver: Identity Matching
The MatchingIdentityResolverImpl uses the identity matching store to resolve identifiers:
@Inject
@SingleIn(SessionScope::class)
@ContributesIntoSet(SessionScope::class, binding = binding<IdentityResolver>())
class MatchingIdentityResolverImpl(
private val resolveMatchingCommand: ResolveMatchingIdentityCommand
) : IdentityResolver {
override val resolverId = "identity-matching"
override val priority = 100
override suspend fun supports(tenantId: String, resolverConfig: ResolverConfig?): Boolean {
return resolverConfig?.enabled == true
&& resolverConfig.properties.containsKey("kms-key-id")
}
override suspend fun resolve(
identifier: String,
tenantId: String,
resolverConfig: ResolverConfig?
): IdkResult<IdentityResolutionResult, IdkError> {
val kmsKeyId = resolverConfig!!.properties["kms-key-id"]!!
val identifierType = resolverConfig.properties["identifier-type"]
?.let { IdentifierType(it) } ?: IdentifierType.SUBJECT_ID
return resolveMatchingCommand.execute(
ResolveMatchingIdentityArgs(
identifier = identifier,
tenantId = tenantId,
kmsKeyId = kmsKeyId,
identifierType = identifierType
)
)
}
}
It HMAC-hashes the identifier using a KMS-managed key, then looks up the hash in the IdentityMatchStore. This means the resolver never sees or stores the plaintext identifier, privacy is maintained end-to-end.
Adding Custom Resolvers
Register additional resolvers via Metro multibinding:
@Inject
@SingleIn(SessionScope::class)
@ContributesIntoSet(SessionScope::class, binding = binding<IdentityResolver>())
class LdapIdentityResolver(
private val ldapClient: LdapClient
) : IdentityResolver {
override val resolverId = "ldap"
override val priority = 50 // Lower than matching — tried second
override suspend fun supports(tenantId: String, resolverConfig: ResolverConfig?): Boolean {
return resolverConfig?.enabled == true
}
override suspend fun resolve(
identifier: String,
tenantId: String,
resolverConfig: ResolverConfig?
): IdkResult<IdentityResolutionResult, IdkError> {
val result = ldapClient.searchByEmail(identifier, tenantId)
?: return Ok(IdentityResolutionResult.NOT_FOUND) // Soft failure — try next
return Ok(IdentityResolutionResult.found(
internalIdentityId = result.uid,
resolverId = resolverId,
identifierType = IdentifierType.EMAIL
))
}
}
With the matching resolver at priority 100 and LDAP at 50, the chain first checks the HMAC-hashed identity store. If no match is found, it falls back to LDAP. If LDAP also returns NOT_FOUND, the overall result is unresolved.
Configuration
sphereon:
identity:
resolution:
enabled: true
resolvers:
identity-matching:
enabled: true
priority: 100
properties:
kms-key-id: "arn:aws:kms:eu-west-1:123:key/abc"
identifier-type: SUBJECT_ID
ldap:
enabled: true
priority: 50
properties:
base-dn: "ou=users,dc=example,dc=com"
data class IdentityResolutionConfig(
val enabled: Boolean = false,
val resolvers: Map<String, ResolverConfig> = emptyMap(),
)
data class ResolverConfig(
val enabled: Boolean = true,
val priority: Int = 0,
val properties: Map<String, String> = emptyMap(),
)
Per-resolver properties are opaque Map<String, String>, each resolver reads what it needs. This keeps the resolution framework independent of specific resolver implementations.
Commands
| Command | ID | Description |
|---|---|---|
ResolveIdentityCommand | identity.resolution.resolve | Run the full resolver chain |
ResolveMatchingIdentityCommand | identity.resolution.resolve-matching | HMAC hash + matching store lookup (used by the built-in resolver) |
Identity Resolution vs Identifier Resolution
These are separate systems:
| Identity Resolution | Identifier Resolution | |
|---|---|---|
| Question | "Which internal identity does this external identifier belong to?" | "What key material does this cryptographic identifier resolve to?" |
| Input | External identifier (email, subject ID) | Cryptographic identifier (DID, X.509 fingerprint, JWK thumbprint) |
| Output | Internal identity ID | Key material, DID document, certificate chain |
| Module | lib-identity-resolution | lib-crypto-identifier-resolution |
| Use case | User authentication, session binding | Signature verification, encryption, trust establishment |
Identity resolution answers "who is this person?" while identifier resolution answers "what keys does this identifier control?"