feat: implement T16, T17 — Keycloak realm import transformer, LDIF generator

- T16: canonical → Keycloak realm JSON (profile-safe: no identity brokering, implicit flow always false)
- T17: canonical → LDIF for openldap/389ds/ad targets with pre-validation

27 migration tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-03-13 02:13:04 +01:00
parent 3ee8090a98
commit fa27adbc77
6 changed files with 1428 additions and 4 deletions

View file

@ -0,0 +1,278 @@
// 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"
"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"`
RedirectUris []string `json:"redirectUris"`
DefaultClientScopes []string `json:"defaultClientScopes"`
}
// 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"`
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
}
// 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 to a Keycloak realm import.
// It maps users, groups, and emits migration_event telemetry.
// Clients default to an empty slice; use TransformWithClients to include them.
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) {
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.
realm.Clients = make([]KeycloakClient, 0, len(clients))
for _, c := range clients {
realm.Clients = append(realm.Clients, mapClient(c))
}
// Roles and scopes — empty in base migration; can be extended.
realm.Roles = KeycloakRoles{Realm: []KeycloakRole{}}
realm.ClientScopes = []KeycloakClientScope{}
// Emit migration telemetry.
t.emitter.Emit(context.Background(), telemetry.Event{
Timestamp: time.Now().UTC(),
EventType: telemetry.EventMigration,
Endpoint: "keycape-to-keycloak",
Result: "success",
})
return realm, nil
}
// 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.
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
// ---------------------------------------------------------------------------
func mapUser(u domain.User) KeycloakUser {
ku := KeycloakUser{
Username: u.Username,
Email: u.Email,
Enabled: u.Enabled,
}
// 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,
}
}
func mapClient(c domain.Client) KeycloakClient {
kc := KeycloakClient{
ClientID: c.ClientID,
Name: c.DisplayName,
Enabled: true,
PublicClient: c.ClientType == "public",
StandardFlowEnabled: true, // authorization_code always enabled
ImplicitFlowEnabled: false, // never — per NetKingdom IAM profile
DirectAccessGrantsEnabled: false, // never — per NetKingdom IAM profile
RedirectUris: c.RedirectURIs,
DefaultClientScopes: c.AllowedScopes,
}
if kc.RedirectUris == nil {
kc.RedirectUris = []string{}
}
if kc.DefaultClientScopes == nil {
kc.DefaultClientScopes = []string{}
}
return kc
}
// 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)
}