What this is about
CIAS writes a lot into the identity provider (IdP for short), that is, into Keycloak: accounts, organizations, roles, groups, attributes. Still, none of the business modules knows Keycloak. Registration does not know Keycloak’s URL, and role granting does not know how Keycloak stores a role.
That comes from a split into two parts:
- A port is a Java interface. It only says what is needed: “create an account”, “grant this role in this tenant”.
- An adapter is a class that fulfils this interface for one specific system. It knows how to do it in Keycloak: which HTTP call, which fields, which answer.
The picture
flowchart LR
subgraph BM["Business modules"]
R["cias-registration"]
U["cias-user"]
A["cias-authorization"]
end
subgraph P["cias-iam-api: ports"]
P1["Accounts"]
P2["Organizations"]
P3["Roles"]
P4["Groups"]
P5["Attributes"]
end
R --> P
U --> P
A --> P
P --> KA["cias-iam-keycloak<br/>Keycloak adapter"]
P --> MA["cias-iam-memory<br/>in-memory adapter"]
KA -- "Admin REST API" --> K["Keycloak"]
MA --> M[("Memory<br/>in the process")]
Read it from left to right: the business modules only know the middle. On the right there is exactly one adapter, depending on the setting. The arrows only point in one direction. No business module goes past the middle to Keycloak. An architecture test in every business module checks this: a test that fails the build as soon as a class imports a Keycloak package.
The ports
There are eleven ports. Each one covers one capability, not a whole system. A module that only looks up addresses does not depend on role management.
| Port | What it can do | Who uses it |
|---|---|---|
IdentityProvisioningPort | create an account disabled, mark the address as verified, enable, disable, require “set password”, get a password link, delete during cleanup | Registration, Users |
IdentityLookupPort | find an account by email address or by ID | Registration, Users |
IdentityDirectoryPort | read all accounts page by page | Users (import) |
IdentityAttributePort | set or remove one attribute on one account | Registration, Users |
OrganizationManagementPort | create an organization, find it by its alias, delete it during cleanup | Registration |
MembershipManagementPort | grant, revoke and read membership in an organization | Registration, Users |
RoleManagementPort | realm roles: check, create, grant globally or in an organization, revoke, read | Roles, Registration |
ClientRoleManagementPort | the same for the roles of a client, that is, module roles | Roles, Registration |
GroupManagementPort | maintain the copy of a CIAS group in Keycloak | Groups |
UserProfileManagementPort | which attributes an account may carry at all | Roles and attributes (reconciliation) |
ClaimMappingPort | get an attribute into the tokens of a client | Roles and attributes (reconciliation) |
Three terms from the table:
- A realm role applies in the whole realm and therefore in all modules, for example
platform-admin. A client role belongs to one client and only means something there, for example a role of CDMS. See Realm role, client role, organization role. - An organization is how Keycloak represents a dynamic tenant. See Static and dynamic tenants.
- The user profile is a document per realm in Keycloak that defines which attributes an account may have.
The rules behind the ports
The ports are cut this way on purpose. Each rule prevents a specific mistake.
- No Keycloak type in a port. No signature and no exception class names Keycloak. The port only knows its own small records such as
NewIdentityorIamIdentity. - No password. There is “require set password”, but no “set password”. CIAS never accepts a password.
- No user name. The email address is the login. One account per address, any number of tenants.
- Global and “in one organization” are separate methods.
assigngrants platform-wide,assignInOrganizationonly in one tenant. If both were one method with an optional tenant, a tenant administrator could give themselves platform-wide rights by accident. - Realm roles and client roles are separate ports. For the same reason: through the port for client roles you cannot reach a realm role.
- Every call may come twice. Granting a role that is already there changes nothing. Enabling an account that is already enabled does not either. There is no shared transaction between CIAS and Keycloak. If an answer gets lost, repeating is the only way, and repeating must not break anything.
- A new capability is a new port. No catch-all interface with thirty methods.
- An attribute needs three steps, each in its own port. The profile allows it (
UserProfileManagementPort), an account gets a value (IdentityAttributePortor at creation), and the value gets into the token (ClaimMappingPort). See The path into the token.
References instead of keys
Keycloak hands out its own IDs. CIAS keeps them in small wrappers: ExternalUserId, ExternalOrganizationId, ExternalGroupId, ExternalRoleId. These are only references: “this thing lives over there in the IdP”.
The business identity stays in CIAS:
| Object | identified in business terms by | found again in Keycloak via |
|---|---|---|
| Person | their CIAS ID | ExternalUserId |
| Tenant | the tenant key | alias of the organization = tenant key |
| Group | the group key | name of the group = group key |
| Role | its name | name of the role |
So whoever replaces the IdP changes references, not data.
When Keycloak does not do something
Every port reports problems with one of four failure kinds. The business modules react to the kind, not to an HTTP status.
When: Keycloak does not answer, answers with a server error, or CIAS does not get a login token for itself.
IamUnavailableException. The only kind that may be retried. Nobody knows whether the call already took effect, and that is why every call is built so that it may come twice.
Result: caller waits or tries again later
When: There is already an account with this address, an organization with this alias, or a group with this name.
IamConflictException. Keycloak enforces uniqueness. This message must never reach a person who is not logged in unfiltered, or a public endpoint would reveal which addresses exist.
Result: caller decides, often: carry on as if the lookup had found the object
When: CIAS refers to an account, a role, a client or a group that does not exist (anymore) in Keycloak.
IamNotFoundException. Usually a sign that CIAS and Keycloak have drifted apart, for example because someone deleted something in the Keycloak console.
Result: error, often a case for a reconciliation
When: Keycloak refuses for another reason, or CIAS is asked to change something that does not belong to CIAS.
IdentityProvisioningException. The honest catch-all: do not retry, record it and stop.
Result: error, the operation stays in a recognizable state
Lookups do not report “not found” as an error but as an empty result. “There is no account for this address” is the normal answer on a public endpoint.
Which adapter runs
Two things have to come together: the adapter’s jar is on the classpath, and the setting names it.
When: codamai.cias.iam-provider: keycloak, and cias-iam-keycloak is on the classpath.
CIAS talks to Keycloak through the Admin REST API. Where Keycloak is and which service account CIAS logs in with is set under codamai.cias.keycloak.*. See The Keycloak adapter.
Result: the normal case of every installation
When: codamai.cias.iam-provider: memory, and cias-iam-memory is on the classpath.
All ports work on one store in the process's memory. After a restart everything is gone. See The in-memory adapter for tests and development.
Result: tests and local development
When: The setting is missing, or it names an adapter whose jar is missing.
There are no ports. The application does not start and names the missing port. That is intended: a CIAS without an IdP would accept registrations it can never carry out.
Result: no start
Value of iam-provider | Jar on the classpath? | Result |
|---|---|---|
keycloak | yes | Keycloak adapter |
memory | yes | in-memory adapter |
keycloak or memory | no | no ports, no start |
| empty | – | no ports, no start |
If both jars are on the classpath, still only the one the setting names runs. The standalone CIAS ships both: the local profile sets memory, everything else keycloak.
A second provider is a second artifact
If CIAS is ever to work with a different IdP, nobody changes the business modules. Instead, a new module is created, for example cias-iam-<name>:
-
1Developercreates a module of its own that only knows
cias-iam-apiand the library of the new IdP -
2Developerimplements the ports the new IdP supports and translates its errors into the four failure kinds
-
3Buildruns the same contract tests as for Keycloak and the in-memory store
-
4Developergives the adapter a name for
codamai.cias.iam-providerand wires itResult: business modules unchanged, only the setting changes
Why a separate artifact and not a switch inside the Keycloak adapter? Every adapter brings the libraries of its IdP. If two IdPs lived in one module, every installation would depend on both. And the contract tests check exactly one adapter at a time.
Not every IdP can do everything. Organizations, roles in organizations or roles on groups are capabilities of Keycloak. An IdP without organizations cannot carry dynamic tenants.