279 lines
9.1 KiB
Go
279 lines
9.1 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"
|
||
|
|
"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)
|
||
|
|
}
|