Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ebd03b4
feat(sync): add --dry-run flag and fix authinfo deduplication (#51, #54)
onuryilmaz Jul 10, 2026
d44d806
fix(sync): context-centric dry-run output with server URLs
onuryilmaz Jul 10, 2026
524582a
fix(sync): address Copilot review — security, correctness, test isola…
onuryilmaz Jul 10, 2026
eba6ce6
fix(output): table layout for dry-run modified entries with change su…
onuryilmaz Jul 10, 2026
4f2d2f9
fix(output): show old → new field values for structural changes in dr…
onuryilmaz Jul 10, 2026
2d3cb0c
feat(sync): add --dry-run-format=table|diff flag
onuryilmaz Jul 10, 2026
4488e57
fix(sync): suppress no-op context diffs from authinfo hash reassignment
onuryilmaz Jul 10, 2026
8d84cea
feat(sync): show actual field diffs for credential changes in dry-run
onuryilmaz Jul 10, 2026
e5c5a24
refactor(sync): drop table view, use diff-style output exclusively fo…
onuryilmaz Jul 10, 2026
0029230
fix(sync): fix dry-run summary breakdown and collapse redacted-equal …
onuryilmaz Jul 10, 2026
7416b4d
fix(sync): address Copilot review — key correctness and diff complete…
onuryilmaz Jul 10, 2026
758809b
fix(sync): gate authinfo reuse on authInfoEqual and deduplicate field…
onuryilmaz Jul 10, 2026
dd0014b
fix(output): remove unused accessChangeSummary function
onuryilmaz Jul 10, 2026
6ce1e4f
fix(sync): pick lexicographically smallest name for deterministic aut…
onuryilmaz Jul 10, 2026
5f019cd
fix(sync): surface namespace changes in dry-run access diff output
onuryilmaz Jul 10, 2026
2a114ec
fix(sync): don't mutate unmanaged authinfo on reuse; fix non-determin…
onuryilmaz Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions cmd/authinfo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Greenhouse contributors
// SPDX-License-Identifier: Apache-2.0

package cmd

import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"maps"
"slices"
"sort"
"strings"

clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
)

// authInfoEqual compares two AuthInfo objects, excluding "id-token" and "refresh-token".
func authInfoEqual(a, b *clientcmdapi.AuthInfo) bool {
// Compare ClientCertificateData
if !bytes.Equal(a.ClientCertificateData, b.ClientCertificateData) {
return false
}

// Compare ClientKeyData
if !bytes.Equal(a.ClientKeyData, b.ClientKeyData) {
return false
}

// Compare Exec first (new style)
if (a.Exec == nil) != (b.Exec == nil) {
return false
}
if a.Exec != nil && b.Exec != nil {
if a.Exec.Command != b.Exec.Command || a.Exec.APIVersion != b.Exec.APIVersion {
return false
}
if !slices.Equal(a.Exec.Args, b.Exec.Args) {
return false
}
if a.Exec.InteractiveMode != b.Exec.InteractiveMode {
return false
}
if !equalExecEnv(a.Exec.Env, b.Exec.Env) {
return false
}
return true
}

// Compare AuthProvider, excluding "id-token" and "refresh-token"
if (a.AuthProvider == nil) != (b.AuthProvider == nil) {
return false
}
if a.AuthProvider != nil && b.AuthProvider != nil {
// Compare AuthProvider Name
if a.AuthProvider.Name != b.AuthProvider.Name {
return false
}

// Compare AuthProvider Config excluding "id-token" and "refresh-token"
aConfigFiltered := filterAuthProviderConfig(a.AuthProvider.Config)
bConfigFiltered := filterAuthProviderConfig(b.AuthProvider.Config)
if !maps.Equal(aConfigFiltered, bConfigFiltered) {
return false
}
}

return true
}

// equalExecEnv compares two ExecEnvVar slices for equality, independent of ordering.
func equalExecEnv(a, b []clientcmdapi.ExecEnvVar) bool {
if len(a) != len(b) {
return false
}
// Build a frequency map so order differences are not treated as changes.
counts := make(map[string]int, len(a))
for _, e := range a {
counts[e.Name+"="+e.Value]++
}
for _, e := range b {
counts[e.Name+"="+e.Value]--
if counts[e.Name+"="+e.Value] < 0 {
return false
}
}
return true
}

// filterAuthProviderConfig returns a copy of the config map excluding "id-token" and "refresh-token".
func filterAuthProviderConfig(config map[string]string) map[string]string {
filtered := make(map[string]string)
for k, v := range config {
if k != "id-token" && k != "refresh-token" {
filtered[k] = v
}
}
return filtered
}

// generateAuthInfoKey creates a unique key for an AuthInfo based on specific AuthProvider fields,
// excluding "id-token" and "refresh-token". It uses "idp-issuer-url", "client-id", "client-secret",
// "auth-request-extra-params", and "extra-scopes" to generate the key.
func generateAuthInfoKey(authInfo *clientcmdapi.AuthInfo) string {
// Exec-based key: derive from stable subset of args to avoid including tokens
if authInfo.Exec != nil {
// Extract known kubelogin flags
var issuer, clientID, clientSecret, extraParams string
var scopes []string
var envParts []string
for _, arg := range authInfo.Exec.Args {
switch {
case strings.HasPrefix(arg, "--oidc-issuer-url="):
issuer = strings.TrimPrefix(arg, "--oidc-issuer-url=")
case strings.HasPrefix(arg, "--oidc-client-id="):
clientID = strings.TrimPrefix(arg, "--oidc-client-id=")
case strings.HasPrefix(arg, "--oidc-client-secret="):
clientSecret = strings.TrimPrefix(arg, "--oidc-client-secret=")
case strings.HasPrefix(arg, "--oidc-extra-scope="):
scopes = append(scopes, strings.TrimPrefix(arg, "--oidc-extra-scope="))
case strings.HasPrefix(arg, "--oidc-auth-request-extra-params="):
extraParams = strings.TrimPrefix(arg, "--oidc-auth-request-extra-params=")
}
}
sort.Strings(scopes)
// Include sorted Env in the key so changes to env vars result in a different key
for _, e := range authInfo.Exec.Env {
envParts = append(envParts, e.Name+"="+e.Value)
}
sort.Strings(envParts)
data := fmt.Sprintf("exec:cmd:%s;api:%s;mode:%s;issuer:%s;client-id:%s;client-secret:%s;extra-params:%s;scopes:%s;env:%s",
authInfo.Exec.Command, authInfo.Exec.APIVersion, authInfo.Exec.InteractiveMode,
issuer, clientID, clientSecret, extraParams, strings.Join(scopes, ","), strings.Join(envParts, ","))
return data
}

if authInfo.AuthProvider == nil {
// For AuthInfos without AuthProvider, use a different unique identifier
// Here, we'll use the hash of ClientCertificateData and ClientKeyData
h := sha256.New()
h.Write(authInfo.ClientCertificateData)
h.Write(authInfo.ClientKeyData)
return fmt.Sprintf("cert:%s", hex.EncodeToString(h.Sum(nil)))
}

// Extract the required fields from AuthProvider Config, including idp-issuer-url
// to avoid incorrectly deduplicating clusters with different issuers but same client-id.
issuerURL := authInfo.AuthProvider.Config["idp-issuer-url"]
clientID := authInfo.AuthProvider.Config["client-id"]
clientSecret := authInfo.AuthProvider.Config["client-secret"]
authRequestExtraParams := authInfo.AuthProvider.Config["auth-request-extra-params"]
extraScopes := authInfo.AuthProvider.Config["extra-scopes"]

// Concatenate the fields in a consistent order, including the provider name
// to prevent collisions between different auth-provider types.
data := fmt.Sprintf("name:%s;idp-issuer-url:%s;client-id:%s;client-secret:%s;auth-request-extra-params:%s;extra-scopes:%s",
authInfo.AuthProvider.Name, issuerURL, clientID, clientSecret, authRequestExtraParams, extraScopes)

return data
}

// mergeAuthInfo merges two AuthInfo objects, preserving id-token and refresh-token from localAuth.
func mergeAuthInfo(serverAuth, localAuth *clientcmdapi.AuthInfo) *clientcmdapi.AuthInfo {
if localAuth == nil {
// If there's no local AuthInfo, return the server AuthInfo as is
return serverAuth
}

// Create a copy of the serverAuth to avoid mutating the original
mergedAuth := serverAuth.DeepCopy()

// Preserve id-token and refresh-token from localAuth
if localAuth.AuthProvider != nil && mergedAuth.AuthProvider != nil {
// Ensure the merged config map is initialized to avoid panics on assignment
if mergedAuth.AuthProvider.Config == nil {
mergedAuth.AuthProvider.Config = make(map[string]string)
}
if idToken, exists := localAuth.AuthProvider.Config["id-token"]; exists {
mergedAuth.AuthProvider.Config["id-token"] = idToken
}
if refreshToken, exists := localAuth.AuthProvider.Config["refresh-token"]; exists {
mergedAuth.AuthProvider.Config["refresh-token"] = refreshToken
}
}

return mergedAuth
}
Loading
Loading