From 4d98da18a0565658ede239054c33c724c6b119b3 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 02:30:40 +0200 Subject: [PATCH] =?UTF-8?q?keycloak:=20full=20nox=20module=20=E2=80=94=20c?= =?UTF-8?q?lient,=20tools=20and=20events=20(ADR=200044/0046)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Identity provider. 22 admin tools (realms, users, clients + secrets, groups, roles) over the admin API, moved out of the shared sdk. Emits user created/ deleted, password reset, client/group/role created — from the write tools themselves, since Keycloak's value is the changes it makes, not pollable state. Consumes nothing: it is upstream of everything that authenticates against it. Typechecks; manifest parses. --- modules/keycloak/client.ts | 219 ++++++++++++++++++ modules/keycloak/index.ts | 44 ++++ modules/keycloak/module.json | 11 +- modules/keycloak/package.json | 14 ++ modules/keycloak/tools/index.ts | 378 ++++++++++++++++++++++++++++++++ modules/keycloak/tsconfig.json | 12 + 6 files changed, 677 insertions(+), 1 deletion(-) create mode 100644 modules/keycloak/client.ts create mode 100644 modules/keycloak/index.ts create mode 100644 modules/keycloak/package.json create mode 100644 modules/keycloak/tools/index.ts create mode 100644 modules/keycloak/tsconfig.json diff --git a/modules/keycloak/client.ts b/modules/keycloak/client.ts new file mode 100644 index 0000000..431d331 --- /dev/null +++ b/modules/keycloak/client.ts @@ -0,0 +1,219 @@ +// The Keycloak admin API client — keycloak's own code, living in the module (novox/hq ADR 0044). +// Moved out of the shared hal sdk, where a change to Keycloak's admin API rebuilt everything; here +// it rebuilds only keycloak. Both this module's tools and its events entrypoint import it, and +// nothing outside keycloak does. + +export class KeycloakClient { + readonly baseUrl: string; + readonly defaultRealm: string; + // The admin token is short-lived; caching it (minus a safety margin) spares every call a fresh + // password grant, and a 401 mid-flight refreshes it once rather than failing the request. + private tokenCache: { token: string; expiresAt: number } | null = null; + + constructor( + url: string, + private readonly adminUser: string, + private readonly adminPass: string, + defaultRealm = "master", + ) { + this.baseUrl = url.replace(/\/+$/, ""); + this.defaultRealm = defaultRealm; + } + + /** + * Build from the module's resolved environment. Admin URL, credentials and the fallback realm are + * read from MESH_KEYCLOAK_* — the names the mesh sets — falling back to the container's own + * KEYCLOAK_ADMIN/KEYCLOAK_ADMIN_PASSWORD so a co-located server needs nothing configured twice. + * Throws when no admin password can be found: without it the client can do nothing, so failing + * here lets the tool runtime expose no keycloak tools rather than tools that always error. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): KeycloakClient { + const url = env.MESH_KEYCLOAK_URL ?? `http://127.0.0.1:${env.KEYCLOAK_PORT ?? "8080"}`; + const adminUser = env.MESH_KEYCLOAK_ADMIN ?? env.KEYCLOAK_ADMIN ?? "admin"; + const adminPass = env.MESH_KEYCLOAK_PASSWORD ?? env.KEYCLOAK_ADMIN_PASSWORD; + if (!adminPass) throw new Error("no Keycloak admin password — set MESH_KEYCLOAK_PASSWORD"); + const realm = env.MESH_KEYCLOAK_REALM ?? "master"; + return new KeycloakClient(url, adminUser, adminPass, realm); + } + + private async getToken(): Promise { + if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) return this.tokenCache.token; + + const res = await fetch(`${this.baseUrl}/realms/master/protocol/openid-connect/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "password", + client_id: "admin-cli", + username: this.adminUser, + password: this.adminPass, + }), + }); + if (!res.ok) throw new Error(`Keycloak token request failed: ${res.status} ${await res.text()}`); + + const data = (await res.json()) as { access_token: string; expires_in: number }; + this.tokenCache = { token: data.access_token, expiresAt: Date.now() + (data.expires_in - 30) * 1000 }; + return data.access_token; + } + + private async request(path: string, options: RequestInit = {}): Promise { + const doRequest = async (token: string): Promise => + fetch(`${this.baseUrl}/admin/realms${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...(options.headers as Record), + }, + }); + + let res = await doRequest(await this.getToken()); + // A cached token that expired against the server's clock reads as 401; drop it and retry once. + if (res.status === 401) { + this.tokenCache = null; + res = await doRequest(await this.getToken()); + } + if (!res.ok) throw new Error(`Keycloak API error ${res.status}: ${await res.text()}`); + // 201/204 carry no body — the admin API's create/update/delete answer with an empty response. + if (res.status === 201 || res.status === 204) return null as T; + return res.json() as Promise; + } + + // Realms + async listRealms(): Promise> { + return this.request("/"); + } + + // Users + async listUsers(realm: string, params: { search?: string; max?: number } = {}): Promise { + const qs = new URLSearchParams(); + if (params.search) qs.set("search", params.search); + if (params.max) qs.set("max", String(params.max)); + const query = qs.toString(); + return this.request(`/${realm}/users${query ? `?${query}` : ""}`); + } + + async createUser(realm: string, data: { + username: string; + email?: string; + enabled?: boolean; + credentials?: Array<{ type: string; value: string; temporary: boolean }>; + }): Promise { + await this.request(`/${realm}/users`, { method: "POST", body: JSON.stringify({ enabled: true, ...data }) }); + } + + async updateUser(realm: string, userId: string, data: Record): Promise { + await this.request(`/${realm}/users/${userId}`, { method: "PUT", body: JSON.stringify(data) }); + } + + async deleteUser(realm: string, userId: string): Promise { + await this.request(`/${realm}/users/${userId}`, { method: "DELETE" }); + } + + async resetPassword(realm: string, userId: string, password: string, temporary = false): Promise { + await this.request(`/${realm}/users/${userId}/reset-password`, { + method: "PUT", + body: JSON.stringify({ type: "password", value: password, temporary }), + }); + } + + async getUserSessions(realm: string, userId: string): Promise { + return this.request(`/${realm}/users/${userId}/sessions`); + } + + // Clients + async listClients(realm: string): Promise { + return this.request(`/${realm}/clients`); + } + + async createClient(realm: string, data: { + clientId: string; + name?: string; + rootUrl?: string; + redirectUris?: string[]; + publicClient?: boolean; + protocol?: string; + }): Promise { + await this.request(`/${realm}/clients`, { + method: "POST", + body: JSON.stringify({ protocol: "openid-connect", enabled: true, ...data }), + }); + } + + // The admin API addresses a client by its internal UUID, not the human clientId a caller knows; + // every client-scoped call resolves the one to the other first. + private async resolveClientId(realm: string, clientId: string): Promise { + const clients = (await this.listClients(realm)) as Array>; + const client = clients.find((c) => c.clientId === clientId); + if (!client) throw new Error(`Client '${clientId}' not found in realm '${realm}'`); + return client.id as string; + } + + async deleteClient(realm: string, clientId: string): Promise { + await this.request(`/${realm}/clients/${await this.resolveClientId(realm, clientId)}`, { method: "DELETE" }); + } + + async getClientSecret(realm: string, clientId: string): Promise { + const id = await this.resolveClientId(realm, clientId); + const result = await this.request<{ value: string }>(`/${realm}/clients/${id}/client-secret`); + return result.value; + } + + async addProtocolMapper(realm: string, clientId: string, mapper: { + name: string; + protocolMapper: string; + config: Record; + }): Promise { + const id = await this.resolveClientId(realm, clientId); + await this.request(`/${realm}/clients/${id}/protocol-mappers/models`, { + method: "POST", + body: JSON.stringify({ protocol: "openid-connect", ...mapper }), + }); + } + + // Roles + async listRealmRoles(realm: string): Promise> { + return this.request(`/${realm}/roles`); + } + + async createRealmRole(realm: string, data: { name: string; description?: string }): Promise { + await this.request(`/${realm}/roles`, { method: "POST", body: JSON.stringify(data) }); + } + + async getUserRealmRoles(realm: string, userId: string): Promise> { + return this.request(`/${realm}/users/${userId}/role-mappings/realm`); + } + + async getAvailableRealmRoles(realm: string, userId: string): Promise> { + return this.request(`/${realm}/users/${userId}/role-mappings/realm/available`); + } + + async assignRealmRoles(realm: string, userId: string, roles: Array<{ id: string; name: string }>): Promise { + await this.request(`/${realm}/users/${userId}/role-mappings/realm`, { method: "POST", body: JSON.stringify(roles) }); + } + + async removeRealmRoles(realm: string, userId: string, roles: Array<{ id: string; name: string }>): Promise { + await this.request(`/${realm}/users/${userId}/role-mappings/realm`, { method: "DELETE", body: JSON.stringify(roles) }); + } + + // Groups + async listGroups(realm: string): Promise> { + return this.request(`/${realm}/groups`); + } + + async createGroup(realm: string, name: string): Promise { + await this.request(`/${realm}/groups`, { method: "POST", body: JSON.stringify({ name }) }); + } + + async getUserGroups(realm: string, userId: string): Promise> { + return this.request(`/${realm}/users/${userId}/groups`); + } + + async addUserToGroup(realm: string, userId: string, groupId: string): Promise { + await this.request(`/${realm}/users/${userId}/groups/${groupId}`, { method: "PUT" }); + } + + async removeUserFromGroup(realm: string, userId: string, groupId: string): Promise { + await this.request(`/${realm}/users/${userId}/groups/${groupId}`, { method: "DELETE" }); + } +} diff --git a/modules/keycloak/index.ts b/modules/keycloak/index.ts new file mode 100644 index 0000000..1a3bd85 --- /dev/null +++ b/modules/keycloak/index.ts @@ -0,0 +1,44 @@ +// keycloak's events. Keycloak's worth to the mesh is in what it changes — an identity created, a +// client registered, a password reset — so its events are emitted from the admin actions themselves +// (novox/hq ADR 0046/0047), not scraped back by polling. This module is the single vocabulary for +// them: every keycloak event goes through one of the helpers here, and the tools call them at the +// point the change succeeds. +// +// Emits: +// module.keycloak.user.created / .deleted — an identity appeared or was removed +// module.keycloak.password.reset — a user's credential was reset (no secret in the body) +// module.keycloak.client.created — an OIDC client was registered +// module.keycloak.group.created — a group was created +// module.keycloak.role.created — a realm role was created +// Consumes: +// nothing — Keycloak is upstream of the things that authenticate against it; it reacts to none of +// their events. There is no honest `on(...)` to write, so there is none. + +import { emit } from "@novox/mesh-sdk/events"; + +// A completed admin action must not be undone by a flaky broker: the change already happened in +// Keycloak, so a failed emit is logged and swallowed rather than thrown back through the tool. +async function announce(type: string, body: Record): Promise { + try { + await emit(type, body); + } catch (err) { + console.error(`[keycloak] emit ${type} failed: ${err}`); + } +} + +export const events = { + userCreated: (realm: string, username: string, email?: string) => + announce("module.keycloak.user.created", { realm, username, ...(email ? { email } : {}) }), + userDeleted: (realm: string, userId: string) => + announce("module.keycloak.user.deleted", { realm, userId }), + passwordReset: (realm: string, userId: string) => + announce("module.keycloak.password.reset", { realm, userId }), + clientCreated: (realm: string, clientId: string, name?: string) => + announce("module.keycloak.client.created", { realm, clientId, ...(name ? { name } : {}) }), + groupCreated: (realm: string, name: string) => + announce("module.keycloak.group.created", { realm, name }), + roleCreated: (realm: string, name: string) => + announce("module.keycloak.role.created", { realm, name }), +}; + +console.log("[keycloak] event surface ready — identity, client, group and role changes are announced"); diff --git a/modules/keycloak/module.json b/modules/keycloak/module.json index 5096b20..404cce3 100644 --- a/modules/keycloak/module.json +++ b/modules/keycloak/module.json @@ -18,6 +18,14 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.keycloak.user.created", + "module.keycloak.user.deleted", + "module.keycloak.password.reset", + "module.keycloak.client.created", + "module.keycloak.group.created", + "module.keycloak.role.created" + ], "listens": [ { "port": 8080, @@ -27,7 +35,8 @@ } ], "own-secrets": { - "admin": "/var/lib/keycloak/admin.secret" + "admin": "/var/lib/keycloak/admin.secret", + "broker": "/var/lib/keycloak/broker" }, "resources": [ { diff --git a/modules/keycloak/package.json b/modules/keycloak/package.json new file mode 100644 index 0000000..6076ad3 --- /dev/null +++ b/modules/keycloak/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-keycloak", + "version": "0.1.0", + "description": "keycloak — identity and access. Its admin API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/keycloak/tools/index.ts b/modules/keycloak/tools/index.ts new file mode 100644 index 0000000..0382713 --- /dev/null +++ b/modules/keycloak/tools/index.ts @@ -0,0 +1,378 @@ +// keycloak's tools — moved here from the shared sdk (novox/hq ADR 0044), importing keycloak's own +// client. They return structured data (not the hal MCP `{content:[...]}` shape); the mesh serves +// them through the sdk's tool harness. Write actions announce themselves through the module's event +// surface at the point they succeed. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { KeycloakClient } from "../client.js"; +import { events } from "../index.js"; + +export function getKeycloakTools(kc: KeycloakClient): ToolDefinition[] { + // Almost every tool is realm-scoped; an omitted realm falls back to the one the module resolved + // from its environment, so the common single-realm case needs no argument. + const realmOf = (args: Readonly>): string => + args.realm ? String(args.realm) : kc.defaultRealm; + + return [ + // Realms & sessions + { + name: "keycloak_list_realms", + description: "List all Keycloak realms.", + input: {}, + run: async () => { + const realms = await kc.listRealms(); + return { realms: realms.map((r) => ({ id: r.id, realm: r.realm, displayName: r.displayName, enabled: r.enabled })) }; + }, + }, + { + name: "keycloak_list_sessions", + description: "List active sessions for a user in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + }, + run: async (args) => ({ sessions: await kc.getUserSessions(realmOf(args), String(args.user_id)) }), + }, + + // Users + { + name: "keycloak_list_users", + description: "List users in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + search: { type: "string", description: "search by username, email, first/last name" }, + max: { type: "number", description: "maximum number of results" }, + }, + run: async (args) => ({ + users: await kc.listUsers(realmOf(args), { + search: args.search ? String(args.search) : undefined, + max: args.max ? Number(args.max) : undefined, + }), + }), + }, + { + name: "keycloak_create_user", + description: "Create a user in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + username: { type: "string", description: "username" }, + email: { type: "string", description: "email address" }, + password: { type: "string", description: "initial password" }, + temporary_password: { type: "boolean", description: "require a password change on first login (default true)" }, + }, + run: async (args) => { + const realm = realmOf(args); + const username = String(args.username); + const email = args.email ? String(args.email) : undefined; + const credentials = args.password + ? [{ type: "password", value: String(args.password), temporary: args.temporary_password !== false }] + : undefined; + await kc.createUser(realm, { username, email, credentials }); + await events.userCreated(realm, username, email); + return { created: { realm, username, email } }; + }, + }, + { + name: "keycloak_delete_user", + description: "Delete a user from a Keycloak realm (requires confirm).", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + confirm: { type: "boolean", description: "must be true to confirm deletion" }, + }, + run: async (args) => { + const realm = realmOf(args); + const userId = String(args.user_id); + if (args.confirm !== true) return { aborted: "confirm must be true to delete a user" }; + await kc.deleteUser(realm, userId); + await events.userDeleted(realm, userId); + return { deleted: { realm, userId } }; + }, + }, + { + name: "keycloak_update_user", + description: "Update a user's attributes in a Keycloak realm (enable/disable, change email, name).", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + enabled: { type: "boolean", description: "enable or disable the user" }, + email: { type: "string", description: "new email address" }, + firstName: { type: "string", description: "new first name" }, + lastName: { type: "string", description: "new last name" }, + }, + run: async (args) => { + const realm = realmOf(args); + const userId = String(args.user_id); + const updates: Record = {}; + if (args.enabled !== undefined) updates.enabled = args.enabled === true; + if (args.email !== undefined) updates.email = String(args.email); + if (args.firstName !== undefined) updates.firstName = String(args.firstName); + if (args.lastName !== undefined) updates.lastName = String(args.lastName); + if (Object.keys(updates).length === 0) return { aborted: "no updates provided" }; + await kc.updateUser(realm, userId, updates); + return { updated: { realm, userId, fields: Object.keys(updates) } }; + }, + }, + { + name: "keycloak_reset_password", + description: "Reset a user's password in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + password: { type: "string", description: "new password" }, + temporary: { type: "boolean", description: "require a password change on next login (default false)" }, + }, + run: async (args) => { + const realm = realmOf(args); + const userId = String(args.user_id); + await kc.resetPassword(realm, userId, String(args.password), args.temporary === true); + await events.passwordReset(realm, userId); + return { reset: { realm, userId } }; + }, + }, + + // Clients + { + name: "keycloak_list_clients", + description: "List OIDC clients in a Keycloak realm.", + input: { realm: { type: "string", description: "realm name (defaults to the module's realm)" } }, + run: async (args) => { + const clients = (await kc.listClients(realmOf(args))) as Array>; + return { + clients: clients.map((c) => ({ + id: c.id, clientId: c.clientId, name: c.name, enabled: c.enabled, + protocol: c.protocol, publicClient: c.publicClient, rootUrl: c.rootUrl, + })), + }; + }, + }, + { + name: "keycloak_create_client", + description: "Create an OIDC client in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + client_id: { type: "string", description: "client ID (e.g. 'my-app')" }, + name: { type: "string", description: "display name" }, + root_url: { type: "string", description: "root URL of the application" }, + redirect_uris: { type: "array", description: "allowed redirect URIs" }, + public_client: { type: "boolean", description: "public client, no client secret (default true)" }, + }, + run: async (args) => { + const realm = realmOf(args); + const clientId = String(args.client_id); + const name = args.name ? String(args.name) : undefined; + await kc.createClient(realm, { + clientId, + name, + rootUrl: args.root_url ? String(args.root_url) : undefined, + redirectUris: Array.isArray(args.redirect_uris) ? args.redirect_uris.map(String) : undefined, + publicClient: args.public_client !== false, + }); + await events.clientCreated(realm, clientId, name); + return { created: { realm, clientId, name } }; + }, + }, + { + name: "keycloak_delete_client", + description: "Delete an OIDC client from a Keycloak realm (requires confirm).", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + client_id: { type: "string", description: "client ID (e.g. 'my-app')" }, + confirm: { type: "boolean", description: "must be true to confirm deletion" }, + }, + run: async (args) => { + const realm = realmOf(args); + const clientId = String(args.client_id); + if (args.confirm !== true) return { aborted: "confirm must be true to delete a client" }; + await kc.deleteClient(realm, clientId); + return { deleted: { realm, clientId } }; + }, + }, + { + name: "keycloak_get_client_secret", + description: "Get the client secret for a confidential OIDC client.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + client_id: { type: "string", description: "client ID" }, + }, + run: async (args) => ({ secret: await kc.getClientSecret(realmOf(args), String(args.client_id)) }), + }, + { + name: "keycloak_add_protocol_mapper", + description: + "Add a protocol mapper to an OIDC client. Common types: oidc-usermodel-realm-role-mapper " + + "(realm roles), oidc-usermodel-attribute-mapper (user attributes), oidc-audience-mapper.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + client_id: { type: "string", description: "client ID (e.g. 'grafana')" }, + name: { type: "string", description: "mapper name (e.g. 'realm roles')" }, + mapper_type: { type: "string", description: "protocol mapper type (e.g. 'oidc-usermodel-realm-role-mapper')" }, + claim_name: { type: "string", description: "token claim name (e.g. 'realm_access.roles')" }, + claim_type: { type: "string", description: "JSON type: String, long, int, boolean (default String)" }, + multivalued: { type: "boolean", description: "whether the claim has multiple values (default false)" }, + id_token: { type: "boolean", description: "include in ID token (default true)" }, + access_token: { type: "boolean", description: "include in access token (default true)" }, + userinfo: { type: "boolean", description: "include in userinfo response (default true)" }, + }, + run: async (args) => { + const realm = realmOf(args); + const clientId = String(args.client_id); + const name = String(args.name); + await kc.addProtocolMapper(realm, clientId, { + name, + protocolMapper: String(args.mapper_type), + config: { + "claim.name": String(args.claim_name), + "jsonType.label": args.claim_type ? String(args.claim_type) : "String", + "multivalued": String(args.multivalued === true), + "id.token.claim": String(args.id_token !== false), + "access.token.claim": String(args.access_token !== false), + "userinfo.token.claim": String(args.userinfo !== false), + }, + }); + return { added: { realm, clientId, mapper: name } }; + }, + }, + + // Groups + { + name: "keycloak_list_groups", + description: "List groups in a Keycloak realm.", + input: { realm: { type: "string", description: "realm name (defaults to the module's realm)" } }, + run: async (args) => ({ groups: await kc.listGroups(realmOf(args)) }), + }, + { + name: "keycloak_create_group", + description: "Create a group in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + name: { type: "string", description: "group name" }, + }, + run: async (args) => { + const realm = realmOf(args); + const name = String(args.name); + await kc.createGroup(realm, name); + await events.groupCreated(realm, name); + return { created: { realm, group: name } }; + }, + }, + { + name: "keycloak_get_user_groups", + description: "List the groups a user belongs to in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + }, + run: async (args) => ({ groups: await kc.getUserGroups(realmOf(args), String(args.user_id)) }), + }, + { + name: "keycloak_add_user_to_group", + description: "Add a user to a group in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + group_id: { type: "string", description: "group ID (UUID)" }, + }, + run: async (args) => { + const realm = realmOf(args); + await kc.addUserToGroup(realm, String(args.user_id), String(args.group_id)); + return { added: { realm, userId: String(args.user_id), groupId: String(args.group_id) } }; + }, + }, + { + name: "keycloak_remove_user_from_group", + description: "Remove a user from a group in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + group_id: { type: "string", description: "group ID (UUID)" }, + }, + run: async (args) => { + const realm = realmOf(args); + await kc.removeUserFromGroup(realm, String(args.user_id), String(args.group_id)); + return { removed: { realm, userId: String(args.user_id), groupId: String(args.group_id) } }; + }, + }, + + // Roles + { + name: "keycloak_get_user_roles", + description: "List the realm roles assigned to a user in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + }, + run: async (args) => ({ roles: await kc.getUserRealmRoles(realmOf(args), String(args.user_id)) }), + }, + { + name: "keycloak_create_role", + description: "Create a realm role in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + role_name: { type: "string", description: "role name" }, + description: { type: "string", description: "role description" }, + }, + run: async (args) => { + const realm = realmOf(args); + const name = String(args.role_name); + await kc.createRealmRole(realm, { name, description: args.description ? String(args.description) : undefined }); + await events.roleCreated(realm, name); + return { created: { realm, role: name } }; + }, + }, + { + name: "keycloak_assign_user_role", + description: "Assign an existing realm role to a user. Create it first with keycloak_create_role if needed.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + role_name: { type: "string", description: "role name to assign" }, + }, + run: async (args) => { + const realm = realmOf(args); + const userId = String(args.user_id); + const roleName = String(args.role_name); + // The mapping API needs the role's UUID, which only the "available" list carries; if the + // role is neither available nor already assigned it does not exist in this realm. + const available = await kc.getAvailableRealmRoles(realm, userId); + const role = available.find((r) => r.name === roleName); + if (!role) { + const assigned = await kc.getUserRealmRoles(realm, userId); + if (assigned.find((r) => r.name === roleName)) return { alreadyAssigned: { realm, userId, role: roleName } }; + return { notFound: { realm, role: roleName } }; + } + await kc.assignRealmRoles(realm, userId, [{ id: role.id, name: role.name }]); + return { assigned: { realm, userId, role: roleName } }; + }, + }, + { + name: "keycloak_remove_user_role", + description: "Remove a realm role from a user in a Keycloak realm.", + input: { + realm: { type: "string", description: "realm name (defaults to the module's realm)" }, + user_id: { type: "string", description: "user ID (UUID)" }, + role_name: { type: "string", description: "role name to remove" }, + }, + run: async (args) => { + const realm = realmOf(args); + const userId = String(args.user_id); + const roleName = String(args.role_name); + const assigned = await kc.getUserRealmRoles(realm, userId); + const role = assigned.find((r) => r.name === roleName); + if (!role) return { notAssigned: { realm, userId, role: roleName } }; + await kc.removeRealmRoles(realm, userId, [{ id: role.id, name: role.name }]); + return { removed: { realm, userId, role: roleName } }; + }, + }, + ]; +} + +// The tools exist only when the client can be configured; without an admin password, keycloak +// contributes none rather than failing the whole runtime. +registerModuleTools("keycloak", (env) => { + try { + return getKeycloakTools(KeycloakClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/keycloak/tsconfig.json b/modules/keycloak/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/keycloak/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +}