Twice the identity provider's admin kept an older password than the one the mesh minted (an adopted, then a moved database), and the provisioner failed every consumer until it was repaired by hand (hq issue 179). The module now checks the admin's login and repairs a refusal itself through the server's bootstrap command, verifies, brakes a failed repair and announces it, and stops asking the server while refused. Ported to Go to change it.
415 lines
18 KiB
Go
415 lines
18 KiB
Go
package main
|
|
|
|
// keycloak's tools — ported from tools/index.ts with the same names, arguments and answers. Write
|
|
// actions announce themselves at the point they succeed, in the module's single event vocabulary:
|
|
//
|
|
// user.created / user.deleted an identity appeared or was removed
|
|
// password.reset a user's credential was reset (no secret in the body)
|
|
// client.created an OIDC client was registered
|
|
// group.created / role.created a group or a realm role was created
|
|
// admin.repaired / .unrepaired the guard set the admin to the mesh's password, or could not
|
|
//
|
|
// Keycloak consumes nothing: it is upstream of everything that authenticates against it.
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
func prop(kind, description string) map[string]any {
|
|
return map[string]any{"type": kind, "description": description}
|
|
}
|
|
|
|
var realmProp = prop("string", "realm name (defaults to the module's realm)")
|
|
|
|
func str(args map[string]any, key string) string {
|
|
switch v := args[key].(type) {
|
|
case string:
|
|
return v
|
|
case nil:
|
|
return ""
|
|
default:
|
|
return fmt.Sprint(v)
|
|
}
|
|
}
|
|
|
|
func flag(args map[string]any, key string) (value, set bool) {
|
|
v, ok := args[key].(bool)
|
|
return v, ok
|
|
}
|
|
|
|
func number(args map[string]any, key string) int {
|
|
switch v := args[key].(type) {
|
|
case float64:
|
|
return int(v)
|
|
case int:
|
|
return v
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func pick(r Rep, keys ...string) Rep {
|
|
out := Rep{}
|
|
for _, k := range keys {
|
|
if v, ok := r[k]; ok {
|
|
out[k] = v
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func bg() (context.Context, context.CancelFunc) {
|
|
return context.WithTimeout(context.Background(), time.Minute)
|
|
}
|
|
|
|
// Tools are keycloak's own; the guard answers keycloak_admin_check.
|
|
func Tools(kc *Client, guard *Guard) []stdio.Tool {
|
|
realmOf := func(args map[string]any) string {
|
|
if r := str(args, "realm"); r != "" {
|
|
return r
|
|
}
|
|
return kc.DefaultRealm
|
|
}
|
|
tool := func(name, description string, input map[string]any, run func(ctx context.Context, args map[string]any) (any, error)) stdio.Tool {
|
|
return stdio.Tool{Name: name, Description: description, Input: input, Run: func(args map[string]any) (any, error) {
|
|
ctx, cancel := bg()
|
|
defer cancel()
|
|
return run(ctx, args)
|
|
}}
|
|
}
|
|
userID := prop("string", "user ID (UUID)")
|
|
|
|
return []stdio.Tool{
|
|
// The guard
|
|
{
|
|
Name: "keycloak_admin_check",
|
|
Description: "Check that Keycloak's admin logs in with the password the mesh minted. With repair: true, a " +
|
|
"refused admin is repaired now — its password set to the mesh's through a temporary bootstrap admin " +
|
|
"inside the container, which is removed again — even inside the brake a failed automatic repair set.",
|
|
Input: map[string]any{"repair": prop("boolean", "repair a refused admin now (default false: only check)")},
|
|
Run: func(args map[string]any) (any, error) {
|
|
repair, _ := flag(args, "repair")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
|
|
defer cancel()
|
|
return guard.Ensure(ctx, repair, true), nil
|
|
},
|
|
},
|
|
|
|
// Realms & sessions
|
|
tool("keycloak_list_realms", "List all Keycloak realms.", map[string]any{},
|
|
func(ctx context.Context, _ map[string]any) (any, error) {
|
|
realms, err := kc.ListRealms(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := []Rep{}
|
|
for _, r := range realms {
|
|
out = append(out, pick(r, "id", "realm", "displayName", "enabled"))
|
|
}
|
|
return map[string]any{"realms": out}, nil
|
|
}),
|
|
tool("keycloak_list_sessions", "List active sessions for a user in a Keycloak realm.",
|
|
map[string]any{"realm": realmProp, "user_id": userID},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
s, err := kc.UserSessions(ctx, realmOf(a), str(a, "user_id"))
|
|
return map[string]any{"sessions": s}, err
|
|
}),
|
|
|
|
// Users
|
|
tool("keycloak_list_users", "List users in a Keycloak realm.",
|
|
map[string]any{"realm": realmProp, "search": prop("string", "search by username, email, first/last name"),
|
|
"max": prop("number", "maximum number of results")},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
u, err := kc.ListUsers(ctx, realmOf(a), str(a, "search"), number(a, "max"))
|
|
return map[string]any{"users": u}, err
|
|
}),
|
|
tool("keycloak_create_user", "Create a user in a Keycloak realm.",
|
|
map[string]any{"realm": realmProp, "username": prop("string", "username"), "email": prop("string", "email address"),
|
|
"password": prop("string", "initial password"),
|
|
"temporary_password": prop("boolean", "require a password change on first login (default true)")},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
realm, username, email := realmOf(a), str(a, "username"), str(a, "email")
|
|
rep := Rep{"username": username}
|
|
if email != "" {
|
|
rep["email"] = email
|
|
}
|
|
if pw := str(a, "password"); pw != "" {
|
|
temporary, set := flag(a, "temporary_password")
|
|
rep["credentials"] = []any{Rep{"type": "password", "value": pw, "temporary": temporary || !set}}
|
|
}
|
|
if err := kc.CreateUser(ctx, realm, rep); err != nil {
|
|
return nil, err
|
|
}
|
|
body := map[string]any{"realm": realm, "username": username}
|
|
if email != "" {
|
|
body["email"] = email
|
|
}
|
|
announce("user.created", body)
|
|
return map[string]any{"created": body}, nil
|
|
}),
|
|
tool("keycloak_delete_user", "Delete a user from a Keycloak realm (requires confirm).",
|
|
map[string]any{"realm": realmProp, "user_id": userID, "confirm": prop("boolean", "must be true to confirm deletion")},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
realm, id := realmOf(a), str(a, "user_id")
|
|
if ok, _ := flag(a, "confirm"); !ok {
|
|
return map[string]any{"aborted": "confirm must be true to delete a user"}, nil
|
|
}
|
|
if err := kc.DeleteUser(ctx, realm, id); err != nil {
|
|
return nil, err
|
|
}
|
|
announce("user.deleted", map[string]any{"realm": realm, "userId": id})
|
|
return map[string]any{"deleted": map[string]any{"realm": realm, "userId": id}}, nil
|
|
}),
|
|
tool("keycloak_update_user", "Update a user's attributes in a Keycloak realm (enable/disable, change email, name).",
|
|
map[string]any{"realm": realmProp, "user_id": userID, "enabled": prop("boolean", "enable or disable the user"),
|
|
"email": prop("string", "new email address"), "firstName": prop("string", "new first name"),
|
|
"lastName": prop("string", "new last name")},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
realm, id := realmOf(a), str(a, "user_id")
|
|
updates := Rep{}
|
|
var fields []string
|
|
if v, set := flag(a, "enabled"); set {
|
|
updates["enabled"], fields = v, append(fields, "enabled")
|
|
}
|
|
for _, k := range []string{"email", "firstName", "lastName"} {
|
|
if _, set := a[k]; set {
|
|
updates[k], fields = str(a, k), append(fields, k)
|
|
}
|
|
}
|
|
if len(updates) == 0 {
|
|
return map[string]any{"aborted": "no updates provided"}, nil
|
|
}
|
|
if err := kc.UpdateUser(ctx, realm, id, updates); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"updated": map[string]any{"realm": realm, "userId": id, "fields": fields}}, nil
|
|
}),
|
|
tool("keycloak_reset_password", "Reset a user's password in a Keycloak realm.",
|
|
map[string]any{"realm": realmProp, "user_id": userID, "password": prop("string", "new password"),
|
|
"temporary": prop("boolean", "require a password change on next login (default false)")},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
realm, id := realmOf(a), str(a, "user_id")
|
|
temporary, _ := flag(a, "temporary")
|
|
if err := kc.ResetPassword(ctx, realm, id, str(a, "password"), temporary); err != nil {
|
|
return nil, err
|
|
}
|
|
announce("password.reset", map[string]any{"realm": realm, "userId": id})
|
|
return map[string]any{"reset": map[string]any{"realm": realm, "userId": id}}, nil
|
|
}),
|
|
|
|
// Clients
|
|
tool("keycloak_list_clients", "List OIDC clients in a Keycloak realm.", map[string]any{"realm": realmProp},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
clients, err := kc.ListClients(ctx, realmOf(a))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := []Rep{}
|
|
for _, c := range clients {
|
|
out = append(out, pick(c, "id", "clientId", "name", "enabled", "protocol", "publicClient", "rootUrl"))
|
|
}
|
|
return map[string]any{"clients": out}, nil
|
|
}),
|
|
tool("keycloak_create_client", "Create an OIDC client in a Keycloak realm.",
|
|
map[string]any{"realm": realmProp, "client_id": prop("string", "client ID (e.g. 'my-app')"),
|
|
"name": prop("string", "display name"), "root_url": prop("string", "root URL of the application"),
|
|
"redirect_uris": prop("array", "allowed redirect URIs"),
|
|
"public_client": prop("boolean", "public client, no client secret (default true)")},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
realm, clientID, name := realmOf(a), str(a, "client_id"), str(a, "name")
|
|
public, set := flag(a, "public_client")
|
|
rep := Rep{"protocol": "openid-connect", "enabled": true, "clientId": clientID, "publicClient": public || !set}
|
|
if name != "" {
|
|
rep["name"] = name
|
|
}
|
|
if u := str(a, "root_url"); u != "" {
|
|
rep["rootUrl"] = u
|
|
}
|
|
if list, ok := a["redirect_uris"].([]any); ok {
|
|
uris := []any{}
|
|
for _, u := range list {
|
|
uris = append(uris, fmt.Sprint(u))
|
|
}
|
|
rep["redirectUris"] = uris
|
|
}
|
|
if err := kc.CreateClient(ctx, realm, rep); err != nil {
|
|
return nil, err
|
|
}
|
|
body := map[string]any{"realm": realm, "clientId": clientID}
|
|
if name != "" {
|
|
body["name"] = name
|
|
}
|
|
announce("client.created", body)
|
|
return map[string]any{"created": body}, nil
|
|
}),
|
|
tool("keycloak_delete_client", "Delete an OIDC client from a Keycloak realm (requires confirm).",
|
|
map[string]any{"realm": realmProp, "client_id": prop("string", "client ID (e.g. 'my-app')"),
|
|
"confirm": prop("boolean", "must be true to confirm deletion")},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
realm, clientID := realmOf(a), str(a, "client_id")
|
|
if ok, _ := flag(a, "confirm"); !ok {
|
|
return map[string]any{"aborted": "confirm must be true to delete a client"}, nil
|
|
}
|
|
if err := kc.DeleteClient(ctx, realm, clientID); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"deleted": map[string]any{"realm": realm, "clientId": clientID}}, nil
|
|
}),
|
|
tool("keycloak_get_client_secret", "Get the client secret for a confidential OIDC client.",
|
|
map[string]any{"realm": realmProp, "client_id": prop("string", "client ID")},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
s, err := kc.GetClientSecret(ctx, realmOf(a), str(a, "client_id"))
|
|
return map[string]any{"secret": s}, err
|
|
}),
|
|
tool("keycloak_add_protocol_mapper",
|
|
"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.",
|
|
map[string]any{"realm": realmProp, "client_id": prop("string", "client ID (e.g. 'grafana')"),
|
|
"name": prop("string", "mapper name (e.g. 'realm roles')"),
|
|
"mapper_type": prop("string", "protocol mapper type (e.g. 'oidc-usermodel-realm-role-mapper')"),
|
|
"claim_name": prop("string", "token claim name (e.g. 'realm_access.roles')"),
|
|
"claim_type": prop("string", "JSON type: String, long, int, boolean (default String)"),
|
|
"multivalued": prop("boolean", "whether the claim has multiple values (default false)"),
|
|
"id_token": prop("boolean", "include in ID token (default true)"),
|
|
"access_token": prop("boolean", "include in access token (default true)"),
|
|
"userinfo": prop("boolean", "include in userinfo response (default true)")},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
realm, clientID, name := realmOf(a), str(a, "client_id"), str(a, "name")
|
|
claimType := str(a, "claim_type")
|
|
if claimType == "" {
|
|
claimType = "String"
|
|
}
|
|
on := func(key string) string {
|
|
v, set := flag(a, key)
|
|
return fmt.Sprint(v || !set)
|
|
}
|
|
multi, _ := flag(a, "multivalued")
|
|
err := kc.AddProtocolMapper(ctx, realm, clientID, Rep{
|
|
"name": name, "protocolMapper": str(a, "mapper_type"),
|
|
"config": map[string]any{
|
|
"claim.name": str(a, "claim_name"), "jsonType.label": claimType,
|
|
"multivalued": fmt.Sprint(multi), "id.token.claim": on("id_token"),
|
|
"access.token.claim": on("access_token"), "userinfo.token.claim": on("userinfo"),
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"added": map[string]any{"realm": realm, "clientId": clientID, "mapper": name}}, nil
|
|
}),
|
|
|
|
// Groups
|
|
tool("keycloak_list_groups", "List groups in a Keycloak realm.", map[string]any{"realm": realmProp},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
g, err := kc.ListGroups(ctx, realmOf(a))
|
|
return map[string]any{"groups": g}, err
|
|
}),
|
|
tool("keycloak_create_group", "Create a group in a Keycloak realm.",
|
|
map[string]any{"realm": realmProp, "name": prop("string", "group name")},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
realm, name := realmOf(a), str(a, "name")
|
|
if err := kc.CreateGroup(ctx, realm, name); err != nil {
|
|
return nil, err
|
|
}
|
|
announce("group.created", map[string]any{"realm": realm, "name": name})
|
|
return map[string]any{"created": map[string]any{"realm": realm, "group": name}}, nil
|
|
}),
|
|
tool("keycloak_get_user_groups", "List the groups a user belongs to in a Keycloak realm.",
|
|
map[string]any{"realm": realmProp, "user_id": userID},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
g, err := kc.UserGroups(ctx, realmOf(a), str(a, "user_id"))
|
|
return map[string]any{"groups": g}, err
|
|
}),
|
|
tool("keycloak_add_user_to_group", "Add a user to a group in a Keycloak realm.",
|
|
map[string]any{"realm": realmProp, "user_id": userID, "group_id": prop("string", "group ID (UUID)")},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
realm := realmOf(a)
|
|
if err := kc.AddUserToGroup(ctx, realm, str(a, "user_id"), str(a, "group_id")); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"added": map[string]any{"realm": realm, "userId": str(a, "user_id"), "groupId": str(a, "group_id")}}, nil
|
|
}),
|
|
tool("keycloak_remove_user_from_group", "Remove a user from a group in a Keycloak realm.",
|
|
map[string]any{"realm": realmProp, "user_id": userID, "group_id": prop("string", "group ID (UUID)")},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
realm := realmOf(a)
|
|
if err := kc.RemoveUserFromGroup(ctx, realm, str(a, "user_id"), str(a, "group_id")); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"removed": map[string]any{"realm": realm, "userId": str(a, "user_id"), "groupId": str(a, "group_id")}}, nil
|
|
}),
|
|
|
|
// Roles
|
|
tool("keycloak_get_user_roles", "List the realm roles assigned to a user in a Keycloak realm.",
|
|
map[string]any{"realm": realmProp, "user_id": userID},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
r, err := kc.UserRealmRoles(ctx, realmOf(a), str(a, "user_id"))
|
|
return map[string]any{"roles": r}, err
|
|
}),
|
|
tool("keycloak_create_role", "Create a realm role in a Keycloak realm.",
|
|
map[string]any{"realm": realmProp, "role_name": prop("string", "role name"), "description": prop("string", "role description")},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
realm, name := realmOf(a), str(a, "role_name")
|
|
rep := Rep{"name": name}
|
|
if d := str(a, "description"); d != "" {
|
|
rep["description"] = d
|
|
}
|
|
if err := kc.CreateRealmRole(ctx, realm, rep); err != nil {
|
|
return nil, err
|
|
}
|
|
announce("role.created", map[string]any{"realm": realm, "name": name})
|
|
return map[string]any{"created": map[string]any{"realm": realm, "role": name}}, nil
|
|
}),
|
|
tool("keycloak_assign_user_role", "Assign an existing realm role to a user. Create it first with keycloak_create_role if needed.",
|
|
map[string]any{"realm": realmProp, "user_id": userID, "role_name": prop("string", "role name to assign")},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
realm, id, role := realmOf(a), str(a, "user_id"), str(a, "role_name")
|
|
// The mapping API needs the role's UUID, which only the "available" list carries; a role
|
|
// neither available nor assigned does not exist in this realm.
|
|
available, err := kc.AvailableRealmRoles(ctx, realm, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range available {
|
|
if r["name"] == role {
|
|
if err := kc.AssignRealmRoles(ctx, realm, id, []Rep{pick(r, "id", "name")}); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"assigned": map[string]any{"realm": realm, "userId": id, "role": role}}, nil
|
|
}
|
|
}
|
|
assigned, err := kc.UserRealmRoles(ctx, realm, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range assigned {
|
|
if r["name"] == role {
|
|
return map[string]any{"alreadyAssigned": map[string]any{"realm": realm, "userId": id, "role": role}}, nil
|
|
}
|
|
}
|
|
return map[string]any{"notFound": map[string]any{"realm": realm, "role": role}}, nil
|
|
}),
|
|
tool("keycloak_remove_user_role", "Remove a realm role from a user in a Keycloak realm.",
|
|
map[string]any{"realm": realmProp, "user_id": userID, "role_name": prop("string", "role name to remove")},
|
|
func(ctx context.Context, a map[string]any) (any, error) {
|
|
realm, id, role := realmOf(a), str(a, "user_id"), str(a, "role_name")
|
|
assigned, err := kc.UserRealmRoles(ctx, realm, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range assigned {
|
|
if r["name"] == role {
|
|
if err := kc.RemoveRealmRoles(ctx, realm, id, []Rep{pick(r, "id", "name")}); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"removed": map[string]any{"realm": realm, "userId": id, "role": role}}, nil
|
|
}
|
|
}
|
|
return map[string]any{"notAssigned": map[string]any{"realm": realm, "userId": id, "role": role}}, nil
|
|
}),
|
|
}
|
|
}
|