All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 40s
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
518 lines
18 KiB
Go
518 lines
18 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"
|
|
"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.MFAOptional {
|
|
kc.Attributes["keycape.mfaOptional"] = "true"
|
|
unpreserved = append(unpreserved, fmt.Sprintf("client %q: mfaOptional requires a manually verified conditional MFA authentication flow; enrollment policy is not enforced by import", c.ClientID))
|
|
}
|
|
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
|
|
}
|