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