key-cape/src/internal/migration/tokeycloak/transformer.go
tegwick ac8ed65203
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 40s
Support opt-in MFA per browser client with authoritative enrollment checks
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
2026-09-13 00:27:28 +02:00

518 lines
18 KiB
Go

// Package tokeycloak transforms a canonical KeyCape export into a Keycloak realm
// import JSON file (spec §7 — migration contract, Keycloak expansion path).
package tokeycloak
import (
"context"
"fmt"
"sort"
"strings"
"time"
"keycape/internal/domain"
"keycape/internal/migration/lldapexport"
"keycape/internal/server/telemetry"
)
// ---------------------------------------------------------------------------
// Keycloak realm import types
// ---------------------------------------------------------------------------
// KeycloakRealm is the top-level realm import JSON structure.
type KeycloakRealm struct {
Realm string `json:"realm"`
DisplayName string `json:"displayName,omitempty"`
Enabled bool `json:"enabled"`
SsoSessionMaxLifespan int `json:"ssoSessionMaxLifespan,omitempty"`
DefaultSignatureAlgorithm string `json:"defaultSignatureAlgorithm,omitempty"`
IdentityProviders []interface{} `json:"identityProviders"`
Clients []KeycloakClient `json:"clients"`
Users []KeycloakUser `json:"users"`
Groups []KeycloakGroup `json:"groups"`
Roles KeycloakRoles `json:"roles"`
ClientScopes []KeycloakClientScope `json:"clientScopes"`
}
// KeycloakClient represents a registered client in the Keycloak realm.
type KeycloakClient struct {
ClientID string `json:"clientId"`
Name string `json:"name,omitempty"`
Enabled bool `json:"enabled"`
PublicClient bool `json:"publicClient"`
StandardFlowEnabled bool `json:"standardFlowEnabled"`
ImplicitFlowEnabled bool `json:"implicitFlowEnabled"`
DirectAccessGrantsEnabled bool `json:"directAccessGrantsEnabled"`
ServiceAccountsEnabled bool `json:"serviceAccountsEnabled"`
RedirectUris []string `json:"redirectUris"`
DefaultClientScopes []string `json:"defaultClientScopes"`
Attributes map[string]string `json:"attributes,omitempty"`
ProtocolMappers []KeycloakProtocolMapper `json:"protocolMappers,omitempty"`
}
// KeycloakProtocolMapper carries a claim into the tokens Keycloak issues.
// KeyCape emits the claims of the NetKingdom IAM profile that Keycloak has no
// native concept for — tenant, principal type, resource audience — as mappers,
// since without them a migrated realm issues tokens the profile does not accept.
type KeycloakProtocolMapper struct {
Name string `json:"name"`
Protocol string `json:"protocol"`
ProtocolMapper string `json:"protocolMapper"`
Config map[string]string `json:"config"`
}
// KeycloakUser represents a user in the Keycloak realm.
type KeycloakUser struct {
Username string `json:"username"`
Email string `json:"email,omitempty"`
FirstName string `json:"firstName,omitempty"`
LastName string `json:"lastName,omitempty"`
Enabled bool `json:"enabled"`
Groups []string `json:"groups,omitempty"`
RealmRoles []string `json:"realmRoles,omitempty"`
Credentials []KeycloakCredential `json:"credentials,omitempty"`
Attributes map[string][]string `json:"attributes,omitempty"`
}
// KeycloakCredential holds a single credential entry (e.g. hashed password placeholder).
type KeycloakCredential struct {
Type string `json:"type"`
Value string `json:"value"`
Temporary bool `json:"temporary"`
}
// KeycloakGroup represents a user group in the Keycloak realm.
type KeycloakGroup struct {
Name string `json:"name"`
Path string `json:"path"`
Attributes map[string][]string `json:"attributes,omitempty"`
}
// KeycloakRoles is the realm-level roles container.
type KeycloakRoles struct {
Realm []KeycloakRole `json:"realm"`
}
// KeycloakRole represents a single realm role.
type KeycloakRole struct {
Name string `json:"name"`
}
// KeycloakClientScope represents a client scope in the realm.
type KeycloakClientScope struct {
Name string `json:"name"`
Protocol string `json:"protocol"`
}
// ---------------------------------------------------------------------------
// Transformer
// ---------------------------------------------------------------------------
// Config holds realm-level configuration for the transformation.
type Config struct {
RealmName string
Issuer string
}
// Transformer converts a canonical lldapexport.ExportResult into a KeycloakRealm.
type Transformer struct {
cfg Config
emitter telemetry.Emitter
// unpreserved accumulates every field the last transform could not carry
// into the realm. ValidationReport surfaces it.
unpreserved []string
}
// New creates a new Transformer with the given configuration and telemetry emitter.
func New(cfg Config, emitter telemetry.Emitter) *Transformer {
return &Transformer{cfg: cfg, emitter: emitter}
}
// Transform converts a canonical export with no client registrations. The
// resulting realm has no clients, which is recorded as an unpreserved item —
// an empty clients array is otherwise indistinguishable from a realm that
// genuinely has none.
func (t *Transformer) Transform(export *lldapexport.ExportResult) (*KeycloakRealm, error) {
return t.TransformWithClients(export, nil)
}
// TransformWithClients converts a canonical export plus an explicit client list
// into a Keycloak realm import structure.
func (t *Transformer) TransformWithClients(export *lldapexport.ExportResult, clients []domain.Client) (*KeycloakRealm, error) {
t.unpreserved = nil
realm := &KeycloakRealm{
Realm: t.cfg.RealmName,
Enabled: true,
IdentityProviders: []interface{}{},
}
// ProfileVersion "0.1" → RS256.
if export.ProfileVersion == "0.1" {
realm.DefaultSignatureAlgorithm = "RS256"
}
// Map users.
realm.Users = make([]KeycloakUser, 0, len(export.Users))
for _, u := range export.Users {
realm.Users = append(realm.Users, mapUser(u))
}
// Map groups.
realm.Groups = make([]KeycloakGroup, 0, len(export.Groups))
for _, g := range export.Groups {
realm.Groups = append(realm.Groups, mapGroup(g))
}
// Map clients, collecting every registration field that could not be
// carried across so the caller can report it.
realm.Clients = make([]KeycloakClient, 0, len(clients))
for _, c := range clients {
mapped, dropped := mapClient(c)
realm.Clients = append(realm.Clients, mapped)
t.unpreserved = append(t.unpreserved, dropped...)
}
if len(clients) == 0 {
t.unpreserved = append(t.unpreserved,
"no client registrations were supplied: the realm carries no clients, so the service-identity, "+
"audience, tenant/role, MFA and lifetime contract is not migrated")
}
// Realm roles and client scopes are derived from the identities and
// registrations actually present. Emitting empty containers made a realm
// that drops every role look like one that has none.
realm.Roles = KeycloakRoles{Realm: realmRoles(export.Users, clients)}
realm.ClientScopes = clientScopes(clients)
// Credential material is out of scope for an artifact generator, but its
// absence must be stated: an operator who imports this realm and finds
// nobody can log in should learn that here, not there.
if len(export.Users) > 0 {
t.unpreserved = append(t.unpreserved,
"passwords and MFA credentials are not migrated: every user in this realm requires credential "+
"re-establishment and factor re-enrolment")
t.unpreserved = append(t.unpreserved,
"subject continuity is not established: the canonical ID is an LLDAP DN carried as the "+
"keycape.canonicalId attribute, while Keycloak mints its own sub, so relying parties keyed "+
"on sub will not recognise migrated users")
}
// Emit migration telemetry. A transform that could not carry part of the
// contract is partial, matching what the LLDAP export reports (KEY-WP-0018):
// an operator scanning events should not see "success" for an artifact that
// still needs manual work.
outcome := "success"
if len(t.unpreserved) > 0 {
outcome = "partial"
}
t.emitter.Emit(context.Background(), telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventMigration,
Endpoint: "keycape-to-keycloak",
Result: outcome,
})
return realm, nil
}
// UnpreservedReport lists what the last transform could not carry into the
// realm. These are not defects in the generated file — they are the parts of the
// KeyCape contract an operator still has to establish by hand.
//
// It is deliberately separate from ValidationReport: an empty validation report
// means the realm is consistent with the canonical data, which is not the same
// as a complete migration, and collapsing the two would make one of those
// questions unanswerable.
func (t *Transformer) UnpreservedReport() []string {
return t.unpreserved
}
// ValidationReport compares a canonical export against a produced Keycloak realm
// and returns a list of incompatibility descriptions.
// An empty slice means the import is consistent with the canonical data — see
// UnpreservedReport for what consistency does not cover.
func (t *Transformer) ValidationReport(export *lldapexport.ExportResult, realm *KeycloakRealm) []string {
var issues []string
// Any pre-existing incompatibilities from the canonical export propagate.
for _, inc := range export.IncompatibilityReport {
issues = append(issues, "canonical export incompatibility: "+inc)
}
// User count must match.
if len(realm.Users) != len(export.Users) {
issues = append(issues, "user count mismatch: canonical has "+
itoa(len(export.Users))+" users but realm has "+itoa(len(realm.Users)))
}
// Group count must match.
if len(realm.Groups) != len(export.Groups) {
issues = append(issues, "group count mismatch: canonical has "+
itoa(len(export.Groups))+" groups but realm has "+itoa(len(realm.Groups)))
}
// Identity providers must be empty per the NetKingdom IAM profile.
if len(realm.IdentityProviders) != 0 {
issues = append(issues, "identity providers must be empty per NetKingdom IAM profile")
}
return issues
}
// ---------------------------------------------------------------------------
// Mapping helpers
// ---------------------------------------------------------------------------
// mapUser carries a canonical user into a Keycloak user. The canonical ID and
// tenant become attributes: Keycloak mints its own sub, so the KeyCape subject
// survives only as data something downstream can map, never as the subject.
func mapUser(u domain.User) KeycloakUser {
ku := KeycloakUser{
Username: u.Username,
Email: u.Email,
Enabled: u.Enabled,
RealmRoles: u.Roles,
Attributes: map[string][]string{},
}
if u.ID != "" {
ku.Attributes["keycape.canonicalId"] = []string{u.ID}
}
if u.Tenant != "" {
ku.Attributes["tenant"] = []string{u.Tenant}
}
if len(ku.Attributes) == 0 {
ku.Attributes = nil
}
// Split DisplayName at first space → FirstName + LastName.
ku.FirstName, ku.LastName = splitDisplayName(u.DisplayName)
// Convert group names to Keycloak paths: "/groupname".
if len(u.Groups) > 0 {
ku.Groups = make([]string, len(u.Groups))
for i, g := range u.Groups {
ku.Groups[i] = "/" + g
}
}
return ku
}
func mapGroup(g domain.Group) KeycloakGroup {
return KeycloakGroup{
Name: g.Name,
Path: "/" + g.Name,
}
}
// mapClient carries a KeyCape registration into a Keycloak client, and appends
// to unpreserved every field it could not represent. A caller that ignores the
// second return value produces a realm that silently differs from the KeyCape
// contract, which is the defect this signature exists to make awkward.
func mapClient(c domain.Client) (KeycloakClient, []string) {
var unpreserved []string
kc := KeycloakClient{
ClientID: c.ClientID,
Name: c.DisplayName,
Enabled: true,
PublicClient: c.ClientType == "public",
// Flows follow the declared grants. Enabling the standard flow for a
// service-only client would widen it during migration.
StandardFlowEnabled: hasGrant(c.GrantTypes, "authorization_code"),
ServiceAccountsEnabled: hasGrant(c.GrantTypes, "client_credentials"),
ImplicitFlowEnabled: false, // never — per NetKingdom IAM profile
DirectAccessGrantsEnabled: false, // never — per NetKingdom IAM profile
RedirectUris: c.RedirectURIs,
DefaultClientScopes: c.AllowedScopes,
Attributes: map[string]string{},
}
if kc.RedirectUris == nil {
kc.RedirectUris = []string{}
}
if kc.DefaultClientScopes == nil {
kc.DefaultClientScopes = []string{}
}
// The resource audience is a claim Keycloak will not produce on its own.
if c.Audience != "" {
kc.ProtocolMappers = append(kc.ProtocolMappers, audienceMapper(c.Audience))
}
if c.Tenant != "" {
kc.ProtocolMappers = append(kc.ProtocolMappers, hardcodedClaim("tenant", c.Tenant))
}
if c.ServiceSubject != "" {
// Keycloak derives a service account's subject from its own user; the
// configured subject cannot be imposed, so carry it as a mapper and
// say that the subject itself will differ.
kc.ProtocolMappers = append(kc.ProtocolMappers, hardcodedClaim("service_subject", c.ServiceSubject))
unpreserved = append(unpreserved, fmt.Sprintf(
"client %q: serviceSubject %q is carried as a claim, but Keycloak mints its own service-account sub; "+
"tokens after migration will not carry the KeyCape subject",
c.ClientID, c.ServiceSubject))
}
if len(c.Roles) > 0 {
kc.ProtocolMappers = append(kc.ProtocolMappers, hardcodedClaim("roles", strings.Join(c.Roles, " ")))
}
if c.TokenLifetime > 0 {
kc.Attributes["access.token.lifespan"] = itoa(int(c.TokenLifetime.Seconds()))
}
if c.SecretRef != "" {
// The reference, never the value: a realm import file is not a secret
// custody boundary.
kc.Attributes["keycape.secretRef"] = c.SecretRef
unpreserved = append(unpreserved, fmt.Sprintf(
"client %q: secret is not migrated; secretRef %q is recorded as an attribute and must be "+
"resolved and set on the Keycloak client out of band",
c.ClientID, c.SecretRef))
}
if c.RegistrationURL != "" {
kc.Attributes["keycape.registrationUrl"] = c.RegistrationURL
}
if c.EnrollmentURL != "" {
kc.Attributes["keycape.enrollmentUrl"] = c.EnrollmentURL
}
if c.MFAOptional {
kc.Attributes["keycape.mfaOptional"] = "true"
unpreserved = append(unpreserved, fmt.Sprintf("client %q: mfaOptional requires a manually verified conditional MFA authentication flow; enrollment policy is not enforced by import", c.ClientID))
}
if c.MFARequired != nil && *c.MFARequired {
// Keycloak expresses this as an authentication flow binding, which a
// realm import cannot synthesise from a boolean.
kc.Attributes["keycape.mfaRequired"] = "true"
unpreserved = append(unpreserved, fmt.Sprintf(
"client %q: mfaRequired is not enforceable by import; a Keycloak authentication flow "+
"requiring a second factor must be bound to this client manually",
c.ClientID))
}
if len(kc.Attributes) == 0 {
kc.Attributes = nil
}
return kc, unpreserved
}
// hasGrant reports whether the registration declares the given grant type.
func hasGrant(grants []string, want string) bool {
for _, g := range grants {
if g == want {
return true
}
}
return false
}
// audienceMapper produces the resource audience on the access token, matching
// KeyCape's split where the ID token keeps the client audience.
func audienceMapper(audience string) KeycloakProtocolMapper {
return KeycloakProtocolMapper{
Name: "keycape-audience",
Protocol: "openid-connect",
ProtocolMapper: "oidc-audience-mapper",
Config: map[string]string{
"included.custom.audience": audience,
"access.token.claim": "true",
"id.token.claim": "false",
},
}
}
// hardcodedClaim produces a fixed claim on both tokens.
func hardcodedClaim(claim, value string) KeycloakProtocolMapper {
return KeycloakProtocolMapper{
Name: "keycape-" + claim,
Protocol: "openid-connect",
ProtocolMapper: "oidc-hardcoded-claim-mapper",
Config: map[string]string{
"claim.name": claim,
"claim.value": value,
"access.token.claim": "true",
"id.token.claim": "true",
"jsonType.label": "String",
},
}
}
// splitDisplayName splits a display name at the first space.
// "Alice Liddell" → ("Alice", "Liddell")
// "Bob" → ("Bob", "")
// "Alice M Smith" → ("Alice", "M Smith")
func splitDisplayName(displayName string) (first, last string) {
idx := strings.Index(displayName, " ")
if idx < 0 {
return displayName, ""
}
return displayName[:idx], displayName[idx+1:]
}
// itoa converts an int to its decimal string representation without importing strconv.
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
buf := make([]byte, 0, 10)
for n > 0 {
buf = append([]byte{byte('0' + n%10)}, buf...)
n /= 10
}
if neg {
buf = append([]byte{'-'}, buf...)
}
return string(buf)
}
// realmRoles collects every role named by a user or a client registration.
// Roles referenced by a token but absent from the realm would be silently
// dropped by Keycloak at issuance.
func realmRoles(users []domain.User, clients []domain.Client) []KeycloakRole {
seen := map[string]bool{}
var names []string
add := func(role string) {
if role == "" || seen[role] {
return
}
seen[role] = true
names = append(names, role)
}
for _, u := range users {
for _, r := range u.Roles {
add(r)
}
}
for _, c := range clients {
for _, r := range c.Roles {
add(r)
}
}
sort.Strings(names)
roles := make([]KeycloakRole, 0, len(names))
for _, n := range names {
roles = append(roles, KeycloakRole{Name: n})
}
return roles
}
// clientScopes collects every scope any registration allows, so the realm can
// grant what KeyCape granted.
func clientScopes(clients []domain.Client) []KeycloakClientScope {
seen := map[string]bool{}
var names []string
for _, c := range clients {
for _, s := range c.AllowedScopes {
if s == "" || seen[s] {
continue
}
seen[s] = true
names = append(names, s)
}
}
sort.Strings(names)
scopes := make([]KeycloakClientScope, 0, len(names))
for _, n := range names {
scopes = append(scopes, KeycloakClientScope{Name: n, Protocol: "openid-connect"})
}
return scopes
}