// 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.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 }