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:
parent
3ee8090a98
commit
fa27adbc77
6 changed files with 1428 additions and 4 deletions
278
src/internal/migration/tokeycloak/transformer.go
Normal file
278
src/internal/migration/tokeycloak/transformer.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue