Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 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
60f8602
fix(output): use omitzero for Fields slices in AccessDiff and DiffEntry
onuryilmaz Jul 11, 2026
ea14589
fix(sync): align auth-provider key with full filtered config to match…
onuryilmaz Jul 11, 2026
eba5435
fix(sync): surface exec Command/APIVersion/InteractiveMode diffs; fix…
onuryilmaz Jul 11, 2026
f8fc25f
fix(sync): use frequency maps in argsDiff for correct duplicate-arg h…
onuryilmaz Jul 11, 2026
0c5843b
fix(sync): emit Exec Env diff; add exec fields to buildAccessDiffs; c…
onuryilmaz Jul 11, 2026
120ea0c
fix(sync): gate DeepCopy on dryRun; try all key candidates in authinf…
onuryilmaz Jul 11, 2026
c436d03
fix(sync): nil-safe authInfoEqual, keyToNames build, and diffAuthInfos
onuryilmaz Jul 11, 2026
609179e
fix(sync): fix spelling in BindPFlags comment
onuryilmaz Jul 11, 2026
95dfa03
fix(sync): nil-guard serverAuth in both mergeKubeconfig AuthInfo loops
onuryilmaz Jul 11, 2026
49cea7d
fix(sync): nil-guard cluster, context, and authinfo map entries in di…
onuryilmaz Jul 11, 2026
2727982
fix(sync): surface auth-provider name changes in diffs; correct authI…
onuryilmaz Jul 11, 2026
1adc9d6
fix(output): render empty field values as <empty> in dry-run diff output
onuryilmaz Jul 11, 2026
4b4f030
fix(sync): isManagedContext checks both old and new config to catch m…
onuryilmaz Jul 11, 2026
8e375fb
fix(output): use symmetric Old/New in Credentials sentinel FieldChange
onuryilmaz Jul 11, 2026
0dfc2d6
fix(sync): key authInfoMap by server authinfo name to prevent collision
onuryilmaz Jul 11, 2026
4c22887
fix(sync): handle hash collision and nil serverAuth in context fallback
onuryilmaz Jul 11, 2026
ad1902d
fix(diff): treat nil authinfo in newCfg as removed in diffAuthInfos
onuryilmaz Jul 11, 2026
76060a5
fix(output): print only present side for Exec Args add/remove diffs
onuryilmaz Jul 11, 2026
42e2ee1
fix(output): categorize Auth provider field as credentials in modifie…
onuryilmaz Jul 11, 2026
262b498
fix(sync): use cluster-ref guard for stale context cleanup instead of…
onuryilmaz Jul 11, 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
208 changes: 208 additions & 0 deletions cmd/authinfo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
// 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 the credential-bearing fields of two AuthInfo objects
// for deduplication purposes. It compares ClientCertificateData, ClientKeyData,
// Exec (all fields except tokens), and AuthProvider (name + config, excluding
// "id-token" and "refresh-token"). Fields that carry local-only or session state
// (Token, TokenFile, Username, Password, Impersonate, etc.) are intentionally
// not compared so that local customisations do not prevent deduplication.
func authInfoEqual(a, b *clientcmdapi.AuthInfo) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
// 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 stable deduplication key for an AuthInfo.
// The key intentionally uses a subset of fields so that tokens and irrelevant
// args do not prevent deduplication of otherwise-identical credentials:
// - Exec-based: Command, APIVersion, InteractiveMode, Env, and the OIDC-
// related flag values (issuer, client-id, client-secret, extra-params,
// scopes). Non-OIDC extra args are intentionally excluded.
// - AuthProvider-based: provider Name plus the full filtered config
// (all keys except "id-token" and "refresh-token"), sorted for stability.
// - Certificate-based: SHA-256 of ClientCertificateData + ClientKeyData.
//
// Note: authInfoEqual compares the full Exec.Args slice, so two authinfos that
// differ only in non-OIDC extra args will have the same key but fail equality.
// The reuse path in mergeKubeconfig guards against this with authInfoEqual.
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)
Comment thread
onuryilmaz marked this conversation as resolved.
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)))
}

// Hash the full filtered config (same set authInfoEqual compares) so the key
// is exactly as discriminating as the equality check. Sorting the keys ensures
// a stable hash regardless of map iteration order.
filtered := filterAuthProviderConfig(authInfo.AuthProvider.Config)
keys := slices.Sorted(maps.Keys(filtered))
var parts []string
for _, k := range keys {
parts = append(parts, k+"="+filtered[k])
}
data := fmt.Sprintf("name:%s;config:%s",
authInfo.AuthProvider.Name, strings.Join(parts, ";"))

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