// 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" "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") 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) } // 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) realm, err := tr.TransformWithClients(&export, clients) if err != nil { fmt.Fprintf(os.Stderr, "keycape-to-keycloak: transform: %v\n", err) os.Exit(1) } // 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) } 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) } 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)) }