Handle Identity, Caching, And Rebinding
Fluent handles (RealmHandle, ClientHandle, ClientScopeHandle, RoleHandle,
UserHandle, ...) carry two pieces of state:
- Routing identity — the realm name, client id, role name, scope name, user name, alias, etc. that the handle targets.
- Cached representation — the most recently resolved upsert-able
representation (
client,role,group,user,flow,workflow,organization,identityProvider,component, ...).
Both are read-only from outside the handle. This prevents callers from
inadvertently redirecting a handle by mutating its identity, or from
poisoning the cached representation in front of a destructive call. The
only supported way to retarget a handle after construction is the public
rebind(newId) method described below.
What This Means For You
Reads are unaffected
All the public read access patterns keep working:
const role = await realm.role('manage-users').ensure({});
role.realmName; // 'demo'
role.roleName; // 'manage-users'
role.role; // the resolved RoleRepresentation (or null/undefined before get())
role.core; // the underlying KeycloakAdminClient (also reachable via kc.core)
role.realmHandle; // the parent RealmHandle
Writes that previously worked now fail at compile time
// These all fail to compile after the encapsulation contract landed:
role.roleName = 'other'; // readonly getter
role.role = undefined; // readonly getter
client.clientId = 'other'; // readonly getter
client.client = undefined; // readonly getter
realm.realmName = 'other'; // readonly getter
realm.realm = undefined; // readonly getter
kc.core and per-handle .core / .realmHandle remain publicly readable
(documented as such in the Client Fluent API)
and are declared readonly so they cannot be redirected after construction.
Re-Targeting A Handle: rebind(newId)
Every parent handle exposes a rebind(newId) method that:
- Updates the handle's routing identity to the new value.
- Clears the cached representation so the next read resolves against the new target.
- Returns
thisfor chaining.
const clientHandle = realm.client('app-a');
await clientHandle.ensure({ description: 'A' });
// Re-target the SAME handle at 'app-b'. Subsequent operations resolve B,
// never a stale snapshot of A.
clientHandle.rebind('app-b');
await clientHandle.ensure({ description: 'B' });
Child handles follow the parent automatically
Child handles derive their parent routing identity from a shared identity
version contract. Rebinding a parent bumps that version, so existing children
notice the parent-generation change, clear any cached representation from the
old parent, and re-resolve on their next operation. There is no separate
"invalidate" call and no child rebind() call you need to make.
const clientHandle = realm.client('app-a');
const roleHandle = clientHandle.role('reader');
await clientHandle.get(); // resolves app-a
await roleHandle.get(); // resolves the reader role on app-a
clientHandle.rebind('app-b');
await roleHandle.get(); // now resolves the reader role on app-b
The same transitive behavior applies across multiple levels, including realm
children, client roles, client protocol mappers, client-scope protocol mappers,
identity-provider mappers, and nested groups. For example, rebinding a
ClientScopeHandle automatically invalidates an already-resolved protocol
mapper under that scope, and rebinding a parent group changes the live path
used by existing child-group handles. Realm-owned cached descendants such as
UserStorageProviderHandle.providerName also clear cached values after a realm
rebind before their next operation or cache read.
If neither the local identity nor any parent identity changed, handles keep their cached parent representation and preserve the no-duplicate-lookup fast path.
Which Handles Support rebind?
| Handle | rebind(newId) parameter |
|---|---|
RealmHandle | new realm name |
ClientHandle (and confidential/public/service-account subclasses) | new client id |
ClientScopeHandle | new scope name |
RoleHandle | new role name |
ClientRoleHandle | new role name (parent client is followed live) |
UserHandle | new username |
OrganizationHandle | new organization alias |
IdentityProviderHandle | new alias |
IdentityProviderMapperHandle | new mapper name |
AuthenticationFlowHandle | new alias |
WorkflowHandle | new workflow name |
ComponentHandle | new component name |
GroupHandle / ChildGroupHandle / NestedChildGroupHandle | new group name |
ProtocolMapperHandle | new mapper name |
ClientScopeProtocolMapperHandle | new mapper name |
AttackDetectionHandle | new user id |
UserStorageProviderHandle is keyed by its provider ID and does not expose a
local rebind() method; when its parent realm is rebound, it follows the new
realm and invalidates its cached provider name automatically.
CacheHandle and ClientPoliciesHandle have no local cached representation or
per-handle identity beyond the realm, so they do not expose rebind; use
RealmHandle.rebind to retarget their realm.
Contract Stability
Re-targeting through rebind is the only intentional way to reuse a handle
across identities. The fields previously exposed as public mutable
(clientId, scopeName, roleName, username, alias, ... and the
matching cached representations) are now backed by private storage accessed
through public readonly getters. This is a TypeScript compile-time
contract change — runtime code that read these fields continues to work,
and the rebind() API replaces direct field mutation. The compile fixture
in tests/implementation-handle-visibility.spec.ts enforces the visibility
contract on every CI run so the encapsulation cannot regress silently.