key-cape/src/cmd/keycape-to-keycloak/main.go

105 lines
3.3 KiB
Go
Raw Normal View History

// keycape-to-keycloak migrates a KeyCape canonical snapshot to a Keycloak
// realm export format. Part of the NetKingdom IAM migration contract.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"github.com/rs/zerolog"
"gopkg.in/yaml.v3"
Make the Keycloak transform preserve or name every policy field keycape-to-keycloak called Transform, which passes no clients, so it wrote a realm with an empty clients array and nothing said the service-identity contract had not been migrated. Where clients were supplied, mapClient hardcoded standardFlowEnabled — silently giving every client_credentials registration the browser flow — and dropped audience, service subject, tenant, roles, lifetime, MFA policy, secret reference and handoff URLs. Realm roles and client scopes were emitted as empty containers. The defect was not the missing mapping but that a dropped field and an inapplicable one looked identical in the output. Add -clients, reading registrations through a new config.Registrations() that converts without resolving secrets, so migration tooling cannot load material it has no business holding. Derive flows from the declared grants. Carry the profile claims as protocol mappers, since Keycloak has no native concept for them, and lifetime, handoff URLs and the secret reference as attributes — the reference, never a value. Derive realm roles and client scopes from what is present. Report what cannot be carried, in UnpreservedReport, kept deliberately separate from ValidationReport: consistency with the snapshot and completeness of the migration are different questions and one list cannot answer both. It names the unmigrated secret, the unenforceable MFA policy, passwords and factor enrolment, and subject continuity. An incomplete transform emits partial telemetry. Closes the semantic-preservation half of G03; proof against a live provider is G04 and stays open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
2026-09-07 13:48:48 +02:00
"keycape/internal/config"
"keycape/internal/domain"
"keycape/internal/migration/lldapexport"
"keycape/internal/migration/tokeycloak"
"keycape/internal/server/telemetry"
)
func main() {
inputFile := flag.String("input", "", "Path to canonical-export.yaml (required)")
outputFile := flag.String("output", "keycloak-realm.json", "Path to write Keycloak realm import JSON")
realmName := flag.String("realm", "netkingdom", "Keycloak realm name")
issuer := flag.String("issuer", "", "OIDC issuer URL")
Make the Keycloak transform preserve or name every policy field keycape-to-keycloak called Transform, which passes no clients, so it wrote a realm with an empty clients array and nothing said the service-identity contract had not been migrated. Where clients were supplied, mapClient hardcoded standardFlowEnabled — silently giving every client_credentials registration the browser flow — and dropped audience, service subject, tenant, roles, lifetime, MFA policy, secret reference and handoff URLs. Realm roles and client scopes were emitted as empty containers. The defect was not the missing mapping but that a dropped field and an inapplicable one looked identical in the output. Add -clients, reading registrations through a new config.Registrations() that converts without resolving secrets, so migration tooling cannot load material it has no business holding. Derive flows from the declared grants. Carry the profile claims as protocol mappers, since Keycloak has no native concept for them, and lifetime, handoff URLs and the secret reference as attributes — the reference, never a value. Derive realm roles and client scopes from what is present. Report what cannot be carried, in UnpreservedReport, kept deliberately separate from ValidationReport: consistency with the snapshot and completeness of the migration are different questions and one list cannot answer both. It names the unmigrated secret, the unenforceable MFA policy, passwords and factor enrolment, and subject continuity. An incomplete transform emits partial telemetry. Closes the semantic-preservation half of G03; proof against a live provider is G04 and stays open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
2026-09-07 13:48:48 +02:00
clientsFile := flag.String("clients", "", "Path to the KeyCape config whose client registrations to migrate")
flag.Parse()
if *inputFile == "" {
fmt.Fprintln(os.Stderr, "keycape-to-keycloak: -input is required")
flag.Usage()
os.Exit(1)
}
data, err := os.ReadFile(*inputFile)
if err != nil {
fmt.Fprintf(os.Stderr, "keycape-to-keycloak: read %q: %v\n", *inputFile, err)
os.Exit(1)
}
var export lldapexport.ExportResult
if err := yaml.Unmarshal(data, &export); err != nil {
fmt.Fprintf(os.Stderr, "keycape-to-keycloak: parse YAML: %v\n", err)
os.Exit(1)
}
Make the Keycloak transform preserve or name every policy field keycape-to-keycloak called Transform, which passes no clients, so it wrote a realm with an empty clients array and nothing said the service-identity contract had not been migrated. Where clients were supplied, mapClient hardcoded standardFlowEnabled — silently giving every client_credentials registration the browser flow — and dropped audience, service subject, tenant, roles, lifetime, MFA policy, secret reference and handoff URLs. Realm roles and client scopes were emitted as empty containers. The defect was not the missing mapping but that a dropped field and an inapplicable one looked identical in the output. Add -clients, reading registrations through a new config.Registrations() that converts without resolving secrets, so migration tooling cannot load material it has no business holding. Derive flows from the declared grants. Carry the profile claims as protocol mappers, since Keycloak has no native concept for them, and lifetime, handoff URLs and the secret reference as attributes — the reference, never a value. Derive realm roles and client scopes from what is present. Report what cannot be carried, in UnpreservedReport, kept deliberately separate from ValidationReport: consistency with the snapshot and completeness of the migration are different questions and one list cannot answer both. It names the unmigrated secret, the unenforceable MFA policy, passwords and factor enrolment, and subject continuity. An incomplete transform emits partial telemetry. Closes the semantic-preservation half of G03; proof against a live provider is G04 and stays open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
2026-09-07 13:48:48 +02:00
// Client registrations are read from the KeyCape config, not the directory
// snapshot: the snapshot carries identities, while the service-identity,
// audience, tenant/role, MFA and lifetime contract lives in the config.
var clients []domain.Client
if *clientsFile != "" {
cfg, cfgErr := config.Load(*clientsFile)
if cfgErr != nil {
fmt.Fprintf(os.Stderr, "keycape-to-keycloak: read clients %q: %v\n", *clientsFile, cfgErr)
os.Exit(1)
}
clients, cfgErr = cfg.Registrations()
if cfgErr != nil {
fmt.Fprintf(os.Stderr, "keycape-to-keycloak: %v\n", cfgErr)
os.Exit(1)
}
}
log := zerolog.New(os.Stderr).With().Timestamp().Logger()
em := telemetry.NewLogEmitter(log)
tr := tokeycloak.New(tokeycloak.Config{
RealmName: *realmName,
Issuer: *issuer,
}, em)
Make the Keycloak transform preserve or name every policy field keycape-to-keycloak called Transform, which passes no clients, so it wrote a realm with an empty clients array and nothing said the service-identity contract had not been migrated. Where clients were supplied, mapClient hardcoded standardFlowEnabled — silently giving every client_credentials registration the browser flow — and dropped audience, service subject, tenant, roles, lifetime, MFA policy, secret reference and handoff URLs. Realm roles and client scopes were emitted as empty containers. The defect was not the missing mapping but that a dropped field and an inapplicable one looked identical in the output. Add -clients, reading registrations through a new config.Registrations() that converts without resolving secrets, so migration tooling cannot load material it has no business holding. Derive flows from the declared grants. Carry the profile claims as protocol mappers, since Keycloak has no native concept for them, and lifetime, handoff URLs and the secret reference as attributes — the reference, never a value. Derive realm roles and client scopes from what is present. Report what cannot be carried, in UnpreservedReport, kept deliberately separate from ValidationReport: consistency with the snapshot and completeness of the migration are different questions and one list cannot answer both. It names the unmigrated secret, the unenforceable MFA policy, passwords and factor enrolment, and subject continuity. An incomplete transform emits partial telemetry. Closes the semantic-preservation half of G03; proof against a live provider is G04 and stays open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
2026-09-07 13:48:48 +02:00
realm, err := tr.TransformWithClients(&export, clients)
if err != nil {
fmt.Fprintf(os.Stderr, "keycape-to-keycloak: transform: %v\n", err)
os.Exit(1)
}
Make the Keycloak transform preserve or name every policy field keycape-to-keycloak called Transform, which passes no clients, so it wrote a realm with an empty clients array and nothing said the service-identity contract had not been migrated. Where clients were supplied, mapClient hardcoded standardFlowEnabled — silently giving every client_credentials registration the browser flow — and dropped audience, service subject, tenant, roles, lifetime, MFA policy, secret reference and handoff URLs. Realm roles and client scopes were emitted as empty containers. The defect was not the missing mapping but that a dropped field and an inapplicable one looked identical in the output. Add -clients, reading registrations through a new config.Registrations() that converts without resolving secrets, so migration tooling cannot load material it has no business holding. Derive flows from the declared grants. Carry the profile claims as protocol mappers, since Keycloak has no native concept for them, and lifetime, handoff URLs and the secret reference as attributes — the reference, never a value. Derive realm roles and client scopes from what is present. Report what cannot be carried, in UnpreservedReport, kept deliberately separate from ValidationReport: consistency with the snapshot and completeness of the migration are different questions and one list cannot answer both. It names the unmigrated secret, the unenforceable MFA policy, passwords and factor enrolment, and subject continuity. An incomplete transform emits partial telemetry. Closes the semantic-preservation half of G03; proof against a live provider is G04 and stays open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
2026-09-07 13:48:48 +02:00
// Consistency problems and migration limits are printed separately: the
// first says the generated realm disagrees with the snapshot, the second
// says what this tool never carries across. Collapsing them would let a
// reader mistake a complete-looking realm for a complete migration.
for _, issue := range tr.ValidationReport(&export, realm) {
fmt.Fprintf(os.Stderr, "WARNING: %s\n", issue)
}
Make the Keycloak transform preserve or name every policy field keycape-to-keycloak called Transform, which passes no clients, so it wrote a realm with an empty clients array and nothing said the service-identity contract had not been migrated. Where clients were supplied, mapClient hardcoded standardFlowEnabled — silently giving every client_credentials registration the browser flow — and dropped audience, service subject, tenant, roles, lifetime, MFA policy, secret reference and handoff URLs. Realm roles and client scopes were emitted as empty containers. The defect was not the missing mapping but that a dropped field and an inapplicable one looked identical in the output. Add -clients, reading registrations through a new config.Registrations() that converts without resolving secrets, so migration tooling cannot load material it has no business holding. Derive flows from the declared grants. Carry the profile claims as protocol mappers, since Keycloak has no native concept for them, and lifetime, handoff URLs and the secret reference as attributes — the reference, never a value. Derive realm roles and client scopes from what is present. Report what cannot be carried, in UnpreservedReport, kept deliberately separate from ValidationReport: consistency with the snapshot and completeness of the migration are different questions and one list cannot answer both. It names the unmigrated secret, the unenforceable MFA policy, passwords and factor enrolment, and subject continuity. An incomplete transform emits partial telemetry. Closes the semantic-preservation half of G03; proof against a live provider is G04 and stays open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WAsfsfQmDu4vcBhiMcmQp Assistant: claude-code Assistant-Model: opus Assistant-Process: 867844@bnt-lap001 Assistant-Session: 3d45905e-0016-4b49-b828-231406881f7b
2026-09-07 13:48:48 +02:00
unpreserved := tr.UnpreservedReport()
if len(unpreserved) > 0 {
fmt.Fprintln(os.Stderr, "\nNOT PRESERVED — this realm is an artifact, not a completed migration:")
for _, item := range unpreserved {
fmt.Fprintln(os.Stderr, " -", item)
}
}
out, err := json.MarshalIndent(realm, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "keycape-to-keycloak: marshal JSON: %v\n", err)
os.Exit(1)
}
Reconcile packaging, bootstrap and migration credential handling Closes gap G09: five loosely related defects. lldap-export took the service account password on argv, where ps exposes it to any local user and shell history and process accounting capture it. It now prefers KEYCAPE_LLDAP_BIND_PW or --bind-pw-file; --bind-pw still works but warns, deprecated rather than removed because existing runbooks use it and breaking them silently would be worse than one more cycle of exposure. Conflicting sources are rejected instead of silently ranked, since an operator otherwise cannot tell which bind was attempted. Both migration scripts pass the password by environment now. The canonical export, generated LDIF and Keycloak realm were written 0644. None carries credential material, but the snapshot is every username, display name, email and group membership in the estate, and it tends to land in /tmp. All three are 0600. The image packaged keycape alone, so the validator and migration binaries needed a Go toolchain on the host -- which defeats shipping an image for the cutover work they exist to support. All five ship; the issuer stays the entrypoint. Verified by building the image and running each binary inside it. The publish workflow named 92.205.130.254:32166 while the cluster runs forgejo.coulomb.social/coulomb/key-cape. It now defaults to the recorded name and stays overridable by a repository variable. This repository cannot verify that the runner resolves that hostname or that the registry credentials are valid for it; if the next publish fails, set the REGISTRY variable back to the address. docker-compose.dev.yml mounts a private key and Authelia material that are correctly absent from the checkout. scripts/bootstrap-dev.sh generates them locally under a restrictive umask rather than chmodding afterwards, so the key is never briefly world-readable. Everything it writes is git-ignored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-08 09:51:59 +02:00
if err := os.WriteFile(*outputFile, out, 0o600); err != nil {
fmt.Fprintf(os.Stderr, "keycape-to-keycloak: write %q: %v\n", *outputFile, err)
os.Exit(1)
}
fmt.Printf("keycape-to-keycloak: wrote %s (%d bytes)\n", *outputFile, len(out))
}