Skip to main content
Version: v0.25.0 (Latest)

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=true with the internal identity ID on success
  • Returns resolved=false to indicate it can't resolve, the chain moves to the next resolver
  • Returns Err for 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

CommandIDDescription
ResolveIdentityCommandidentity.resolution.resolveRun the full resolver chain
ResolveMatchingIdentityCommandidentity.resolution.resolve-matchingHMAC hash + matching store lookup (used by the built-in resolver)

Identity Resolution vs Identifier Resolution

These are separate systems:

Identity ResolutionIdentifier Resolution
Question"Which internal identity does this external identifier belong to?""What key material does this cryptographic identifier resolve to?"
InputExternal identifier (email, subject ID)Cryptographic identifier (DID, X.509 fingerprint, JWK thumbprint)
OutputInternal identity IDKey material, DID document, certificate chain
Modulelib-identity-resolutionlib-crypto-identifier-resolution
Use caseUser authentication, session bindingSignature verification, encryption, trust establishment

Identity resolution answers "who is this person?" while identifier resolution answers "what keys does this identifier control?"