From 6d0287e77016f8e4a6f2cb379ca84226488fe406 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Thu, 13 Aug 2026 18:07:10 +0200 Subject: [PATCH 1/8] feat: add grant k8s list and the internal/k8s SCA service wrapper Wires the SDK's pkg/services/sca/k8s into grant behind a thin, grant-shaped wrapper and lands the first command on top of it. - internal/k8s: Service wrapper with grant-owned Cluster/Connection/ ElevateResult models, CSP validation, wrapped errors, and context propagation (direct for GenerateKubeconfigParallel, enforced at the wrapper boundary for the SDK entry points that take no context) - internal/cache: CachedClusterLister decorator (clusters_ keys) - internal/ui: cluster selector quartet with TTY detection - cmd: grant k8s parent + grant k8s list (--provider, --refresh, --output json) Implements decision D-4/5B: importing the SDK k8s package adds exactly the 16 modules enumerated in the roadmap (Azure SDK, azidentity, MSAL, armauthorization, aws-sdk-go-v2 + sts + credentials, smithy-go, go-jose/v4, pkg/browser, godebug). This is a deliberate departure from the zero-new-deps goal, recorded in CLAUDE.md. Untested against a live Kubernetes cluster. --- CHANGELOG.md | 4 + CLAUDE.md | 21 ++ README.md | 15 + cmd/commands.go | 1 + cmd/interfaces.go | 6 + cmd/k8s.go | 70 +++++ cmd/k8s_integration_test.go | 81 +++++ cmd/k8s_list.go | 126 ++++++++ cmd/k8s_list_test.go | 168 ++++++++++ cmd/output_types.go | 22 ++ go.mod | 16 + go.sum | 51 +++- internal/cache/cached_clusters.go | 69 +++++ internal/cache/cached_clusters_test.go | 104 +++++++ internal/k8s/service.go | 377 +++++++++++++++++++++++ internal/k8s/service_test.go | 404 +++++++++++++++++++++++++ internal/ui/cluster_selector.go | 80 +++++ internal/ui/cluster_selector_test.go | 107 +++++++ 18 files changed, 1718 insertions(+), 4 deletions(-) create mode 100644 cmd/k8s.go create mode 100644 cmd/k8s_integration_test.go create mode 100644 cmd/k8s_list.go create mode 100644 cmd/k8s_list_test.go create mode 100644 internal/cache/cached_clusters.go create mode 100644 internal/cache/cached_clusters_test.go create mode 100644 internal/k8s/service.go create mode 100644 internal/k8s/service_test.go create mode 100644 internal/ui/cluster_selector.go create mode 100644 internal/ui/cluster_selector_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e6d6ee1..23b65b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- `grant k8s list` shows the Secure Cloud Access Kubernetes clusters you are eligible for; untested against a live cluster + ## [0.9.0] - 2026-08-14 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 3487905..9532322 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,7 @@ - **Language:** Go 1.25+ - **Module:** `github.com/aaearon/grant-cli` - **Dependencies:** `github.com/cyberark/idsec-sdk-golang` is the primary dependency; zero-new-Go-module-deps is a goal, not an absolute rule. Documented exception: `github.com/minio/selfupdate` (+ its one transitive `aead.dev/minisign`) for `grant update`, adopted to remove the abandoned `rhysd/go-github-selfupdate` and advisory GO-2026-5932. It was a net *reduction* in every dependency measure — the exception cost nothing. Measure with `go list -deps` / `go list -m all` if a current figure is needed; do not record one here +- **Second documented exception:** the SDK's SCA K8s package (`pkg/services/sca/k8s`) pulls in the Azure/AWS/JOSE trees — see `## SCA K8s API`, decision D-4/5B ## SDK Import Conventions ```go @@ -50,6 +51,24 @@ Custom `SCAAccessService` follows SDK conventions: - `POST /api/access/elevate/groups` — request group membership elevation (response wrapped in `response` key, same as cloud elevation) - **Headers:** `Authorization: Bearer {jwt}`, `X-API-Version: 2.0`, `Content-Type: application/json` +## SCA K8s API +- **Package:** `internal/k8s/` — `Service`, a thin wrapper over the SDK's `pkg/services/sca/k8s`. grant owns the command/selector/cache/kubeconfig layer; the SDK owns transport and credential flows. +- **Base URLs:** control plane `https://{subdomain}.sca.{platform_domain}/api`, kubeconfig generation `https://{subdomain}.dpa.{platform_domain}/api` +- **Endpoints (all called via the SDK, not reimplemented):** + - `GET /api/access/{CSP}/eligibility/clusters` — list eligible clusters (nextToken pagination, no `X-CLI-Signature`) + - `POST /api/access/{CSP}/eligibility/clusters/evaluate` — resolve connection method (`direct` | `proxy`) + - `POST /api/access/elevate/clusters` — JIT elevation for a cluster + - `GET /api/k8s/kube-config[/{AWS|azure_resource}]` — DPA-generated kubeconfig + - `POST /api/adb/sso/acquire` — short-lived client certificate for the proxy connection method +- **Providers:** `aws` (EKS) and `azure` (AKS) only — GCP is not supported by this API. +- **Do NOT reimplement** the `X-CLI-Signature` HMAC scheme or the `Content-Type` remove/restore dance around GETs; the SDK handles both. +- **Context propagation:** only `GenerateKubeconfigParallel` accepts a `context.Context`. Every other SDK entry point uses `context.Background()` internally, so `internal/k8s.runWithContext` enforces cancellation at the wrapper boundary — the caller returns promptly but the in-flight HTTP request is not cancelled. +- **Dependency decision (D-4 / 5B):** importing the SDK's `pkg/services/sca/k8s` adds exactly 16 modules (Azure azcore/azidentity/armauthorization, MSAL, aws-sdk-go-v2 + credentials + sts + smithy-go, go-jose/v4, pkg/browser, kylelemons/godebug). This is a **deliberate, eyes-open departure** from the "zero new Go module deps" goal: the zero-dep alternative provably cannot ship `grant k8s exec-credential` (direct-AWS needs `sts`, direct-Azure needs `azidentity`, proxy needs `go-jose` for JWE), so it would produce a kubeconfig `kubectl` cannot authenticate with. Accepted costs: larger binary and an enlarged CVE surface — keep `govulncheck ./...` in the verify sequence and track Azure/AWS SDK advisories. +- **Azure prerequisite:** the SDK uses `azidentity.NewAzureCLICredential`, so the Azure path requires the Azure CLI installed and logged in (`az login`). Never document it as "no Azure CLI needed". +- **Cache:** `internal/cache/cached_clusters.go` — `CachedClusterLister`, keys `clusters_` (`clusters_all` when no provider), same TTL/`--refresh` semantics as eligibility. +- **Selector:** `internal/ui/cluster_selector.go` — Format/Build/Find/Select quartet mirroring `internal/ui/selector.go`. +- **Untested against a live cluster.** No Kubernetes entitlements were available; README carries a single note saying so. + ## Access Requests API (Workflows) - **Base URL:** `https://{subdomain}.uar.{platform_domain}/api` - **Package:** `internal/workflows/` — `AccessRequestService` (mirrors SCA service pattern with ISP client for "uar" service) @@ -102,6 +121,8 @@ Custom `SCAAccessService` follows SDK conventions: - `grant request cancel [id]` — cancel an open request; optional `--reason`. Omitting `` in a TTY opens a picker scoped to STARTING/RUNNING/PENDING requests you created (role=CREATOR) - `grant request approve [id]` / `grant request reject [id]` — finalize a request; optional `--reason`. Omitting `` in a TTY opens a picker scoped to PENDING requests assigned to you (role=APPROVER) - Request picker: `internal/ui/request_selector.go` mirrors the role-selector Format/Build/Select quartet; `resolveRequestIDFn` in `cmd/request_picker.go` is injectable for tests. Non-TTY invocation without `` returns `ErrNotInteractive` with a hint to run `grant request list` +- `grant k8s` — Kubernetes cluster access; subcommands: `list` +- `grant k8s list` — list SCA-eligible clusters; flags: `--provider` (aws|azure), `--refresh`, `--output json` - `grant update` — self-update binary via GitHub Releases; guards against dev builds. Implemented in `internal/selfupdate/`: - Discovery: `GET https://api.github.com/repos/aaearon/grant-cli/releases/latest` (`apiBaseURL` field injectable for tests) - Version compare: in-house SemVer 2.0.0 parser (`ParseVersion`/`CompareVersions`). Handles pre-release and build metadata (GoReleaser can emit both) with SemVer precedence: build metadata ignored for ordering, pre-release sorts before its release. A leading `v`/`V` is tolerated; leading zeroes are rejected diff --git a/README.md b/README.md index 4070f39..b3160ba 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ Running `grant` with no subcommand elevates cloud permissions (the core behavior | `favorites` | Manage saved role favorites (`add`/`list`/`remove`) | | `revoke` | Revoke sessions (interactive, by ID, or `--all`) — see exit codes below | | `request` | Manage access requests through an approval workflow (see subcommands below) | +| `k8s` | Work with SCA-eligible Kubernetes clusters (see subcommands below) | | `update` | Self-update to the latest release from GitHub | | `version` | Print version information | @@ -155,6 +156,17 @@ on stdout even on exit 1. | `approve [id]` | Approve a pending request (approvers only); omit `` in a TTY to pick from pending requests | | `reject [id]` | Reject a pending request (approvers only); omit `` in a TTY to pick from pending requests | +### `grant k8s` subcommands + +> **Untested against a live cluster.** The `grant k8s` commands are implemented against the SCA Kubernetes API and SDK but have not been exercised against a real tenant with Kubernetes entitlements — please report issues. + +| Subcommand | Description | +|------------|-------------| +| `list` | List Kubernetes clusters you are eligible for (`--provider aws\|azure`, `--refresh`, `--output json`) | + +Supported providers are `aws` (EKS) and `azure` (AKS). GCP is not supported by the SCA Kubernetes API. +The Azure path additionally requires the [Azure CLI](https://learn.microsoft.com/cli/azure/) to be installed and logged in (`az login`). + ### Flags **Global:** `--verbose, -v` (detailed output) | `--output, -o` (`text` or `json`) @@ -162,6 +174,9 @@ on stdout even on exit 1. **Elevation** (`grant`, `env`, `favorites add`): `--provider, -p` | `--target, -t` | `--role, -r` | `--favorite, -f` | `--group, -g` | `--groups` | `--refresh` +**`grant k8s list`:** +`--provider, -p` | `--refresh` + **`grant request submit`:** `--provider, -p` | `--target, -t` | `--role` | `--role-id` | `--reason` | `--priority` | `--date` | `--timezone` | `--from` | `--to` | `--yes` | `--refresh` diff --git a/cmd/commands.go b/cmd/commands.go index 1c6d7d2..e9ef722 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -13,5 +13,6 @@ func init() { NewUpdateCommand(), NewListCommand(), NewRequestCommand(), + NewK8sCommand(), ) } diff --git a/cmd/interfaces.go b/cmd/interfaces.go index c8e7ee0..5c339d3 100644 --- a/cmd/interfaces.go +++ b/cmd/interfaces.go @@ -3,6 +3,7 @@ package cmd import ( "context" + "github.com/aaearon/grant-cli/internal/k8s" "github.com/aaearon/grant-cli/internal/sca/models" "github.com/aaearon/grant-cli/internal/workflows" wfmodels "github.com/aaearon/grant-cli/internal/workflows/models" @@ -91,6 +92,11 @@ type selfUpdater interface { UpdateSelf(ctx context.Context, current string) (newVersion string, updated bool, err error) } +// clusterLister interface for listing eligible Kubernetes clusters +type clusterLister interface { + ListClusters(ctx context.Context, csp string) ([]k8s.Cluster, error) +} + // accessRequestService interface for access request operations type accessRequestService interface { ListRequests(ctx context.Context, params workflows.ListRequestsParams) ([]wfmodels.AccessRequest, int, error) diff --git a/cmd/k8s.go b/cmd/k8s.go new file mode 100644 index 0000000..558d88c --- /dev/null +++ b/cmd/k8s.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "fmt" + + "github.com/aaearon/grant-cli/internal/cache" + "github.com/aaearon/grant-cli/internal/config" + "github.com/aaearon/grant-cli/internal/k8s" + "github.com/cyberark/idsec-sdk-golang/pkg/common" + "github.com/spf13/cobra" +) + +// NewK8sCommand creates the "grant k8s" parent command. +func NewK8sCommand() *cobra.Command { + cmd := newK8sParent() + cmd.AddCommand( + NewK8sListCommand(), + ) + return cmd +} + +// NewK8sCommandWithDeps creates the k8s parent with injected dependencies for testing. +func NewK8sCommandWithDeps(auth authLoader, clusters clusterLister) *cobra.Command { + cmd := newK8sParent() + cmd.AddCommand( + newK8sListCommand(func(c *cobra.Command, _ []string) error { + return runK8sList(c, auth, clusters) + }), + ) + return cmd +} + +func newK8sParent() *cobra.Command { + return &cobra.Command{ + Use: "k8s", + Short: "Work with SCA-eligible Kubernetes clusters", + Long: `Discover and access Kubernetes clusters you are eligible for via +Secure Cloud Access. + +Supported providers: aws (EKS) and azure (AKS). The Azure path requires the +Azure CLI to be installed and logged in (` + "`az login`" + `).`, + } +} + +// bootstrapK8sService loads the profile, authenticates, and creates the SCA K8s +// service. The underlying auth is memoized across calls within one invocation. +func bootstrapK8sService() (*k8s.Service, error) { + ispAuth, _, err := bootstrapISPAuth() + if err != nil { + return nil, err + } + + svc, err := k8s.NewService(ispAuth) + if err != nil { + return nil, fmt.Errorf("failed to create SCA k8s service: %w", err) + } + return svc, nil +} + +// buildCachedClusterLister wraps a cluster lister with the on-disk cache. +// If the cache directory cannot be resolved it degrades to an always-miss store. +func buildCachedClusterLister(cfg *config.Config, refresh bool, inner cache.ClusterLister) *cache.CachedClusterLister { + cacheLog := common.GetLogger("grant", -1) + cacheDir, err := cache.CacheDir() + if err != nil { + return cache.NewCachedClusterLister(inner, cache.NewStore("", 0), true, nil) + } + store := cache.NewStore(cacheDir, config.ParseCacheTTL(cfg)) + return cache.NewCachedClusterLister(inner, store, refresh, cacheLog) +} diff --git a/cmd/k8s_integration_test.go b/cmd/k8s_integration_test.go new file mode 100644 index 0000000..921119e --- /dev/null +++ b/cmd/k8s_integration_test.go @@ -0,0 +1,81 @@ +//go:build integration + +package cmd + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestIntegration_K8sHelp(t *testing.T) { + cmd := exec.Command(getBinaryPath(), "k8s", "--help") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("grant k8s --help failed: %v\n%s", err, output) + } + + outputStr := string(output) + for _, want := range []string{"list", "Kubernetes"} { + if !strings.Contains(outputStr, want) { + t.Errorf("expected %q in `grant k8s --help` output, got:\n%s", want, outputStr) + } + } + + // exec-credential is Hidden and must not be advertised. + if strings.Contains(outputStr, "exec-credential") { + t.Errorf("exec-credential must be hidden from help, got:\n%s", outputStr) + } +} + +func TestIntegration_K8sListHelp(t *testing.T) { + cmd := exec.Command(getBinaryPath(), "k8s", "list", "--help") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("grant k8s list --help failed: %v\n%s", err, output) + } + + outputStr := string(output) + for _, want := range []string{"--provider", "--refresh"} { + if !strings.Contains(outputStr, want) { + t.Errorf("expected %q in `grant k8s list --help` output, got:\n%s", want, outputStr) + } + } +} + +// TestIntegration_K8sListJSONWithoutLogin asserts the command fails cleanly and +// makes no network call when there is no authentication. No stub server is +// contacted: the binary must bail out before any request. +func TestIntegration_K8sListJSONWithoutLogin(t *testing.T) { + tempDir := t.TempDir() + + cmd := exec.Command(getBinaryPath(), "k8s", "list", "--output", "json") + cmd.Env = append(os.Environ(), "GRANT_CONFIG="+filepath.Join(tempDir, "config.yaml")) + cmd.Env = append(cmd.Env, "HOME="+tempDir) + + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("expected `grant k8s list` to fail without authentication, got:\n%s", output) + } + + outputStr := strings.ToLower(string(output)) + if !strings.Contains(outputStr, "auth") && !strings.Contains(outputStr, "profile") && !strings.Contains(outputStr, "error") { + t.Errorf("expected an authentication-shaped error, got:\n%s", output) + } +} + +func TestIntegration_K8sListRejectsUnsupportedProvider(t *testing.T) { + tempDir := t.TempDir() + + cmd := exec.Command(getBinaryPath(), "k8s", "list", "--provider", "gcp") + cmd.Env = append(os.Environ(), "GRANT_CONFIG="+filepath.Join(tempDir, "config.yaml")) + cmd.Env = append(cmd.Env, "HOME="+tempDir) + + output, _ := cmd.CombinedOutput() + if !strings.Contains(strings.ToLower(string(output)), "gcp") && + !strings.Contains(strings.ToLower(string(output)), "auth") { + t.Errorf("expected a provider or auth error, got:\n%s", output) + } +} diff --git a/cmd/k8s_list.go b/cmd/k8s_list.go new file mode 100644 index 0000000..54ab999 --- /dev/null +++ b/cmd/k8s_list.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/aaearon/grant-cli/internal/config" + "github.com/aaearon/grant-cli/internal/k8s" + "github.com/aaearon/grant-cli/internal/ui" + "github.com/spf13/cobra" +) + +// newK8sListCommand creates the "grant k8s list" command with the given RunE. +func newK8sListCommand(runFn func(*cobra.Command, []string) error) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List eligible Kubernetes clusters", + Long: `List the Kubernetes clusters you are eligible to access via Secure Cloud Access. + +Examples: + # List clusters across all supported providers + grant k8s list + + # Only AWS (EKS) clusters + grant k8s list --provider aws + + # JSON output for programmatic use + grant k8s list --output json + + # Bypass the cluster cache + grant k8s list --refresh`, + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + RunE: runFn, + } + + cmd.Flags().StringP("provider", "p", "", "Cloud provider: aws, azure (omit to show all)") + cmd.Flags().Bool("refresh", false, "Bypass the cluster cache and fetch fresh data") + + return cmd +} + +// NewK8sListCommand creates the production "grant k8s list" command. +func NewK8sListCommand() *cobra.Command { + return newK8sListCommand(func(cmd *cobra.Command, args []string) error { + ispAuth, _, err := bootstrapISPAuth() + if err != nil { + return err + } + + svc, err := bootstrapK8sService() + if err != nil { + return err + } + + cfg, _, err := config.LoadDefaultWithPath() + if err != nil { + return err + } + + refresh, _ := cmd.Flags().GetBool("refresh") + return runK8sList(cmd, ispAuth, buildCachedClusterLister(cfg, refresh, svc)) + }) +} + +// runK8sList lists eligible clusters in text or JSON form. +func runK8sList(cmd *cobra.Command, auth authLoader, lister clusterLister) error { + if _, err := auth.LoadAuthentication(nil, true); err != nil { + return fmt.Errorf("not authenticated, run 'grant login' first: %w", err) + } + + provider, _ := cmd.Flags().GetString("provider") + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider != "" { + if _, err := k8s.NormalizeCSP(provider); err != nil { + return err + } + } + + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) + defer cancel() + + clusters, err := lister.ListClusters(ctx, provider) + if err != nil { + return fmt.Errorf("failed to list clusters: %w", err) + } + + if len(clusters) == 0 { + return errors.New("no eligible Kubernetes clusters found, check your SCA policies") + } + + if isJSONOutput() { + return writeJSON(cmd.OutOrStdout(), buildK8sListOutput(clusters)) + } + + fmt.Fprintln(cmd.OutOrStdout(), "Clusters:") + for _, opt := range ui.BuildClusterOptions(clusters) { + fmt.Fprintf(cmd.OutOrStdout(), " %s\n", opt) + } + return nil +} + +func buildK8sListOutput(clusters []k8s.Cluster) k8sListOutput { + out := k8sListOutput{Clusters: make([]clusterOutput, 0, len(clusters))} + for _, c := range clusters { + out.Clusters = append(out.Clusters, clusterOutput{ + Provider: c.Provider, + Name: c.Name, + ClusterID: c.ClusterID, + FQDN: c.FQDN, + Region: c.Region, + Scope: c.Scope, + Namespace: c.Namespace, + WorkspaceID: c.WorkspaceID, + WorkspaceName: c.WorkspaceName, + WorkspaceType: strings.ToLower(c.WorkspaceType), + Role: c.RoleName, + RoleID: c.RoleID, + OrganizationID: c.OrganizationID, + }) + } + return out +} diff --git a/cmd/k8s_list_test.go b/cmd/k8s_list_test.go new file mode 100644 index 0000000..d531b00 --- /dev/null +++ b/cmd/k8s_list_test.go @@ -0,0 +1,168 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/aaearon/grant-cli/internal/k8s" + "github.com/spf13/cobra" +) + +func sampleClusters() []k8s.Cluster { + return []k8s.Cluster{ + { + Provider: "aws", Name: "prod", ClusterID: "arn:aws:eks:us-east-1:1:cluster/prod", + FQDN: "abc.eks.amazonaws.com", Region: "us-east-1", Scope: "cluster", + WorkspaceID: "111", WorkspaceName: "prod-account", WorkspaceType: "ACCOUNT", + RoleName: "admin", RoleID: "arn:aws:iam::1:role/admin", OrganizationID: "o-1", + }, + { + Provider: "azure", Name: "aks1", ClusterID: "/subscriptions/s/.../aks1", + FQDN: "aks1.hcp.westeurope.azmk8s.io", RoleName: "reader", RoleID: "/rd/reader", + }, + } +} + +func TestK8sListJSONOutput(t *testing.T) { + old := outputFormat + defer func() { outputFormat = old }() + outputFormat = "json" + + cmd := NewK8sCommandWithDeps(&mockAuthLoader{}, &mockClusterLister{clusters: sampleClusters()}) + out, err := executeCommand(cmd, "list") + if err != nil { + t.Fatalf("execute: %v\n%s", err, out) + } + + var got k8sListOutput + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, out) + } + if len(got.Clusters) != 2 { + t.Fatalf("got %d clusters, want 2", len(got.Clusters)) + } + first := got.Clusters[0] + if first.Provider != "aws" || first.Name != "prod" || first.Region != "us-east-1" { + t.Errorf("unexpected first cluster: %+v", first) + } + if first.FQDN != "abc.eks.amazonaws.com" || first.RoleID != "arn:aws:iam::1:role/admin" { + t.Errorf("unexpected first cluster: %+v", first) + } +} + +func TestK8sListTextOutput(t *testing.T) { + cmd := NewK8sCommandWithDeps(&mockAuthLoader{}, &mockClusterLister{clusters: sampleClusters()}) + out, err := executeCommand(cmd, "list") + if err != nil { + t.Fatalf("execute: %v\n%s", err, out) + } + if !strings.Contains(out, "prod") || !strings.Contains(out, "aks1") { + t.Errorf("expected both clusters in output, got:\n%s", out) + } +} + +func TestK8sListProviderValidation(t *testing.T) { + tests := []struct { + name string + provider string + wantErr bool + }{ + {name: "aws", provider: "aws"}, + {name: "azure", provider: "azure"}, + {name: "uppercase", provider: "AWS"}, + {name: "gcp rejected", provider: "gcp", wantErr: true}, + {name: "garbage rejected", provider: "nope", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lister := &mockClusterLister{clusters: sampleClusters()} + cmd := NewK8sCommandWithDeps(&mockAuthLoader{}, lister) + _, err := executeCommand(cmd, "list", "--provider", tt.provider) + if tt.wantErr && err == nil { + t.Fatalf("expected error for provider %q", tt.provider) + } + if !tt.wantErr { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.EqualFold(lister.gotCSP, tt.provider) { + t.Errorf("lister got csp %q, want %q", lister.gotCSP, strings.ToLower(tt.provider)) + } + } + }) + } +} + +func TestK8sListPassesEmptyProviderForAll(t *testing.T) { + lister := &mockClusterLister{clusters: sampleClusters()} + cmd := NewK8sCommandWithDeps(&mockAuthLoader{}, lister) + if _, err := executeCommand(cmd, "list"); err != nil { + t.Fatalf("execute: %v", err) + } + if lister.gotCSP != "" { + t.Errorf("csp = %q, want empty (all providers)", lister.gotCSP) + } +} + +func TestK8sListRefreshFlagReachesLister(t *testing.T) { + var gotRefresh bool + lister := &mockClusterLister{clusters: sampleClusters()} + cmd := newK8sListCommand(func(c *cobra.Command, _ []string) error { + gotRefresh, _ = c.Flags().GetBool("refresh") + return runK8sList(c, &mockAuthLoader{}, lister) + }) + + if _, err := executeCommand(cmd, "--refresh"); err != nil { + t.Fatalf("execute: %v", err) + } + if !gotRefresh { + t.Error("--refresh flag was not visible to the command") + } +} + +func TestK8sListNoClusters(t *testing.T) { + cmd := NewK8sCommandWithDeps(&mockAuthLoader{}, &mockClusterLister{}) + _, err := executeCommand(cmd, "list") + if err == nil { + t.Fatal("expected an error when no clusters are eligible") + } + if !strings.Contains(err.Error(), "no eligible") { + t.Errorf("err = %v, want a no-eligible-clusters message", err) + } +} + +func TestK8sListRequiresAuth(t *testing.T) { + auth := &mockAuthLoader{loadErr: errNotAuthenticated} + cmd := NewK8sCommandWithDeps(auth, &mockClusterLister{clusters: sampleClusters()}) + _, err := executeCommand(cmd, "list") + if err == nil || !strings.Contains(err.Error(), "not authenticated") { + t.Fatalf("err = %v, want a not-authenticated error", err) + } +} + +func TestK8sListPropagatesListerError(t *testing.T) { + sentinel := errors.New("api exploded") + cmd := NewK8sCommandWithDeps(&mockAuthLoader{}, &mockClusterLister{err: sentinel}) + _, err := executeCommand(cmd, "list") + if !errors.Is(err, sentinel) { + t.Fatalf("err = %v, want wrapped %v", err, sentinel) + } +} + +// mockClusterLister implements clusterLister for testing. +type mockClusterLister struct { + clusters []k8s.Cluster + err error + gotCSP string + calls int +} + +func (m *mockClusterLister) ListClusters(_ context.Context, csp string) ([]k8s.Cluster, error) { + m.gotCSP = csp + m.calls++ + return m.clusters, m.err +} diff --git a/cmd/output_types.go b/cmd/output_types.go index fe903f7..57634c9 100644 --- a/cmd/output_types.go +++ b/cmd/output_types.go @@ -103,3 +103,25 @@ type accessRequestListOutput struct { Requests []accessRequestOutput `json:"requests"` TotalCount int `json:"totalCount"` } + +// clusterOutput is the JSON representation of an eligible Kubernetes cluster. +type clusterOutput struct { + Provider string `json:"provider"` + Name string `json:"name"` + ClusterID string `json:"clusterId"` + FQDN string `json:"fqdn,omitempty"` + Region string `json:"region,omitempty"` + Scope string `json:"scope,omitempty"` + Namespace string `json:"namespace,omitempty"` + WorkspaceID string `json:"workspaceId,omitempty"` + WorkspaceName string `json:"workspaceName,omitempty"` + WorkspaceType string `json:"workspaceType,omitempty"` + Role string `json:"role,omitempty"` + RoleID string `json:"roleId,omitempty"` + OrganizationID string `json:"organizationId,omitempty"` +} + +// k8sListOutput is the JSON representation of `grant k8s list`. +type k8sListOutput struct { + Clusters []clusterOutput `json:"clusters"` +} diff --git a/go.mod b/go.mod index 5d7a83a..fa7ff48 100644 --- a/go.mod +++ b/go.mod @@ -15,11 +15,25 @@ require ( aead.dev/minisign v0.2.0 // indirect github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/99designs/keyring v1.2.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect github.com/EDDYCJY/fake-useragent v0.2.0 // indirect github.com/PuerkitoBio/goquery v1.10.3 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/aws/aws-sdk-go-v2 v1.42.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect + github.com/aws/smithy-go v1.27.3 // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/dvsekhvalnov/jose2go v1.7.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.6.0 // indirect @@ -29,9 +43,11 @@ require ( github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a // indirect github.com/juju/persistent-cookiejar v1.0.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mtibben/percent v0.2.1 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/toqueteos/webbrowser v1.2.0 // indirect golang.design/x/clipboard v0.7.0 // indirect diff --git a/go.sum b/go.sum index 93ef7fc..8d58df8 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,20 @@ github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMb github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0 h1:Hp+EScFOu9HeCbeW8WU2yQPJd4gGwhMgKxWe+G6jNzw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0/go.mod h1:/pz8dyNQe+Ey3yBp/XuYz7oqX8YDNWVpPB0hH3XWfbc= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/EDDYCJY/fake-useragent v0.2.0 h1:Jcnkk2bgXmDpX0z+ELlUErTkoLb/mxFBNd2YdcpvJBs= github.com/EDDYCJY/fake-useragent v0.2.0/go.mod h1:5wn3zzlDxhKW6NYknushqinPcAqZcAPHy8lLczCdJdc= @@ -15,6 +29,22 @@ github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiU github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= +github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc= +github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI= +github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= +github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= @@ -30,6 +60,8 @@ github.com/dvsekhvalnov/jose2go v1.7.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB github.com/frankban/quicktest v1.2.2/go.mod h1:Qh/WofXFeiAFII1aEBu529AtJo6Zg2VHscnEsbBnJ20= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -54,11 +86,17 @@ github.com/juju/persistent-cookiejar v1.0.0 h1:Ag7+QLzqC2m+OYXy2QQnRjb3gTkEBSZag github.com/juju/persistent-cookiejar v1.0.0/go.mod h1:zrbmo4nBKaiP/Ez3F67ewkMbzGYfXyMvRtbOfuAwG0w= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -72,12 +110,15 @@ github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDw github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a h1:3QH7VyOaaiUHNrA9Se4YQIRkDTCw1EJls9xTUCaCeRM= github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a/go.mod h1:4r5QyqhjIWCcK8DO4KMclc5Iknq5qVBAlbYYzAbUScQ= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= @@ -164,6 +205,7 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -209,8 +251,9 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v1 v1.0.1 h1:oQFRXzZ7CkBGdm1XZm/EbQYaYNNEElNBOd09M6cqNso= gopkg.in/errgo.v1 v1.0.1/go.mod h1:3NjfXwocQRYAPTq4/fzX+CwUhPRcR/azYRhj8G+LqMo= gopkg.in/retry.v1 v1.0.3 h1:a9CArYczAVv6Qs6VGoLMio99GEs7kY9UzSF9+LD+iGs= diff --git a/internal/cache/cached_clusters.go b/internal/cache/cached_clusters.go new file mode 100644 index 0000000..3962fc0 --- /dev/null +++ b/internal/cache/cached_clusters.go @@ -0,0 +1,69 @@ +package cache + +import ( + "context" + "strings" + + "github.com/aaearon/grant-cli/internal/k8s" +) + +// ClusterLister mirrors cmd.clusterLister to avoid import cycles. +type ClusterLister interface { + ListClusters(ctx context.Context, csp string) ([]k8s.Cluster, error) +} + +// CachedClusterLister decorates a ClusterLister with file-based caching, +// mirroring CachedEligibilityLister. +type CachedClusterLister struct { + inner ClusterLister + store *Store + refresh bool + log Logger +} + +// NewCachedClusterLister creates a caching decorator around a ClusterLister. +// When refresh is true the cache read is bypassed but the response is still cached. +// Logger is optional — pass nil for silent operation. +func NewCachedClusterLister(inner ClusterLister, store *Store, refresh bool, log Logger) *CachedClusterLister { + if log == nil { + log = nopLogger{} + } + return &CachedClusterLister{inner: inner, store: store, refresh: refresh, log: log} +} + +// ListClusters checks the cache first, then falls through to the inner lister. +func (c *CachedClusterLister) ListClusters(ctx context.Context, csp string) ([]k8s.Cluster, error) { + key := clustersCacheKey(csp) + + if c.refresh { + c.log.Info("Cache refresh requested for %s clusters, bypassing cache", key) + } else { + var cached []k8s.Cluster + if Get(c.store, key, &cached) { + c.log.Info("Cache hit for %s (%d clusters)", key, len(cached)) + return cached, nil + } + c.log.Info("Cache miss for %s, fetching from API", key) + } + + clusters, err := c.inner.ListClusters(ctx, csp) + if err != nil { + return nil, err + } + + if err := Set(c.store, key, clusters); err != nil { + c.log.Info("Cache write failed for %s: %v", key, err) + } else { + c.log.Info("Cached %s (%d clusters)", key, len(clusters)) + } + + return clusters, nil +} + +func clustersCacheKey(csp string) string { + normalized := strings.ToLower(strings.TrimSpace(csp)) + if normalized == "" { + normalized = "all" + } + return "clusters_" + normalized +} diff --git a/internal/cache/cached_clusters_test.go b/internal/cache/cached_clusters_test.go new file mode 100644 index 0000000..217da5b --- /dev/null +++ b/internal/cache/cached_clusters_test.go @@ -0,0 +1,104 @@ +package cache + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/aaearon/grant-cli/internal/k8s" +) + +type fakeClusterLister struct { + calls int + clusters []k8s.Cluster + err error +} + +func (f *fakeClusterLister) ListClusters(_ context.Context, _ string) ([]k8s.Cluster, error) { + f.calls++ + return f.clusters, f.err +} + +func newClusterStore(t *testing.T) *Store { + t.Helper() + return NewStore(t.TempDir(), time.Hour) +} + +func TestCachedClusterListerCachesResults(t *testing.T) { + inner := &fakeClusterLister{clusters: []k8s.Cluster{{Provider: "aws", Name: "prod"}}} + store := newClusterStore(t) + + lister := NewCachedClusterLister(inner, store, false, nil) + + first, err := lister.ListClusters(t.Context(), "aws") + if err != nil { + t.Fatalf("first call: %v", err) + } + second, err := lister.ListClusters(t.Context(), "aws") + if err != nil { + t.Fatalf("second call: %v", err) + } + + if inner.calls != 1 { + t.Errorf("inner called %d times, want 1 (second call should hit the cache)", inner.calls) + } + if len(first) != 1 || len(second) != 1 || second[0].Name != "prod" { + t.Errorf("first=%v second=%v", first, second) + } +} + +func TestCachedClusterListerRefreshBypassesCache(t *testing.T) { + inner := &fakeClusterLister{clusters: []k8s.Cluster{{Provider: "aws", Name: "prod"}}} + store := newClusterStore(t) + + if _, err := NewCachedClusterLister(inner, store, false, nil).ListClusters(t.Context(), "aws"); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := NewCachedClusterLister(inner, store, true, nil).ListClusters(t.Context(), "aws"); err != nil { + t.Fatalf("refresh: %v", err) + } + + if inner.calls != 2 { + t.Errorf("inner called %d times, want 2 (refresh must bypass the cache)", inner.calls) + } +} + +func TestCachedClusterListerKeysPerCSP(t *testing.T) { + inner := &fakeClusterLister{clusters: []k8s.Cluster{{Provider: "aws"}}} + store := newClusterStore(t) + lister := NewCachedClusterLister(inner, store, false, nil) + + for _, csp := range []string{"aws", "azure", ""} { + if _, err := lister.ListClusters(t.Context(), csp); err != nil { + t.Fatalf("csp %q: %v", csp, err) + } + } + + if inner.calls != 3 { + t.Errorf("inner called %d times, want 3 (one per distinct cache key)", inner.calls) + } +} + +func TestCachedClusterListerPropagatesError(t *testing.T) { + sentinel := errors.New("api down") + inner := &fakeClusterLister{err: sentinel} + lister := NewCachedClusterLister(inner, newClusterStore(t), false, nil) + + if _, err := lister.ListClusters(t.Context(), "aws"); !errors.Is(err, sentinel) { + t.Fatalf("err = %v, want %v", err, sentinel) + } +} + +func TestClustersCacheKey(t *testing.T) { + tests := []struct{ csp, want string }{ + {csp: "aws", want: "clusters_aws"}, + {csp: "AZURE", want: "clusters_azure"}, + {csp: "", want: "clusters_all"}, + } + for _, tt := range tests { + if got := clustersCacheKey(tt.csp); got != tt.want { + t.Errorf("clustersCacheKey(%q) = %q, want %q", tt.csp, got, tt.want) + } + } +} diff --git a/internal/k8s/service.go b/internal/k8s/service.go new file mode 100644 index 0000000..1c63f71 --- /dev/null +++ b/internal/k8s/service.go @@ -0,0 +1,377 @@ +// Package k8s wraps the SDK's SCA Kubernetes service with grant-shaped models, +// context propagation and errors. +// +// The SDK package github.com/cyberark/idsec-sdk-golang/pkg/services/sca/k8s owns +// all transport and credential flows (X-CLI-Signature, DPA SSO acquire, STS +// presign, Azure CLI token acquisition, JWE decryption). grant owns the command, +// selector, cache and kubeconfig-file layers on top of it. +// +// Context propagation: only GenerateKubeconfigParallel accepts a context.Context. +// Every other SDK entry point uses context.Background() internally, so grant +// enforces cancellation at this wrapper boundary via runWithContext. That aborts +// the caller promptly but cannot cancel the in-flight HTTP request; the goroutine +// runs to completion and its result is discarded. +package k8s + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/cyberark/idsec-sdk-golang/pkg/auth" + sdkk8s "github.com/cyberark/idsec-sdk-golang/pkg/services/sca/k8s" + k8smodels "github.com/cyberark/idsec-sdk-golang/pkg/services/sca/k8s/models" +) + +// Connection method values returned by the evaluate endpoint. +const ( + ConnectionDirect = "direct" + ConnectionProxy = "proxy" +) + +// SupportedCSPs lists the cloud providers SCA supports for Kubernetes clusters. +// GCP is deliberately absent: the SCA k8s API does not support it. +var SupportedCSPs = []string{"aws", "azure"} + +// ErrUnsupportedCSP is returned when a provider outside SupportedCSPs is requested. +var ErrUnsupportedCSP = errors.New("unsupported cloud provider for Kubernetes") + +// Cluster is grant's view of a single SCA-eligible Kubernetes cluster. +type Cluster struct { + Provider string `json:"provider"` + Name string `json:"name"` + ClusterID string `json:"clusterId"` + FQDN string `json:"fqdn,omitempty"` + Region string `json:"region,omitempty"` + Scope string `json:"scope,omitempty"` + Namespace string `json:"namespace,omitempty"` + WorkspaceID string `json:"workspaceId,omitempty"` + WorkspaceName string `json:"workspaceName,omitempty"` + WorkspaceType string `json:"workspaceType,omitempty"` + RoleName string `json:"role,omitempty"` + RoleID string `json:"roleId,omitempty"` + OrganizationID string `json:"organizationId,omitempty"` +} + +// Connection is the evaluate result for a single cluster. +type Connection struct { + ConnectionMethod string + CertificateData string + ClusterID string + Region string + RoleID string + WorkspaceID string + OrganizationID string + Namespace string +} + +// ElevateParams are the inputs for a cluster elevation. +type ElevateParams struct { + CSP string + FQDN string + RoleID string + OrganizationID string + Namespace string +} + +// ElevateResult is grant's view of a single elevate result. +type ElevateResult struct { + SessionID string + SessionExpTime string + RoleName string + RoleID string + TargetID string + WorkspaceID string + CSP string + + // SDK is the raw SDK result, needed by the SDK token providers. + SDK *k8smodels.IdsecSCAK8sElevateResult +} + +// KubeconfigFailure records a per-CSP kubeconfig generation failure. +type KubeconfigFailure struct { + CSP string `json:"csp"` + Error string `json:"error"` +} + +// backend is the seam over the SDK service, so tests never touch the network. +type backend interface { + ListTargets(req *k8smodels.IdsecSCAk8sListClustersRequest) (*k8smodels.IdsecSCAk8sListClustersResponse, error) + EvaluateEligibility(req *k8smodels.IdsecSCAK8sEvaluateRequest, csp string) (*k8smodels.IdsecSCAK8sEvaluateResponse, error) + Elevate(req *k8smodels.IdsecSCAK8sElevateKubectlRequest) (*k8smodels.IdsecSCAK8sElevateResponse, error) + GenerateKubeconfigParallel(ctx context.Context, csps []string, kubeconfigLocation string) *k8smodels.IdsecSCAK8sGenerateKubeconfigParallelResponse + GenerateProxyExecCredential(csp string, cctx *sdkk8s.IdsecSCAK8sClusterContext) (*k8smodels.IdsecSCAK8sExecCredential, error) +} + +// Service is grant's wrapper around the SDK SCA K8s service. +type Service struct { + backend backend +} + +// NewService creates a Service backed by the real SDK k8s service. +func NewService(authenticators ...auth.IdsecAuth) (*Service, error) { + sdkSvc, err := sdkk8s.NewIdsecSCAK8sService(authenticators...) + if err != nil { + return nil, fmt.Errorf("failed to create SCA k8s service: %w", err) + } + return &Service{backend: sdkSvc}, nil +} + +// NewServiceWithBackend creates a Service over an injected backend. For tests. +func NewServiceWithBackend(b backend) *Service { + return &Service{backend: b} +} + +// NormalizeCSP validates and upper-cases a provider name for the SCA k8s API. +func NormalizeCSP(csp string) (string, error) { + trimmed := strings.ToLower(strings.TrimSpace(csp)) + for _, supported := range SupportedCSPs { + if trimmed == supported { + return strings.ToUpper(trimmed), nil + } + } + return "", fmt.Errorf("%w: %q (supported: %s)", ErrUnsupportedCSP, csp, strings.Join(SupportedCSPs, ", ")) +} + +// runWithContext runs fn on a goroutine and returns as soon as either fn +// completes or ctx is done. The SDK call itself is not cancellable, so on +// cancellation the goroutine keeps running and its result is dropped. +func runWithContext[T any](ctx context.Context, fn func() (T, error)) (T, error) { + type outcome struct { + val T + err error + } + done := make(chan outcome, 1) + go func() { + val, err := fn() + done <- outcome{val: val, err: err} + }() + + select { + case <-ctx.Done(): + var zero T + return zero, ctx.Err() + case res := <-done: + return res.val, res.err + } +} + +// ListClusters lists SCA-eligible clusters. An empty csp lists every supported +// provider; results are merged into a single flat slice. +func (s *Service) ListClusters(ctx context.Context, csp string) ([]Cluster, error) { + req := &k8smodels.IdsecSCAk8sListClustersRequest{} + if strings.TrimSpace(csp) == "" { + req.All = true + } else { + normalized, err := NormalizeCSP(csp) + if err != nil { + return nil, err + } + req.CSP = normalized + } + + resp, err := runWithContext(ctx, func() (*k8smodels.IdsecSCAk8sListClustersResponse, error) { + return s.backend.ListTargets(req) + }) + if err != nil { + return nil, fmt.Errorf("list clusters failed: %w", err) + } + if resp == nil { + return nil, nil + } + + clusters := make([]Cluster, 0, resp.Total) + if len(resp.Responses) > 0 { + for _, provider := range SupportedCSPs { + perCSP, ok := resp.Responses[provider] + if !ok { + continue + } + clusters = append(clusters, convertTargets(provider, perCSP.Response)...) + } + return clusters, nil + } + + return append(clusters, convertTargets(strings.ToLower(req.CSP), resp.Response)...), nil +} + +func convertTargets(provider string, targets []k8smodels.IdsecSCAk8sListClustersEligibleTarget) []Cluster { + out := make([]Cluster, 0, len(targets)) + for _, t := range targets { + c := Cluster{ + Provider: provider, + ClusterID: t.Target.ClusterID, + Region: t.Target.Region, + Scope: t.Target.Scope, + WorkspaceID: t.WorkspaceID, + WorkspaceName: t.WorkspaceName, + WorkspaceType: t.WorkspaceType, + RoleName: t.Role.Name, + RoleID: t.Role.ID, + } + if t.Target.FQDN != nil { + c.FQDN = *t.Target.FQDN + } + if t.Target.NamespaceID != nil { + c.Namespace = sdkk8s.ParseNamespaceName(*t.Target.NamespaceID) + } + if t.OrganizationID != nil { + c.OrganizationID = *t.OrganizationID + } + c.Name = clusterDisplayName(c.ClusterID, c.FQDN) + out = append(out, c) + } + return out +} + +// clusterDisplayName derives a short cluster name from an EKS ARN or an Azure +// resource ID, falling back to the raw ID and then the FQDN. +func clusterDisplayName(clusterID, fqdn string) string { + id := strings.TrimSpace(clusterID) + if id == "" { + return fqdn + } + if idx := strings.LastIndex(id, "/"); idx >= 0 && idx < len(id)-1 { + return id[idx+1:] + } + return id +} + +// Evaluate resolves the connection method (direct or proxy) for a cluster FQDN. +func (s *Service) Evaluate(ctx context.Context, csp, fqdn string) (*Connection, error) { + normalized, err := NormalizeCSP(csp) + if err != nil { + return nil, err + } + if strings.TrimSpace(fqdn) == "" { + return nil, errors.New("cluster FQDN is required to evaluate eligibility") + } + + req := &k8smodels.IdsecSCAK8sEvaluateRequest{ + Targets: []k8smodels.IdsecSCAK8sEvaluateTarget{{FQDN: fqdn}}, + } + + resp, err := runWithContext(ctx, func() (*k8smodels.IdsecSCAK8sEvaluateResponse, error) { + return s.backend.EvaluateEligibility(req, normalized) + }) + if err != nil { + return nil, fmt.Errorf("evaluate cluster eligibility failed: %w", err) + } + if resp == nil || len(resp.Response) == 0 { + return nil, fmt.Errorf("cluster %q is not eligible for %s, run 'grant k8s list' to see eligible clusters", fqdn, strings.ToLower(normalized)) + } + + r := resp.Response[0] + conn := &Connection{ + ConnectionMethod: strings.ToLower(strings.TrimSpace(r.ConnectionMethod)), + CertificateData: r.CertificateData, + ClusterID: r.Target.ClusterID, + Region: r.Target.Region, + RoleID: r.Role.ID, + WorkspaceID: r.WorkspaceID, + } + if r.OrganizationID != nil { + conn.OrganizationID = *r.OrganizationID + } + if r.Target.NamespaceID != nil { + conn.Namespace = sdkk8s.ParseNamespaceName(*r.Target.NamespaceID) + } + return conn, nil +} + +// Elevate performs a JIT elevation for a single cluster. +func (s *Service) Elevate(ctx context.Context, p ElevateParams) (*ElevateResult, error) { + normalized, err := NormalizeCSP(p.CSP) + if err != nil { + return nil, err + } + if strings.TrimSpace(p.FQDN) == "" { + return nil, errors.New("cluster FQDN is required to elevate") + } + if strings.TrimSpace(p.RoleID) == "" { + return nil, errors.New("role ID is required to elevate") + } + + req := &k8smodels.IdsecSCAK8sElevateKubectlRequest{ + CSP: normalized, + FQDN: p.FQDN, + RoleID: p.RoleID, + OrganizationID: p.OrganizationID, + Namespace: p.Namespace, + } + + resp, err := runWithContext(ctx, func() (*k8smodels.IdsecSCAK8sElevateResponse, error) { + return s.backend.Elevate(req) + }) + if err != nil { + return nil, fmt.Errorf("cluster elevation failed: %w", err) + } + if resp == nil || len(resp.Response.Results) == 0 { + return nil, errors.New("cluster elevation returned no results") + } + + r := resp.Response.Results[0] + return &ElevateResult{ + SessionID: r.SessionID, + SessionExpTime: r.SessionExpTime, + RoleName: r.RoleName, + RoleID: r.RoleID, + TargetID: r.TargetID, + WorkspaceID: r.WorkspaceID, + CSP: resp.Response.CSP, + SDK: &r, + }, nil +} + +// GenerateKubeconfigs fetches DPA-generated kubeconfigs for the given providers. +// This is the single SDK entry point that accepts a context, so the caller's +// context is propagated directly. +func (s *Service) GenerateKubeconfigs(ctx context.Context, csps []string) (map[string]string, []KubeconfigFailure, error) { + if len(csps) == 0 { + return nil, nil, errors.New("at least one cloud provider is required to generate a kubeconfig") + } + + normalized := make([]string, 0, len(csps)) + for _, csp := range csps { + n, err := NormalizeCSP(csp) + if err != nil { + return nil, nil, err + } + normalized = append(normalized, strings.ToLower(n)) + } + + resp := s.backend.GenerateKubeconfigParallel(ctx, normalized, "") + if resp == nil { + return nil, nil, errors.New("kubeconfig generation returned no response") + } + + succeeded := make(map[string]string, len(resp.Succeeded)) + for _, outcome := range resp.Succeeded { + succeeded[strings.ToLower(outcome.CSP)] = outcome.Kubeconfig + } + + failures := make([]KubeconfigFailure, 0, len(resp.Failed)) + for _, outcome := range resp.Failed { + failures = append(failures, KubeconfigFailure{CSP: strings.ToLower(outcome.CSP), Error: outcome.Error}) + } + + return succeeded, failures, nil +} + +// ProxyExecCredential asks the SDK for a proxy-method ExecCredential. The SDK +// bakes its early-refresh buffer into status.expirationTimestamp; grant never +// re-applies it. +func (s *Service) ProxyExecCredential(ctx context.Context, csp string, cctx *sdkk8s.IdsecSCAK8sClusterContext) (*k8smodels.IdsecSCAK8sExecCredential, error) { + normalized, err := NormalizeCSP(csp) + if err != nil { + return nil, err + } + cred, err := runWithContext(ctx, func() (*k8smodels.IdsecSCAK8sExecCredential, error) { + return s.backend.GenerateProxyExecCredential(normalized, cctx) + }) + if err != nil { + return nil, fmt.Errorf("proxy credential generation failed: %w", err) + } + return cred, nil +} diff --git a/internal/k8s/service_test.go b/internal/k8s/service_test.go new file mode 100644 index 0000000..d79e889 --- /dev/null +++ b/internal/k8s/service_test.go @@ -0,0 +1,404 @@ +package k8s + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + sdkk8s "github.com/cyberark/idsec-sdk-golang/pkg/services/sca/k8s" + k8smodels "github.com/cyberark/idsec-sdk-golang/pkg/services/sca/k8s/models" +) + +// stubBackend is a hand-written double for the SDK k8s service. +type stubBackend struct { + listFn func(*k8smodels.IdsecSCAk8sListClustersRequest) (*k8smodels.IdsecSCAk8sListClustersResponse, error) + evaluateFn func(*k8smodels.IdsecSCAK8sEvaluateRequest, string) (*k8smodels.IdsecSCAK8sEvaluateResponse, error) + elevateFn func(*k8smodels.IdsecSCAK8sElevateKubectlRequest) (*k8smodels.IdsecSCAK8sElevateResponse, error) + kubecfgFn func(context.Context, []string, string) *k8smodels.IdsecSCAK8sGenerateKubeconfigParallelResponse + proxyFn func(string, *sdkk8s.IdsecSCAK8sClusterContext) (*k8smodels.IdsecSCAK8sExecCredential, error) +} + +func (s *stubBackend) ListTargets(req *k8smodels.IdsecSCAk8sListClustersRequest) (*k8smodels.IdsecSCAk8sListClustersResponse, error) { + if s.listFn != nil { + return s.listFn(req) + } + return &k8smodels.IdsecSCAk8sListClustersResponse{}, nil +} + +func (s *stubBackend) EvaluateEligibility(req *k8smodels.IdsecSCAK8sEvaluateRequest, csp string) (*k8smodels.IdsecSCAK8sEvaluateResponse, error) { + if s.evaluateFn != nil { + return s.evaluateFn(req, csp) + } + return &k8smodels.IdsecSCAK8sEvaluateResponse{}, nil +} + +func (s *stubBackend) Elevate(req *k8smodels.IdsecSCAK8sElevateKubectlRequest) (*k8smodels.IdsecSCAK8sElevateResponse, error) { + if s.elevateFn != nil { + return s.elevateFn(req) + } + return &k8smodels.IdsecSCAK8sElevateResponse{}, nil +} + +func (s *stubBackend) GenerateKubeconfigParallel(ctx context.Context, csps []string, loc string) *k8smodels.IdsecSCAK8sGenerateKubeconfigParallelResponse { + if s.kubecfgFn != nil { + return s.kubecfgFn(ctx, csps, loc) + } + return &k8smodels.IdsecSCAK8sGenerateKubeconfigParallelResponse{} +} + +func (s *stubBackend) GenerateProxyExecCredential(csp string, cctx *sdkk8s.IdsecSCAK8sClusterContext) (*k8smodels.IdsecSCAK8sExecCredential, error) { + if s.proxyFn != nil { + return s.proxyFn(csp, cctx) + } + return &k8smodels.IdsecSCAK8sExecCredential{}, nil +} + +func strptr(s string) *string { return &s } + +func TestNormalizeCSP(t *testing.T) { + tests := []struct { + name string + in string + want string + wantErr bool + }{ + {name: "aws lowercase", in: "aws", want: "AWS"}, + {name: "azure mixed case", in: "AzUrE", want: "AZURE"}, + {name: "trims whitespace", in: " aws ", want: "AWS"}, + {name: "empty is invalid", in: "", wantErr: true}, + {name: "gcp unsupported", in: "gcp", wantErr: true}, + {name: "garbage", in: "nope", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NormalizeCSP(tt.in) + if tt.wantErr { + if err == nil { + t.Fatalf("NormalizeCSP(%q) = %q, want error", tt.in, got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("NormalizeCSP(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestServiceListClusters(t *testing.T) { + resp := &k8smodels.IdsecSCAk8sListClustersResponse{ + Total: 1, + Response: []k8smodels.IdsecSCAk8sListClustersEligibleTarget{ + { + OrganizationID: strptr("o-123"), + WorkspaceID: "111122223333", + WorkspaceName: "prod-account", + WorkspaceType: "ACCOUNT", + Role: k8smodels.IdsecSCAk8sListClustersRole{ID: "arn:aws:iam::1:role/admin", Name: "admin"}, + Target: k8smodels.IdsecSCAk8sListClustersTarget{ + Scope: "cluster", + Region: "us-east-1", + ClusterID: "arn:aws:eks:us-east-1:1:cluster/prod", + FQDN: strptr("abc.gr7.us-east-1.eks.amazonaws.com"), + }, + }, + }, + } + + var gotReq *k8smodels.IdsecSCAk8sListClustersRequest + svc := NewServiceWithBackend(&stubBackend{ + listFn: func(r *k8smodels.IdsecSCAk8sListClustersRequest) (*k8smodels.IdsecSCAk8sListClustersResponse, error) { + gotReq = r + return resp, nil + }, + }) + + clusters, err := svc.ListClusters(t.Context(), "aws") + if err != nil { + t.Fatalf("ListClusters: %v", err) + } + if gotReq.CSP != "AWS" || gotReq.All { + t.Errorf("request = %+v, want CSP=AWS All=false", gotReq) + } + if len(clusters) != 1 { + t.Fatalf("got %d clusters, want 1", len(clusters)) + } + c := clusters[0] + if c.Provider != "aws" { + t.Errorf("Provider = %q, want aws", c.Provider) + } + if c.Name != "prod" { + t.Errorf("Name = %q, want prod (derived from cluster ARN)", c.Name) + } + if c.FQDN != "abc.gr7.us-east-1.eks.amazonaws.com" { + t.Errorf("FQDN = %q", c.FQDN) + } + if c.OrganizationID != "o-123" { + t.Errorf("OrganizationID = %q, want o-123", c.OrganizationID) + } + if c.RoleID != "arn:aws:iam::1:role/admin" || c.RoleName != "admin" { + t.Errorf("role = %q/%q", c.RoleID, c.RoleName) + } +} + +func TestServiceListClustersAllCSPs(t *testing.T) { + svc := NewServiceWithBackend(&stubBackend{ + listFn: func(r *k8smodels.IdsecSCAk8sListClustersRequest) (*k8smodels.IdsecSCAk8sListClustersResponse, error) { + if !r.All { + t.Errorf("expected All=true when csp is empty, got %+v", r) + } + return &k8smodels.IdsecSCAk8sListClustersResponse{ + Responses: map[string]k8smodels.IdsecSCAk8sListClustersResponse{ + "aws": {Response: []k8smodels.IdsecSCAk8sListClustersEligibleTarget{ + {WorkspaceName: "acct", Target: k8smodels.IdsecSCAk8sListClustersTarget{ClusterID: "c1"}}, + }}, + "azure": {Response: []k8smodels.IdsecSCAk8sListClustersEligibleTarget{ + {WorkspaceName: "sub", Target: k8smodels.IdsecSCAk8sListClustersTarget{ClusterID: "c2"}}, + }}, + }, + }, nil + }, + }) + + clusters, err := svc.ListClusters(t.Context(), "") + if err != nil { + t.Fatalf("ListClusters: %v", err) + } + if len(clusters) != 2 { + t.Fatalf("got %d clusters, want 2 (merged across CSPs)", len(clusters)) + } + seen := map[string]bool{} + for _, c := range clusters { + seen[c.Provider] = true + } + if !seen["aws"] || !seen["azure"] { + t.Errorf("expected both providers, got %v", seen) + } +} + +func TestServiceListClustersInvalidCSP(t *testing.T) { + svc := NewServiceWithBackend(&stubBackend{}) + if _, err := svc.ListClusters(t.Context(), "gcp"); err == nil { + t.Fatal("expected error for unsupported CSP") + } +} + +func TestServiceListClustersWrapsSDKError(t *testing.T) { + sentinel := errors.New("boom") + svc := NewServiceWithBackend(&stubBackend{ + listFn: func(*k8smodels.IdsecSCAk8sListClustersRequest) (*k8smodels.IdsecSCAk8sListClustersResponse, error) { + return nil, sentinel + }, + }) + + _, err := svc.ListClusters(t.Context(), "aws") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, sentinel) { + t.Errorf("error does not wrap the SDK error: %v", err) + } + if !strings.Contains(err.Error(), "list clusters") { + t.Errorf("error lacks grant-shaped context: %v", err) + } +} + +// The SDK's ListTargets takes no context, so grant enforces cancellation itself. +func TestServiceListClustersHonoursCancelledContext(t *testing.T) { + release := make(chan struct{}) + t.Cleanup(func() { close(release) }) + + svc := NewServiceWithBackend(&stubBackend{ + listFn: func(*k8smodels.IdsecSCAk8sListClustersRequest) (*k8smodels.IdsecSCAk8sListClustersResponse, error) { + <-release + return &k8smodels.IdsecSCAk8sListClustersResponse{}, nil + }, + }) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + if _, err := svc.ListClusters(ctx, "aws"); !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } +} + +// GenerateKubeconfigParallel is the one SDK entry point that accepts a context; +// grant passes the caller's context straight through. +func TestServiceGenerateKubeconfigsPropagatesContext(t *testing.T) { + var gotCtx context.Context + svc := NewServiceWithBackend(&stubBackend{ + kubecfgFn: func(ctx context.Context, csps []string, loc string) *k8smodels.IdsecSCAK8sGenerateKubeconfigParallelResponse { + gotCtx = ctx + return &k8smodels.IdsecSCAK8sGenerateKubeconfigParallelResponse{ + Succeeded: []k8smodels.IdsecSCAK8sKubeconfigOutcome{{CSP: "aws", Kubeconfig: "apiVersion: v1"}}, + Failed: []k8smodels.IdsecSCAK8sKubeconfigOutcome{{CSP: "azure", Error: "nope"}}, + } + }, + }) + + type ctxKey string + ctx := context.WithValue(t.Context(), ctxKey("marker"), "yes") + + ok, failed, err := svc.GenerateKubeconfigs(ctx, []string{"aws", "azure"}) + if err != nil { + t.Fatalf("GenerateKubeconfigs: %v", err) + } + if gotCtx == nil || gotCtx.Value(ctxKey("marker")) != "yes" { + t.Error("caller context was not propagated to GenerateKubeconfigParallel") + } + if len(ok) != 1 || ok["aws"] != "apiVersion: v1" { + t.Errorf("succeeded = %v", ok) + } + if len(failed) != 1 || failed[0].CSP != "azure" { + t.Errorf("failed = %v", failed) + } +} + +func TestServiceGenerateKubeconfigsValidatesCSPs(t *testing.T) { + svc := NewServiceWithBackend(&stubBackend{}) + if _, _, err := svc.GenerateKubeconfigs(t.Context(), []string{"gcp"}); err == nil { + t.Fatal("expected error for unsupported CSP") + } + if _, _, err := svc.GenerateKubeconfigs(t.Context(), nil); err == nil { + t.Fatal("expected error for empty CSP list") + } +} + +func TestServiceEvaluate(t *testing.T) { + svc := NewServiceWithBackend(&stubBackend{ + evaluateFn: func(req *k8smodels.IdsecSCAK8sEvaluateRequest, csp string) (*k8smodels.IdsecSCAK8sEvaluateResponse, error) { + if csp != "AWS" { + t.Errorf("csp = %q, want AWS", csp) + } + if len(req.Targets) != 1 || req.Targets[0].FQDN != "host" { + t.Errorf("targets = %+v", req.Targets) + } + return &k8smodels.IdsecSCAK8sEvaluateResponse{ + Response: []k8smodels.IdsecSCAK8sEvaluateResult{{ + ConnectionMethod: "proxy", + CertificateData: "Y2E=", + WorkspaceID: "ws", + Role: k8smodels.IdsecSCAk8sListClustersRole{ID: "r"}, + Target: k8smodels.IdsecSCAk8sListClustersTarget{ClusterID: "cid", Region: "us-east-1"}, + }}, + }, nil + }, + }) + + got, err := svc.Evaluate(t.Context(), "aws", "host") + if err != nil { + t.Fatalf("Evaluate: %v", err) + } + if got.ConnectionMethod != ConnectionProxy { + t.Errorf("ConnectionMethod = %q, want proxy", got.ConnectionMethod) + } + if got.CertificateData != "Y2E=" { + t.Errorf("CertificateData = %q", got.CertificateData) + } +} + +func TestServiceEvaluateNoResults(t *testing.T) { + svc := NewServiceWithBackend(&stubBackend{ + evaluateFn: func(*k8smodels.IdsecSCAK8sEvaluateRequest, string) (*k8smodels.IdsecSCAK8sEvaluateResponse, error) { + return &k8smodels.IdsecSCAK8sEvaluateResponse{}, nil + }, + }) + _, err := svc.Evaluate(t.Context(), "aws", "host") + if err == nil || !strings.Contains(err.Error(), "not eligible") { + t.Fatalf("err = %v, want a not-eligible error", err) + } +} + +func TestServiceElevate(t *testing.T) { + svc := NewServiceWithBackend(&stubBackend{ + elevateFn: func(req *k8smodels.IdsecSCAK8sElevateKubectlRequest) (*k8smodels.IdsecSCAK8sElevateResponse, error) { + if req.CSP != "AWS" || req.FQDN != "host" || req.RoleID != "role" { + t.Errorf("req = %+v", req) + } + return &k8smodels.IdsecSCAK8sElevateResponse{ + Response: k8smodels.IdsecSCAK8sElevateResponseBody{ + CSP: "AWS", + Results: []k8smodels.IdsecSCAK8sElevateResult{{ + SessionID: "s1", RoleName: "admin", TargetID: "arn:aws:eks:us-east-1:1:cluster/prod", + SessionExpTime: "2026-08-13T10:00:00Z", + }}, + }, + }, nil + }, + }) + + res, err := svc.Elevate(t.Context(), ElevateParams{CSP: "aws", FQDN: "host", RoleID: "role"}) + if err != nil { + t.Fatalf("Elevate: %v", err) + } + if res.SessionID != "s1" || res.RoleName != "admin" { + t.Errorf("result = %+v", res) + } +} + +func TestServiceElevateEmptyResults(t *testing.T) { + svc := NewServiceWithBackend(&stubBackend{ + elevateFn: func(*k8smodels.IdsecSCAK8sElevateKubectlRequest) (*k8smodels.IdsecSCAK8sElevateResponse, error) { + return &k8smodels.IdsecSCAK8sElevateResponse{}, nil + }, + }) + if _, err := svc.Elevate(t.Context(), ElevateParams{CSP: "aws", FQDN: "h", RoleID: "r"}); err == nil { + t.Fatal("expected error when elevate returns no results") + } +} + +func TestServiceElevateRequiresFields(t *testing.T) { + svc := NewServiceWithBackend(&stubBackend{}) + tests := []struct { + name string + p ElevateParams + }{ + {name: "missing fqdn", p: ElevateParams{CSP: "aws", RoleID: "r"}}, + {name: "missing role", p: ElevateParams{CSP: "aws", FQDN: "h"}}, + {name: "bad csp", p: ElevateParams{CSP: "gcp", FQDN: "h", RoleID: "r"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := svc.Elevate(t.Context(), tt.p); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + +func TestClusterDisplayName(t *testing.T) { + tests := []struct { + name string + in Cluster + want string + }{ + {name: "eks arn", in: Cluster{ClusterID: "arn:aws:eks:us-east-1:1:cluster/prod"}, want: "prod"}, + {name: "azure resource id", in: Cluster{ClusterID: "/subscriptions/s/resourceGroups/rg/providers/Microsoft.ContainerService/managedClusters/aks1"}, want: "aks1"}, + {name: "plain name", in: Cluster{ClusterID: "plain"}, want: "plain"}, + {name: "empty falls back to fqdn", in: Cluster{FQDN: "host.example"}, want: "host.example"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := clusterDisplayName(tt.in.ClusterID, tt.in.FQDN); got != tt.want { + t.Errorf("clusterDisplayName = %q, want %q", got, tt.want) + } + }) + } +} + +func TestRunWithContextReturnsResult(t *testing.T) { + got, err := runWithContext(t.Context(), func() (int, error) { + time.Sleep(time.Millisecond) + return 42, nil + }) + if err != nil || got != 42 { + t.Fatalf("got %d, %v", got, err) + } +} diff --git a/internal/ui/cluster_selector.go b/internal/ui/cluster_selector.go new file mode 100644 index 0000000..ec1bd19 --- /dev/null +++ b/internal/ui/cluster_selector.go @@ -0,0 +1,80 @@ +package ui + +import ( + "errors" + "fmt" + "os" + "sort" + "strings" + + survey "github.com/Iilun/survey/v2" + "github.com/aaearon/grant-cli/internal/k8s" +) + +// FormatClusterOption formats an eligible cluster into a display string. +func FormatClusterOption(cluster k8s.Cluster) string { + var b strings.Builder + b.WriteString(cluster.Name) + if cluster.Region != "" { + fmt.Fprintf(&b, " (%s)", cluster.Region) + } + if cluster.Namespace != "" { + fmt.Fprintf(&b, " [ns: %s]", cluster.Namespace) + } + if cluster.RoleName != "" { + fmt.Fprintf(&b, " / Role: %s", cluster.RoleName) + } + if cluster.Provider != "" { + fmt.Fprintf(&b, " (%s)", strings.ToLower(cluster.Provider)) + } + return b.String() +} + +// BuildClusterOptions builds a sorted list of display options from clusters. +func BuildClusterOptions(clusters []k8s.Cluster) []string { + if len(clusters) == 0 { + return []string{} + } + options := make([]string, len(clusters)) + for i, c := range clusters { + options[i] = FormatClusterOption(c) + } + sort.Strings(options) + return options +} + +// FindClusterByDisplay finds a cluster by its formatted display string. +func FindClusterByDisplay(clusters []k8s.Cluster, display string) (*k8s.Cluster, error) { + for i := range clusters { + if FormatClusterOption(clusters[i]) == display { + return &clusters[i], nil + } + } + return nil, fmt.Errorf("cluster not found: %s", display) +} + +// SelectCluster presents an interactive selector for choosing a cluster. +func SelectCluster(clusters []k8s.Cluster) (*k8s.Cluster, error) { + if !IsInteractive() { + return nil, fmt.Errorf("%w; pass a cluster name or --fqdn, or run 'grant k8s list' to see eligible clusters", ErrNotInteractive) + } + + if len(clusters) == 0 { + return nil, errors.New("no eligible clusters available") + } + + options := BuildClusterOptions(clusters) + + var selected string + prompt := &survey.Select{ + Message: "Select a cluster:", + Options: options, + Filter: nil, // Enable default fuzzy filter + } + + if err := survey.AskOne(prompt, &selected, survey.WithStdio(os.Stdin, os.Stderr, os.Stderr)); err != nil { + return nil, fmt.Errorf("cluster selection failed: %w", err) + } + + return FindClusterByDisplay(clusters, selected) +} diff --git a/internal/ui/cluster_selector_test.go b/internal/ui/cluster_selector_test.go new file mode 100644 index 0000000..105a35c --- /dev/null +++ b/internal/ui/cluster_selector_test.go @@ -0,0 +1,107 @@ +package ui + +import ( + "errors" + "strings" + "testing" + + "github.com/aaearon/grant-cli/internal/k8s" +) + +func TestFormatClusterOption(t *testing.T) { + tests := []struct { + name string + in k8s.Cluster + want string + }{ + { + name: "aws cluster with role", + in: k8s.Cluster{Provider: "aws", Name: "prod", Region: "us-east-1", RoleName: "admin"}, + want: "prod (us-east-1) / Role: admin (aws)", + }, + { + name: "azure cluster without region", + in: k8s.Cluster{Provider: "azure", Name: "aks1", RoleName: "reader"}, + want: "aks1 / Role: reader (azure)", + }, + { + name: "namespace scoped", + in: k8s.Cluster{Provider: "azure", Name: "aks1", Namespace: "team-a", RoleName: "reader"}, + want: "aks1 [ns: team-a] / Role: reader (azure)", + }, + { + name: "no provider", + in: k8s.Cluster{Name: "solo", RoleName: "admin"}, + want: "solo / Role: admin", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := FormatClusterOption(tt.in); got != tt.want { + t.Errorf("FormatClusterOption() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBuildClusterOptionsSorted(t *testing.T) { + clusters := []k8s.Cluster{ + {Provider: "aws", Name: "zeta", RoleName: "r"}, + {Provider: "aws", Name: "alpha", RoleName: "r"}, + } + options := BuildClusterOptions(clusters) + if len(options) != 2 { + t.Fatalf("got %d options, want 2", len(options)) + } + if options[0] != "alpha / Role: r (aws)" { + t.Errorf("options not sorted: %v", options) + } + + if got := BuildClusterOptions(nil); len(got) != 0 { + t.Errorf("BuildClusterOptions(nil) = %v, want empty", got) + } +} + +func TestFindClusterByDisplay(t *testing.T) { + clusters := []k8s.Cluster{ + {Provider: "aws", Name: "alpha", RoleName: "r"}, + {Provider: "azure", Name: "beta", RoleName: "r"}, + } + + got, err := FindClusterByDisplay(clusters, "beta / Role: r (azure)") + if err != nil { + t.Fatalf("FindClusterByDisplay: %v", err) + } + if got.Name != "beta" { + t.Errorf("got %q, want beta", got.Name) + } + + if _, err := FindClusterByDisplay(clusters, "nope"); err == nil { + t.Error("expected error for unknown display string") + } +} + +func TestSelectClusterNonInteractive(t *testing.T) { + original := IsTerminalFunc + t.Cleanup(func() { IsTerminalFunc = original }) + IsTerminalFunc = func(uintptr) bool { return false } + + _, err := SelectCluster([]k8s.Cluster{{Name: "a"}}) + if !errors.Is(err, ErrNotInteractive) { + t.Fatalf("err = %v, want ErrNotInteractive", err) + } + if got := err.Error(); !strings.Contains(got, "grant k8s list") { + t.Errorf("error should hint at 'grant k8s list', got %q", got) + } +} + +func TestSelectClusterEmpty(t *testing.T) { + original := IsTerminalFunc + t.Cleanup(func() { IsTerminalFunc = original }) + IsTerminalFunc = func(uintptr) bool { return true } + + if _, err := SelectCluster(nil); err == nil { + t.Fatal("expected error when there are no clusters") + } +} From 1c29db7fe84a99cc36c1ea8accd529c0f3d328ab Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Thu, 13 Aug 2026 18:22:49 +0200 Subject: [PATCH 2/8] feat: add grant k8s kubeconfig, elevate and exec-credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the grant k8s surface with the kubectl-protocol and filesystem half of the feature. - grant k8s elevate [cluster]: JIT elevation for one cluster, resolving by name, FQDN or cluster ID, with the interactive picker (and ErrNotInteractive plus a 'grant k8s list' hint) when omitted - grant k8s kubeconfig: fetch the DPA-generated kubeconfig and MERGE it. Entries grant owns are named grant--; colliding grant entries are replaced and reported, everything else survives untouched (a yaml.Node tree is edited in place so comments and structure are preserved). current-context changes only with --set-current-context. Atomic write via a temp file in the same directory + fsync + rename, mode 0600 (an existing narrower mode is preserved, a group/world readable one is re-secured with a warning), parent dir 0700, and a one-time .grant.bak. $KUBECONFIG list form honoured. Escape hatches --stdout and --file; --file rather than --output, which is the global text|json flag and must not be shadowed - grant k8s exec-credential (Hidden): kubectl credential plugin. Stdout carries the ExecCredential JSON and nothing else. The response apiVersion echoes the one kubectl requests via KUBERNETES_EXEC_INFO (v1beta1 and v1); spec.interactive gates flows that would open a browser or run az login, which now fail fast rather than hanging. Credentials are cached at 0600 until their stamped expiry — the SDK's refresh buffer is applied once at the source and never again - internal/k8s: kubeconfig merge/write, ExecCredential cache with an injectable clock, and the evaluate -> elevate -> direct/proxy credential orchestration behind an injectable seam R-5a is implemented defensively: exec stanzas are rewritten to point at grant only when the command basename is a known official CLI binary, carrying over only flags grant understands. The real shape of the DPA-generated kubeconfig could not be observed without a live tenant. Untested against a live Kubernetes cluster. --- CHANGELOG.md | 4 + CLAUDE.md | 8 +- README.md | 20 +- cmd/interfaces.go | 15 + cmd/k8s.go | 29 ++ cmd/k8s_elevate.go | 184 ++++++++++ cmd/k8s_elevate_test.go | 157 +++++++++ cmd/k8s_exec_credential.go | 206 +++++++++++ cmd/k8s_exec_credential_test.go | 355 +++++++++++++++++++ cmd/k8s_integration_test.go | 2 +- cmd/k8s_kubeconfig.go | 284 +++++++++++++++ cmd/k8s_kubeconfig_test.go | 381 ++++++++++++++++++++ cmd/k8s_list_test.go | 16 +- cmd/output_types.go | 27 ++ internal/k8s/execcred.go | 247 +++++++++++++ internal/k8s/execcred_cache.go | 129 +++++++ internal/k8s/execcred_cache_test.go | 242 +++++++++++++ internal/k8s/execcred_test.go | 202 +++++++++++ internal/k8s/kubeconfig.go | 459 ++++++++++++++++++++++++ internal/k8s/kubeconfig_test.go | 525 ++++++++++++++++++++++++++++ internal/k8s/service.go | 6 + 21 files changed, 3491 insertions(+), 7 deletions(-) create mode 100644 cmd/k8s_elevate.go create mode 100644 cmd/k8s_elevate_test.go create mode 100644 cmd/k8s_exec_credential.go create mode 100644 cmd/k8s_exec_credential_test.go create mode 100644 cmd/k8s_kubeconfig.go create mode 100644 cmd/k8s_kubeconfig_test.go create mode 100644 internal/k8s/execcred.go create mode 100644 internal/k8s/execcred_cache.go create mode 100644 internal/k8s/execcred_cache_test.go create mode 100644 internal/k8s/execcred_test.go create mode 100644 internal/k8s/kubeconfig.go create mode 100644 internal/k8s/kubeconfig_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 23b65b0..2aea0b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ All notable changes to this project will be documented in this file. ### Added - `grant k8s list` shows the Secure Cloud Access Kubernetes clusters you are eligible for; untested against a live cluster +- `grant k8s elevate` requests JIT access to a cluster, prompting for one when the name is omitted +- `grant k8s kubeconfig` merges a cluster entry into your kubeconfig, leaving your other contexts untouched +- Hidden `grant k8s exec-credential` plugin lets `kubectl` fetch and cache short-lived cluster credentials +- The `grant` binary is larger now that Kubernetes support pulls the SDK's Azure, AWS and JOSE modules into the build ## [0.9.0] - 2026-08-14 diff --git a/CLAUDE.md b/CLAUDE.md index 9532322..5988baa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,9 @@ Custom `SCAAccessService` follows SDK conventions: - **Azure prerequisite:** the SDK uses `azidentity.NewAzureCLICredential`, so the Azure path requires the Azure CLI installed and logged in (`az login`). Never document it as "no Azure CLI needed". - **Cache:** `internal/cache/cached_clusters.go` — `CachedClusterLister`, keys `clusters_` (`clusters_all` when no provider), same TTL/`--refresh` semantics as eligibility. - **Selector:** `internal/ui/cluster_selector.go` — Format/Build/Find/Select quartet mirroring `internal/ui/selector.go`. +- **Kubeconfig write semantics** (`internal/k8s/kubeconfig.go`): MERGE, never overwrite. grant-owned entries are named `grant--` so ownership is decidable without extra state; colliding grant entries are replaced and reported on stderr; all other entries survive (a `yaml.Node` tree is edited in place, so comments and structure are preserved). `current-context` changes only with `--set-current-context`. Atomic write via a temp file **in the same directory** + `fsync` + `rename`. Mode `0600` (an existing narrower mode is preserved; an existing group/world-readable file is re-secured with a warning), parent dir `0700`. First merge into a pre-existing file writes `.grant.bak` once. Target resolution honours `$KUBECONFIG` including the list form (first non-empty entry). +- **Exec-credential cache** (`internal/k8s/execcred_cache.go`): `~/.grant/cache/execcred_.json`, file `0600` / dir `0700`. A cache file with looser permissions is **refused and removed**. Expiry uses `status.expirationTimestamp` **verbatim** — the SDK bakes its early-refresh buffer in once, at the point the raw DPA/STS/AKS expiry is known, and grant never re-applies it. Credentials with an absent or malformed expiry are not cacheable; an expiry more than `maxCredentialLifetime` (24h) ahead is treated as clock skew and refused. +- **R-5a (unresolved without a live tenant):** the DPA-generated kubeconfig is expected to point `users[].user.exec.command` at the official `idsec`/`ark` binary. `Kubeconfig.RewriteExecCommands` rewrites it defensively — only when the command basename matches a known official binary, carrying over only the flags grant's `exec-credential` understands (`--csp`, `--fqdn`, `--role-id`, `--organization-id`, `--namespace`); anything else passes through untouched. The real shape has not been observed. - **Untested against a live cluster.** No Kubernetes entitlements were available; README carries a single note saying so. ## Access Requests API (Workflows) @@ -121,8 +124,11 @@ Custom `SCAAccessService` follows SDK conventions: - `grant request cancel [id]` — cancel an open request; optional `--reason`. Omitting `` in a TTY opens a picker scoped to STARTING/RUNNING/PENDING requests you created (role=CREATOR) - `grant request approve [id]` / `grant request reject [id]` — finalize a request; optional `--reason`. Omitting `` in a TTY opens a picker scoped to PENDING requests assigned to you (role=APPROVER) - Request picker: `internal/ui/request_selector.go` mirrors the role-selector Format/Build/Select quartet; `resolveRequestIDFn` in `cmd/request_picker.go` is injectable for tests. Non-TTY invocation without `` returns `ErrNotInteractive` with a hint to run `grant request list` -- `grant k8s` — Kubernetes cluster access; subcommands: `list` +- `grant k8s` — Kubernetes cluster access; subcommands: `list`, `elevate`, `kubeconfig`, `exec-credential` (hidden) - `grant k8s list` — list SCA-eligible clusters; flags: `--provider` (aws|azure), `--refresh`, `--output json` +- `grant k8s elevate [cluster]` — JIT elevation for one cluster; omitting `` in a TTY opens the cluster picker; flags: `--provider`, `--role-id`, `--refresh` +- `grant k8s kubeconfig` — fetch and merge a kubeconfig; flags: `--provider`, `--all`, `--file `, `--stdout`, `--set-current-context`. **`--file`, not `--output`**: `--output/-o` is the global text|json flag and must not be shadowed +- `grant k8s exec-credential` — `Hidden: true`; kubectl exec-credential plugin. Stdout carries the ExecCredential JSON and nothing else (same discipline as `grant env`). Reads `KUBERNETES_EXEC_INFO` for the requested `apiVersion` (echoed back verbatim; `client.authentication.k8s.io/v1beta1` and `/v1` supported) and `spec.interactive`. When kubectl reports stdin is unavailable, flows needing a browser or `az login` fail fast with `k8s.ErrInteractionRequired` instead of hanging - `grant update` — self-update binary via GitHub Releases; guards against dev builds. Implemented in `internal/selfupdate/`: - Discovery: `GET https://api.github.com/repos/aaearon/grant-cli/releases/latest` (`apiBaseURL` field injectable for tests) - Version compare: in-house SemVer 2.0.0 parser (`ParseVersion`/`CompareVersions`). Handles pre-release and build metadata (GoReleaser can emit both) with SemVer precedence: build metadata ignored for ordering, pre-release sorts before its release. A leading `v`/`V` is tolerated; leading zeroes are rejected diff --git a/README.md b/README.md index b3160ba..92a1ef3 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,19 @@ on stdout even on exit 1. | Subcommand | Description | |------------|-------------| | `list` | List Kubernetes clusters you are eligible for (`--provider aws\|azure`, `--refresh`, `--output json`) | +| `elevate [cluster]` | Elevate access for a cluster; omit `` in a TTY to open an interactive picker | +| `kubeconfig` | Fetch a kubeconfig and merge it into `$KUBECONFIG` / `~/.kube/config` | + +```bash +grant k8s list # what can I reach? +grant k8s elevate prod-cluster # JIT elevation for one cluster +grant k8s kubeconfig # merge into your existing kubeconfig +kubectl --context grant-aws-prod get ns +``` + +`grant k8s kubeconfig` is a **merge**, not an overwrite. Only entries grant owns — named `grant--` — are added or replaced; every other cluster, user and context in your kubeconfig is left alone, and `current-context` is not changed unless you pass `--set-current-context`. The file is written atomically at mode `0600`, and the first merge into a pre-existing kubeconfig leaves a `.grant.bak` copy behind. Use `--stdout` to print without touching any file, or `--file ` to target a different one. + +The generated kubeconfig authenticates through a hidden `grant k8s exec-credential` plugin that kubectl invokes for you. Supported providers are `aws` (EKS) and `azure` (AKS). GCP is not supported by the SCA Kubernetes API. The Azure path additionally requires the [Azure CLI](https://learn.microsoft.com/cli/azure/) to be installed and logged in (`az login`). @@ -174,8 +187,11 @@ The Azure path additionally requires the [Azure CLI](https://learn.microsoft.com **Elevation** (`grant`, `env`, `favorites add`): `--provider, -p` | `--target, -t` | `--role, -r` | `--favorite, -f` | `--group, -g` | `--groups` | `--refresh` -**`grant k8s list`:** -`--provider, -p` | `--refresh` +**`grant k8s list`:** `--provider, -p` | `--refresh` + +**`grant k8s elevate`:** `--provider, -p` | `--role-id` | `--refresh` + +**`grant k8s kubeconfig`:** `--provider, -p` | `--all` | `--file` (target path) | `--stdout` | `--set-current-context` **`grant request submit`:** `--provider, -p` | `--target, -t` | `--role` | `--role-id` | `--reason` | `--priority` | `--date` | `--timezone` | `--from` | `--to` | `--yes` | `--refresh` diff --git a/cmd/interfaces.go b/cmd/interfaces.go index 5c339d3..43c1a9d 100644 --- a/cmd/interfaces.go +++ b/cmd/interfaces.go @@ -97,6 +97,21 @@ type clusterLister interface { ListClusters(ctx context.Context, csp string) ([]k8s.Cluster, error) } +// clusterElevator interface for elevating access to a Kubernetes cluster +type clusterElevator interface { + Elevate(ctx context.Context, p k8s.ElevateParams) (*k8s.ElevateResult, error) +} + +// kubeconfigGenerator interface for fetching DPA-generated kubeconfigs +type kubeconfigGenerator interface { + GenerateKubeconfigs(ctx context.Context, csps []string) (map[string]string, []k8s.KubeconfigFailure, error) +} + +// clusterCredentialProvider interface for the kubectl exec-credential flow +type clusterCredentialProvider interface { + ExecCredential(ctx context.Context, p k8s.ExecCredentialParams) (*k8s.ExecCredential, error) +} + // accessRequestService interface for access request operations type accessRequestService interface { ListRequests(ctx context.Context, params workflows.ListRequestsParams) ([]wfmodels.AccessRequest, int, error) diff --git a/cmd/k8s.go b/cmd/k8s.go index 558d88c..f9aed28 100644 --- a/cmd/k8s.go +++ b/cmd/k8s.go @@ -15,6 +15,9 @@ func NewK8sCommand() *cobra.Command { cmd := newK8sParent() cmd.AddCommand( NewK8sListCommand(), + NewK8sElevateCommand(), + NewK8sKubeconfigCommand(), + NewK8sExecCredentialCommand(), ) return cmd } @@ -30,6 +33,32 @@ func NewK8sCommandWithDeps(auth authLoader, clusters clusterLister) *cobra.Comma return cmd } +// NewK8sElevateCommandWithDeps creates the elevate command with injected deps. +func NewK8sElevateCommandWithDeps(auth authLoader, clusters clusterLister, elevator clusterElevator) *cobra.Command { + return newK8sElevateCommand(func(c *cobra.Command, args []string) error { + return runK8sElevate(c, args, auth, clusters, elevator) + }) +} + +// NewK8sKubeconfigCommandWithDeps creates the kubeconfig command with injected deps. +func NewK8sKubeconfigCommandWithDeps(auth authLoader, generator kubeconfigGenerator) *cobra.Command { + return newK8sKubeconfigCommand(func(c *cobra.Command, _ []string) error { + return runK8sKubeconfig(c, auth, generator) + }) +} + +// NewK8sExecCredentialCommandWithDeps creates the exec-credential command with +// injected deps. execInfo stands in for the KUBERNETES_EXEC_INFO env var. +func NewK8sExecCredentialCommandWithDeps( + provider clusterCredentialProvider, + credCache *k8s.CredentialCache, + execInfo string, +) *cobra.Command { + return newK8sExecCredentialCommand(func(c *cobra.Command, _ []string) error { + return runK8sExecCredential(c, provider, credCache, execInfo) + }) +} + func newK8sParent() *cobra.Command { return &cobra.Command{ Use: "k8s", diff --git a/cmd/k8s_elevate.go b/cmd/k8s_elevate.go new file mode 100644 index 0000000..0ea642c --- /dev/null +++ b/cmd/k8s_elevate.go @@ -0,0 +1,184 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/aaearon/grant-cli/internal/config" + "github.com/aaearon/grant-cli/internal/k8s" + "github.com/aaearon/grant-cli/internal/ui" + "github.com/spf13/cobra" +) + +// newK8sElevateCommand creates the "grant k8s elevate" command with the given RunE. +func newK8sElevateCommand(runFn func(*cobra.Command, []string) error) *cobra.Command { + cmd := &cobra.Command{ + Use: "elevate [cluster]", + Short: "Elevate access for a Kubernetes cluster", + Long: `Request JIT elevation for a Kubernetes cluster you are eligible for. + +The cluster may be given by name or by API endpoint FQDN. Omit it in a terminal +to pick from an interactive list. + +Examples: + grant k8s elevate # interactive picker + grant k8s elevate prod-cluster + grant k8s elevate --provider azure aks1 + grant k8s elevate prod-cluster --output json`, + Args: cobra.MaximumNArgs(1), + SilenceErrors: true, + SilenceUsage: true, + RunE: runFn, + } + + cmd.Flags().StringP("provider", "p", "", "Cloud provider: aws, azure") + cmd.Flags().String("role-id", "", "Cloud role ID to elevate (defaults to the eligible role)") + cmd.Flags().Bool("refresh", false, "Bypass the cluster cache and fetch fresh data") + + return cmd +} + +// NewK8sElevateCommand creates the production "grant k8s elevate" command. +func NewK8sElevateCommand() *cobra.Command { + return newK8sElevateCommand(func(cmd *cobra.Command, args []string) error { + ispAuth, _, err := bootstrapISPAuth() + if err != nil { + return err + } + svc, err := bootstrapK8sService() + if err != nil { + return err + } + cfg, _, err := config.LoadDefaultWithPath() + if err != nil { + return err + } + refresh, _ := cmd.Flags().GetBool("refresh") + return runK8sElevate(cmd, args, ispAuth, buildCachedClusterLister(cfg, refresh, svc), svc) + }) +} + +// runK8sElevate resolves a cluster then elevates access for it. +func runK8sElevate( + cmd *cobra.Command, + args []string, + auth authLoader, + lister clusterLister, + elevator clusterElevator, +) error { + if _, err := auth.LoadAuthentication(nil, true); err != nil { + return fmt.Errorf("not authenticated, run 'grant login' first: %w", err) + } + + provider, _ := cmd.Flags().GetString("provider") + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider != "" { + if _, err := k8s.NormalizeCSP(provider); err != nil { + return err + } + } + + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) + defer cancel() + + clusters, err := lister.ListClusters(ctx, provider) + if err != nil { + return fmt.Errorf("failed to list clusters: %w", err) + } + if len(clusters) == 0 { + return errors.New("no eligible Kubernetes clusters found, check your SCA policies") + } + + cluster, err := resolveCluster(clusters, args) + if err != nil { + return err + } + if cluster.FQDN == "" { + return fmt.Errorf("cluster %q has no API endpoint FQDN and cannot be elevated", cluster.Name) + } + + roleID, _ := cmd.Flags().GetString("role-id") + if strings.TrimSpace(roleID) == "" { + roleID = cluster.RoleID + } + + result, err := elevator.Elevate(ctx, k8s.ElevateParams{ + CSP: cluster.Provider, + FQDN: cluster.FQDN, + RoleID: roleID, + OrganizationID: cluster.OrganizationID, + Namespace: cluster.Namespace, + }) + if err != nil { + return err + } + + return writeK8sElevateResult(cmd, cluster, result) +} + +// resolveCluster picks a cluster from args, or opens the interactive selector. +func resolveCluster(clusters []k8s.Cluster, args []string) (*k8s.Cluster, error) { + if len(args) == 0 { + return ui.SelectCluster(clusters) + } + + needle := strings.ToLower(strings.TrimSpace(args[0])) + var matches []k8s.Cluster + for i := range clusters { + c := clusters[i] + if strings.EqualFold(c.Name, needle) || strings.EqualFold(c.FQDN, needle) || + strings.EqualFold(c.ClusterID, needle) { + return &clusters[i], nil + } + if strings.Contains(strings.ToLower(c.Name), needle) { + matches = append(matches, c) + } + } + + switch len(matches) { + case 0: + return nil, fmt.Errorf("no eligible cluster matches %q, run 'grant k8s list' to see your clusters", args[0]) + case 1: + return &matches[0], nil + default: + names := make([]string, 0, len(matches)) + for _, m := range matches { + names = append(names, m.Name) + } + return nil, fmt.Errorf("%q matches multiple clusters (%s); use the full name or FQDN", + args[0], strings.Join(names, ", ")) + } +} + +func writeK8sElevateResult(cmd *cobra.Command, cluster *k8s.Cluster, result *k8s.ElevateResult) error { + if isJSONOutput() { + return writeJSON(cmd.OutOrStdout(), k8sElevateOutput{ + Provider: cluster.Provider, + Cluster: cluster.Name, + FQDN: cluster.FQDN, + Role: result.RoleName, + RoleID: result.RoleID, + SessionID: result.SessionID, + ExpiresAt: result.SessionExpTime, + TargetID: result.TargetID, + Namespace: cluster.Namespace, + Kubeconfig: "run 'grant k8s kubeconfig' to update your kubeconfig", + }) + } + + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Elevated access to %s (%s)\n", cluster.Name, cluster.Provider) + if result.RoleName != "" { + fmt.Fprintf(out, " Role: %s\n", result.RoleName) + } + if result.SessionID != "" { + fmt.Fprintf(out, " Session: %s\n", result.SessionID) + } + if result.SessionExpTime != "" { + fmt.Fprintf(out, " Expires: %s\n", result.SessionExpTime) + } + fmt.Fprintln(out, "\nRun 'grant k8s kubeconfig' to add this cluster to your kubeconfig.") + return nil +} diff --git a/cmd/k8s_elevate_test.go b/cmd/k8s_elevate_test.go new file mode 100644 index 0000000..19daea1 --- /dev/null +++ b/cmd/k8s_elevate_test.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/aaearon/grant-cli/internal/k8s" + "github.com/aaearon/grant-cli/internal/ui" +) + +// mockClusterElevator implements clusterElevator. +type mockClusterElevator struct { + result *k8s.ElevateResult + err error + got k8s.ElevateParams + calls int +} + +func (m *mockClusterElevator) Elevate(_ context.Context, p k8s.ElevateParams) (*k8s.ElevateResult, error) { + m.calls++ + m.got = p + if m.result == nil && m.err == nil { + return &k8s.ElevateResult{SessionID: "s1", RoleName: "admin"}, nil + } + return m.result, m.err +} + +func TestK8sElevateByName(t *testing.T) { + setOutputFormat(t, "text") + + elevator := &mockClusterElevator{} + cmd := NewK8sElevateCommandWithDeps(&mockAuthLoader{}, &mockClusterLister{clusters: sampleClusters()}, elevator) + + out, err := executeCommand(cmd, "prod") + if err != nil { + t.Fatalf("execute: %v\n%s", err, out) + } + if elevator.got.FQDN != "abc.eks.amazonaws.com" { + t.Errorf("FQDN = %q", elevator.got.FQDN) + } + if elevator.got.CSP != "aws" { + t.Errorf("CSP = %q", elevator.got.CSP) + } + if elevator.got.RoleID != "arn:aws:iam::1:role/admin" { + t.Errorf("RoleID = %q, want the eligible role", elevator.got.RoleID) + } + if !strings.Contains(out, "Elevated access to prod") { + t.Errorf("output = %q", out) + } +} + +func TestK8sElevateByFQDN(t *testing.T) { + elevator := &mockClusterElevator{} + cmd := NewK8sElevateCommandWithDeps(&mockAuthLoader{}, &mockClusterLister{clusters: sampleClusters()}, elevator) + + if _, err := executeCommand(cmd, "aks1.hcp.westeurope.azmk8s.io"); err != nil { + t.Fatalf("execute: %v", err) + } + if elevator.got.CSP != "azure" { + t.Errorf("CSP = %q, want azure", elevator.got.CSP) + } +} + +func TestK8sElevateRoleIDOverride(t *testing.T) { + elevator := &mockClusterElevator{} + cmd := NewK8sElevateCommandWithDeps(&mockAuthLoader{}, &mockClusterLister{clusters: sampleClusters()}, elevator) + + if _, err := executeCommand(cmd, "prod", "--role-id", "custom-role"); err != nil { + t.Fatalf("execute: %v", err) + } + if elevator.got.RoleID != "custom-role" { + t.Errorf("RoleID = %q, want the --role-id override", elevator.got.RoleID) + } +} + +func TestK8sElevateJSONOutput(t *testing.T) { + setOutputFormat(t, "json") + + elevator := &mockClusterElevator{result: &k8s.ElevateResult{ + SessionID: "sess-1", RoleName: "admin", SessionExpTime: "2026-08-13T18:00:00Z", + }} + cmd := NewK8sElevateCommandWithDeps(&mockAuthLoader{}, &mockClusterLister{clusters: sampleClusters()}, elevator) + + out, err := executeCommand(cmd, "prod") + if err != nil { + t.Fatalf("execute: %v\n%s", err, out) + } + + var got k8sElevateOutput + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, out) + } + if got.Cluster != "prod" || got.SessionID != "sess-1" || got.Provider != "aws" { + t.Errorf("output = %+v", got) + } +} + +func TestK8sElevateNoArgsNonInteractive(t *testing.T) { + original := ui.IsTerminalFunc + t.Cleanup(func() { ui.IsTerminalFunc = original }) + ui.IsTerminalFunc = func(uintptr) bool { return false } + + elevator := &mockClusterElevator{} + cmd := NewK8sElevateCommandWithDeps(&mockAuthLoader{}, &mockClusterLister{clusters: sampleClusters()}, elevator) + + _, err := executeCommand(cmd) + if !errors.Is(err, ui.ErrNotInteractive) { + t.Fatalf("err = %v, want ErrNotInteractive", err) + } + if !strings.Contains(err.Error(), "grant k8s list") { + t.Errorf("error should hint at 'grant k8s list': %v", err) + } + if elevator.calls != 0 { + t.Error("no elevation should be attempted without a cluster") + } +} + +func TestK8sElevateUnknownCluster(t *testing.T) { + cmd := NewK8sElevateCommandWithDeps(&mockAuthLoader{}, &mockClusterLister{clusters: sampleClusters()}, &mockClusterElevator{}) + _, err := executeCommand(cmd, "nope") + if err == nil || !strings.Contains(err.Error(), "no eligible cluster matches") { + t.Fatalf("err = %v", err) + } +} + +func TestK8sElevateAmbiguousCluster(t *testing.T) { + clusters := []k8s.Cluster{ + {Provider: "aws", Name: "prod-east", FQDN: "a", RoleID: "r"}, + {Provider: "aws", Name: "prod-west", FQDN: "b", RoleID: "r"}, + } + cmd := NewK8sElevateCommandWithDeps(&mockAuthLoader{}, &mockClusterLister{clusters: clusters}, &mockClusterElevator{}) + _, err := executeCommand(cmd, "prod") + if err == nil || !strings.Contains(err.Error(), "matches multiple clusters") { + t.Fatalf("err = %v, want an ambiguity error", err) + } +} + +func TestK8sElevateProviderValidation(t *testing.T) { + cmd := NewK8sElevateCommandWithDeps(&mockAuthLoader{}, &mockClusterLister{clusters: sampleClusters()}, &mockClusterElevator{}) + if _, err := executeCommand(cmd, "prod", "--provider", "gcp"); err == nil { + t.Fatal("expected an unsupported-provider error") + } +} + +func TestK8sElevateRequiresAuth(t *testing.T) { + cmd := NewK8sElevateCommandWithDeps( + &mockAuthLoader{loadErr: errNotAuthenticated}, + &mockClusterLister{clusters: sampleClusters()}, + &mockClusterElevator{}, + ) + if _, err := executeCommand(cmd, "prod"); err == nil || !strings.Contains(err.Error(), "not authenticated") { + t.Fatalf("err = %v", err) + } +} diff --git a/cmd/k8s_exec_credential.go b/cmd/k8s_exec_credential.go new file mode 100644 index 0000000..01afc6a --- /dev/null +++ b/cmd/k8s_exec_credential.go @@ -0,0 +1,206 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "github.com/aaearon/grant-cli/internal/cache" + "github.com/aaearon/grant-cli/internal/k8s" + "github.com/spf13/cobra" +) + +// execInfoEnvVar is the environment variable kubectl sets for exec credential +// plugins. It carries the requested apiVersion and whether stdin is available. +const execInfoEnvVar = "KUBERNETES_EXEC_INFO" + +// supportedExecCredentialAPIVersions are the client-go exec plugin API versions +// grant answers. The response apiVersion always echoes the request. +var supportedExecCredentialAPIVersions = map[string]bool{ + "client.authentication.k8s.io/v1beta1": true, + "client.authentication.k8s.io/v1": true, +} + +// kubeExecInfo is the subset of KUBERNETES_EXEC_INFO grant reads. +type kubeExecInfo struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Spec struct { + Interactive bool `json:"interactive"` + } `json:"spec"` +} + +// execCredentialFlags are the cluster identifiers embedded in the kubeconfig +// exec stanza. +type execCredentialFlags struct { + csp string + fqdn string + roleID string + organizationID string + namespace string +} + +// newK8sExecCredentialCommand creates the hidden kubectl credential plugin command. +func newK8sExecCredentialCommand(runFn func(*cobra.Command, []string) error) *cobra.Command { + cmd := &cobra.Command{ + Use: "exec-credential", + Short: "kubectl credential plugin (invoked by kubectl, not by hand)", + Long: `Emit a kubectl ExecCredential for an SCA-eligible cluster. + +This command implements the client.authentication.k8s.io exec plugin protocol. +kubectl invokes it via the exec stanza in a kubeconfig generated by +'grant k8s kubeconfig'. It writes nothing to stdout but the ExecCredential JSON.`, + Hidden: true, + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + RunE: runFn, + } + + cmd.Flags().String("csp", "", "Cloud provider: aws, azure") + cmd.Flags().String("fqdn", "", "Cluster API endpoint FQDN") + cmd.Flags().String("role-id", "", "Cloud role ID to elevate") + cmd.Flags().String("organization-id", "", "Azure Entra directory (tenant) ID or AWS organization ID") + cmd.Flags().String("namespace", "", "Kubernetes namespace (Azure namespace-scoped targets)") + + return cmd +} + +// NewK8sExecCredentialCommand creates the production exec-credential command. +func NewK8sExecCredentialCommand() *cobra.Command { + return newK8sExecCredentialCommand(func(cmd *cobra.Command, args []string) error { + svc, err := bootstrapK8sService() + if err != nil { + return err + } + return runK8sExecCredential(cmd, svc, defaultCredentialCache(), os.Getenv(execInfoEnvVar)) + }) +} + +// defaultCredentialCache returns the on-disk ExecCredential cache, or nil when +// the cache directory cannot be resolved (credentials are then never cached). +func defaultCredentialCache() *k8s.CredentialCache { + dir, err := cache.CacheDir() + if err != nil { + return nil + } + return k8s.NewCredentialCache(dir) +} + +// runK8sExecCredential emits the ExecCredential JSON on stdout and nothing else. +func runK8sExecCredential( + cmd *cobra.Command, + provider clusterCredentialProvider, + credCache *k8s.CredentialCache, + rawExecInfo string, +) error { + info, err := parseExecInfo(rawExecInfo) + if err != nil { + return err + } + + flags := parseExecCredentialFlags(cmd) + if strings.TrimSpace(flags.fqdn) == "" { + return errors.New("--fqdn is required; regenerate your kubeconfig with 'grant k8s kubeconfig'") + } + if _, err := k8s.NormalizeCSP(flags.csp); err != nil { + return err + } + + key := k8s.CredentialKey{ + CSP: flags.csp, + FQDN: flags.fqdn, + RoleID: flags.roleID, + Namespace: flags.namespace, + } + + if credCache != nil { + if cred, ok := credCache.Get(key); ok { + log.Info("Reusing cached cluster credential for %s", flags.fqdn) + return writeExecCredential(cmd, cred, info.APIVersion) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) + defer cancel() + + cred, err := provider.ExecCredential(ctx, k8s.ExecCredentialParams{ + CSP: flags.csp, + FQDN: flags.fqdn, + RoleID: flags.roleID, + OrganizationID: flags.organizationID, + Namespace: flags.namespace, + Interactive: info.Spec.Interactive, + Diagnostics: verbose, + }) + if err != nil { + return err + } + + if credCache != nil { + if err := credCache.Put(key, cred); err != nil { + log.Info("Failed to cache cluster credential: %v", err) + } + } + + return writeExecCredential(cmd, cred, info.APIVersion) +} + +func parseExecCredentialFlags(cmd *cobra.Command) execCredentialFlags { + var f execCredentialFlags + f.csp, _ = cmd.Flags().GetString("csp") + f.fqdn, _ = cmd.Flags().GetString("fqdn") + f.roleID, _ = cmd.Flags().GetString("role-id") + f.organizationID, _ = cmd.Flags().GetString("organization-id") + f.namespace, _ = cmd.Flags().GetString("namespace") + return f +} + +// parseExecInfo reads KUBERNETES_EXEC_INFO and validates the requested apiVersion. +func parseExecInfo(raw string) (*kubeExecInfo, error) { + if strings.TrimSpace(raw) == "" { + return nil, fmt.Errorf( + "%s is not set; this command is invoked by kubectl, not directly", execInfoEnvVar) + } + + var info kubeExecInfo + if err := json.Unmarshal([]byte(raw), &info); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", execInfoEnvVar, err) + } + + version := strings.TrimSpace(info.APIVersion) + if version == "" { + return nil, fmt.Errorf("%s did not specify an apiVersion", execInfoEnvVar) + } + if !supportedExecCredentialAPIVersions[version] { + return nil, fmt.Errorf( + "unsupported client authentication apiVersion %q (supported: client.authentication.k8s.io/v1beta1, client.authentication.k8s.io/v1)", + version) + } + info.APIVersion = version + return &info, nil +} + +// writeExecCredential prints the ExecCredential as the sole content of stdout, +// echoing the apiVersion kubectl asked for. +func writeExecCredential(cmd *cobra.Command, cred *k8s.ExecCredential, apiVersion string) error { + if cred == nil { + return errors.New("no cluster credential was produced") + } + + out := k8s.ExecCredential{ + APIVersion: apiVersion, + Kind: "ExecCredential", + Status: cred.Status, + } + + data, err := json.Marshal(out) + if err != nil { + return fmt.Errorf("failed to encode ExecCredential: %w", err) + } + _, err = cmd.OutOrStdout().Write(append(data, '\n')) + return err +} diff --git a/cmd/k8s_exec_credential_test.go b/cmd/k8s_exec_credential_test.go new file mode 100644 index 0000000..899fbfb --- /dev/null +++ b/cmd/k8s_exec_credential_test.go @@ -0,0 +1,355 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/aaearon/grant-cli/internal/k8s" + "github.com/spf13/cobra" +) + +// mockCredentialProvider implements clusterCredentialProvider. +type mockCredentialProvider struct { + cred *k8s.ExecCredential + err error + calls int + gotParams k8s.ExecCredentialParams + interactive []bool +} + +func (m *mockCredentialProvider) ExecCredential(_ context.Context, p k8s.ExecCredentialParams) (*k8s.ExecCredential, error) { + m.calls++ + m.gotParams = p + m.interactive = append(m.interactive, p.Interactive) + return m.cred, m.err +} + +func execInfoJSON(t *testing.T, apiVersion string, interactive bool) string { + t.Helper() + payload := map[string]any{ + "apiVersion": apiVersion, + "kind": "ExecCredential", + "spec": map[string]any{"interactive": interactive}, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func sampleCredential(expiry time.Time) *k8s.ExecCredential { + return &k8s.ExecCredential{ + APIVersion: "client.authentication.k8s.io/v1beta1", + Kind: "ExecCredential", + Status: k8s.ExecCredentialStatus{ + Token: "k8s-aws-v1.abc", + ExpirationTimestamp: expiry.UTC().Format(time.RFC3339), + }, + } +} + +// runExecCred executes the command capturing stdout and stderr separately so we +// can assert stdout carries the ExecCredential JSON and nothing else. +func runExecCred(t *testing.T, cmd *cobra.Command, args ...string) (stdout, stderr string, err error) { + t.Helper() + var out, errBuf bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errBuf) + cmd.SetArgs(args) + err = cmd.Execute() + return out.String(), errBuf.String(), err +} + +func TestExecCredentialStdoutIsOnlyJSON(t *testing.T) { + // Verbose logging on: any log output must go to stderr, never stdout. + oldVerbose := verbose + defer func() { verbose = oldVerbose }() + verbose = true + + provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} + cmd := NewK8sExecCredentialCommandWithDeps(provider, nil, + execInfoJSON(t, "client.authentication.k8s.io/v1beta1", true)) + + stdout, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "prod.eks.example") + if err != nil { + t.Fatalf("execute: %v", err) + } + + trimmed := strings.TrimSpace(stdout) + if !strings.HasPrefix(trimmed, "{") || !strings.HasSuffix(trimmed, "}") { + t.Fatalf("stdout must be exactly the ExecCredential JSON, got:\n%q", stdout) + } + + var got map[string]any + if err := json.Unmarshal([]byte(trimmed), &got); err != nil { + t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout) + } + if got["kind"] != "ExecCredential" { + t.Errorf("kind = %v", got["kind"]) + } + + // Exactly one JSON document, nothing appended. + dec := json.NewDecoder(strings.NewReader(stdout)) + var first any + if err := dec.Decode(&first); err != nil { + t.Fatalf("decode: %v", err) + } + if dec.More() { + t.Errorf("stdout contains trailing content after the ExecCredential JSON:\n%q", stdout) + } +} + +func TestExecCredentialAPIVersionNegotiation(t *testing.T) { + tests := []struct { + name string + apiVersion string + wantErr bool + }{ + {name: "v1beta1", apiVersion: "client.authentication.k8s.io/v1beta1"}, + {name: "v1", apiVersion: "client.authentication.k8s.io/v1"}, + {name: "unknown version", apiVersion: "client.authentication.k8s.io/v2", wantErr: true}, + {name: "empty version", apiVersion: "", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // The provider always returns v1beta1; the response must echo the request. + provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} + cmd := NewK8sExecCredentialCommandWithDeps(provider, nil, execInfoJSON(t, tt.apiVersion, true)) + + stdout, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host") + if tt.wantErr { + if err == nil { + t.Fatalf("expected an error for apiVersion %q", tt.apiVersion) + } + if stdout != "" { + t.Errorf("nothing may be written to stdout on error, got %q", stdout) + } + return + } + if err != nil { + t.Fatalf("execute: %v", err) + } + + var got map[string]any + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got["apiVersion"] != tt.apiVersion { + t.Errorf("apiVersion = %v, want the requested %q (never hardcoded)", got["apiVersion"], tt.apiVersion) + } + }) + } +} + +func TestExecCredentialRequiresExecInfo(t *testing.T) { + provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} + cmd := NewK8sExecCredentialCommandWithDeps(provider, nil, "") + + stdout, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host") + if err == nil { + t.Fatal("expected an error when KUBERNETES_EXEC_INFO is absent") + } + if !strings.Contains(err.Error(), "KUBERNETES_EXEC_INFO") { + t.Errorf("err = %v, want it to name the env var", err) + } + if stdout != "" { + t.Errorf("stdout must stay empty on error, got %q", stdout) + } +} + +func TestExecCredentialRejectsMalformedExecInfo(t *testing.T) { + provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} + cmd := NewK8sExecCredentialCommandWithDeps(provider, nil, "{not json") + + if _, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host"); err == nil { + t.Fatal("expected an error for malformed KUBERNETES_EXEC_INFO") + } +} + +func TestExecCredentialInteractiveModePropagated(t *testing.T) { + for _, interactive := range []bool{true, false} { + provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} + cmd := NewK8sExecCredentialCommandWithDeps(provider, nil, + execInfoJSON(t, "client.authentication.k8s.io/v1beta1", interactive)) + + if _, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host"); err != nil { + t.Fatalf("execute: %v", err) + } + if provider.gotParams.Interactive != interactive { + t.Errorf("Interactive = %v, want %v", provider.gotParams.Interactive, interactive) + } + } +} + +// interactive-forbidden + cache miss must fail cleanly, not hang on a browser flow. +func TestExecCredentialNonInteractiveCacheMissFailsCleanly(t *testing.T) { + provider := &mockCredentialProvider{ + err: k8s.ErrInteractionRequired, + } + cmd := NewK8sExecCredentialCommandWithDeps(provider, nil, + execInfoJSON(t, "client.authentication.k8s.io/v1beta1", false)) + + stdout, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host") + if !errors.Is(err, k8s.ErrInteractionRequired) { + t.Fatalf("err = %v, want ErrInteractionRequired", err) + } + if stdout != "" { + t.Errorf("stdout must stay empty on error, got %q", stdout) + } +} + +// interactive-forbidden + cache hit must succeed without calling the provider. +func TestExecCredentialNonInteractiveCacheHitSucceeds(t *testing.T) { + dir := t.TempDir() + credCache := k8s.NewCredentialCache(dir) + key := k8s.CredentialKey{CSP: "aws", FQDN: "host", RoleID: "r"} + if err := credCache.Put(key, sampleCredential(time.Now().Add(time.Hour))); err != nil { + t.Fatalf("seed cache: %v", err) + } + + provider := &mockCredentialProvider{err: k8s.ErrInteractionRequired} + cmd := NewK8sExecCredentialCommandWithDeps(provider, credCache, + execInfoJSON(t, "client.authentication.k8s.io/v1beta1", false)) + + stdout, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host", "--role-id", "r") + if err != nil { + t.Fatalf("execute: %v", err) + } + if provider.calls != 0 { + t.Errorf("provider called %d times, want 0 (cache hit)", provider.calls) + } + if !strings.Contains(stdout, "k8s-aws-v1.abc") { + t.Errorf("cached token not replayed: %s", stdout) + } +} + +func TestExecCredentialCachesAndRefetchesAfterExpiry(t *testing.T) { + dir := t.TempDir() + credCache := k8s.NewCredentialCache(dir) + + expiry := time.Now().Add(2 * time.Minute) + provider := &mockCredentialProvider{cred: sampleCredential(expiry)} + info := execInfoJSON(t, "client.authentication.k8s.io/v1beta1", true) + + // First call: cache miss, provider invoked, credential cached. + cmd := NewK8sExecCredentialCommandWithDeps(provider, credCache, info) + if _, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host"); err != nil { + t.Fatalf("first call: %v", err) + } + if provider.calls != 1 { + t.Fatalf("provider calls = %d, want 1", provider.calls) + } + + // Second call: cache hit, provider not invoked again. + cmd = NewK8sExecCredentialCommandWithDeps(provider, credCache, info) + if _, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host"); err != nil { + t.Fatalf("second call: %v", err) + } + if provider.calls != 1 { + t.Errorf("provider calls = %d, want it to still be 1 (cached)", provider.calls) + } + + // Once the stamped expiry passes, the credential is refetched. + files, _ := filepath.Glob(filepath.Join(dir, "execcred_*.json")) + if len(files) != 1 { + t.Fatalf("expected exactly one cache file, got %v", files) + } + expired := sampleCredential(time.Now().Add(-time.Minute)) + data, _ := json.Marshal(expired) + if err := os.WriteFile(files[0], data, 0o600); err != nil { + t.Fatal(err) + } + + cmd = NewK8sExecCredentialCommandWithDeps(provider, credCache, info) + if _, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host"); err != nil { + t.Fatalf("third call: %v", err) + } + if provider.calls != 2 { + t.Errorf("provider calls = %d, want 2 after the cached credential expired", provider.calls) + } +} + +func TestExecCredentialCacheFilePermissions(t *testing.T) { + dir := t.TempDir() + credCache := k8s.NewCredentialCache(dir) + provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} + + cmd := NewK8sExecCredentialCommandWithDeps(provider, credCache, + execInfoJSON(t, "client.authentication.k8s.io/v1beta1", true)) + if _, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host"); err != nil { + t.Fatalf("execute: %v", err) + } + + files, _ := filepath.Glob(filepath.Join(dir, "execcred_*.json")) + if len(files) != 1 { + t.Fatalf("expected one cache file, got %v", files) + } + fi, err := os.Stat(files[0]) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o600 { + t.Errorf("cache file mode = %o, want 0600", fi.Mode().Perm()) + } +} + +// The buffer is baked in once by the credential source; the command replays the +// stamped expirationTimestamp verbatim. +func TestExecCredentialDoesNotAdjustExpiry(t *testing.T) { + expiry := time.Now().Add(37 * time.Minute).UTC().Truncate(time.Second) + provider := &mockCredentialProvider{cred: sampleCredential(expiry)} + cmd := NewK8sExecCredentialCommandWithDeps(provider, nil, + execInfoJSON(t, "client.authentication.k8s.io/v1beta1", true)) + + stdout, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host") + if err != nil { + t.Fatalf("execute: %v", err) + } + + var got k8s.ExecCredential + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Status.ExpirationTimestamp != expiry.Format(time.RFC3339) { + t.Errorf("expirationTimestamp = %q, want the source value %q unchanged", + got.Status.ExpirationTimestamp, expiry.Format(time.RFC3339)) + } +} + +func TestExecCredentialValidatesFlags(t *testing.T) { + info := execInfoJSON(t, "client.authentication.k8s.io/v1beta1", true) + tests := []struct { + name string + args []string + }{ + {name: "missing fqdn", args: []string{"--csp", "aws"}}, + {name: "missing csp", args: []string{"--fqdn", "host"}}, + {name: "unsupported csp", args: []string{"--csp", "gcp", "--fqdn", "host"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} + cmd := NewK8sExecCredentialCommandWithDeps(provider, nil, info) + if _, _, err := runExecCred(t, cmd, tt.args...); err == nil { + t.Fatal("expected a validation error") + } + }) + } +} + +func TestExecCredentialIsHidden(t *testing.T) { + cmd := newK8sExecCredentialCommand(nil) + if !cmd.Hidden { + t.Error("exec-credential must be Hidden so it does not appear in help") + } +} diff --git a/cmd/k8s_integration_test.go b/cmd/k8s_integration_test.go index 921119e..ea6e605 100644 --- a/cmd/k8s_integration_test.go +++ b/cmd/k8s_integration_test.go @@ -18,7 +18,7 @@ func TestIntegration_K8sHelp(t *testing.T) { } outputStr := string(output) - for _, want := range []string{"list", "Kubernetes"} { + for _, want := range []string{"list", "elevate", "kubeconfig", "Kubernetes"} { if !strings.Contains(outputStr, want) { t.Errorf("expected %q in `grant k8s --help` output, got:\n%s", want, outputStr) } diff --git a/cmd/k8s_kubeconfig.go b/cmd/k8s_kubeconfig.go new file mode 100644 index 0000000..e80fa59 --- /dev/null +++ b/cmd/k8s_kubeconfig.go @@ -0,0 +1,284 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + + "github.com/aaearon/grant-cli/internal/k8s" + "github.com/spf13/cobra" +) + +// grantExecutablePath resolves the absolute path of the running binary. It is a +// variable so tests can pin it. +var grantExecutablePath = os.Executable + +// newK8sKubeconfigCommand creates the "grant k8s kubeconfig" command. +func newK8sKubeconfigCommand(runFn func(*cobra.Command, []string) error) *cobra.Command { + cmd := &cobra.Command{ + Use: "kubeconfig", + Short: "Generate and merge a kubeconfig for eligible clusters", + Long: `Fetch a kubeconfig for your SCA-eligible Kubernetes clusters and merge it +into your existing kubeconfig. + +The merge is additive: only entries grant owns (named grant--) +are added or replaced. Your other clusters, users and contexts are left alone, +and current-context is not changed unless you pass --set-current-context. + +The file is written atomically at mode 0600, and the first merge into a +pre-existing kubeconfig leaves a .grant.bak copy behind. + +Examples: + grant k8s kubeconfig # merge into $KUBECONFIG or ~/.kube/config + grant k8s kubeconfig --provider aws + grant k8s kubeconfig --file ./my-kubeconfig + grant k8s kubeconfig --stdout # print, touch no file`, + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + RunE: runFn, + } + + cmd.Flags().StringP("provider", "p", "", "Cloud provider: aws, azure (omit for all)") + cmd.Flags().Bool("all", false, "Generate for every supported provider (default when --provider is omitted)") + // NOTE: named --file, not --output: --output/-o is the global text|json flag. + cmd.Flags().String("file", "", "Write to this kubeconfig instead of $KUBECONFIG / ~/.kube/config") + cmd.Flags().Bool("stdout", false, "Write the generated kubeconfig to stdout and touch no file") + cmd.Flags().Bool("set-current-context", false, "Point current-context at the generated context") + + cmd.MarkFlagsMutuallyExclusive("stdout", "file") + cmd.MarkFlagsMutuallyExclusive("stdout", "set-current-context") + cmd.MarkFlagsMutuallyExclusive("provider", "all") + + return cmd +} + +// NewK8sKubeconfigCommand creates the production kubeconfig command. +func NewK8sKubeconfigCommand() *cobra.Command { + return newK8sKubeconfigCommand(func(cmd *cobra.Command, args []string) error { + ispAuth, _, err := bootstrapISPAuth() + if err != nil { + return err + } + svc, err := bootstrapK8sService() + if err != nil { + return err + } + return runK8sKubeconfig(cmd, ispAuth, svc) + }) +} + +// kubeconfigFlags holds the parsed command-line flags. +type kubeconfigFlags struct { + provider string + file string + toStdout bool + setCurrentContext bool +} + +func parseKubeconfigFlags(cmd *cobra.Command) kubeconfigFlags { + var f kubeconfigFlags + f.provider, _ = cmd.Flags().GetString("provider") + f.provider = strings.ToLower(strings.TrimSpace(f.provider)) + f.file, _ = cmd.Flags().GetString("file") + f.toStdout, _ = cmd.Flags().GetBool("stdout") + f.setCurrentContext, _ = cmd.Flags().GetBool("set-current-context") + return f +} + +// runK8sKubeconfig generates kubeconfigs and merges or prints them. +func runK8sKubeconfig(cmd *cobra.Command, auth authLoader, generator kubeconfigGenerator) error { + if _, err := auth.LoadAuthentication(nil, true); err != nil { + return fmt.Errorf("not authenticated, run 'grant login' first: %w", err) + } + + flags := parseKubeconfigFlags(cmd) + + csps := k8s.SupportedCSPs + if flags.provider != "" { + if _, err := k8s.NormalizeCSP(flags.provider); err != nil { + return err + } + csps = []string{flags.provider} + } + + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) + defer cancel() + + generated, failures, err := generator.GenerateKubeconfigs(ctx, csps) + if err != nil { + return err + } + if len(generated) == 0 { + return fmt.Errorf("no kubeconfig could be generated: %s", describeKubeconfigFailures(failures)) + } + + merged, rewrites, err := buildMergedKubeconfig(generated) + if err != nil { + return err + } + + if flags.toStdout { + data, err := merged.doc.Bytes() + if err != nil { + return err + } + _, err = cmd.OutOrStdout().Write(data) + return err + } + + return writeMergedKubeconfig(cmd, flags, merged, rewrites, failures) +} + +// mergedKubeconfig carries a freshly built grant-owned kubeconfig. +type mergedKubeconfig struct { + doc *k8s.Kubeconfig + contexts []string +} + +// buildMergedKubeconfig prefixes, rewrites and combines the per-provider +// kubeconfigs into a single grant-owned document. +func buildMergedKubeconfig(generated map[string]string) (*mergedKubeconfig, []k8s.ExecRewrite, error) { + execPath, err := grantExecutablePath() + if err != nil { + return nil, nil, fmt.Errorf("failed to resolve the grant binary path: %w", err) + } + + combined, err := k8s.ParseKubeconfig(nil) + if err != nil { + return nil, nil, err + } + + var rewrites []k8s.ExecRewrite + for _, csp := range sortedKeys(generated) { + doc, err := k8s.ParseKubeconfig([]byte(generated[csp])) + if err != nil { + return nil, nil, fmt.Errorf("provider %s returned an unparsable kubeconfig: %w", csp, err) + } + doc.PrefixEntries(csp) + rewrites = append(rewrites, doc.RewriteExecCommands(execPath)...) + combined.Merge(doc) + } + + return &mergedKubeconfig{doc: combined, contexts: combined.ContextNames()}, rewrites, nil +} + +// writeMergedKubeconfig merges into the target kubeconfig and reports what changed. +func writeMergedKubeconfig( + cmd *cobra.Command, + flags kubeconfigFlags, + merged *mergedKubeconfig, + rewrites []k8s.ExecRewrite, + failures []k8s.KubeconfigFailure, +) error { + target := flags.file + if target == "" { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to resolve your home directory: %w", err) + } + target = k8s.ResolveKubeconfigPath(os.Getenv("KUBECONFIG"), home) + } + + backedUp, err := k8s.BackupOnce(target) + if err != nil { + return err + } + + existingData, err := os.ReadFile(target) //nolint:gosec // target is the user's chosen kubeconfig + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to read %s: %w", target, err) + } + + doc, err := k8s.ParseKubeconfig(existingData) + if err != nil { + return err + } + + report := doc.Merge(merged.doc) + if flags.setCurrentContext && len(merged.contexts) > 0 { + doc.SetCurrentContext(merged.contexts[0]) + } + + data, err := doc.Bytes() + if err != nil { + return err + } + + warnings, err := k8s.WriteKubeconfigAtomic(target, data) + if err != nil { + return err + } + + if isJSONOutput() { + return writeJSON(cmd.OutOrStdout(), kubeconfigOutput{ + Path: target, + Added: report.Added, + Replaced: report.Replaced, + Contexts: merged.contexts, + Warnings: warnings, + BackupCreated: backedUp, + Failures: failures, + }) + } + + reportKubeconfigText(cmd, target, report, merged, rewrites, warnings, failures, backedUp) + return nil +} + +//nolint:gocritic // report and flags are grouped for readability, not hot-path performance +func reportKubeconfigText( + cmd *cobra.Command, + target string, + report k8s.MergeReport, + merged *mergedKubeconfig, + rewrites []k8s.ExecRewrite, + warnings []string, + failures []k8s.KubeconfigFailure, + backedUp bool, +) { + errOut := cmd.ErrOrStderr() + for _, w := range warnings { + fmt.Fprintf(errOut, "Warning: %s\n", w) + } + for _, f := range failures { + fmt.Fprintf(errOut, "Warning: %s kubeconfig generation failed: %s\n", f.CSP, f.Error) + } + for _, name := range report.Replaced { + fmt.Fprintf(errOut, "Replaced existing entry %s\n", name) + } + for _, r := range rewrites { + fmt.Fprintf(errOut, "Rewrote exec plugin for user %s: %s -> %s\n", r.User, r.From, r.To) + } + if backedUp { + fmt.Fprintf(errOut, "Backed up your previous kubeconfig to %s.grant.bak\n", target) + } + + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Updated %s (%d added, %d replaced)\n", target, len(report.Added), len(report.Replaced)) + for _, name := range merged.contexts { + fmt.Fprintf(out, " kubectl --context %s get ns\n", name) + } +} + +func describeKubeconfigFailures(failures []k8s.KubeconfigFailure) string { + if len(failures) == 0 { + return "no eligible clusters were returned" + } + parts := make([]string, 0, len(failures)) + for _, f := range failures { + parts = append(parts, f.CSP+": "+f.Error) + } + return strings.Join(parts, "; ") +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/cmd/k8s_kubeconfig_test.go b/cmd/k8s_kubeconfig_test.go new file mode 100644 index 0000000..15e9469 --- /dev/null +++ b/cmd/k8s_kubeconfig_test.go @@ -0,0 +1,381 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aaearon/grant-cli/internal/k8s" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +const generatedAWSKubeconfig = `apiVersion: v1 +kind: Config +clusters: + - name: eks-prod + cluster: + server: https://prod.eks.example +users: + - name: eks-prod-user + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + command: idsec + args: ["sca", "k8s", "kubectl-login", "--csp", "aws", "--fqdn", "prod.eks.example"] +contexts: + - name: eks-prod + context: + cluster: eks-prod + user: eks-prod-user +` + +const preExistingKubeconfig = `apiVersion: v1 +kind: Config +current-context: work +clusters: + - name: work + cluster: + server: https://work.example +users: + - name: work-user + user: + token: mytoken +contexts: + - name: work + context: + cluster: work + user: work-user +` + +// mockKubeconfigGenerator implements kubeconfigGenerator. +type mockKubeconfigGenerator struct { + configs map[string]string + failures []k8s.KubeconfigFailure + err error + gotCSPs []string +} + +func (m *mockKubeconfigGenerator) GenerateKubeconfigs(_ context.Context, csps []string) (map[string]string, []k8s.KubeconfigFailure, error) { + m.gotCSPs = csps + return m.configs, m.failures, m.err +} + +func pinGrantPath(t *testing.T, path string) { + t.Helper() + setOutputFormat(t, "text") + original := grantExecutablePath + t.Cleanup(func() { grantExecutablePath = original }) + grantExecutablePath = func() (string, error) { return path, nil } +} + +func runKubeconfig(t *testing.T, cmd *cobra.Command, args ...string) (stdout, stderr string, err error) { + t.Helper() + var out, errBuf bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errBuf) + cmd.SetArgs(args) + err = cmd.Execute() + return out.String(), errBuf.String(), err +} + +func awsGenerator() *mockKubeconfigGenerator { + return &mockKubeconfigGenerator{configs: map[string]string{"aws": generatedAWSKubeconfig}} +} + +func TestKubeconfigMergesIntoExistingFile(t *testing.T) { + pinGrantPath(t, "/usr/local/bin/grant") + + dir := t.TempDir() + target := filepath.Join(dir, "config") + if err := os.WriteFile(target, []byte(preExistingKubeconfig), 0o600); err != nil { + t.Fatal(err) + } + + cmd := NewK8sKubeconfigCommandWithDeps(&mockAuthLoader{}, awsGenerator()) + stdout, _, err := runKubeconfig(t, cmd, "--file", target) + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(stdout, target) { + t.Errorf("output should name the target file: %q", stdout) + } + + data, err := os.ReadFile(target) //nolint:gosec // test-controlled path + if err != nil { + t.Fatal(err) + } + var cfg map[string]any + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("result is not valid YAML: %v\n%s", err, data) + } + + // The user's own entries survive and current-context is untouched. + if cfg["current-context"] != "work" { + t.Errorf("current-context = %v, want it left at work", cfg["current-context"]) + } + names := sectionNames(t, cfg, "clusters") + if !hasName(names, "work") { + t.Errorf("the user's own cluster was lost: %v", names) + } + if !hasName(names, "grant-aws-eks-prod") { + t.Errorf("the generated cluster was not added: %v", names) + } +} + +func TestKubeconfigSetsCurrentContextOnlyWithOptIn(t *testing.T) { + pinGrantPath(t, "/usr/local/bin/grant") + + dir := t.TempDir() + target := filepath.Join(dir, "config") + if err := os.WriteFile(target, []byte(preExistingKubeconfig), 0o600); err != nil { + t.Fatal(err) + } + + cmd := NewK8sKubeconfigCommandWithDeps(&mockAuthLoader{}, awsGenerator()) + if _, _, err := runKubeconfig(t, cmd, "--file", target, "--set-current-context"); err != nil { + t.Fatalf("execute: %v", err) + } + + data, _ := os.ReadFile(target) //nolint:gosec // test-controlled path + var cfg map[string]any + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + if cfg["current-context"] != "grant-aws-eks-prod" { + t.Errorf("current-context = %v, want the generated context after opt-in", cfg["current-context"]) + } +} + +func TestKubeconfigRewritesExecCommandToGrant(t *testing.T) { + pinGrantPath(t, "/opt/bin/grant") + + target := filepath.Join(t.TempDir(), "config") + cmd := NewK8sKubeconfigCommandWithDeps(&mockAuthLoader{}, awsGenerator()) + _, stderr, err := runKubeconfig(t, cmd, "--file", target) + if err != nil { + t.Fatalf("execute: %v", err) + } + + data, _ := os.ReadFile(target) //nolint:gosec // test-controlled path + body := string(data) + if !strings.Contains(body, "/opt/bin/grant") { + t.Errorf("exec.command was not rewritten to the grant binary:\n%s", body) + } + if !strings.Contains(body, "exec-credential") { + t.Errorf("exec args were not rewritten:\n%s", body) + } + if strings.Contains(body, "kubectl-login") { + t.Errorf("the official CLI args survived the rewrite:\n%s", body) + } + if !strings.Contains(stderr, "Rewrote exec plugin") { + t.Errorf("the rewrite should be reported on stderr, got:\n%s", stderr) + } +} + +func TestKubeconfigStdoutTouchesNoFile(t *testing.T) { + pinGrantPath(t, "/usr/local/bin/grant") + + dir := t.TempDir() + target := filepath.Join(dir, "config") + if err := os.WriteFile(target, []byte(preExistingKubeconfig), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("KUBECONFIG", target) + + cmd := NewK8sKubeconfigCommandWithDeps(&mockAuthLoader{}, awsGenerator()) + stdout, _, err := runKubeconfig(t, cmd, "--stdout") + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(stdout, "grant-aws-eks-prod") { + t.Errorf("stdout should carry the generated kubeconfig:\n%s", stdout) + } + + data, _ := os.ReadFile(target) //nolint:gosec // test-controlled path + if string(data) != preExistingKubeconfig { + t.Errorf("--stdout must not touch any file, but the target changed:\n%s", data) + } + entries, _ := os.ReadDir(dir) + if len(entries) != 1 { + t.Errorf("--stdout wrote extra files: %d entries", len(entries)) + } +} + +func TestKubeconfigHonoursKubeconfigEnvListForm(t *testing.T) { + pinGrantPath(t, "/usr/local/bin/grant") + + dir := t.TempDir() + first := filepath.Join(dir, "first") + second := filepath.Join(dir, "second") + for _, p := range []string{first, second} { + if err := os.WriteFile(p, []byte(preExistingKubeconfig), 0o600); err != nil { + t.Fatal(err) + } + } + t.Setenv("KUBECONFIG", first+string(os.PathListSeparator)+second) + + cmd := NewK8sKubeconfigCommandWithDeps(&mockAuthLoader{}, awsGenerator()) + if _, _, err := runKubeconfig(t, cmd); err != nil { + t.Fatalf("execute: %v", err) + } + + firstData, _ := os.ReadFile(first) //nolint:gosec // test-controlled path + secondData, _ := os.ReadFile(second) //nolint:gosec // test-controlled path + if !strings.Contains(string(firstData), "grant-aws-eks-prod") { + t.Errorf("the first $KUBECONFIG entry was not written:\n%s", firstData) + } + if string(secondData) != preExistingKubeconfig { + t.Errorf("only the first $KUBECONFIG entry may be written; the second changed:\n%s", secondData) + } +} + +func TestKubeconfigFilePermissionsAndBackup(t *testing.T) { + pinGrantPath(t, "/usr/local/bin/grant") + + dir := t.TempDir() + target := filepath.Join(dir, "config") + if err := os.WriteFile(target, []byte(preExistingKubeconfig), 0o600); err != nil { + t.Fatal(err) + } + + cmd := NewK8sKubeconfigCommandWithDeps(&mockAuthLoader{}, awsGenerator()) + if _, stderr, err := runKubeconfig(t, cmd, "--file", target); err != nil { + t.Fatalf("execute: %v\n%s", err, stderr) + } + + fi, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o600 { + t.Errorf("mode = %o, want 0600", fi.Mode().Perm()) + } + + backup, err := os.ReadFile(target + ".grant.bak") //nolint:gosec // test-controlled path + if err != nil { + t.Fatalf("no backup written: %v", err) + } + if string(backup) != preExistingKubeconfig { + t.Error("the backup does not match the pre-merge content") + } +} + +func TestKubeconfigProviderSelection(t *testing.T) { + pinGrantPath(t, "/usr/local/bin/grant") + target := filepath.Join(t.TempDir(), "config") + + gen := awsGenerator() + cmd := NewK8sKubeconfigCommandWithDeps(&mockAuthLoader{}, gen) + if _, _, err := runKubeconfig(t, cmd, "--file", target, "--provider", "aws"); err != nil { + t.Fatalf("execute: %v", err) + } + if len(gen.gotCSPs) != 1 || gen.gotCSPs[0] != "aws" { + t.Errorf("csps = %v, want [aws]", gen.gotCSPs) + } + + gen2 := awsGenerator() + cmd2 := NewK8sKubeconfigCommandWithDeps(&mockAuthLoader{}, gen2) + if _, _, err := runKubeconfig(t, cmd2, "--file", target); err != nil { + t.Fatalf("execute: %v", err) + } + if len(gen2.gotCSPs) != len(k8s.SupportedCSPs) { + t.Errorf("csps = %v, want all supported providers", gen2.gotCSPs) + } +} + +func TestKubeconfigRejectsUnsupportedProvider(t *testing.T) { + cmd := NewK8sKubeconfigCommandWithDeps(&mockAuthLoader{}, awsGenerator()) + if _, _, err := runKubeconfig(t, cmd, "--provider", "gcp"); err == nil { + t.Fatal("expected an unsupported-provider error") + } +} + +func TestKubeconfigReportsPartialFailures(t *testing.T) { + pinGrantPath(t, "/usr/local/bin/grant") + target := filepath.Join(t.TempDir(), "config") + + gen := &mockKubeconfigGenerator{ + configs: map[string]string{"aws": generatedAWSKubeconfig}, + failures: []k8s.KubeconfigFailure{{CSP: "azure", Error: "not entitled"}}, + } + cmd := NewK8sKubeconfigCommandWithDeps(&mockAuthLoader{}, gen) + _, stderr, err := runKubeconfig(t, cmd, "--file", target) + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(stderr, "azure kubeconfig generation failed") { + t.Errorf("partial failure not reported:\n%s", stderr) + } +} + +func TestKubeconfigFailsWhenNothingGenerated(t *testing.T) { + gen := &mockKubeconfigGenerator{ + configs: map[string]string{}, + failures: []k8s.KubeconfigFailure{{CSP: "aws", Error: "boom"}}, + } + cmd := NewK8sKubeconfigCommandWithDeps(&mockAuthLoader{}, gen) + _, _, err := runKubeconfig(t, cmd, "--file", filepath.Join(t.TempDir(), "config")) + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("err = %v, want the provider failure surfaced", err) + } +} + +func TestKubeconfigJSONOutput(t *testing.T) { + pinGrantPath(t, "/usr/local/bin/grant") + setOutputFormat(t, "json") + + target := filepath.Join(t.TempDir(), "config") + cmd := NewK8sKubeconfigCommandWithDeps(&mockAuthLoader{}, awsGenerator()) + stdout, _, err := runKubeconfig(t, cmd, "--file", target) + if err != nil { + t.Fatalf("execute: %v", err) + } + + var got kubeconfigOutput + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, stdout) + } + if got.Path != target { + t.Errorf("path = %q, want %q", got.Path, target) + } + if len(got.Added) != 3 { + t.Errorf("added = %v, want 3 entries", got.Added) + } + if !hasName(got.Contexts, "grant-aws-eks-prod") { + t.Errorf("contexts = %v", got.Contexts) + } +} + +func TestKubeconfigRequiresAuth(t *testing.T) { + cmd := NewK8sKubeconfigCommandWithDeps(&mockAuthLoader{loadErr: errNotAuthenticated}, awsGenerator()) + if _, _, err := runKubeconfig(t, cmd, "--file", filepath.Join(t.TempDir(), "config")); err == nil || + !strings.Contains(err.Error(), "not authenticated") { + t.Fatalf("err = %v", err) + } +} + +func sectionNames(t *testing.T, cfg map[string]any, section string) []string { + t.Helper() + list, _ := cfg[section].([]any) + names := make([]string, 0, len(list)) + for _, item := range list { + m, _ := item.(map[string]any) + name, _ := m["name"].(string) + names = append(names, name) + } + return names +} + +func hasName(names []string, want string) bool { + for _, n := range names { + if n == want { + return true + } + } + return false +} diff --git a/cmd/k8s_list_test.go b/cmd/k8s_list_test.go index d531b00..3717eb6 100644 --- a/cmd/k8s_list_test.go +++ b/cmd/k8s_list_test.go @@ -11,6 +11,16 @@ import ( "github.com/spf13/cobra" ) +// setOutputFormat pins the global output format for one test. The package-level +// outputFormat var is bound to the persistent --output flag, so it leaks between +// tests unless each test states what it expects. +func setOutputFormat(t *testing.T, format string) { + t.Helper() + old := outputFormat + t.Cleanup(func() { outputFormat = old }) + outputFormat = format +} + func sampleClusters() []k8s.Cluster { return []k8s.Cluster{ { @@ -27,9 +37,7 @@ func sampleClusters() []k8s.Cluster { } func TestK8sListJSONOutput(t *testing.T) { - old := outputFormat - defer func() { outputFormat = old }() - outputFormat = "json" + setOutputFormat(t, "json") cmd := NewK8sCommandWithDeps(&mockAuthLoader{}, &mockClusterLister{clusters: sampleClusters()}) out, err := executeCommand(cmd, "list") @@ -54,6 +62,8 @@ func TestK8sListJSONOutput(t *testing.T) { } func TestK8sListTextOutput(t *testing.T) { + setOutputFormat(t, "text") + cmd := NewK8sCommandWithDeps(&mockAuthLoader{}, &mockClusterLister{clusters: sampleClusters()}) out, err := executeCommand(cmd, "list") if err != nil { diff --git a/cmd/output_types.go b/cmd/output_types.go index 57634c9..81dd7c5 100644 --- a/cmd/output_types.go +++ b/cmd/output_types.go @@ -1,5 +1,7 @@ package cmd +import "github.com/aaearon/grant-cli/internal/k8s" + // cloudElevationOutput is the JSON representation of a cloud elevation result. type cloudElevationOutput struct { Type string `json:"type"` @@ -125,3 +127,28 @@ type clusterOutput struct { type k8sListOutput struct { Clusters []clusterOutput `json:"clusters"` } + +// k8sElevateOutput is the JSON representation of `grant k8s elevate`. +type k8sElevateOutput struct { + Provider string `json:"provider"` + Cluster string `json:"cluster"` + FQDN string `json:"fqdn,omitempty"` + Role string `json:"role,omitempty"` + RoleID string `json:"roleId,omitempty"` + SessionID string `json:"sessionId,omitempty"` + ExpiresAt string `json:"expiresAt,omitempty"` + TargetID string `json:"targetId,omitempty"` + Namespace string `json:"namespace,omitempty"` + Kubeconfig string `json:"nextStep,omitempty"` +} + +// kubeconfigOutput is the JSON representation of `grant k8s kubeconfig`. +type kubeconfigOutput struct { + Path string `json:"path"` + Added []string `json:"added"` + Replaced []string `json:"replaced"` + Contexts []string `json:"contexts"` + Warnings []string `json:"warnings,omitempty"` + BackupCreated bool `json:"backupCreated"` + Failures []k8s.KubeconfigFailure `json:"failures,omitempty"` +} diff --git a/internal/k8s/execcred.go b/internal/k8s/execcred.go new file mode 100644 index 0000000..b4ddb1a --- /dev/null +++ b/internal/k8s/execcred.go @@ -0,0 +1,247 @@ +package k8s + +import ( + "context" + "errors" + "fmt" + "strings" + + sdkk8s "github.com/cyberark/idsec-sdk-golang/pkg/services/sca/k8s" + k8smodels "github.com/cyberark/idsec-sdk-golang/pkg/services/sca/k8s/models" +) + +// ErrInteractionRequired is returned when a credential flow needs a browser or +// an interactive login but kubectl told us stdin is unavailable. +var ErrInteractionRequired = errors.New("interactive authentication required") + +// ExecCredential is the kubectl exec-credential plugin payload. Aliased from the +// SDK so command signatures do not leak SDK type names. +type ExecCredential = k8smodels.IdsecSCAK8sExecCredential + +// ExecCredentialStatus is the status block of an ExecCredential. +type ExecCredentialStatus = k8smodels.IdsecSCAK8sExecCredentialStatus + +// ExecCredentialParams are the inputs for a kubectl exec-credential request. +type ExecCredentialParams struct { + CSP string + FQDN string + RoleID string + OrganizationID string + Namespace string + + // ElevateToken is the ISP session JWT. The Azure provider decodes it to check + // the az CLI session belongs to the same identity. + ElevateToken string + + // Interactive reports whether kubectl said stdin is available. When false, + // flows that would open a browser or run `az login` fail fast instead. + Interactive bool + + // Diagnostics enables the SDK's kubectl-login stderr diagnostics. + Diagnostics bool +} + +// credentialFlow is the seam over the SDK's CSP-specific credential providers, +// which reach out to AWS STS / SSO OIDC and the Azure CLI. Tests inject a stub. +type credentialFlow interface { + Direct(res *ElevateResult, cctx *sdkk8s.IdsecSCAK8sClusterContext, interactive bool) (*k8smodels.IdsecSCAK8sExecCredential, error) + ProxyToken(res *ElevateResult, cctx *sdkk8s.IdsecSCAK8sClusterContext, interactive bool) (string, error) +} + +// ExecCredential runs the full evaluate → elevate → credential flow for one +// cluster and returns the kubectl ExecCredential. +// +// The returned status.expirationTimestamp already has the SDK's early-refresh +// buffer subtracted; callers must not apply another one. +func (s *Service) ExecCredential(ctx context.Context, p ExecCredentialParams) (*k8smodels.IdsecSCAK8sExecCredential, error) { + csp, err := NormalizeCSP(p.CSP) + if err != nil { + return nil, err + } + + conn, err := s.Evaluate(ctx, csp, p.FQDN) + if err != nil { + return nil, err + } + + roleID := p.RoleID + if strings.TrimSpace(roleID) == "" { + roleID = conn.RoleID + } + orgID := p.OrganizationID + if strings.TrimSpace(orgID) == "" { + orgID = conn.OrganizationID + } + + res, err := s.Elevate(ctx, ElevateParams{ + CSP: csp, + FQDN: p.FQDN, + RoleID: roleID, + OrganizationID: orgID, + Namespace: p.Namespace, + }) + if err != nil { + return nil, err + } + + cctx := buildClusterContext(csp, roleID, orgID, p, conn, res) + + if conn.ConnectionMethod == ConnectionProxy { + return s.proxyCredential(ctx, csp, cctx, res, p.Interactive) + } + return s.flow().Direct(res, cctx, p.Interactive) +} + +func (s *Service) proxyCredential( + ctx context.Context, + csp string, + cctx *sdkk8s.IdsecSCAK8sClusterContext, + res *ElevateResult, + interactive bool, +) (*k8smodels.IdsecSCAK8sExecCredential, error) { + token, err := s.flow().ProxyToken(res, cctx, interactive) + if err != nil { + return nil, err + } + cctx.K8sToken = token + if token != "" && cctx.RootCA == "" { + return nil, errors.New("proxy connection requires the cluster certificate data, which the evaluate API did not return") + } + return s.ProxyExecCredential(ctx, csp, cctx) +} + +func buildClusterContext( + csp, roleID, orgID string, + p ExecCredentialParams, + conn *Connection, + res *ElevateResult, +) *sdkk8s.IdsecSCAK8sClusterContext { + clusterID := conn.ClusterID + region := conn.Region + if csp == "AWS" { + if r, name, err := sdkk8s.ParseEKSARN(res.TargetID); err == nil { + region, clusterID = r, name + } + } + + return &sdkk8s.IdsecSCAK8sClusterContext{ + CSP: csp, + ClusterID: clusterID, + RoleID: roleID, + Region: region, + FQDN: p.FQDN, + OrganizationID: orgID, + Namespace: p.Namespace, + ElevateToken: p.ElevateToken, + RootCA: conn.CertificateData, + Diagnostics: p.Diagnostics, + } +} + +func (s *Service) flow() credentialFlow { + if s.credFlow != nil { + return s.credFlow + } + return sdkCredentialFlow{} +} + +// sdkCredentialFlow delegates to the SDK's per-CSP providers. +type sdkCredentialFlow struct{} + +// Direct produces an ExecCredential for the direct connection method. +func (sdkCredentialFlow) Direct( + res *ElevateResult, + cctx *sdkk8s.IdsecSCAK8sClusterContext, + interactive bool, +) (*k8smodels.IdsecSCAK8sExecCredential, error) { + switch cctx.CSP { + case "AZURE": + return azureCredential(cctx, interactive) + case "AWS": + if err := hydrateAWSIDC(res, cctx, interactive); err != nil { + return nil, err + } + } + + provider, err := sdkk8s.GetTokenProvider(cctx.CSP) + if err != nil { + return nil, err + } + cred, err := provider.GenerateToken(res.SDK, cctx) + if err != nil { + return nil, fmt.Errorf("failed to generate cluster credential: %w", err) + } + return cred, nil +} + +// ProxyToken returns the cluster API token that must be JWE-wrapped for the DPA +// proxy. AWS IAM-role clusters need none. +func (f sdkCredentialFlow) ProxyToken( + res *ElevateResult, + cctx *sdkk8s.IdsecSCAK8sClusterContext, + interactive bool, +) (string, error) { + switch cctx.CSP { + case "AZURE": + return azureAccessToken(cctx, interactive) + case "AWS": + if !sdkk8s.IsAWSIDCPermissionSetRole(cctx.RoleID) { + return "", nil + } + cred, err := f.Direct(res, cctx, interactive) + if err != nil { + return "", err + } + return cred.Status.Token, nil + } + return "", nil +} + +func hydrateAWSIDC(res *ElevateResult, cctx *sdkk8s.IdsecSCAK8sClusterContext, interactive bool) error { + if !sdkk8s.NeedsAWSIDCDeviceRegistration(res.SDK) { + return nil + } + if !interactive { + return fmt.Errorf( + "%w: this AWS Identity Center role needs a browser-based device login, but kubectl reported that stdin is unavailable; run 'grant k8s elevate %s' in a terminal first", + ErrInteractionRequired, cctx.FQDN) + } + if err := sdkk8s.HydrateAWSAccessCredentialsFromElevate(res.SDK, cctx.Diagnostics, nil); err != nil { + return fmt.Errorf("AWS Identity Center device registration failed: %w", err) + } + return nil +} + +func azureCredential(cctx *sdkk8s.IdsecSCAK8sClusterContext, interactive bool) (*k8smodels.IdsecSCAK8sExecCredential, error) { + token, err := azureAccessToken(cctx, interactive) + if err != nil { + return nil, err + } + return sdkk8s.BuildAzureExecCredential(token), nil +} + +// azureAccessToken acquires an AKS token through the Azure CLI. The Azure path +// requires the Azure CLI to be installed and logged in; when kubectl says stdin +// is unavailable we only verify an existing session rather than running +// `az login`. +func azureAccessToken(cctx *sdkk8s.IdsecSCAK8sClusterContext, interactive bool) (string, error) { + if !interactive { + token, err := sdkk8s.VerifyAzureCLISession(cctx.OrganizationID, cctx.ElevateToken) + if err != nil { + return "", fmt.Errorf( + "%w: no usable Azure CLI session and kubectl reported that stdin is unavailable; run 'az login' in a terminal first: %w", + ErrInteractionRequired, err) + } + return token, nil + } + + token, err := sdkk8s.EnsureAzureCLISession(cctx.OrganizationID, cctx.ElevateToken, azureSubscription(cctx), cctx.Diagnostics) + if err != nil { + return "", fmt.Errorf("Azure CLI authentication failed (the Azure path requires the Azure CLI installed and logged in): %w", err) + } + return token, nil +} + +func azureSubscription(cctx *sdkk8s.IdsecSCAK8sClusterContext) string { + return sdkk8s.AzureSubscriptionFromTargetID(cctx.ClusterID) +} diff --git a/internal/k8s/execcred_cache.go b/internal/k8s/execcred_cache.go new file mode 100644 index 0000000..e79da5a --- /dev/null +++ b/internal/k8s/execcred_cache.go @@ -0,0 +1,129 @@ +package k8s + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + k8smodels "github.com/cyberark/idsec-sdk-golang/pkg/services/sca/k8s/models" +) + +// maxCredentialLifetime caps how far ahead a cached credential's expiry may sit. +// A value beyond this indicates client/server clock skew (or a tampered file), +// and the credential is not trusted rather than being cached indefinitely. +const maxCredentialLifetime = 24 * time.Hour + +// CredentialKey identifies a cached ExecCredential. +type CredentialKey struct { + CSP string + FQDN string + RoleID string + Namespace string +} + +// CredentialCache stores kubectl ExecCredentials on disk between invocations. +// +// Expiry semantics: status.expirationTimestamp is written by the SDK with its +// early-refresh buffer already subtracted. The cache treats that value as final +// and applies no further arithmetic — the buffer is baked in once, at the point +// the raw DPA/STS/AKS expiry is known, and never re-applied here. +type CredentialCache struct { + dir string + now func() time.Time +} + +// NewCredentialCache creates a credential cache rooted at dir. +func NewCredentialCache(dir string) *CredentialCache { + return &CredentialCache{dir: dir, now: time.Now} +} + +// pathFor returns the on-disk path for a key. +func (c *CredentialCache) pathFor(key CredentialKey) string { + raw := strings.Join([]string{ + strings.ToUpper(key.CSP), key.FQDN, key.RoleID, key.Namespace, + }, "\x00") + sum := sha256.Sum256([]byte(raw)) + return filepath.Join(c.dir, "execcred_"+hex.EncodeToString(sum[:])+".json") +} + +// Get returns a cached credential when one exists and is still valid. +// Any problem — missing file, unreadable JSON, loose permissions, absent or +// implausible expiry — is reported as a plain miss. +func (c *CredentialCache) Get(key CredentialKey) (*k8smodels.IdsecSCAK8sExecCredential, bool) { + path := c.pathFor(key) + + fi, err := os.Stat(path) + if err != nil { + return nil, false + } + // A credential file readable beyond its owner cannot be trusted. + if fi.Mode().Perm()&0o077 != 0 { + _ = os.Remove(path) + return nil, false + } + + data, err := os.ReadFile(path) //nolint:gosec // path is derived from a hashed key inside the cache dir + if err != nil { + return nil, false + } + + var cred k8smodels.IdsecSCAK8sExecCredential + if err := json.Unmarshal(data, &cred); err != nil { + return nil, false + } + + expiry, ok := credentialExpiry(&cred) + if !ok { + return nil, false + } + + now := c.now() + if !expiry.After(now) || expiry.After(now.Add(maxCredentialLifetime)) { + return nil, false + } + return &cred, true +} + +// Put stores a credential. Credentials without a usable expiry are not cacheable +// and are silently dropped rather than being cached forever. +func (c *CredentialCache) Put(key CredentialKey, cred *k8smodels.IdsecSCAK8sExecCredential) error { + if _, ok := credentialExpiry(cred); !ok { + return nil + } + + if err := os.MkdirAll(c.dir, 0o700); err != nil { + return fmt.Errorf("failed to create credential cache directory: %w", err) + } + + data, err := json.Marshal(cred) + if err != nil { + return fmt.Errorf("failed to encode credential: %w", err) + } + + if err := os.WriteFile(c.pathFor(key), data, 0o600); err != nil { + return fmt.Errorf("failed to write credential cache: %w", err) + } + return nil +} + +// credentialExpiry parses status.expirationTimestamp verbatim. No buffer is +// applied: the SDK already subtracted one. +func credentialExpiry(cred *k8smodels.IdsecSCAK8sExecCredential) (time.Time, bool) { + if cred == nil { + return time.Time{}, false + } + raw := strings.TrimSpace(cred.Status.ExpirationTimestamp) + if raw == "" { + return time.Time{}, false + } + t, err := time.Parse(time.RFC3339, raw) + if err != nil { + return time.Time{}, false + } + return t, true +} diff --git a/internal/k8s/execcred_cache_test.go b/internal/k8s/execcred_cache_test.go new file mode 100644 index 0000000..dd6a255 --- /dev/null +++ b/internal/k8s/execcred_cache_test.go @@ -0,0 +1,242 @@ +package k8s + +import ( + "os" + "path/filepath" + "runtime" + "testing" + "time" + + k8smodels "github.com/cyberark/idsec-sdk-golang/pkg/services/sca/k8s/models" +) + +func testKey() CredentialKey { + return CredentialKey{CSP: "AWS", FQDN: "prod.eks.example", RoleID: "arn:role/admin"} +} + +func credWithExpiry(t time.Time) *k8smodels.IdsecSCAK8sExecCredential { + return &k8smodels.IdsecSCAK8sExecCredential{ + APIVersion: "client.authentication.k8s.io/v1beta1", + Kind: "ExecCredential", + Status: k8smodels.IdsecSCAK8sExecCredentialStatus{ + Token: "tok", + ExpirationTimestamp: t.UTC().Format(time.RFC3339), + }, + } +} + +func TestCredentialCacheRoundTrip(t *testing.T) { + now := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC) + c := NewCredentialCache(t.TempDir()) + c.now = func() time.Time { return now } + + cred := credWithExpiry(now.Add(10 * time.Minute)) + if err := c.Put(testKey(), cred); err != nil { + t.Fatalf("Put: %v", err) + } + + got, ok := c.Get(testKey()) + if !ok { + t.Fatal("expected a cache hit") + } + if got.Status.Token != "tok" { + t.Errorf("token = %q", got.Status.Token) + } + if got.Status.ExpirationTimestamp != cred.Status.ExpirationTimestamp { + t.Errorf("expirationTimestamp changed on the round trip: %q -> %q", + cred.Status.ExpirationTimestamp, got.Status.ExpirationTimestamp) + } +} + +// The SDK bakes its early-refresh buffer into expirationTimestamp. The cache +// must treat that value as final and never subtract another buffer. +func TestCredentialCacheDoesNotReapplyBuffer(t *testing.T) { + now := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC) + c := NewCredentialCache(t.TempDir()) + c.now = func() time.Time { return now } + + expiry := now.Add(30 * time.Second) + if err := c.Put(testKey(), credWithExpiry(expiry)); err != nil { + t.Fatalf("Put: %v", err) + } + + // 30s before the stamped expiry the credential is still valid, even though a + // naive second application of a 60s buffer would have expired it. + if _, ok := c.Get(testKey()); !ok { + t.Fatal("credential expired early — the refresh buffer was applied twice") + } +} + +func TestCredentialCacheExpiry(t *testing.T) { + base := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC) + tests := []struct { + name string + expiry time.Time + readAt time.Time + wantHit bool + }{ + {name: "valid", expiry: base.Add(time.Hour), readAt: base, wantHit: true}, + {name: "exactly at expiry is a miss", expiry: base, readAt: base, wantHit: false}, + {name: "past expiry", expiry: base.Add(-time.Second), readAt: base, wantHit: false}, + { + name: "expiry implausibly far ahead (clock skew) is a miss", + expiry: base.Add(maxCredentialLifetime + time.Hour), + readAt: base, + wantHit: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewCredentialCache(t.TempDir()) + c.now = func() time.Time { return tt.readAt } + if err := c.Put(testKey(), credWithExpiry(tt.expiry)); err != nil { + t.Fatalf("Put: %v", err) + } + if _, ok := c.Get(testKey()); ok != tt.wantHit { + t.Errorf("hit = %v, want %v", ok, tt.wantHit) + } + }) + } +} + +func TestCredentialCacheRejectsUncacheableCredentials(t *testing.T) { + tests := []struct { + name string + cred *k8smodels.IdsecSCAK8sExecCredential + }{ + {name: "nil", cred: nil}, + { + name: "no expirationTimestamp", + cred: &k8smodels.IdsecSCAK8sExecCredential{Status: k8smodels.IdsecSCAK8sExecCredentialStatus{Token: "t"}}, + }, + { + name: "malformed expirationTimestamp", + cred: &k8smodels.IdsecSCAK8sExecCredential{Status: k8smodels.IdsecSCAK8sExecCredentialStatus{ + Token: "t", ExpirationTimestamp: "not-a-time", + }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + c := NewCredentialCache(dir) + if err := c.Put(testKey(), tt.cred); err != nil { + t.Fatalf("Put should be a silent no-op, got %v", err) + } + if _, ok := c.Get(testKey()); ok { + t.Error("expected a miss: credentials without a usable expiry are not cacheable") + } + entries, _ := os.ReadDir(dir) + if len(entries) != 0 { + t.Errorf("nothing should have been written, found %d files", len(entries)) + } + }) + } +} + +func TestCredentialCacheFilePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX file modes are not meaningful on Windows") + } + + dir := filepath.Join(t.TempDir(), "cache") + now := time.Now() + c := NewCredentialCache(dir) + + if err := c.Put(testKey(), credWithExpiry(now.Add(time.Hour))); err != nil { + t.Fatalf("Put: %v", err) + } + + di, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat dir: %v", err) + } + if di.Mode().Perm() != 0o700 { + t.Errorf("cache dir mode = %o, want 0700", di.Mode().Perm()) + } + + path := c.pathFor(testKey()) + fi, err := os.Stat(path) + if err != nil { + t.Fatalf("stat file: %v", err) + } + if fi.Mode().Perm() != 0o600 { + t.Errorf("cache file mode = %o, want 0600", fi.Mode().Perm()) + } +} + +// A cache file that is readable beyond the owner may have been tampered with or +// observed; it is refused and removed rather than trusted. +func TestCredentialCacheRefusesLooseFilePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX file modes are not meaningful on Windows") + } + + c := NewCredentialCache(t.TempDir()) + if err := c.Put(testKey(), credWithExpiry(time.Now().Add(time.Hour))); err != nil { + t.Fatalf("Put: %v", err) + } + + path := c.pathFor(testKey()) + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + + if _, ok := c.Get(testKey()); ok { + t.Fatal("expected a miss for a world-readable cache file") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("the insecure cache file should have been removed") + } +} + +func TestCredentialCacheReusedUntilExpiryThenRefetched(t *testing.T) { + base := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC) + current := base + + c := NewCredentialCache(t.TempDir()) + c.now = func() time.Time { return current } + + if err := c.Put(testKey(), credWithExpiry(base.Add(5*time.Minute))); err != nil { + t.Fatalf("Put: %v", err) + } + + current = base.Add(4 * time.Minute) + if _, ok := c.Get(testKey()); !ok { + t.Error("credential should still be reused before its stamped expiry") + } + + current = base.Add(6 * time.Minute) + if _, ok := c.Get(testKey()); ok { + t.Error("credential should be refetched after its stamped expiry") + } +} + +func TestCredentialCacheKeysAreDistinct(t *testing.T) { + c := NewCredentialCache(t.TempDir()) + keys := []CredentialKey{ + {CSP: "AWS", FQDN: "a", RoleID: "r"}, + {CSP: "AZURE", FQDN: "a", RoleID: "r"}, + {CSP: "AWS", FQDN: "b", RoleID: "r"}, + {CSP: "AWS", FQDN: "a", RoleID: "r2"}, + {CSP: "AWS", FQDN: "a", RoleID: "r", Namespace: "ns"}, + } + + seen := map[string]bool{} + for _, k := range keys { + path := c.pathFor(k) + if seen[path] { + t.Errorf("cache key collision for %+v", k) + } + seen[path] = true + } +} + +func TestCredentialCacheMissOnUnwritableDir(t *testing.T) { + c := NewCredentialCache(t.TempDir()) + if _, ok := c.Get(testKey()); ok { + t.Error("expected a miss on an empty cache") + } +} diff --git a/internal/k8s/execcred_test.go b/internal/k8s/execcred_test.go new file mode 100644 index 0000000..6cffa4b --- /dev/null +++ b/internal/k8s/execcred_test.go @@ -0,0 +1,202 @@ +package k8s + +import ( + "errors" + "strings" + "testing" + + sdkk8s "github.com/cyberark/idsec-sdk-golang/pkg/services/sca/k8s" + k8smodels "github.com/cyberark/idsec-sdk-golang/pkg/services/sca/k8s/models" +) + +type stubFlow struct { + directFn func(*ElevateResult, *sdkk8s.IdsecSCAK8sClusterContext, bool) (*k8smodels.IdsecSCAK8sExecCredential, error) + proxyFn func(*ElevateResult, *sdkk8s.IdsecSCAK8sClusterContext, bool) (string, error) +} + +func (s *stubFlow) Direct(res *ElevateResult, cctx *sdkk8s.IdsecSCAK8sClusterContext, interactive bool) (*k8smodels.IdsecSCAK8sExecCredential, error) { + if s.directFn != nil { + return s.directFn(res, cctx, interactive) + } + return &k8smodels.IdsecSCAK8sExecCredential{Kind: "ExecCredential"}, nil +} + +func (s *stubFlow) ProxyToken(res *ElevateResult, cctx *sdkk8s.IdsecSCAK8sClusterContext, interactive bool) (string, error) { + if s.proxyFn != nil { + return s.proxyFn(res, cctx, interactive) + } + return "", nil +} + +func evaluateBackend(method, certData string) *stubBackend { + return &stubBackend{ + evaluateFn: func(*k8smodels.IdsecSCAK8sEvaluateRequest, string) (*k8smodels.IdsecSCAK8sEvaluateResponse, error) { + return &k8smodels.IdsecSCAK8sEvaluateResponse{ + Response: []k8smodels.IdsecSCAK8sEvaluateResult{{ + ConnectionMethod: method, + CertificateData: certData, + Role: k8smodels.IdsecSCAk8sListClustersRole{ID: "role-from-evaluate"}, + Target: k8smodels.IdsecSCAk8sListClustersTarget{ + ClusterID: "cluster-id", Region: "eu-west-1", + }, + }}, + }, nil + }, + elevateFn: func(*k8smodels.IdsecSCAK8sElevateKubectlRequest) (*k8smodels.IdsecSCAK8sElevateResponse, error) { + return &k8smodels.IdsecSCAK8sElevateResponse{ + Response: k8smodels.IdsecSCAK8sElevateResponseBody{ + CSP: "AWS", + Results: []k8smodels.IdsecSCAK8sElevateResult{{ + SessionID: "s1", + TargetID: "arn:aws:eks:us-east-1:1:cluster/prod", + }}, + }, + }, nil + }, + } +} + +func TestExecCredentialDirectFlow(t *testing.T) { + var gotCtx *sdkk8s.IdsecSCAK8sClusterContext + svc := NewServiceWithBackend(evaluateBackend(ConnectionDirect, "Y2E=")) + svc.SetCredentialFlow(&stubFlow{ + directFn: func(_ *ElevateResult, cctx *sdkk8s.IdsecSCAK8sClusterContext, _ bool) (*k8smodels.IdsecSCAK8sExecCredential, error) { + gotCtx = cctx + return &k8smodels.IdsecSCAK8sExecCredential{ + Kind: "ExecCredential", + Status: k8smodels.IdsecSCAK8sExecCredentialStatus{Token: "tok"}, + }, nil + }, + }) + + cred, err := svc.ExecCredential(t.Context(), ExecCredentialParams{ + CSP: "aws", FQDN: "prod.eks.example", Interactive: true, + }) + if err != nil { + t.Fatalf("ExecCredential: %v", err) + } + if cred.Status.Token != "tok" { + t.Errorf("token = %q", cred.Status.Token) + } + + // AWS cluster id and region come from the elevate targetId ARN, not evaluate. + if gotCtx.ClusterID != "prod" || gotCtx.Region != "us-east-1" { + t.Errorf("cluster context = %+v, want clusterID=prod region=us-east-1", gotCtx) + } + if gotCtx.RootCA != "Y2E=" { + t.Errorf("RootCA = %q, want the evaluate certificateData", gotCtx.RootCA) + } +} + +func TestExecCredentialFallsBackToEvaluateRole(t *testing.T) { + var gotRole string + svc := NewServiceWithBackend(evaluateBackend(ConnectionDirect, "Y2E=")) + svc.SetCredentialFlow(&stubFlow{ + directFn: func(_ *ElevateResult, cctx *sdkk8s.IdsecSCAK8sClusterContext, _ bool) (*k8smodels.IdsecSCAK8sExecCredential, error) { + gotRole = cctx.RoleID + return &k8smodels.IdsecSCAK8sExecCredential{}, nil + }, + }) + + if _, err := svc.ExecCredential(t.Context(), ExecCredentialParams{CSP: "aws", FQDN: "host"}); err != nil { + t.Fatalf("ExecCredential: %v", err) + } + if gotRole != "role-from-evaluate" { + t.Errorf("RoleID = %q, want the role from evaluate when --role-id is omitted", gotRole) + } +} + +func TestExecCredentialProxyFlow(t *testing.T) { + var proxyCSP string + var proxyCtx *sdkk8s.IdsecSCAK8sClusterContext + + backend := evaluateBackend(ConnectionProxy, "Y2E=") + backend.proxyFn = func(csp string, cctx *sdkk8s.IdsecSCAK8sClusterContext) (*k8smodels.IdsecSCAK8sExecCredential, error) { + proxyCSP, proxyCtx = csp, cctx + return &k8smodels.IdsecSCAK8sExecCredential{ + Status: k8smodels.IdsecSCAK8sExecCredentialStatus{ClientCertificateData: "cert", ClientKeyData: "key"}, + }, nil + } + + svc := NewServiceWithBackend(backend) + svc.SetCredentialFlow(&stubFlow{ + proxyFn: func(*ElevateResult, *sdkk8s.IdsecSCAK8sClusterContext, bool) (string, error) { + return "k8s-token", nil + }, + directFn: func(*ElevateResult, *sdkk8s.IdsecSCAK8sClusterContext, bool) (*k8smodels.IdsecSCAK8sExecCredential, error) { + t.Error("Direct must not be called for a proxy cluster") + return nil, nil + }, + }) + + cred, err := svc.ExecCredential(t.Context(), ExecCredentialParams{CSP: "aws", FQDN: "host"}) + if err != nil { + t.Fatalf("ExecCredential: %v", err) + } + if cred.Status.ClientCertificateData != "cert" { + t.Errorf("cred = %+v", cred.Status) + } + if proxyCSP != "AWS" { + t.Errorf("proxy csp = %q", proxyCSP) + } + if proxyCtx.K8sToken != "k8s-token" { + t.Errorf("K8sToken = %q, want it set from the proxy token flow", proxyCtx.K8sToken) + } +} + +func TestExecCredentialProxyRequiresCertificateData(t *testing.T) { + svc := NewServiceWithBackend(evaluateBackend(ConnectionProxy, "")) + svc.SetCredentialFlow(&stubFlow{ + proxyFn: func(*ElevateResult, *sdkk8s.IdsecSCAK8sClusterContext, bool) (string, error) { + return "k8s-token", nil + }, + }) + + _, err := svc.ExecCredential(t.Context(), ExecCredentialParams{CSP: "aws", FQDN: "host"}) + if err == nil || !strings.Contains(err.Error(), "certificate data") { + t.Fatalf("err = %v, want a missing-certificate-data error", err) + } +} + +func TestExecCredentialPropagatesInteractiveFlag(t *testing.T) { + for _, interactive := range []bool{true, false} { + var got bool + svc := NewServiceWithBackend(evaluateBackend(ConnectionDirect, "Y2E=")) + svc.SetCredentialFlow(&stubFlow{ + directFn: func(_ *ElevateResult, _ *sdkk8s.IdsecSCAK8sClusterContext, i bool) (*k8smodels.IdsecSCAK8sExecCredential, error) { + got = i + return &k8smodels.IdsecSCAK8sExecCredential{}, nil + }, + }) + + if _, err := svc.ExecCredential(t.Context(), ExecCredentialParams{ + CSP: "aws", FQDN: "host", Interactive: interactive, + }); err != nil { + t.Fatalf("ExecCredential: %v", err) + } + if got != interactive { + t.Errorf("interactive = %v, want %v", got, interactive) + } + } +} + +func TestExecCredentialSurfacesInteractionRequired(t *testing.T) { + svc := NewServiceWithBackend(evaluateBackend(ConnectionDirect, "Y2E=")) + svc.SetCredentialFlow(&stubFlow{ + directFn: func(*ElevateResult, *sdkk8s.IdsecSCAK8sClusterContext, bool) (*k8smodels.IdsecSCAK8sExecCredential, error) { + return nil, ErrInteractionRequired + }, + }) + + _, err := svc.ExecCredential(t.Context(), ExecCredentialParams{CSP: "aws", FQDN: "host"}) + if !errors.Is(err, ErrInteractionRequired) { + t.Fatalf("err = %v, want ErrInteractionRequired", err) + } +} + +func TestExecCredentialValidatesCSP(t *testing.T) { + svc := NewServiceWithBackend(evaluateBackend(ConnectionDirect, "")) + if _, err := svc.ExecCredential(t.Context(), ExecCredentialParams{CSP: "gcp", FQDN: "host"}); err == nil { + t.Fatal("expected an unsupported-CSP error") + } +} diff --git a/internal/k8s/kubeconfig.go b/internal/k8s/kubeconfig.go new file mode 100644 index 0000000..dbb7977 --- /dev/null +++ b/internal/k8s/kubeconfig.go @@ -0,0 +1,459 @@ +package k8s + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// kubeconfigSections are the named-entry lists grant merges into. +var kubeconfigSections = []string{"clusters", "users", "contexts"} + +// execPluginBinaries are the official CLI binaries whose exec stanzas grant +// rewrites to point at itself (risk R-5a). Anything else is left alone. +var execPluginBinaries = map[string]bool{ + "idsec": true, + "idsec-cli": true, + "ark": true, + "ark-cli": true, +} + +// execPassThroughFlags are the kubectl-login flags grant's exec-credential +// command understands and therefore preserves when rewriting an exec stanza. +var execPassThroughFlags = map[string]bool{ + "--csp": true, + "--fqdn": true, + "--role-id": true, + "--organization-id": true, + "--namespace": true, +} + +// kubeconfigWriteFailPoint is an injectable hook fired after the temp file is +// fully written and before the rename. Tests use it to simulate an interrupted +// write; it is nil in production. +var kubeconfigWriteFailPoint func(tmpPath string) error + +// Kubeconfig is a parsed kubeconfig document. It keeps the original YAML node +// tree so entries grant does not own survive re-serialization with their +// structure and comments intact. +type Kubeconfig struct { + root *yaml.Node +} + +// MergeReport records what a merge did, for reporting on stderr. +type MergeReport struct { + Added []string + Replaced []string +} + +// ExecRewrite records a rewritten exec-credential stanza. +type ExecRewrite struct { + User string + From string + To string +} + +// ParseKubeconfig parses kubeconfig YAML. Empty input yields a valid skeleton. +func ParseKubeconfig(data []byte) (*Kubeconfig, error) { + if strings.TrimSpace(string(data)) == "" { + return &Kubeconfig{root: newKubeconfigSkeleton()}, nil + } + + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("failed to parse kubeconfig: %w", err) + } + if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 { + return nil, errors.New("kubeconfig is not a YAML document") + } + root := doc.Content[0] + if root.Kind != yaml.MappingNode { + return nil, errors.New("kubeconfig root is not a YAML mapping") + } + return &Kubeconfig{root: root}, nil +} + +// Bytes serializes the kubeconfig back to YAML. +func (k *Kubeconfig) Bytes() ([]byte, error) { + var sb strings.Builder + enc := yaml.NewEncoder(&sb) + enc.SetIndent(2) + if err := enc.Encode(k.root); err != nil { + return nil, fmt.Errorf("failed to serialize kubeconfig: %w", err) + } + if err := enc.Close(); err != nil { + return nil, fmt.Errorf("failed to serialize kubeconfig: %w", err) + } + return []byte(sb.String()), nil +} + +// CurrentContext returns the current-context value, or "" when unset. +func (k *Kubeconfig) CurrentContext() string { + if node := mapGet(k.root, "current-context"); node != nil { + return node.Value + } + return "" +} + +// SetCurrentContext sets current-context. Callers must only do this behind an +// explicit user opt-in: silently repointing kubectl is a production foot-gun. +func (k *Kubeconfig) SetCurrentContext(name string) { + mapSet(k.root, "current-context", scalarNode(name)) +} + +// ContextNames returns every context name in the document. +func (k *Kubeconfig) ContextNames() []string { + seq := mapGet(k.root, "contexts") + if seq == nil { + return nil + } + names := make([]string, 0, len(seq.Content)) + for _, entry := range seq.Content { + if name := mapGet(entry, "name"); name != nil { + names = append(names, name.Value) + } + } + return names +} + +// PrefixEntries renames every cluster, user and context to a deterministic +// grant-owned name (grant--) and remaps context references, so +// ownership is decidable on the next merge without extra state. +func (k *Kubeconfig) PrefixEntries(csp string) { + prefix := "grant-" + strings.ToLower(strings.TrimSpace(csp)) + "-" + + renames := map[string]map[string]string{} + for _, section := range kubeconfigSections { + renames[section] = renameSection(mapGet(k.root, section), prefix) + } + + // Remap each context's cluster/user references through the rename maps. + contexts := mapGet(k.root, "contexts") + if contexts == nil { + return + } + for _, entry := range contexts.Content { + inner := mapGet(entry, "context") + if inner == nil { + continue + } + remapRef(inner, "cluster", renames["clusters"]) + remapRef(inner, "user", renames["users"]) + } +} + +func renameSection(seq *yaml.Node, prefix string) map[string]string { + renames := map[string]string{} + if seq == nil { + return renames + } + for _, entry := range seq.Content { + name := mapGet(entry, "name") + if name == nil || name.Value == "" || strings.HasPrefix(name.Value, prefix) { + continue + } + renamed := prefix + name.Value + renames[name.Value] = renamed + name.Value = renamed + } + return renames +} + +func remapRef(inner *yaml.Node, key string, renames map[string]string) { + ref := mapGet(inner, key) + if ref == nil { + return + } + if renamed, ok := renames[ref.Value]; ok { + ref.Value = renamed + } +} + +// Merge folds other's clusters, users and contexts into k. Entries whose names +// collide are replaced; everything else in k is left untouched, including +// current-context. +func (k *Kubeconfig) Merge(other *Kubeconfig) MergeReport { + ensureScalar(k.root, "apiVersion", "v1") + ensureScalar(k.root, "kind", "Config") + + var report MergeReport + for _, section := range kubeconfigSections { + incoming := mapGet(other.root, section) + if incoming == nil { + continue + } + target := ensureSequence(k.root, section) + for _, entry := range incoming.Content { + name := mapGet(entry, "name") + if name == nil { + continue + } + if idx := indexByName(target, name.Value); idx >= 0 { + target.Content[idx] = entry + report.Replaced = append(report.Replaced, section+"/"+name.Value) + continue + } + target.Content = append(target.Content, entry) + report.Added = append(report.Added, section+"/"+name.Value) + } + } + return report +} + +// RewriteExecCommands repoints exec-credential plugin stanzas at grantPath. +// +// R-5a: the DPA-generated kubeconfig is expected to reference the official +// idsec/ark CLI. This rewrite is deliberately conservative — a stanza is only +// touched when its command basename is a known official binary, and only flags +// grant's own exec-credential command understands are carried over. Everything +// else passes through untouched. This has not been validated against a live +// tenant's generated kubeconfig. +func (k *Kubeconfig) RewriteExecCommands(grantPath string) []ExecRewrite { + users := mapGet(k.root, "users") + if users == nil { + return nil + } + + var rewrites []ExecRewrite + for _, entry := range users.Content { + userName := "" + if n := mapGet(entry, "name"); n != nil { + userName = n.Value + } + exec := execNode(entry) + if exec == nil { + continue + } + command := mapGet(exec, "command") + if command == nil || !isOfficialCLIBinary(command.Value) { + continue + } + + rewrites = append(rewrites, ExecRewrite{User: userName, From: command.Value, To: grantPath}) + command.Value = grantPath + mapSet(exec, "args", sequenceNode(rewriteExecArgs(mapGet(exec, "args")))) + } + return rewrites +} + +func execNode(userEntry *yaml.Node) *yaml.Node { + inner := mapGet(userEntry, "user") + if inner == nil { + return nil + } + exec := mapGet(inner, "exec") + if exec == nil || exec.Kind != yaml.MappingNode { + return nil + } + return exec +} + +func isOfficialCLIBinary(command string) bool { + base := strings.ToLower(filepath.Base(strings.TrimSpace(command))) + base = strings.TrimSuffix(base, ".exe") + return execPluginBinaries[base] +} + +// rewriteExecArgs rebuilds the argument vector for grant's exec-credential +// command, carrying over only recognized flags. +func rewriteExecArgs(args *yaml.Node) []string { + out := []string{"k8s", "exec-credential"} + if args == nil || args.Kind != yaml.SequenceNode { + return out + } + + for i := 0; i < len(args.Content); i++ { + arg := args.Content[i].Value + flag, inlineValue, hasInline := strings.Cut(arg, "=") + if !execPassThroughFlags[flag] { + continue + } + if hasInline { + out = append(out, flag, inlineValue) + continue + } + if i+1 < len(args.Content) { + out = append(out, flag, args.Content[i+1].Value) + i++ + } + } + return out +} + +// ResolveKubeconfigPath returns the kubeconfig grant writes to: the first +// non-empty entry of a $KUBECONFIG list, else /.kube/config. +func ResolveKubeconfigPath(kubeconfigEnv, home string) string { + for _, entry := range filepath.SplitList(kubeconfigEnv) { + if strings.TrimSpace(entry) != "" { + return entry + } + } + return filepath.Join(home, ".kube", "config") +} + +// WriteKubeconfigAtomic writes data to path via a temp file in the same +// directory followed by a rename, so an interrupted write can never leave a +// truncated kubeconfig behind. It returns any permission warnings. +func WriteKubeconfigAtomic(path string, data []byte) ([]string, error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("failed to create %s: %w", dir, err) + } + + perm, warnings := targetFileMode(path) + + tmp, err := os.CreateTemp(dir, ".grant-kubeconfig-*.tmp") + if err != nil { + return warnings, fmt.Errorf("failed to create temp file in %s: %w", dir, err) + } + tmpName := tmp.Name() + + if err := writeTempFile(tmp, data, perm); err != nil { + _ = os.Remove(tmpName) + return warnings, err + } + + if kubeconfigWriteFailPoint != nil { + if err := kubeconfigWriteFailPoint(tmpName); err != nil { + _ = os.Remove(tmpName) + return warnings, err + } + } + + if err := os.Rename(tmpName, path); err != nil { + _ = os.Remove(tmpName) + return warnings, fmt.Errorf("failed to replace %s: %w", path, err) + } + return warnings, nil +} + +func writeTempFile(tmp *os.File, data []byte, perm os.FileMode) error { + defer func() { _ = tmp.Close() }() + + if _, err := tmp.Write(data); err != nil { + return fmt.Errorf("failed to write kubeconfig: %w", err) + } + if err := tmp.Sync(); err != nil { + return fmt.Errorf("failed to flush kubeconfig: %w", err) + } + if err := tmp.Chmod(perm); err != nil { + return fmt.Errorf("failed to set kubeconfig permissions: %w", err) + } + return nil +} + +// targetFileMode decides the mode for the written file: 0600 by default, an +// existing narrower mode is preserved, and an existing group/world-accessible +// mode is tightened with a warning. +func targetFileMode(path string) (perm os.FileMode, warnings []string) { + fi, err := os.Stat(path) + if err != nil { + return 0o600, nil + } + mode := fi.Mode().Perm() + if mode&0o077 != 0 { + return 0o600, []string{fmt.Sprintf( + "%s is mode %o and readable beyond your user; grant is rewriting it as 0600", path, mode)} + } + if mode&^os.FileMode(0o600) == 0 && mode != 0 { + return mode, nil + } + return 0o600, nil +} + +// BackupOnce writes .grant.bak the first time grant merges into an +// existing kubeconfig. It never overwrites an existing backup. +func BackupOnce(path string) (bool, error) { + backup := path + ".grant.bak" + if _, err := os.Stat(backup); err == nil { + return false, nil + } + + data, err := os.ReadFile(path) //nolint:gosec // path is the user's chosen kubeconfig + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("failed to read %s for backup: %w", path, err) + } + + if err := os.WriteFile(backup, data, 0o600); err != nil { + return false, fmt.Errorf("failed to write %s: %w", backup, err) + } + return true, nil +} + +// --- yaml.Node helpers --- + +func mapGet(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1] + } + } + return nil +} + +func mapSet(node *yaml.Node, key string, value *yaml.Node) { + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + node.Content[i+1] = value + return + } + } + node.Content = append(node.Content, scalarNode(key), value) +} + +func ensureScalar(node *yaml.Node, key, value string) { + if mapGet(node, key) == nil { + mapSet(node, key, scalarNode(value)) + } +} + +func ensureSequence(node *yaml.Node, key string) *yaml.Node { + existing := mapGet(node, key) + if existing != nil && existing.Kind == yaml.SequenceNode { + return existing + } + seq := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + mapSet(node, key, seq) + return seq +} + +func indexByName(seq *yaml.Node, name string) int { + for i, entry := range seq.Content { + if n := mapGet(entry, "name"); n != nil && n.Value == name { + return i + } + } + return -1 +} + +func scalarNode(value string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} +} + +func sequenceNode(values []string) *yaml.Node { + seq := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + for _, v := range values { + seq.Content = append(seq.Content, scalarNode(v)) + } + return seq +} + +func newKubeconfigSkeleton() *yaml.Node { + root := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + mapSet(root, "apiVersion", scalarNode("v1")) + mapSet(root, "kind", scalarNode("Config")) + for _, section := range kubeconfigSections { + mapSet(root, section, &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}) + } + return root +} diff --git a/internal/k8s/kubeconfig_test.go b/internal/k8s/kubeconfig_test.go new file mode 100644 index 0000000..108676f --- /dev/null +++ b/internal/k8s/kubeconfig_test.go @@ -0,0 +1,525 @@ +package k8s + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +const existingKubeconfig = `apiVersion: v1 +kind: Config +current-context: work +clusters: + # my own cluster, do not touch + - name: work + cluster: + server: https://work.example + certificate-authority-data: d29yaw== +users: + - name: work-user + user: + token: mytoken +contexts: + - name: work + context: + cluster: work + user: work-user +preferences: {} +` + +const generatedKubeconfig = `apiVersion: v1 +kind: Config +current-context: eks-prod +clusters: + - name: eks-prod + cluster: + server: https://prod.eks.example +users: + - name: eks-prod-user + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + command: idsec + args: + - sca + - k8s + - kubectl-login + - --csp + - aws + - --fqdn + - prod.eks.example + - --role-id + - arn:aws:iam::1:role/admin +contexts: + - name: eks-prod + context: + cluster: eks-prod + user: eks-prod-user +` + +func decodeConfig(t *testing.T, data []byte) map[string]any { + t.Helper() + var out map[string]any + if err := yaml.Unmarshal(data, &out); err != nil { + t.Fatalf("decode kubeconfig: %v\n%s", err, data) + } + return out +} + +func namesIn(t *testing.T, cfg map[string]any, section string) []string { + t.Helper() + list, _ := cfg[section].([]any) + names := make([]string, 0, len(list)) + for _, item := range list { + m, ok := item.(map[string]any) + if !ok { + t.Fatalf("%s entry is not a map: %#v", section, item) + } + name, _ := m["name"].(string) + names = append(names, name) + } + return names +} + +func entryByName(t *testing.T, cfg map[string]any, section, name string) map[string]any { + t.Helper() + list, _ := cfg[section].([]any) + for _, item := range list { + m, _ := item.(map[string]any) + if m["name"] == name { + return m + } + } + t.Fatalf("%s entry %q not found", section, name) + return nil +} + +func TestMergePreservesExistingEntries(t *testing.T) { + target, err := ParseKubeconfig([]byte(existingKubeconfig)) + if err != nil { + t.Fatalf("parse target: %v", err) + } + generated, err := ParseKubeconfig([]byte(generatedKubeconfig)) + if err != nil { + t.Fatalf("parse generated: %v", err) + } + generated.PrefixEntries("aws") + + report := target.Merge(generated) + out, err := target.Bytes() + if err != nil { + t.Fatalf("serialize: %v", err) + } + + cfg := decodeConfig(t, out) + + // Every pre-existing entry survives untouched. + original := decodeConfig(t, []byte(existingKubeconfig)) + for _, section := range []string{"clusters", "users", "contexts"} { + for _, name := range namesIn(t, original, section) { + got := entryByName(t, cfg, section, name) + want := entryByName(t, original, section, name) + if !yamlEqual(t, got, want) { + t.Errorf("%s/%s changed:\ngot %#v\nwant %#v", section, name, got, want) + } + } + } + + // The YAML comment on the user's own cluster survives the round trip. + if !strings.Contains(string(out), "my own cluster, do not touch") { + t.Errorf("comment on an existing entry was lost:\n%s", out) + } + + if len(report.Added) != 3 { + t.Errorf("Added = %v, want 3 entries (cluster, user, context)", report.Added) + } + if len(report.Replaced) != 0 { + t.Errorf("Replaced = %v, want none", report.Replaced) + } +} + +func TestMergePrefixesGrantOwnedNames(t *testing.T) { + target, _ := ParseKubeconfig([]byte(existingKubeconfig)) + generated, _ := ParseKubeconfig([]byte(generatedKubeconfig)) + generated.PrefixEntries("aws") + + target.Merge(generated) + cfg := decodeConfig(t, mustBytes(t, target)) + + if !containsString(namesIn(t, cfg, "clusters"), "grant-aws-eks-prod") { + t.Errorf("clusters = %v, want a grant-aws- prefixed entry", namesIn(t, cfg, "clusters")) + } + + ctx := entryByName(t, cfg, "contexts", "grant-aws-eks-prod") + inner, _ := ctx["context"].(map[string]any) + if inner["cluster"] != "grant-aws-eks-prod" || inner["user"] != "grant-aws-eks-prod-user" { + t.Errorf("context references were not remapped: %#v", inner) + } +} + +func TestMergeReplacesCollidingGrantEntry(t *testing.T) { + target, _ := ParseKubeconfig([]byte(existingKubeconfig)) + generated, _ := ParseKubeconfig([]byte(generatedKubeconfig)) + generated.PrefixEntries("aws") + target.Merge(generated) + + // Merge the same generated config again: entries are replaced, not duplicated. + second, _ := ParseKubeconfig([]byte(generatedKubeconfig)) + second.PrefixEntries("aws") + report := target.Merge(second) + + cfg := decodeConfig(t, mustBytes(t, target)) + count := 0 + for _, n := range namesIn(t, cfg, "clusters") { + if n == "grant-aws-eks-prod" { + count++ + } + } + if count != 1 { + t.Errorf("grant-aws-eks-prod appears %d times, want 1", count) + } + if len(report.Replaced) != 3 { + t.Errorf("Replaced = %v, want 3", report.Replaced) + } + if len(report.Added) != 0 { + t.Errorf("Added = %v, want none", report.Added) + } +} + +func TestMergeLeavesCurrentContextAlone(t *testing.T) { + target, _ := ParseKubeconfig([]byte(existingKubeconfig)) + generated, _ := ParseKubeconfig([]byte(generatedKubeconfig)) + generated.PrefixEntries("aws") + target.Merge(generated) + + if got := target.CurrentContext(); got != "work" { + t.Errorf("current-context = %q, want it unchanged (work)", got) + } + + target.SetCurrentContext("grant-aws-eks-prod") + if got := target.CurrentContext(); got != "grant-aws-eks-prod" { + t.Errorf("current-context = %q after explicit opt-in", got) + } +} + +func TestMergeIntoEmptyKubeconfig(t *testing.T) { + target, err := ParseKubeconfig(nil) + if err != nil { + t.Fatalf("parse empty: %v", err) + } + generated, _ := ParseKubeconfig([]byte(generatedKubeconfig)) + generated.PrefixEntries("aws") + target.Merge(generated) + + cfg := decodeConfig(t, mustBytes(t, target)) + if cfg["apiVersion"] != "v1" || cfg["kind"] != "Config" { + t.Errorf("empty target did not get a valid kubeconfig skeleton: %#v", cfg) + } + if len(namesIn(t, cfg, "clusters")) != 1 { + t.Errorf("clusters = %v, want 1", namesIn(t, cfg, "clusters")) + } + // An empty target has no current-context; adopting the single new one is fine + // only when explicitly asked for. + if cfg["current-context"] != nil && cfg["current-context"] != "" { + t.Errorf("current-context = %v, want empty by default", cfg["current-context"]) + } +} + +// R-5a: the DPA-generated kubeconfig points exec.command at the official CLI. +func TestRewriteExecCommands(t *testing.T) { + generated, _ := ParseKubeconfig([]byte(generatedKubeconfig)) + rewrites := generated.RewriteExecCommands("/usr/local/bin/grant") + + if len(rewrites) != 1 { + t.Fatalf("got %d rewrites, want 1: %+v", len(rewrites), rewrites) + } + if rewrites[0].From != "idsec" { + t.Errorf("From = %q, want idsec", rewrites[0].From) + } + + cfg := decodeConfig(t, mustBytes(t, generated)) + user := entryByName(t, cfg, "users", "eks-prod-user") + exec, _ := user["user"].(map[string]any)["exec"].(map[string]any) + if exec["command"] != "/usr/local/bin/grant" { + t.Errorf("command = %v, want the grant binary path", exec["command"]) + } + + args, _ := exec["args"].([]any) + got := make([]string, len(args)) + for i, a := range args { + got[i], _ = a.(string) + } + want := []string{ + "k8s", "exec-credential", + "--csp", "aws", + "--fqdn", "prod.eks.example", + "--role-id", "arn:aws:iam::1:role/admin", + } + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Errorf("args = %v, want %v", got, want) + } +} + +func TestRewriteExecCommandsLeavesForeignPluginsAlone(t *testing.T) { + const foreign = `apiVersion: v1 +kind: Config +users: + - name: gke + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + command: gke-gcloud-auth-plugin +` + cfg, _ := ParseKubeconfig([]byte(foreign)) + if rewrites := cfg.RewriteExecCommands("/usr/local/bin/grant"); len(rewrites) != 0 { + t.Fatalf("rewrote a foreign exec plugin: %+v", rewrites) + } + + out := decodeConfig(t, mustBytes(t, cfg)) + user := entryByName(t, out, "users", "gke") + exec, _ := user["user"].(map[string]any)["exec"].(map[string]any) + if exec["command"] != "gke-gcloud-auth-plugin" { + t.Errorf("foreign command was modified: %v", exec["command"]) + } +} + +func TestResolveKubeconfigPath(t *testing.T) { + sep := string(os.PathListSeparator) + testHome := filepath.Join(string(filepath.Separator)+"home", "u") + homeKubeconfig := filepath.Join(testHome, ".kube", "config") + tests := []struct { + name string + env string + home string + want string + }{ + {name: "unset falls back to home", env: "", home: testHome, want: homeKubeconfig}, + {name: "single path", env: "/tmp/kc", home: testHome, want: "/tmp/kc"}, + {name: "list form takes first", env: "/tmp/a" + sep + "/tmp/b", home: testHome, want: "/tmp/a"}, + {name: "leading empty entry is skipped", env: sep + "/tmp/b", home: testHome, want: "/tmp/b"}, + {name: "all empty falls back", env: sep + sep, home: testHome, want: homeKubeconfig}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ResolveKubeconfigPath(tt.env, tt.home); got != tt.want { + t.Errorf("ResolveKubeconfigPath(%q) = %q, want %q", tt.env, got, tt.want) + } + }) + } +} + +func TestWriteAtomicCreatesFileWithSecureModes(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX file modes are not meaningful on Windows") + } + + dir := filepath.Join(t.TempDir(), "nested", "kube") + path := filepath.Join(dir, "config") + + if _, err := WriteKubeconfigAtomic(path, []byte("apiVersion: v1\n")); err != nil { + t.Fatalf("WriteKubeconfigAtomic: %v", err) + } + + fi, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if fi.Mode().Perm() != 0o600 { + t.Errorf("file mode = %o, want 0600", fi.Mode().Perm()) + } + + di, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat dir: %v", err) + } + if di.Mode().Perm() != 0o700 { + t.Errorf("dir mode = %o, want 0700", di.Mode().Perm()) + } +} + +func TestWriteAtomicDoesNotWidenExistingMode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX file modes are not meaningful on Windows") + } + + path := filepath.Join(t.TempDir(), "config") + if err := os.WriteFile(path, []byte("old"), 0o400); err != nil { + t.Fatal(err) + } + + if _, err := WriteKubeconfigAtomic(path, []byte("new")); err != nil { + t.Fatalf("WriteKubeconfigAtomic: %v", err) + } + + fi, _ := os.Stat(path) + if fi.Mode().Perm() != 0o400 { + t.Errorf("mode = %o, want the existing narrower 0400 preserved", fi.Mode().Perm()) + } +} + +func TestWriteAtomicWarnsOnWorldReadableTarget(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX file modes are not meaningful on Windows") + } + + path := filepath.Join(t.TempDir(), "config") + if err := os.WriteFile(path, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + + warnings, err := WriteKubeconfigAtomic(path, []byte("new")) + if err != nil { + t.Fatalf("WriteKubeconfigAtomic: %v", err) + } + if len(warnings) == 0 { + t.Fatal("expected a warning about the world-readable target") + } + if !strings.Contains(warnings[0], "644") { + t.Errorf("warning should mention the offending mode: %q", warnings[0]) + } + + fi, _ := os.Stat(path) + if fi.Mode().Perm() != 0o600 { + t.Errorf("mode = %o, want the file re-secured to 0600", fi.Mode().Perm()) + } +} + +func TestWriteAtomicUsesTempFileInSameDirectory(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + + var tmpDir string + original := kubeconfigWriteFailPoint + t.Cleanup(func() { kubeconfigWriteFailPoint = original }) + kubeconfigWriteFailPoint = func(tmp string) error { + tmpDir = filepath.Dir(tmp) + return nil + } + + if _, err := WriteKubeconfigAtomic(path, []byte("data")); err != nil { + t.Fatalf("WriteKubeconfigAtomic: %v", err) + } + if tmpDir != dir { + t.Errorf("temp file created in %q, want the target directory %q", tmpDir, dir) + } +} + +func TestWriteAtomicLeavesOriginalIntactOnFailure(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + if err := os.WriteFile(path, []byte(existingKubeconfig), 0o600); err != nil { + t.Fatal(err) + } + + boom := errors.New("interrupted") + original := kubeconfigWriteFailPoint + t.Cleanup(func() { kubeconfigWriteFailPoint = original }) + kubeconfigWriteFailPoint = func(string) error { return boom } + + if _, err := WriteKubeconfigAtomic(path, []byte("clobbered")); !errors.Is(err, boom) { + t.Fatalf("err = %v, want %v", err, boom) + } + + got, err := os.ReadFile(path) //nolint:gosec // test-controlled path + if err != nil { + t.Fatalf("original file is gone: %v", err) + } + if string(got) != existingKubeconfig { + t.Errorf("original file was modified:\n%s", got) + } + + // No temp files left behind. + entries, _ := os.ReadDir(dir) + if len(entries) != 1 { + t.Errorf("directory has %d entries, want only the original file", len(entries)) + } +} + +func TestBackupOnceCreatesSidecar(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + if err := os.WriteFile(path, []byte(existingKubeconfig), 0o600); err != nil { + t.Fatal(err) + } + + created, err := BackupOnce(path) + if err != nil { + t.Fatalf("BackupOnce: %v", err) + } + if !created { + t.Error("expected a backup to be created") + } + + data, err := os.ReadFile(path + ".grant.bak") //nolint:gosec // test-controlled path + if err != nil { + t.Fatalf("backup missing: %v", err) + } + if string(data) != existingKubeconfig { + t.Error("backup content differs from the original") + } + + // A second call must not overwrite the first backup. + if err := os.WriteFile(path, []byte("changed"), 0o600); err != nil { + t.Fatal(err) + } + created, err = BackupOnce(path) + if err != nil { + t.Fatalf("BackupOnce (second): %v", err) + } + if created { + t.Error("backup was recreated; it must only be written once") + } + data, _ = os.ReadFile(path + ".grant.bak") //nolint:gosec // test-controlled path + if string(data) != existingKubeconfig { + t.Error("existing backup was overwritten") + } +} + +func TestBackupOnceNoopWhenTargetMissing(t *testing.T) { + path := filepath.Join(t.TempDir(), "config") + created, err := BackupOnce(path) + if err != nil { + t.Fatalf("BackupOnce: %v", err) + } + if created { + t.Error("expected no backup for a non-existent target") + } +} + +func mustBytes(t *testing.T, k *Kubeconfig) []byte { + t.Helper() + data, err := k.Bytes() + if err != nil { + t.Fatalf("serialize kubeconfig: %v", err) + } + return data +} + +func yamlEqual(t *testing.T, a, b any) bool { + t.Helper() + ay, err := yaml.Marshal(a) + if err != nil { + t.Fatal(err) + } + by, err := yaml.Marshal(b) + if err != nil { + t.Fatal(err) + } + return bytes.Equal(ay, by) +} + +func containsString(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} diff --git a/internal/k8s/service.go b/internal/k8s/service.go index 1c63f71..fbaec8e 100644 --- a/internal/k8s/service.go +++ b/internal/k8s/service.go @@ -107,8 +107,14 @@ type backend interface { // Service is grant's wrapper around the SDK SCA K8s service. type Service struct { backend backend + + // credFlow overrides the SDK credential providers. Tests only. + credFlow credentialFlow } +// SetCredentialFlow overrides the credential providers. For tests. +func (s *Service) SetCredentialFlow(f credentialFlow) { s.credFlow = f } + // NewService creates a Service backed by the real SDK k8s service. func NewService(authenticators ...auth.IdsecAuth) (*Service, error) { sdkSvc, err := sdkk8s.NewIdsecSCAK8sService(authenticators...) From 8471b59121fb1c3da2f9fb155541d13da87a4ecc Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Thu, 13 Aug 2026 19:03:58 +0200 Subject: [PATCH 3/8] fix: address Codex review of the grant k8s surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two blockers and five majors from the PR #55 review. Blockers: - exec-credential no longer corrupts the kubectl protocol under --verbose. The SDK logger is built with log.New(os.Stdout, ...) and re-reads IDSEC_LOG_LEVEL on every call, so the root pre-run's INFO level put log lines on stdout ahead of the credential JSON. The command now forces CRITICAL for its duration (restoring it after) and routes diagnostics to stderr via the SDK's kubectl-login channel. The old test missed this because it ran the child command without the root PersistentPreRunE; it now drives the real root, redirects os.Stdout to a pipe, and asserts the pipe stays empty. Verified the test fails when the fix is reverted. - exec-credential no longer authenticates before it knows whether it needs to. Exec info parsing, flag validation and the credential cache lookup all happen first, so a cache hit — or a malformed apiVersion — can never trigger a browser/MFA prompt. On a miss it uses LoadAuthentication (keyring read, never prompts) and only escalates to interactive Authenticate when kubectl said stdin is available. Majors: - Azure identity binding is no longer silently disabled: the Idira session JWT is now populated and an Azure request without one is refused rather than authenticating as whoever is logged into the Azure CLI. - Credential cache hardening: writes create a fresh 0600 file and rename over the target (a loose pre-existing file is replaced, never written into); reads reject symlinks, non-regular files, loose file or directory modes, foreign ownership, and payloads with no credential material; the cache key now includes the organization so tenants cannot collide. - connectionMethod fails closed — anything that is not exactly direct or proxy is rejected before elevation instead of running the direct flow. - Rewritten exec stanzas always carry interactiveMode (IfAvailable), which client-go REQUIRES for the v1 API and without which the kubeconfig is rejected before grant is invoked. - $KUBECONFIG resolution follows kubectl's write rule: the first entry that exists, or the last when none do — no longer creating an earlier nonexistent file that shadows the real config. Minors: - runWithContext is documented honestly as a caller-side timeout that abandons rather than cancels, including the leaked goroutine and the shared-header mutation it can cause afterwards. - BackupOnce uses O_CREATE|O_EXCL so concurrent runs cannot clobber the backup, and refuses a symlinked or non-regular source. R-5a flag names and the certificateData hard-fail were both confirmed correct against SDK source during this review. --- CLAUDE.md | 14 +- cmd/k8s.go | 6 +- cmd/k8s_exec_credential.go | 148 ++++++++++++++++--- cmd/k8s_exec_credential_test.go | 211 +++++++++++++++++++++++++--- internal/k8s/execcred.go | 25 +++- internal/k8s/execcred_cache.go | 136 ++++++++++++++++-- internal/k8s/execcred_cache_test.go | 159 +++++++++++++++++++++ internal/k8s/execcred_test.go | 80 +++++++++++ internal/k8s/kubeconfig.go | 68 +++++++-- internal/k8s/kubeconfig_test.go | 150 +++++++++++++++++++- internal/k8s/ownership_unix.go | 24 ++++ internal/k8s/ownership_windows.go | 11 ++ internal/k8s/service.go | 29 ++-- 13 files changed, 980 insertions(+), 81 deletions(-) create mode 100644 internal/k8s/ownership_unix.go create mode 100644 internal/k8s/ownership_windows.go diff --git a/CLAUDE.md b/CLAUDE.md index 5988baa..e8a78ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,14 +62,20 @@ Custom `SCAAccessService` follows SDK conventions: - `POST /api/adb/sso/acquire` — short-lived client certificate for the proxy connection method - **Providers:** `aws` (EKS) and `azure` (AKS) only — GCP is not supported by this API. - **Do NOT reimplement** the `X-CLI-Signature` HMAC scheme or the `Content-Type` remove/restore dance around GETs; the SDK handles both. -- **Context propagation:** only `GenerateKubeconfigParallel` accepts a `context.Context`. Every other SDK entry point uses `context.Background()` internally, so `internal/k8s.runWithContext` enforces cancellation at the wrapper boundary — the caller returns promptly but the in-flight HTTP request is not cancelled. +- **Context handling:** only `GenerateKubeconfigParallel` accepts a `context.Context` (passed straight through). Every other SDK entry point issues its request on `context.Background()` internally. `internal/k8s.runWithContext` is therefore a **caller-side timeout, not context propagation** — on `ctx.Done` it unblocks the caller and *abandons* the call: the goroutine leaks until the request finishes, its result is dropped, and it can still mutate shared SDK client headers afterwards. Never read a returned `context.Canceled` as "the request stopped". A real fix needs context-accepting methods upstream. - **Dependency decision (D-4 / 5B):** importing the SDK's `pkg/services/sca/k8s` adds exactly 16 modules (Azure azcore/azidentity/armauthorization, MSAL, aws-sdk-go-v2 + credentials + sts + smithy-go, go-jose/v4, pkg/browser, kylelemons/godebug). This is a **deliberate, eyes-open departure** from the "zero new Go module deps" goal: the zero-dep alternative provably cannot ship `grant k8s exec-credential` (direct-AWS needs `sts`, direct-Azure needs `azidentity`, proxy needs `go-jose` for JWE), so it would produce a kubeconfig `kubectl` cannot authenticate with. Accepted costs: larger binary and an enlarged CVE surface — keep `govulncheck ./...` in the verify sequence and track Azure/AWS SDK advisories. - **Azure prerequisite:** the SDK uses `azidentity.NewAzureCLICredential`, so the Azure path requires the Azure CLI installed and logged in (`az login`). Never document it as "no Azure CLI needed". - **Cache:** `internal/cache/cached_clusters.go` — `CachedClusterLister`, keys `clusters_` (`clusters_all` when no provider), same TTL/`--refresh` semantics as eligibility. - **Selector:** `internal/ui/cluster_selector.go` — Format/Build/Find/Select quartet mirroring `internal/ui/selector.go`. -- **Kubeconfig write semantics** (`internal/k8s/kubeconfig.go`): MERGE, never overwrite. grant-owned entries are named `grant--` so ownership is decidable without extra state; colliding grant entries are replaced and reported on stderr; all other entries survive (a `yaml.Node` tree is edited in place, so comments and structure are preserved). `current-context` changes only with `--set-current-context`. Atomic write via a temp file **in the same directory** + `fsync` + `rename`. Mode `0600` (an existing narrower mode is preserved; an existing group/world-readable file is re-secured with a warning), parent dir `0700`. First merge into a pre-existing file writes `.grant.bak` once. Target resolution honours `$KUBECONFIG` including the list form (first non-empty entry). -- **Exec-credential cache** (`internal/k8s/execcred_cache.go`): `~/.grant/cache/execcred_.json`, file `0600` / dir `0700`. A cache file with looser permissions is **refused and removed**. Expiry uses `status.expirationTimestamp` **verbatim** — the SDK bakes its early-refresh buffer in once, at the point the raw DPA/STS/AKS expiry is known, and grant never re-applies it. Credentials with an absent or malformed expiry are not cacheable; an expiry more than `maxCredentialLifetime` (24h) ahead is treated as clock skew and refused. -- **R-5a (unresolved without a live tenant):** the DPA-generated kubeconfig is expected to point `users[].user.exec.command` at the official `idsec`/`ark` binary. `Kubeconfig.RewriteExecCommands` rewrites it defensively — only when the command basename matches a known official binary, carrying over only the flags grant's `exec-credential` understands (`--csp`, `--fqdn`, `--role-id`, `--organization-id`, `--namespace`); anything else passes through untouched. The real shape has not been observed. +- **Fail closed on `connectionMethod`.** The evaluate API returns an unconstrained string. Anything that is not exactly `direct` or `proxy` is rejected **before elevation** — an empty or future value must never fall through to the direct flow and bypass the DPA proxy the tenant expects. +- **Azure identity binding is mandatory.** `ExecCredentialParams.ElevateToken` (the Idira session JWT) is what makes the SDK's `validateAzureCLIIdentity` compare the SCA identity against the logged-in `az` account. It is only checked when non-empty, so `ExecCredential` **refuses** an Azure request without it rather than authenticating as whoever happens to be logged into the Azure CLI. +- **Kubeconfig write semantics** (`internal/k8s/kubeconfig.go`): MERGE, never overwrite. grant-owned entries are named `grant--` so ownership is decidable without extra state; colliding grant entries are replaced and reported on stderr; all other entries survive (a `yaml.Node` tree is edited in place, so comments and structure are preserved). `current-context` changes only with `--set-current-context`. Atomic write via a temp file **in the same directory** + `fsync` + `rename`. Mode `0600` (an existing narrower mode is preserved; an existing group/world-readable file is re-secured with a warning), parent dir `0700`. `BackupOnce` writes `.grant.bak` with `O_EXCL` (concurrency-safe) and refuses a non-regular/symlinked source. +- **`$KUBECONFIG` resolution follows kubectl's write rule:** the first entry that **exists**, or the **last** entry when none do — never just the first, which would create a new file that shadows the user's real config. kubectl reads the whole chain with the first file winning, so this is also the only placement where grant's entries are the ones that resolve. grant merges into exactly one file and never rewrites the rest of the chain. +- **Rewritten exec stanzas always carry `interactiveMode`.** client-go **requires** it for `client.authentication.k8s.io/v1` (optional only in v1beta1) and rejects the kubeconfig before grant is ever invoked when it is missing. `RewriteExecCommands` sets `IfAvailable` when absent and preserves an explicit value. +- **Exec-credential cache** (`internal/k8s/execcred_cache.go`): `~/.grant/cache/execcred_.json`. **Organization is part of the key** — the same cluster and role reached through different tenants must not share an entry. Written by creating a fresh `0600` temp file in the cache dir and renaming over the target, so a pre-existing loose file is *replaced*, never written into. Reads reject: symlinks (`Lstat`, and the symlink target is left alone), non-regular files, a file or directory readable beyond the owner, a file owned by another user (POSIX only — Windows relies on profile-directory ACLs), and payloads carrying no actual credential material. Expiry uses `status.expirationTimestamp` **verbatim** — the SDK bakes its early-refresh buffer in once, at the point the raw DPA/STS/AKS expiry is known, and grant never re-applies it. Absent/malformed expiry ⇒ not cacheable; more than `maxCredentialLifetime` (24h) ahead ⇒ clock skew, refused. +- **`exec-credential` reserves stdout.** The SDK logger is built with `log.New(os.Stdout, …)` and re-reads `IDSEC_LOG_LEVEL` on every call, so `grant --verbose k8s exec-credential` would otherwise interleave log lines with the credential JSON and break kubectl. `reserveStdoutForProtocol` forces `IDSEC_LOG_LEVEL=CRITICAL` for the command's duration (restoring it after) and, when `--verbose` was asked for, switches on the SDK's `IDSEC_KUBELOGIN_LOG_LEVEL` diagnostics channel, which writes to stderr by design. grant's own diagnostics use `execDiag` → stderr, never the package-level `log`. +- **`exec-credential` orders auth last.** Exec info is parsed and validated, flags are checked, and the credential cache is consulted **before** any authentication — a cache hit must never trigger a browser/MFA prompt, because kubectl may run with no terminal. On a miss, `defaultExecCredentialDeps` uses `LoadAuthentication` (keyring read + non-interactive refresh, never prompts); only if that yields no token does it fall back to interactive `Authenticate`, and when `spec.interactive` is false it returns `ErrInteractionRequired` instead. +- **R-5a (partially resolved):** the flag names in the defensive rewrite are **confirmed correct** against the SDK CLI schema (`models/idsec_sca_k8s_elevate.go:89-94` defines exactly `--csp`, `--role-id`, `--fqdn`, `--organization-id`, `--namespace`). Still unobserved: whether the DPA-generated kubeconfig actually points `users[].user.exec.command` at `idsec`/`ark`. `RewriteExecCommands` only rewrites when the command basename matches a known official binary; anything else passes through untouched. - **Untested against a live cluster.** No Kubernetes entitlements were available; README carries a single note saying so. ## Access Requests API (Workflows) diff --git a/cmd/k8s.go b/cmd/k8s.go index f9aed28..526aafb 100644 --- a/cmd/k8s.go +++ b/cmd/k8s.go @@ -49,13 +49,15 @@ func NewK8sKubeconfigCommandWithDeps(auth authLoader, generator kubeconfigGenera // NewK8sExecCredentialCommandWithDeps creates the exec-credential command with // injected deps. execInfo stands in for the KUBERNETES_EXEC_INFO env var. +// resolveDeps is called only on a cache miss, so tests can assert that a cache +// hit never authenticates. func NewK8sExecCredentialCommandWithDeps( - provider clusterCredentialProvider, + resolveDeps func(interactive bool) (*execCredentialDeps, error), credCache *k8s.CredentialCache, execInfo string, ) *cobra.Command { return newK8sExecCredentialCommand(func(c *cobra.Command, _ []string) error { - return runK8sExecCredential(c, provider, credCache, execInfo) + return runK8sExecCredential(c, resolveDeps, credCache, execInfo) }) } diff --git a/cmd/k8s_exec_credential.go b/cmd/k8s_exec_credential.go index 01afc6a..db63817 100644 --- a/cmd/k8s_exec_credential.go +++ b/cmd/k8s_exec_credential.go @@ -10,6 +10,11 @@ import ( "github.com/aaearon/grant-cli/internal/cache" "github.com/aaearon/grant-cli/internal/k8s" + "github.com/cyberark/idsec-sdk-golang/pkg/auth" + sdkconfig "github.com/cyberark/idsec-sdk-golang/pkg/config" + authmodels "github.com/cyberark/idsec-sdk-golang/pkg/models/auth" + "github.com/cyberark/idsec-sdk-golang/pkg/profiles" + sdkk8s "github.com/cyberark/idsec-sdk-golang/pkg/services/sca/k8s" "github.com/spf13/cobra" ) @@ -43,16 +48,32 @@ type execCredentialFlags struct { namespace string } +// execCredentialDeps are the authenticated dependencies of the credential flow. +// They are resolved lazily — only after the exec info is validated and the +// credential cache has been consulted — so a cache hit never triggers a login. +type execCredentialDeps struct { + provider clusterCredentialProvider + + // elevateToken is the Idira session JWT. The Azure providers compare the + // identity in it against the logged-in Azure CLI account. + elevateToken string +} + +// resolveExecCredentialDeps authenticates and builds the SCA k8s service. +// Overridable for tests. +var resolveExecCredentialDeps = defaultExecCredentialDeps + // newK8sExecCredentialCommand creates the hidden kubectl credential plugin command. func newK8sExecCredentialCommand(runFn func(*cobra.Command, []string) error) *cobra.Command { cmd := &cobra.Command{ - Use: "exec-credential", - Short: "kubectl credential plugin (invoked by kubectl, not by hand)", + Use: "exec-credential", + Short: "kubectl credential plugin (invoked by kubectl, not by hand)", Long: `Emit a kubectl ExecCredential for an SCA-eligible cluster. This command implements the client.authentication.k8s.io exec plugin protocol. kubectl invokes it via the exec stanza in a kubeconfig generated by -'grant k8s kubeconfig'. It writes nothing to stdout but the ExecCredential JSON.`, +'grant k8s kubeconfig'. It writes nothing to stdout but the ExecCredential JSON; +all diagnostics go to stderr.`, Hidden: true, Args: cobra.NoArgs, SilenceErrors: true, @@ -72,11 +93,7 @@ kubectl invokes it via the exec stanza in a kubeconfig generated by // NewK8sExecCredentialCommand creates the production exec-credential command. func NewK8sExecCredentialCommand() *cobra.Command { return newK8sExecCredentialCommand(func(cmd *cobra.Command, args []string) error { - svc, err := bootstrapK8sService() - if err != nil { - return err - } - return runK8sExecCredential(cmd, svc, defaultCredentialCache(), os.Getenv(execInfoEnvVar)) + return runK8sExecCredential(cmd, resolveExecCredentialDeps, defaultCredentialCache(), os.Getenv(execInfoEnvVar)) }) } @@ -90,13 +107,54 @@ func defaultCredentialCache() *k8s.CredentialCache { return k8s.NewCredentialCache(dir) } +// reserveStdoutForProtocol silences every logger that writes to os.Stdout for the +// duration of this command. +// +// The SDK's logger is constructed with log.New(os.Stdout, ...) and its Info level +// is resolved from IDSEC_LOG_LEVEL on every call, so `grant --verbose k8s +// exec-credential` would otherwise interleave log lines with the credential JSON +// and break kubectl. Forcing the level to CRITICAL suppresses all of them at the +// source. When the user asked for verbose output we instead switch on the SDK's +// kubectl-login diagnostics channel, which writes to stderr by design. +func reserveStdoutForProtocol(wantDiagnostics bool) func() { + prevLevel, hadLevel := os.LookupEnv(sdkconfig.IdsecLogLevelEnvVar) + sdkconfig.DisableVerboseLogging() + + prevKube, hadKube := os.LookupEnv(sdkk8s.KubectlLoginLogLevelEnvVar) + if wantDiagnostics { + _ = os.Setenv(sdkk8s.KubectlLoginLogLevelEnvVar, "INFO") + } + + return func() { + restoreEnv(sdkconfig.IdsecLogLevelEnvVar, prevLevel, hadLevel) + if wantDiagnostics { + restoreEnv(sdkk8s.KubectlLoginLogLevelEnvVar, prevKube, hadKube) + } + } +} + +func restoreEnv(key, value string, had bool) { + if had { + _ = os.Setenv(key, value) + return + } + _ = os.Unsetenv(key) +} + // runK8sExecCredential emits the ExecCredential JSON on stdout and nothing else. +// +// Order matters: the exec info and flags are validated, then the credential cache +// is consulted, and only on a miss is authentication attempted. A cache hit must +// never trigger a login prompt — kubectl may be running with no terminal at all. func runK8sExecCredential( cmd *cobra.Command, - provider clusterCredentialProvider, + resolveDeps func(interactive bool) (*execCredentialDeps, error), credCache *k8s.CredentialCache, rawExecInfo string, ) error { + restore := reserveStdoutForProtocol(verbose) + defer restore() + info, err := parseExecInfo(rawExecInfo) if err != nil { return err @@ -111,28 +169,36 @@ func runK8sExecCredential( } key := k8s.CredentialKey{ - CSP: flags.csp, - FQDN: flags.fqdn, - RoleID: flags.roleID, - Namespace: flags.namespace, + CSP: flags.csp, + FQDN: flags.fqdn, + RoleID: flags.roleID, + Namespace: flags.namespace, + OrganizationID: flags.organizationID, } + // Cache first, before any authentication. if credCache != nil { if cred, ok := credCache.Get(key); ok { - log.Info("Reusing cached cluster credential for %s", flags.fqdn) + execDiag(cmd, "reusing cached cluster credential for %s", flags.fqdn) return writeExecCredential(cmd, cred, info.APIVersion) } } + deps, err := resolveDeps(info.Spec.Interactive) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) defer cancel() - cred, err := provider.ExecCredential(ctx, k8s.ExecCredentialParams{ + cred, err := deps.provider.ExecCredential(ctx, k8s.ExecCredentialParams{ CSP: flags.csp, FQDN: flags.fqdn, RoleID: flags.roleID, OrganizationID: flags.organizationID, Namespace: flags.namespace, + ElevateToken: deps.elevateToken, Interactive: info.Spec.Interactive, Diagnostics: verbose, }) @@ -142,13 +208,63 @@ func runK8sExecCredential( if credCache != nil { if err := credCache.Put(key, cred); err != nil { - log.Info("Failed to cache cluster credential: %v", err) + execDiag(cmd, "failed to cache cluster credential: %v", err) } } return writeExecCredential(cmd, cred, info.APIVersion) } +// execDiag writes a verbose diagnostic to stderr. It deliberately bypasses the +// package-level `log`, whose SDK backend writes to stdout. +func execDiag(cmd *cobra.Command, format string, args ...any) { + if !verbose { + return + } + fmt.Fprintf(cmd.ErrOrStderr(), "grant k8s exec-credential | "+format+"\n", args...) +} + +// defaultExecCredentialDeps authenticates without prompting where possible. +// +// A cached, still-valid Idira token is used as-is. Only when there is none does +// it fall back to an interactive Authenticate — and when kubectl reported that +// stdin is unavailable it refuses instead, because a browser or MFA prompt from +// inside a kubectl invocation would hang. +func defaultExecCredentialDeps(interactive bool) (*execCredentialDeps, error) { + loader := profiles.DefaultProfilesLoader() + profile, err := (*loader).LoadProfile("grant") + if err != nil { + return nil, fmt.Errorf("failed to load profile: %w", err) + } + + ispAuth := auth.NewIdsecISPAuth(true) + + // LoadAuthentication reads the keyring and may refresh over the network, but + // never prompts. It reports a missing/expired token as (nil, nil). + token, err := ispAuth.LoadAuthentication(profile, true) + if err != nil || token == nil || strings.TrimSpace(token.Token) == "" { + if !interactive { + return nil, fmt.Errorf( + "%w: no valid cached Idira session and kubectl reported that stdin is unavailable; run 'grant login' in a terminal first", + k8s.ErrInteractionRequired) + } + token, err = ispAuth.Authenticate(profile, nil, &authmodels.IdsecSecret{Secret: ""}, false, false) + if err != nil { + return nil, fmt.Errorf("authentication failed: %w", err) + } + } + if token == nil || strings.TrimSpace(token.Token) == "" { + return nil, errors.New("authentication produced no session token, run 'grant login'") + } + + svc, err := k8s.NewService(ispAuth) + if err != nil { + return nil, fmt.Errorf("failed to create SCA k8s service: %w", err) + } + + return &execCredentialDeps{provider: svc, elevateToken: token.Token}, nil +} + func parseExecCredentialFlags(cmd *cobra.Command) execCredentialFlags { var f execCredentialFlags f.csp, _ = cmd.Flags().GetString("csp") diff --git a/cmd/k8s_exec_credential_test.go b/cmd/k8s_exec_credential_test.go index 899fbfb..47bccaf 100644 --- a/cmd/k8s_exec_credential_test.go +++ b/cmd/k8s_exec_credential_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "io" "os" "path/filepath" "strings" @@ -12,6 +13,7 @@ import ( "time" "github.com/aaearon/grant-cli/internal/k8s" + "github.com/cyberark/idsec-sdk-golang/pkg/common" "github.com/spf13/cobra" ) @@ -31,6 +33,15 @@ func (m *mockCredentialProvider) ExecCredential(_ context.Context, p k8s.ExecCre return m.cred, m.err } +// depsFor wraps a provider in the lazy dependency resolver the command expects. +// It records whether it was called, so tests can assert that a cache hit never +// reaches authentication. +func depsFor(provider clusterCredentialProvider) func(bool) (*execCredentialDeps, error) { + return func(bool) (*execCredentialDeps, error) { + return &execCredentialDeps{provider: provider, elevateToken: "isp-jwt"}, nil + } +} + func execInfoJSON(t *testing.T, apiVersion string, interactive bool) string { t.Helper() payload := map[string]any{ @@ -68,19 +79,84 @@ func runExecCred(t *testing.T, cmd *cobra.Command, args ...string) (stdout, stde return out.String(), errBuf.String(), err } +// TestExecCredentialStdoutIsOnlyJSON drives the command through the REAL root +// command, so the production PersistentPreRunE runs and `--verbose` genuinely +// enables SDK logging. That matters: the SDK logger is built with +// log.New(os.Stdout, ...) and resolves its level from IDSEC_LOG_LEVEL on every +// call, so a command that merely sets the package `verbose` var would keep this +// test green while emitting log lines into kubectl's stdout. func TestExecCredentialStdoutIsOnlyJSON(t *testing.T) { - // Verbose logging on: any log output must go to stderr, never stdout. - oldVerbose := verbose - defer func() { verbose = oldVerbose }() - verbose = true - + restoreVerbose := verbose + restoreArgValidation := passedArgValidation + t.Cleanup(func() { + verbose = restoreVerbose + passedArgValidation = restoreArgValidation + }) + // t.Setenv restores the previous value (or unsets it) at test end. + t.Setenv("IDSEC_LOG_LEVEL", os.Getenv("IDSEC_LOG_LEVEL")) + + // The SDK logger is built with log.New(os.Stdout, ...) and captures the file + // handle at construction, so it bypasses cmd.SetOut entirely. Redirect the + // real os.Stdout to a pipe and rebuild the logger from it, so this test sees + // exactly what kubectl would see on the process's stdout. + realStdout := os.Stdout + pipeR, pipeW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = pipeW + restoreLog := log + log = common.GetLogger("grant", -1) + t.Cleanup(func() { + os.Stdout = realStdout + log = restoreLog + }) + + // levelDuringFlow records IDSEC_LOG_LEVEL at the moment the credential flow + // runs. Every SDK logger re-reads it on each call, so this is the value that + // decides whether SDK log lines hit stdout mid-protocol. + var levelDuringFlow string provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} - cmd := NewK8sExecCredentialCommandWithDeps(provider, nil, + resolveDeps := func(bool) (*execCredentialDeps, error) { + levelDuringFlow = os.Getenv("IDSEC_LOG_LEVEL") + // Anything the SDK or grant logs at this point must not reach stdout. + log.Info("this line must never appear on stdout") + return &execCredentialDeps{provider: provider, elevateToken: "isp-jwt"}, nil + } + + execCmd := NewK8sExecCredentialCommandWithDeps(resolveDeps, nil, execInfoJSON(t, "client.authentication.k8s.io/v1beta1", true)) - stdout, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "prod.eks.example") + k8sParent := newK8sParent() + k8sParent.AddCommand(execCmd) + root := newRootCommand(nil) + root.AddCommand(k8sParent) + + stdout, stderr, err := runExecCred(t, root, + "--verbose", "k8s", "exec-credential", "--csp", "aws", "--fqdn", "prod.eks.example") if err != nil { - t.Fatalf("execute: %v", err) + t.Fatalf("execute: %v\nstderr: %s", err, stderr) + } + if !verbose { + t.Fatal("the root PersistentPreRunE did not run; this test is not exercising production wiring") + } + + // Nothing may have reached the process's real stdout. + _ = pipeW.Close() + leaked, err := io.ReadAll(pipeR) + if err != nil { + t.Fatal(err) + } + if len(leaked) != 0 { + t.Errorf("log output leaked onto the process stdout, which would corrupt the kubectl protocol:\n%s", leaked) + } + + if levelDuringFlow != "CRITICAL" { + t.Errorf("IDSEC_LOG_LEVEL during the credential flow = %q, want CRITICAL so no SDK logger writes to stdout", levelDuringFlow) + } + // The caller's setting is restored once the command is done. + if got := os.Getenv("IDSEC_LOG_LEVEL"); got != "INFO" { + t.Errorf("IDSEC_LOG_LEVEL = %q after the command, want it restored to INFO", got) } trimmed := strings.TrimSpace(stdout) @@ -123,7 +199,7 @@ func TestExecCredentialAPIVersionNegotiation(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // The provider always returns v1beta1; the response must echo the request. provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} - cmd := NewK8sExecCredentialCommandWithDeps(provider, nil, execInfoJSON(t, tt.apiVersion, true)) + cmd := NewK8sExecCredentialCommandWithDeps(depsFor(provider), nil, execInfoJSON(t, tt.apiVersion, true)) stdout, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host") if tt.wantErr { @@ -152,7 +228,7 @@ func TestExecCredentialAPIVersionNegotiation(t *testing.T) { func TestExecCredentialRequiresExecInfo(t *testing.T) { provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} - cmd := NewK8sExecCredentialCommandWithDeps(provider, nil, "") + cmd := NewK8sExecCredentialCommandWithDeps(depsFor(provider), nil, "") stdout, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host") if err == nil { @@ -168,7 +244,7 @@ func TestExecCredentialRequiresExecInfo(t *testing.T) { func TestExecCredentialRejectsMalformedExecInfo(t *testing.T) { provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} - cmd := NewK8sExecCredentialCommandWithDeps(provider, nil, "{not json") + cmd := NewK8sExecCredentialCommandWithDeps(depsFor(provider), nil, "{not json") if _, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host"); err == nil { t.Fatal("expected an error for malformed KUBERNETES_EXEC_INFO") @@ -178,7 +254,7 @@ func TestExecCredentialRejectsMalformedExecInfo(t *testing.T) { func TestExecCredentialInteractiveModePropagated(t *testing.T) { for _, interactive := range []bool{true, false} { provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} - cmd := NewK8sExecCredentialCommandWithDeps(provider, nil, + cmd := NewK8sExecCredentialCommandWithDeps(depsFor(provider), nil, execInfoJSON(t, "client.authentication.k8s.io/v1beta1", interactive)) if _, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host"); err != nil { @@ -195,7 +271,7 @@ func TestExecCredentialNonInteractiveCacheMissFailsCleanly(t *testing.T) { provider := &mockCredentialProvider{ err: k8s.ErrInteractionRequired, } - cmd := NewK8sExecCredentialCommandWithDeps(provider, nil, + cmd := NewK8sExecCredentialCommandWithDeps(depsFor(provider), nil, execInfoJSON(t, "client.authentication.k8s.io/v1beta1", false)) stdout, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host") @@ -217,7 +293,7 @@ func TestExecCredentialNonInteractiveCacheHitSucceeds(t *testing.T) { } provider := &mockCredentialProvider{err: k8s.ErrInteractionRequired} - cmd := NewK8sExecCredentialCommandWithDeps(provider, credCache, + cmd := NewK8sExecCredentialCommandWithDeps(depsFor(provider), credCache, execInfoJSON(t, "client.authentication.k8s.io/v1beta1", false)) stdout, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host", "--role-id", "r") @@ -241,7 +317,7 @@ func TestExecCredentialCachesAndRefetchesAfterExpiry(t *testing.T) { info := execInfoJSON(t, "client.authentication.k8s.io/v1beta1", true) // First call: cache miss, provider invoked, credential cached. - cmd := NewK8sExecCredentialCommandWithDeps(provider, credCache, info) + cmd := NewK8sExecCredentialCommandWithDeps(depsFor(provider), credCache, info) if _, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host"); err != nil { t.Fatalf("first call: %v", err) } @@ -250,7 +326,7 @@ func TestExecCredentialCachesAndRefetchesAfterExpiry(t *testing.T) { } // Second call: cache hit, provider not invoked again. - cmd = NewK8sExecCredentialCommandWithDeps(provider, credCache, info) + cmd = NewK8sExecCredentialCommandWithDeps(depsFor(provider), credCache, info) if _, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host"); err != nil { t.Fatalf("second call: %v", err) } @@ -269,7 +345,7 @@ func TestExecCredentialCachesAndRefetchesAfterExpiry(t *testing.T) { t.Fatal(err) } - cmd = NewK8sExecCredentialCommandWithDeps(provider, credCache, info) + cmd = NewK8sExecCredentialCommandWithDeps(depsFor(provider), credCache, info) if _, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host"); err != nil { t.Fatalf("third call: %v", err) } @@ -283,7 +359,7 @@ func TestExecCredentialCacheFilePermissions(t *testing.T) { credCache := k8s.NewCredentialCache(dir) provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} - cmd := NewK8sExecCredentialCommandWithDeps(provider, credCache, + cmd := NewK8sExecCredentialCommandWithDeps(depsFor(provider), credCache, execInfoJSON(t, "client.authentication.k8s.io/v1beta1", true)) if _, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host"); err != nil { t.Fatalf("execute: %v", err) @@ -307,7 +383,7 @@ func TestExecCredentialCacheFilePermissions(t *testing.T) { func TestExecCredentialDoesNotAdjustExpiry(t *testing.T) { expiry := time.Now().Add(37 * time.Minute).UTC().Truncate(time.Second) provider := &mockCredentialProvider{cred: sampleCredential(expiry)} - cmd := NewK8sExecCredentialCommandWithDeps(provider, nil, + cmd := NewK8sExecCredentialCommandWithDeps(depsFor(provider), nil, execInfoJSON(t, "client.authentication.k8s.io/v1beta1", true)) stdout, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host") @@ -339,7 +415,7 @@ func TestExecCredentialValidatesFlags(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} - cmd := NewK8sExecCredentialCommandWithDeps(provider, nil, info) + cmd := NewK8sExecCredentialCommandWithDeps(depsFor(provider), nil, info) if _, _, err := runExecCred(t, cmd, tt.args...); err == nil { t.Fatal("expected a validation error") } @@ -347,6 +423,101 @@ func TestExecCredentialValidatesFlags(t *testing.T) { } } +// A cache hit must short-circuit before authentication: kubectl may be running +// with no terminal, and a login prompt there would hang. +func TestExecCredentialCacheHitDoesNotAuthenticate(t *testing.T) { + credCache := k8s.NewCredentialCache(t.TempDir()) + key := k8s.CredentialKey{CSP: "aws", FQDN: "host", RoleID: "r"} + if err := credCache.Put(key, sampleCredential(time.Now().Add(time.Hour))); err != nil { + t.Fatalf("seed cache: %v", err) + } + + resolved := false + resolveDeps := func(bool) (*execCredentialDeps, error) { + resolved = true + return nil, errors.New("authentication must not be attempted on a cache hit") + } + + cmd := NewK8sExecCredentialCommandWithDeps(resolveDeps, credCache, + execInfoJSON(t, "client.authentication.k8s.io/v1beta1", true)) + + stdout, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host", "--role-id", "r") + if err != nil { + t.Fatalf("execute: %v", err) + } + if resolved { + t.Error("authentication was attempted despite a cache hit") + } + if !strings.Contains(stdout, "k8s-aws-v1.abc") { + t.Errorf("cached credential not replayed: %s", stdout) + } +} + +// Invalid exec info must be rejected before authentication too. +func TestExecCredentialDoesNotAuthenticateOnInvalidExecInfo(t *testing.T) { + tests := []struct { + name string + execInfo string + }{ + {name: "absent", execInfo: ""}, + {name: "malformed", execInfo: "{not json"}, + {name: "unsupported apiVersion", execInfo: execInfoJSON(t, "client.authentication.k8s.io/v2", true)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolved := false + resolveDeps := func(bool) (*execCredentialDeps, error) { + resolved = true + return nil, nil + } + cmd := NewK8sExecCredentialCommandWithDeps(resolveDeps, nil, tt.execInfo) + if _, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host"); err == nil { + t.Fatal("expected an error") + } + if resolved { + t.Error("authentication was attempted before the exec info was validated") + } + }) + } +} + +// The Idira session JWT must reach the Azure identity-binding check. +func TestExecCredentialPropagatesElevateToken(t *testing.T) { + provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} + cmd := NewK8sExecCredentialCommandWithDeps(depsFor(provider), nil, + execInfoJSON(t, "client.authentication.k8s.io/v1beta1", true)) + + if _, _, err := runExecCred(t, cmd, "--csp", "azure", "--fqdn", "host"); err != nil { + t.Fatalf("execute: %v", err) + } + if provider.gotParams.ElevateToken != "isp-jwt" { + t.Errorf("ElevateToken = %q, want the session JWT to be propagated", provider.gotParams.ElevateToken) + } +} + +// The organization is part of the cache identity: two tenants must not share an +// entry for the same cluster and role. +func TestExecCredentialCacheIsolatedByOrganization(t *testing.T) { + credCache := k8s.NewCredentialCache(t.TempDir()) + info := execInfoJSON(t, "client.authentication.k8s.io/v1beta1", true) + provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} + + cmd := NewK8sExecCredentialCommandWithDeps(depsFor(provider), credCache, info) + if _, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host", "--organization-id", "org-a"); err != nil { + t.Fatalf("first: %v", err) + } + + cmd = NewK8sExecCredentialCommandWithDeps(depsFor(provider), credCache, info) + if _, _, err := runExecCred(t, cmd, "--csp", "aws", "--fqdn", "host", "--organization-id", "org-b"); err != nil { + t.Fatalf("second: %v", err) + } + + if provider.calls != 2 { + t.Errorf("provider calls = %d, want 2 — a different organization must not hit the cache", provider.calls) + } +} + func TestExecCredentialIsHidden(t *testing.T) { cmd := newK8sExecCredentialCommand(nil) if !cmd.Hidden { diff --git a/internal/k8s/execcred.go b/internal/k8s/execcred.go index b4ddb1a..bcba354 100644 --- a/internal/k8s/execcred.go +++ b/internal/k8s/execcred.go @@ -29,8 +29,11 @@ type ExecCredentialParams struct { OrganizationID string Namespace string - // ElevateToken is the ISP session JWT. The Azure provider decodes it to check - // the az CLI session belongs to the same identity. + // ElevateToken is the ISP session JWT. The Azure provider decodes it and + // compares the identity against the logged-in Azure CLI account, so that a + // different az session cannot be used to reach a cluster someone else + // elevated for. Leaving it empty silently disables that binding check, so + // callers must always populate it on the Azure path. ElevateToken string // Interactive reports whether kubectl said stdin is available. When false, @@ -59,11 +62,29 @@ func (s *Service) ExecCredential(ctx context.Context, p ExecCredentialParams) (* return nil, err } + // The Azure providers only bind the az CLI identity to the SCA identity when + // an elevate token is supplied. Refuse rather than authenticate as whoever + // happens to be logged into the Azure CLI. + if csp == "AZURE" && strings.TrimSpace(p.ElevateToken) == "" { + return nil, errors.New( + "an Idira session token is required on the Azure path so the Azure CLI identity can be verified against the identity that elevated") + } + conn, err := s.Evaluate(ctx, csp, p.FQDN) if err != nil { return nil, err } + // Fail closed. connectionMethod is an unconstrained string from the evaluate + // API; an empty, malformed or future value must not silently fall through to + // the direct flow, which would bypass the DPA proxy the tenant expects. This + // is checked before elevation so an unrecognized value costs no session. + if conn.ConnectionMethod != ConnectionDirect && conn.ConnectionMethod != ConnectionProxy { + return nil, fmt.Errorf( + "cluster %q reported an unrecognized connection method %q (expected %q or %q); upgrade grant or report this", + p.FQDN, conn.ConnectionMethod, ConnectionDirect, ConnectionProxy) + } + roleID := p.RoleID if strings.TrimSpace(roleID) == "" { roleID = conn.RoleID diff --git a/internal/k8s/execcred_cache.go b/internal/k8s/execcred_cache.go index e79da5a..31438d0 100644 --- a/internal/k8s/execcred_cache.go +++ b/internal/k8s/execcred_cache.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -19,11 +20,16 @@ import ( const maxCredentialLifetime = 24 * time.Hour // CredentialKey identifies a cached ExecCredential. +// +// OrganizationID is part of the key: the same cluster FQDN and role ID can be +// reached through different Entra tenants / AWS organizations, and a credential +// minted for one must never be replayed for another. type CredentialKey struct { - CSP string - FQDN string - RoleID string - Namespace string + CSP string + FQDN string + RoleID string + Namespace string + OrganizationID string } // CredentialCache stores kubectl ExecCredentials on disk between invocations. @@ -45,24 +51,31 @@ func NewCredentialCache(dir string) *CredentialCache { // pathFor returns the on-disk path for a key. func (c *CredentialCache) pathFor(key CredentialKey) string { raw := strings.Join([]string{ - strings.ToUpper(key.CSP), key.FQDN, key.RoleID, key.Namespace, + strings.ToUpper(key.CSP), key.FQDN, key.RoleID, key.Namespace, key.OrganizationID, }, "\x00") sum := sha256.Sum256([]byte(raw)) return filepath.Join(c.dir, "execcred_"+hex.EncodeToString(sum[:])+".json") } -// Get returns a cached credential when one exists and is still valid. -// Any problem — missing file, unreadable JSON, loose permissions, absent or -// implausible expiry — is reported as a plain miss. +// Get returns a cached credential when one exists and is trustworthy. +// +// Any doubt is reported as a plain miss: a missing file, a symlink or anything +// that is not a regular file, a file or directory readable beyond the owner, a +// file owned by another user, unreadable JSON, or an absent/implausible expiry. +// Untrustworthy files are removed so the next run re-mints the credential. func (c *CredentialCache) Get(key CredentialKey) (*k8smodels.IdsecSCAK8sExecCredential, bool) { path := c.pathFor(key) - fi, err := os.Stat(path) + if err := c.checkDirSecure(); err != nil { + return nil, false + } + + // Lstat, not Stat: a symlink here must be rejected, not followed. + fi, err := os.Lstat(path) if err != nil { return nil, false } - // A credential file readable beyond its owner cannot be trusted. - if fi.Mode().Perm()&0o077 != 0 { + if err := checkCredentialFileSecure(fi); err != nil { _ = os.Remove(path) return nil, false } @@ -76,6 +89,9 @@ func (c *CredentialCache) Get(key CredentialKey) (*k8smodels.IdsecSCAK8sExecCred if err := json.Unmarshal(data, &cred); err != nil { return nil, false } + if !isUsableCredential(&cred) { + return nil, false + } expiry, ok := credentialExpiry(&cred) if !ok { @@ -91,26 +107,120 @@ func (c *CredentialCache) Get(key CredentialKey) (*k8smodels.IdsecSCAK8sExecCred // Put stores a credential. Credentials without a usable expiry are not cacheable // and are silently dropped rather than being cached forever. +// +// The file is created fresh in the cache directory with O_EXCL at mode 0600 and +// renamed over any previous entry, so a pre-existing file with loose permissions +// is replaced rather than written into. func (c *CredentialCache) Put(key CredentialKey, cred *k8smodels.IdsecSCAK8sExecCredential) error { if _, ok := credentialExpiry(cred); !ok { return nil } + if !isUsableCredential(cred) { + return nil + } if err := os.MkdirAll(c.dir, 0o700); err != nil { return fmt.Errorf("failed to create credential cache directory: %w", err) } + // Tighten a pre-existing cache directory rather than writing secrets into a + // world-readable one. + if err := os.Chmod(c.dir, 0o700); err != nil { + return fmt.Errorf("failed to secure credential cache directory: %w", err) + } data, err := json.Marshal(cred) if err != nil { return fmt.Errorf("failed to encode credential: %w", err) } - if err := os.WriteFile(c.pathFor(key), data, 0o600); err != nil { - return fmt.Errorf("failed to write credential cache: %w", err) + return writeSecretFileAtomic(c.pathFor(key), data) +} + +// writeSecretFileAtomic creates a new 0600 file in the target's directory with +// O_EXCL and renames it over the target. It never writes into a file it did not +// create, so an attacker-planted file or symlink cannot receive the secret. +func writeSecretFileAtomic(path string, data []byte) error { + dir := filepath.Dir(path) + + tmp, err := os.CreateTemp(dir, ".grant-cred-*.tmp") + if err != nil { + return fmt.Errorf("failed to create credential cache file: %w", err) + } + tmpName := tmp.Name() + + if err := writeAndSecure(tmp, data); err != nil { + _ = os.Remove(tmpName) + return err + } + if err := os.Rename(tmpName, path); err != nil { + _ = os.Remove(tmpName) + return fmt.Errorf("failed to store credential: %w", err) } return nil } +func writeAndSecure(f *os.File, data []byte) error { + defer func() { _ = f.Close() }() + + if err := f.Chmod(0o600); err != nil { + return fmt.Errorf("failed to secure credential cache file: %w", err) + } + if _, err := f.Write(data); err != nil { + return fmt.Errorf("failed to write credential: %w", err) + } + if err := f.Sync(); err != nil { + return fmt.Errorf("failed to flush credential: %w", err) + } + return nil +} + +// checkDirSecure rejects a cache directory that is a symlink, is not a +// directory, or is accessible beyond the owner. +func (c *CredentialCache) checkDirSecure() error { + fi, err := os.Lstat(c.dir) + if err != nil { + return err + } + if !fi.IsDir() { + return errors.New("credential cache path is not a directory") + } + if fi.Mode()&os.ModeSymlink != 0 { + return errors.New("credential cache directory is a symlink") + } + if fi.Mode().Perm()&0o077 != 0 { + return errors.New("credential cache directory is accessible beyond its owner") + } + return checkOwnedByCurrentUser(fi) +} + +// checkCredentialFileSecure rejects anything that is not a private regular file +// owned by the current user. +func checkCredentialFileSecure(fi os.FileInfo) error { + if fi.Mode()&os.ModeSymlink != 0 { + return errors.New("credential cache entry is a symlink") + } + if !fi.Mode().IsRegular() { + return errors.New("credential cache entry is not a regular file") + } + if fi.Mode().Perm()&0o077 != 0 { + return errors.New("credential cache entry is accessible beyond its owner") + } + return checkOwnedByCurrentUser(fi) +} + +// isUsableCredential rejects a decoded credential that carries no actual +// credential material, so a truncated or hand-crafted file is not replayed. +func isUsableCredential(cred *k8smodels.IdsecSCAK8sExecCredential) bool { + if cred == nil { + return false + } + if strings.TrimSpace(cred.Status.Token) != "" { + return true + } + return strings.TrimSpace(cred.Status.ClientCertificateData) != "" && + strings.TrimSpace(cred.Status.ClientKeyData) != "" +} + // credentialExpiry parses status.expirationTimestamp verbatim. No buffer is // applied: the SDK already subtracted one. func credentialExpiry(cred *k8smodels.IdsecSCAK8sExecCredential) (time.Time, bool) { diff --git a/internal/k8s/execcred_cache_test.go b/internal/k8s/execcred_cache_test.go index dd6a255..5af1339 100644 --- a/internal/k8s/execcred_cache_test.go +++ b/internal/k8s/execcred_cache_test.go @@ -1,6 +1,7 @@ package k8s import ( + "encoding/json" "os" "path/filepath" "runtime" @@ -234,6 +235,164 @@ func TestCredentialCacheKeysAreDistinct(t *testing.T) { } } +// Replacing a loose file must not write the token into it — the entry is +// re-created private, so the secret is never exposed even momentarily. +func TestCredentialCachePutReplacesLooseFileSecurely(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX file modes are not meaningful on Windows") + } + + dir := t.TempDir() + c := NewCredentialCache(dir) + path := c.pathFor(testKey()) + + // An attacker (or an older grant) left a world-readable file behind. + if err := os.WriteFile(path, []byte("{}"), 0o666); err != nil { + t.Fatal(err) + } + + if err := c.Put(testKey(), credWithExpiry(time.Now().Add(time.Hour))); err != nil { + t.Fatalf("Put: %v", err) + } + + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o600 { + t.Errorf("mode = %o, want the entry re-created at 0600, not written into the loose file", fi.Mode().Perm()) + } + if _, ok := c.Get(testKey()); !ok { + t.Error("the freshly written entry should be readable") + } +} + +func TestCredentialCachePutTightensLooseDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX file modes are not meaningful on Windows") + } + + dir := filepath.Join(t.TempDir(), "cache") + if err := os.MkdirAll(dir, 0o777); err != nil { + t.Fatal(err) + } + + c := NewCredentialCache(dir) + if err := c.Put(testKey(), credWithExpiry(time.Now().Add(time.Hour))); err != nil { + t.Fatalf("Put: %v", err) + } + + fi, _ := os.Stat(dir) + if fi.Mode().Perm() != 0o700 { + t.Errorf("cache dir mode = %o, want it tightened to 0700", fi.Mode().Perm()) + } +} + +func TestCredentialCacheRefusesLooseDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX file modes are not meaningful on Windows") + } + + dir := filepath.Join(t.TempDir(), "cache") + c := NewCredentialCache(dir) + if err := c.Put(testKey(), credWithExpiry(time.Now().Add(time.Hour))); err != nil { + t.Fatalf("Put: %v", err) + } + + if err := os.Chmod(dir, 0o755); err != nil { + t.Fatal(err) + } + if _, ok := c.Get(testKey()); ok { + t.Error("expected a miss when the cache directory is readable beyond its owner") + } +} + +func TestCredentialCacheRefusesSymlinkedEntry(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink semantics differ on Windows") + } + + dir := t.TempDir() + // t.TempDir() is 0755 on some systems; tighten it so this test exercises the + // symlink check rather than the directory-permission check. + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + c := NewCredentialCache(dir) + path := c.pathFor(testKey()) + + target := filepath.Join(dir, "elsewhere.json") + data, _ := json.Marshal(credWithExpiry(time.Now().Add(time.Hour))) + if err := os.WriteFile(target, data, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + + if _, ok := c.Get(testKey()); ok { + t.Fatal("a symlinked cache entry must not be followed") + } + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Error("the symlink should have been removed") + } + // The symlink target itself must not have been touched. + if _, err := os.Stat(target); err != nil { + t.Errorf("the symlink target was removed: %v", err) + } +} + +// A file carrying no credential material must not be replayed. +func TestCredentialCacheRejectsEmptyCredentialMaterial(t *testing.T) { + dir := t.TempDir() + c := NewCredentialCache(dir) + + empty := &k8smodels.IdsecSCAK8sExecCredential{ + APIVersion: "client.authentication.k8s.io/v1beta1", + Kind: "ExecCredential", + Status: k8smodels.IdsecSCAK8sExecCredentialStatus{ + ExpirationTimestamp: time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + }, + } + if err := c.Put(testKey(), empty); err != nil { + t.Fatalf("Put: %v", err) + } + if _, ok := c.Get(testKey()); ok { + t.Error("a credential with neither a token nor client cert material must not be cached or replayed") + } +} + +func TestCredentialCacheAcceptsClientCertificateCredentials(t *testing.T) { + dir := t.TempDir() + c := NewCredentialCache(dir) + + proxyCred := &k8smodels.IdsecSCAK8sExecCredential{ + Kind: "ExecCredential", + Status: k8smodels.IdsecSCAK8sExecCredentialStatus{ + ClientCertificateData: "cert", + ClientKeyData: "key", + ExpirationTimestamp: time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + }, + } + if err := c.Put(testKey(), proxyCred); err != nil { + t.Fatalf("Put: %v", err) + } + if _, ok := c.Get(testKey()); !ok { + t.Error("a proxy client-certificate credential should be cacheable") + } +} + +// Two organizations must never share a cache entry for the same cluster+role. +func TestCredentialCacheKeyIncludesOrganization(t *testing.T) { + c := NewCredentialCache(t.TempDir()) + a := CredentialKey{CSP: "AWS", FQDN: "host", RoleID: "r", OrganizationID: "org-a"} + b := CredentialKey{CSP: "AWS", FQDN: "host", RoleID: "r", OrganizationID: "org-b"} + + if c.pathFor(a) == c.pathFor(b) { + t.Error("cache key collides across organizations") + } +} + func TestCredentialCacheMissOnUnwritableDir(t *testing.T) { c := NewCredentialCache(t.TempDir()) if _, ok := c.Get(testKey()); ok { diff --git a/internal/k8s/execcred_test.go b/internal/k8s/execcred_test.go index 6cffa4b..92e63ee 100644 --- a/internal/k8s/execcred_test.go +++ b/internal/k8s/execcred_test.go @@ -194,6 +194,86 @@ func TestExecCredentialSurfacesInteractionRequired(t *testing.T) { } } +// Fail closed: an unrecognized connectionMethod must not fall through to the +// direct flow, and must be caught before any elevation is spent. +func TestExecCredentialRejectsUnknownConnectionMethod(t *testing.T) { + tests := []struct { + name string + method string + }{ + {name: "empty", method: ""}, + {name: "whitespace", method: " "}, + {name: "future value", method: "tunnel"}, + {name: "typo", method: "proxied"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + backend := evaluateBackend(tt.method, "Y2E=") + elevated := false + backend.elevateFn = func(*k8smodels.IdsecSCAK8sElevateKubectlRequest) (*k8smodels.IdsecSCAK8sElevateResponse, error) { + elevated = true + return nil, errors.New("must not be reached") + } + + svc := NewServiceWithBackend(backend) + svc.SetCredentialFlow(&stubFlow{ + directFn: func(*ElevateResult, *sdkk8s.IdsecSCAK8sClusterContext, bool) (*k8smodels.IdsecSCAK8sExecCredential, error) { + t.Error("the direct flow must not run for an unknown connection method") + return nil, nil + }, + }) + + _, err := svc.ExecCredential(t.Context(), ExecCredentialParams{CSP: "aws", FQDN: "host"}) + if err == nil || !strings.Contains(err.Error(), "unrecognized connection method") { + t.Fatalf("err = %v, want a fail-closed connection-method error", err) + } + if elevated { + t.Error("elevation was attempted before the connection method was validated") + } + }) + } +} + +// Azure identity binding only happens when the SDK gets an elevate token, so an +// empty one must be refused rather than silently skipping the check. +func TestExecCredentialAzureRequiresElevateToken(t *testing.T) { + svc := NewServiceWithBackend(evaluateBackend(ConnectionDirect, "Y2E=")) + svc.SetCredentialFlow(&stubFlow{}) + + _, err := svc.ExecCredential(t.Context(), ExecCredentialParams{CSP: "azure", FQDN: "host"}) + if err == nil || !strings.Contains(err.Error(), "Idira session token is required") { + t.Fatalf("err = %v, want a missing-elevate-token error", err) + } + + // With a token it proceeds. + if _, err := svc.ExecCredential(t.Context(), ExecCredentialParams{ + CSP: "azure", FQDN: "host", ElevateToken: "jwt", + }); err != nil { + t.Fatalf("unexpected error with a token: %v", err) + } +} + +func TestExecCredentialPassesElevateTokenToFlow(t *testing.T) { + var got string + svc := NewServiceWithBackend(evaluateBackend(ConnectionDirect, "Y2E=")) + svc.SetCredentialFlow(&stubFlow{ + directFn: func(_ *ElevateResult, cctx *sdkk8s.IdsecSCAK8sClusterContext, _ bool) (*k8smodels.IdsecSCAK8sExecCredential, error) { + got = cctx.ElevateToken + return &k8smodels.IdsecSCAK8sExecCredential{}, nil + }, + }) + + if _, err := svc.ExecCredential(t.Context(), ExecCredentialParams{ + CSP: "azure", FQDN: "host", ElevateToken: "isp-jwt", + }); err != nil { + t.Fatalf("ExecCredential: %v", err) + } + if got != "isp-jwt" { + t.Errorf("ElevateToken = %q, want it in the cluster context", got) + } +} + func TestExecCredentialValidatesCSP(t *testing.T) { svc := NewServiceWithBackend(evaluateBackend(ConnectionDirect, "")) if _, err := svc.ExecCredential(t.Context(), ExecCredentialParams{CSP: "gcp", FQDN: "host"}); err == nil { diff --git a/internal/k8s/kubeconfig.go b/internal/k8s/kubeconfig.go index dbb7977..c6802a7 100644 --- a/internal/k8s/kubeconfig.go +++ b/internal/k8s/kubeconfig.go @@ -236,10 +236,25 @@ func (k *Kubeconfig) RewriteExecCommands(grantPath string) []ExecRewrite { rewrites = append(rewrites, ExecRewrite{User: userName, From: command.Value, To: grantPath}) command.Value = grantPath mapSet(exec, "args", sequenceNode(rewriteExecArgs(mapGet(exec, "args")))) + ensureInteractiveMode(exec) } return rewrites } +// ensureInteractiveMode guarantees the exec stanza carries an interactiveMode. +// +// client-go REQUIRES interactiveMode for client.authentication.k8s.io/v1 (it is +// only optional in v1beta1), and rejects the kubeconfig outright when it is +// missing — before grant is ever invoked. "IfAvailable" is the right value for +// grant: it replays cached credentials without stdin, and can run a browser or +// `az login` flow when kubectl says stdin is available. +func ensureInteractiveMode(exec *yaml.Node) { + if node := mapGet(exec, "interactiveMode"); node != nil && strings.TrimSpace(node.Value) != "" { + return + } + mapSet(exec, "interactiveMode", scalarNode("IfAvailable")) +} + func execNode(userEntry *yaml.Node) *yaml.Node { inner := mapGet(userEntry, "user") if inner == nil { @@ -284,15 +299,33 @@ func rewriteExecArgs(args *yaml.Node) []string { return out } -// ResolveKubeconfigPath returns the kubeconfig grant writes to: the first -// non-empty entry of a $KUBECONFIG list, else /.kube/config. +// ResolveKubeconfigPath returns the kubeconfig grant writes to, following +// kubectl's own write rule for a $KUBECONFIG list: the first file in the list +// that EXISTS, or — when none of them exist — the last entry. Writing to the +// first entry unconditionally would create a brand-new file that shadows the +// user's real config instead of updating it. +// +// Note that kubectl READS the whole chain and merges it with the first file +// winning on a name collision, so writing to the first existing file is also the +// only placement that guarantees grant's entries are the ones kubectl resolves. +// grant merges into exactly one file and never rewrites the rest of the chain. func ResolveKubeconfigPath(kubeconfigEnv, home string) string { + var entries []string for _, entry := range filepath.SplitList(kubeconfigEnv) { if strings.TrimSpace(entry) != "" { + entries = append(entries, entry) + } + } + if len(entries) == 0 { + return filepath.Join(home, ".kube", "config") + } + + for _, entry := range entries { + if _, err := os.Stat(entry); err == nil { return entry } } - return filepath.Join(home, ".kube", "config") + return entries[len(entries)-1] } // WriteKubeconfigAtomic writes data to path via a temp file in the same @@ -367,21 +400,38 @@ func targetFileMode(path string) (perm os.FileMode, warnings []string) { // BackupOnce writes .grant.bak the first time grant merges into an // existing kubeconfig. It never overwrites an existing backup. +// +// The backup is created with O_EXCL so two concurrent grant runs cannot clobber +// each other's copy, and the source must be a regular file — a symlinked or +// special kubeconfig is refused rather than dereferenced into a new file. func BackupOnce(path string) (bool, error) { - backup := path + ".grant.bak" - if _, err := os.Stat(backup); err == nil { - return false, nil + fi, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("failed to inspect %s: %w", path, err) + } + if !fi.Mode().IsRegular() { + return false, fmt.Errorf("refusing to back up %s: not a regular file", path) } data, err := os.ReadFile(path) //nolint:gosec // path is the user's chosen kubeconfig if err != nil { - if os.IsNotExist(err) { + return false, fmt.Errorf("failed to read %s for backup: %w", path, err) + } + + backup := path + ".grant.bak" + f, err := os.OpenFile(backup, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + if os.IsExist(err) { return false, nil } - return false, fmt.Errorf("failed to read %s for backup: %w", path, err) + return false, fmt.Errorf("failed to write %s: %w", backup, err) } + defer func() { _ = f.Close() }() - if err := os.WriteFile(backup, data, 0o600); err != nil { + if _, err := f.Write(data); err != nil { return false, fmt.Errorf("failed to write %s: %w", backup, err) } return true, nil diff --git a/internal/k8s/kubeconfig_test.go b/internal/k8s/kubeconfig_test.go index 108676f..a1dab2a 100644 --- a/internal/k8s/kubeconfig_test.go +++ b/internal/k8s/kubeconfig_test.go @@ -289,32 +289,168 @@ users: } } +// ResolveKubeconfigPath must follow kubectl's write rule: the first entry that +// EXISTS, or the last entry when none do. Always taking the first entry would +// create a new file that shadows the user's real config. func TestResolveKubeconfigPath(t *testing.T) { sep := string(os.PathListSeparator) testHome := filepath.Join(string(filepath.Separator)+"home", "u") homeKubeconfig := filepath.Join(testHome, ".kube", "config") + + dir := t.TempDir() + existingA := filepath.Join(dir, "a") + existingB := filepath.Join(dir, "b") + missing1 := filepath.Join(dir, "missing1") + missing2 := filepath.Join(dir, "missing2") + for _, p := range []string{existingA, existingB} { + if err := os.WriteFile(p, []byte("apiVersion: v1\n"), 0o600); err != nil { + t.Fatal(err) + } + } + tests := []struct { name string env string - home string want string }{ - {name: "unset falls back to home", env: "", home: testHome, want: homeKubeconfig}, - {name: "single path", env: "/tmp/kc", home: testHome, want: "/tmp/kc"}, - {name: "list form takes first", env: "/tmp/a" + sep + "/tmp/b", home: testHome, want: "/tmp/a"}, - {name: "leading empty entry is skipped", env: sep + "/tmp/b", home: testHome, want: "/tmp/b"}, - {name: "all empty falls back", env: sep + sep, home: testHome, want: homeKubeconfig}, + {name: "unset falls back to home", env: "", want: homeKubeconfig}, + {name: "single existing path", env: existingA, want: existingA}, + {name: "single missing path is still the target", env: missing1, want: missing1}, + {name: "first existing entry wins", env: existingA + sep + existingB, want: existingA}, + { + name: "skips a missing earlier entry rather than creating it", + env: missing1 + sep + existingB, + want: existingB, + }, + {name: "none exist falls back to the last entry", env: missing1 + sep + missing2, want: missing2}, + {name: "leading empty entry is skipped", env: sep + existingB, want: existingB}, + {name: "all empty falls back to home", env: sep + sep, want: homeKubeconfig}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := ResolveKubeconfigPath(tt.env, tt.home); got != tt.want { + if got := ResolveKubeconfigPath(tt.env, testHome); got != tt.want { t.Errorf("ResolveKubeconfigPath(%q) = %q, want %q", tt.env, got, tt.want) } }) } } +// client-go REQUIRES interactiveMode for client.authentication.k8s.io/v1 and +// rejects the kubeconfig before grant is ever invoked when it is missing. +func TestRewriteExecCommandsSetsInteractiveMode(t *testing.T) { + const v1Generated = `apiVersion: v1 +kind: Config +users: + - name: eks-prod-user + user: + exec: + apiVersion: client.authentication.k8s.io/v1 + command: idsec + args: ["sca", "k8s", "kubectl-login", "--csp", "aws", "--fqdn", "prod.eks.example"] +` + cfg, err := ParseKubeconfig([]byte(v1Generated)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if rewrites := cfg.RewriteExecCommands("/usr/local/bin/grant"); len(rewrites) != 1 { + t.Fatalf("got %d rewrites, want 1", len(rewrites)) + } + + out := decodeConfig(t, mustBytes(t, cfg)) + user := entryByName(t, out, "users", "eks-prod-user") + exec, _ := user["user"].(map[string]any)["exec"].(map[string]any) + if exec["interactiveMode"] != "IfAvailable" { + t.Errorf("interactiveMode = %v, want IfAvailable (required for the v1 API)", exec["interactiveMode"]) + } +} + +func TestRewriteExecCommandsPreservesExplicitInteractiveMode(t *testing.T) { + const withMode = `apiVersion: v1 +kind: Config +users: + - name: u + user: + exec: + apiVersion: client.authentication.k8s.io/v1 + command: idsec + interactiveMode: Never +` + cfg, _ := ParseKubeconfig([]byte(withMode)) + cfg.RewriteExecCommands("/usr/local/bin/grant") + + out := decodeConfig(t, mustBytes(t, cfg)) + exec, _ := entryByName(t, out, "users", "u")["user"].(map[string]any)["exec"].(map[string]any) + if exec["interactiveMode"] != "Never" { + t.Errorf("interactiveMode = %v, want the explicit value preserved", exec["interactiveMode"]) + } +} + +// Two concurrent runs must not clobber each other's backup. +func TestBackupOnceIsExclusive(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + if err := os.WriteFile(path, []byte(existingKubeconfig), 0o600); err != nil { + t.Fatal(err) + } + + const runs = 8 + results := make(chan bool, runs) + errs := make(chan error, runs) + start := make(chan struct{}) + + for range runs { + go func() { + <-start + created, err := BackupOnce(path) + results <- created + errs <- err + }() + } + close(start) + + created := 0 + for range runs { + if <-results { + created++ + } + if err := <-errs; err != nil { + t.Errorf("BackupOnce: %v", err) + } + } + + if created != 1 { + t.Errorf("%d goroutines reported creating the backup, want exactly 1", created) + } + data, err := os.ReadFile(path + ".grant.bak") //nolint:gosec // test-controlled path + if err != nil { + t.Fatalf("backup missing: %v", err) + } + if string(data) != existingKubeconfig { + t.Error("the backup content was corrupted by concurrent writers") + } +} + +func TestBackupOnceRefusesNonRegularSource(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink semantics differ on Windows") + } + + dir := t.TempDir() + realPath := filepath.Join(dir, "real") + link := filepath.Join(dir, "link") + if err := os.WriteFile(realPath, []byte(existingKubeconfig), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(realPath, link); err != nil { + t.Fatal(err) + } + + if _, err := BackupOnce(link); err == nil { + t.Fatal("expected a symlinked kubeconfig to be refused") + } +} + func TestWriteAtomicCreatesFileWithSecureModes(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("POSIX file modes are not meaningful on Windows") diff --git a/internal/k8s/ownership_unix.go b/internal/k8s/ownership_unix.go new file mode 100644 index 0000000..e9c1b2a --- /dev/null +++ b/internal/k8s/ownership_unix.go @@ -0,0 +1,24 @@ +//go:build !windows + +package k8s + +import ( + "errors" + "os" + "syscall" +) + +// checkOwnedByCurrentUser rejects a path owned by another user. Root (uid 0) is +// accepted as an owner so a sudo-created cache is still usable by root itself. +func checkOwnedByCurrentUser(fi os.FileInfo) error { + stat, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + // Unknown filesystem metadata: fall back to the permission-bit check the + // caller already performed rather than failing closed on every platform. + return nil + } + if int(stat.Uid) != os.Getuid() { + return errors.New("path is owned by another user") + } + return nil +} diff --git a/internal/k8s/ownership_windows.go b/internal/k8s/ownership_windows.go new file mode 100644 index 0000000..7c7acd1 --- /dev/null +++ b/internal/k8s/ownership_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package k8s + +import "os" + +// checkOwnedByCurrentUser is a no-op on Windows: POSIX uid semantics do not +// apply, and the ACL-based equivalent would need golang.org/x/sys/windows, +// which grant does not depend on. Windows users rely on the default per-user +// profile directory ACLs on %USERPROFILE%\.grant. +func checkOwnedByCurrentUser(os.FileInfo) error { return nil } diff --git a/internal/k8s/service.go b/internal/k8s/service.go index fbaec8e..f823b26 100644 --- a/internal/k8s/service.go +++ b/internal/k8s/service.go @@ -6,11 +6,20 @@ // presign, Azure CLI token acquisition, JWE decryption). grant owns the command, // selector, cache and kubeconfig-file layers on top of it. // -// Context propagation: only GenerateKubeconfigParallel accepts a context.Context. -// Every other SDK entry point uses context.Background() internally, so grant -// enforces cancellation at this wrapper boundary via runWithContext. That aborts -// the caller promptly but cannot cancel the in-flight HTTP request; the goroutine -// runs to completion and its result is discarded. +// Context handling — read this before assuming cancellation works: +// +// Only GenerateKubeconfigParallel accepts a context.Context, and grant passes the +// caller's context straight through. Every other SDK entry point takes no context +// and issues its HTTP request on context.Background() internally. +// +// For those, runWithContext provides a caller-side timeout, NOT context +// propagation. When the context is done it unblocks the caller and abandons the +// call; the SDK request keeps running to completion. Three consequences worth +// naming: the goroutine leaks until the request finishes, its result is +// discarded, and — because the SDK mutates shared client headers around some +// GETs (RemoveHeader/SetHeader on Content-Type) — an abandoned call can still +// touch that shared client state after the caller has returned. Real +// cancellation needs context-accepting methods upstream in the SDK. package k8s import ( @@ -140,9 +149,13 @@ func NormalizeCSP(csp string) (string, error) { return "", fmt.Errorf("%w: %q (supported: %s)", ErrUnsupportedCSP, csp, strings.Join(SupportedCSPs, ", ")) } -// runWithContext runs fn on a goroutine and returns as soon as either fn -// completes or ctx is done. The SDK call itself is not cancellable, so on -// cancellation the goroutine keeps running and its result is dropped. +// runWithContext gives the caller a deadline over an SDK call that cannot be +// canceled. It returns as soon as fn completes or ctx is done. +// +// This is abandonment, not cancellation: on ctx.Done the goroutine keeps running +// to completion, its result is dropped, and it may still mutate shared SDK client +// state afterwards. Do not read a returned context.Canceled as "the request +// stopped". func runWithContext[T any](ctx context.Context, fn func() (T, error)) (T, error) { type outcome struct { val T From 648e573aa8ade57f2c3eab57c546066ee3443360 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Thu, 13 Aug 2026 21:39:08 +0200 Subject: [PATCH 4/8] fix: reserve SDK stdout for the exec-credential protocol; unbreak the cache on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review pass on the grant k8s surface. Blocker — the SDK has a stdout-reservation switch that was never flipped. Silencing the loggers was only half the job: during interactive auth the SDK prints its browser-redirect message straight to os.Stdout unless config.IsStdoutReservedForData() is true (pkg/auth/identity/idsec_identity.go :546-556), and that path is reachable on a cache miss with spec.interactive:true. reserveStdoutForProtocol now calls sdkconfig.ReserveStdoutForData() and releases it on exit. The stdout test reproduces the SDK's exact branch inside the credential flow and asserts the redirect text never reaches the pipe standing in for the process stdout; verified it fails when the call is removed. Regression — the credential cache could never hit on Windows. Go synthesizes Windows FileMode bits from a single read-only attribute (every file 0666, every directory 0777) and os.Chmod there has no ACL semantics, so the POSIX perm&0o077 checks rejected everything and kubectl would have re-authenticated on every call. Permission, ownership and chmod logic now sit behind a posixPermissions build-tag const, alongside openNoFollowFlag; the same gate is applied to targetFileMode, which would otherwise have warned on every Windows run. Added a windows-tagged regression test and GOOS=windows go vet to the verify sequence. Minors: - Closed the TOCTOU in both readers. The credential cache and BackupOnce now open with O_NOFOLLOW and validate through fstat, so the file that is checked is the file that is read. BackupOnce had no private-directory guarantee to fall back on, so this mattered more there. - Cache rejections are no longer silent. CredentialCache.Warn reports the rejected path and reason on stderr before the command degrades into a fresh login; ordinary misses stay quiet. - Documented that the env vars and the stdout reservation are process-global (safe: one command per process) and that a runWithContext-abandoned goroutine can log after the restore — though only once the JSON is out. - Corrected the LoadAuthentication contract comment: (nil, nil) is not universal; unusable refresh state returns an error (pkg/auth/idsec_isp_auth.go:144). The code already handled both. --- CLAUDE.md | 7 +- cmd/k8s_exec_credential.go | 44 +++++++-- cmd/k8s_exec_credential_test.go | 26 ++++- internal/k8s/execcred_cache.go | 102 +++++++++++++------- internal/k8s/execcred_cache_test.go | 82 ++++++++++++++++ internal/k8s/execcred_cache_windows_test.go | 38 ++++++++ internal/k8s/kubeconfig.go | 24 ++++- internal/k8s/ownership_unix.go | 25 +++-- internal/k8s/ownership_windows.go | 23 ++++- 9 files changed, 310 insertions(+), 61 deletions(-) create mode 100644 internal/k8s/execcred_cache_windows_test.go diff --git a/CLAUDE.md b/CLAUDE.md index e8a78ae..ba91c42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,9 +72,10 @@ Custom `SCAAccessService` follows SDK conventions: - **Kubeconfig write semantics** (`internal/k8s/kubeconfig.go`): MERGE, never overwrite. grant-owned entries are named `grant--` so ownership is decidable without extra state; colliding grant entries are replaced and reported on stderr; all other entries survive (a `yaml.Node` tree is edited in place, so comments and structure are preserved). `current-context` changes only with `--set-current-context`. Atomic write via a temp file **in the same directory** + `fsync` + `rename`. Mode `0600` (an existing narrower mode is preserved; an existing group/world-readable file is re-secured with a warning), parent dir `0700`. `BackupOnce` writes `.grant.bak` with `O_EXCL` (concurrency-safe) and refuses a non-regular/symlinked source. - **`$KUBECONFIG` resolution follows kubectl's write rule:** the first entry that **exists**, or the **last** entry when none do — never just the first, which would create a new file that shadows the user's real config. kubectl reads the whole chain with the first file winning, so this is also the only placement where grant's entries are the ones that resolve. grant merges into exactly one file and never rewrites the rest of the chain. - **Rewritten exec stanzas always carry `interactiveMode`.** client-go **requires** it for `client.authentication.k8s.io/v1` (optional only in v1beta1) and rejects the kubeconfig before grant is ever invoked when it is missing. `RewriteExecCommands` sets `IfAvailable` when absent and preserves an explicit value. -- **Exec-credential cache** (`internal/k8s/execcred_cache.go`): `~/.grant/cache/execcred_.json`. **Organization is part of the key** — the same cluster and role reached through different tenants must not share an entry. Written by creating a fresh `0600` temp file in the cache dir and renaming over the target, so a pre-existing loose file is *replaced*, never written into. Reads reject: symlinks (`Lstat`, and the symlink target is left alone), non-regular files, a file or directory readable beyond the owner, a file owned by another user (POSIX only — Windows relies on profile-directory ACLs), and payloads carrying no actual credential material. Expiry uses `status.expirationTimestamp` **verbatim** — the SDK bakes its early-refresh buffer in once, at the point the raw DPA/STS/AKS expiry is known, and grant never re-applies it. Absent/malformed expiry ⇒ not cacheable; more than `maxCredentialLifetime` (24h) ahead ⇒ clock skew, refused. -- **`exec-credential` reserves stdout.** The SDK logger is built with `log.New(os.Stdout, …)` and re-reads `IDSEC_LOG_LEVEL` on every call, so `grant --verbose k8s exec-credential` would otherwise interleave log lines with the credential JSON and break kubectl. `reserveStdoutForProtocol` forces `IDSEC_LOG_LEVEL=CRITICAL` for the command's duration (restoring it after) and, when `--verbose` was asked for, switches on the SDK's `IDSEC_KUBELOGIN_LOG_LEVEL` diagnostics channel, which writes to stderr by design. grant's own diagnostics use `execDiag` → stderr, never the package-level `log`. -- **`exec-credential` orders auth last.** Exec info is parsed and validated, flags are checked, and the credential cache is consulted **before** any authentication — a cache hit must never trigger a browser/MFA prompt, because kubectl may run with no terminal. On a miss, `defaultExecCredentialDeps` uses `LoadAuthentication` (keyring read + non-interactive refresh, never prompts); only if that yields no token does it fall back to interactive `Authenticate`, and when `spec.interactive` is false it returns `ErrInteractionRequired` instead. +- **Exec-credential cache** (`internal/k8s/execcred_cache.go`): `~/.grant/cache/execcred_.json`. **Organization is part of the key** — the same cluster and role reached through different tenants must not share an entry. Written by creating a fresh `0600` temp file in the cache dir and renaming over the target, so a pre-existing loose file is *replaced*, never written into. Reads **open first, then validate via the descriptor** (`O_NOFOLLOW` + `fstat`), closing the TOCTOU window a path-based `Lstat`-then-read would leave; the same pattern is used by `BackupOnce`. Rejected: symlinks (the link is removed, its target untouched), non-regular files, a file or directory readable beyond the owner, a file owned by another user, and payloads carrying no actual credential material. Rejections are **reported on stderr** via `CredentialCache.Warn` — failing safe is right, failing silently into a login prompt is not; ordinary misses stay quiet. Expiry uses `status.expirationTimestamp` **verbatim** — the SDK bakes its early-refresh buffer in once, at the point the raw DPA/STS/AKS expiry is known, and grant never re-applies it. Absent/malformed expiry ⇒ not cacheable; more than `maxCredentialLifetime` (24h) ahead ⇒ clock skew, refused. +- **Permission checks are POSIX-only** (`internal/k8s/ownership_{unix,windows}.go`). Go synthesizes Windows `FileMode` bits from a single read-only attribute — every ordinary file reads as `0666`, every directory as `0777` — and `os.Chmod` there only toggles read-only, with no ACL semantics. A POSIX `perm&0o077` check therefore rejects *everything* on Windows, which once made the credential cache a permanent miss and forced re-authentication on every kubectl call. `posixPermissions` gates the mode, ownership and chmod logic (also in `targetFileMode`), and `openNoFollowFlag` is `syscall.O_NOFOLLOW` / `0`. Windows relies on user-profile-directory ACLs instead. `GOOS=windows go vet ./...` is part of the verify sequence, and `execcred_cache_windows_test.go` guards the regression. +- **`exec-credential` reserves stdout — two switches, both required.** (1) The SDK logger is built with `log.New(os.Stdout, …)` and re-reads `IDSEC_LOG_LEVEL` on every call, so `--verbose` would interleave log lines with the credential JSON; `reserveStdoutForProtocol` forces `IDSEC_LOG_LEVEL=CRITICAL` for the command's duration. (2) During interactive auth the SDK prints its browser-redirect message to `os.Stdout` **unless** `sdkconfig.IsStdoutReservedForData()` is true (`pkg/auth/identity/idsec_identity.go:546-556`) — reachable on a cache miss with `spec.interactive:true` — so the command calls **`sdkconfig.ReserveStdoutForData()`** and releases it on exit. When `--verbose` was asked for, the SDK's `IDSEC_KUBELOGIN_LOG_LEVEL` diagnostics channel is switched on instead; it writes to stderr by design. grant's own diagnostics use `execDiag` → stderr, never the package-level `log`. Both switches are process-global, which is safe because the CLI runs one command per process; the residual caveat is that a goroutine abandoned by `runWithContext` can log after the restore, but only once the JSON has already been written. +- **`exec-credential` orders auth last.** Exec info is parsed and validated, flags are checked, and the credential cache is consulted **before** any authentication — a cache hit must never trigger a browser/MFA prompt, because kubectl may run with no terminal. On a miss, `defaultExecCredentialDeps` uses `LoadAuthentication` (keyring read + non-interactive refresh, never prompts); only if that yields no token does it fall back to interactive `Authenticate`, and when `spec.interactive` is false it returns `ErrInteractionRequired` instead. Note `LoadAuthentication` signals "no usable session" in **two** ways — `(nil, nil)` for an absent auth profile (`pkg/auth/idsec_auth.go:326`) and an *error* for unusable refresh state (`pkg/auth/idsec_isp_auth.go:144`) — so callers must check the token, not just the error. - **R-5a (partially resolved):** the flag names in the defensive rewrite are **confirmed correct** against the SDK CLI schema (`models/idsec_sca_k8s_elevate.go:89-94` defines exactly `--csp`, `--role-id`, `--fqdn`, `--organization-id`, `--namespace`). Still unobserved: whether the DPA-generated kubeconfig actually points `users[].user.exec.command` at `idsec`/`ark`. `RewriteExecCommands` only rewrites when the command basename matches a known official binary; anything else passes through untouched. - **Untested against a live cluster.** No Kubernetes entitlements were available; README carries a single note saying so. diff --git a/cmd/k8s_exec_credential.go b/cmd/k8s_exec_credential.go index db63817..183574d 100644 --- a/cmd/k8s_exec_credential.go +++ b/cmd/k8s_exec_credential.go @@ -107,25 +107,44 @@ func defaultCredentialCache() *k8s.CredentialCache { return k8s.NewCredentialCache(dir) } -// reserveStdoutForProtocol silences every logger that writes to os.Stdout for the -// duration of this command. +// reserveStdoutForProtocol keeps everything except the ExecCredential JSON off +// os.Stdout for the duration of this command. There are two independent writers +// to shut up, and both must be handled: // -// The SDK's logger is constructed with log.New(os.Stdout, ...) and its Info level -// is resolved from IDSEC_LOG_LEVEL on every call, so `grant --verbose k8s -// exec-credential` would otherwise interleave log lines with the credential JSON -// and break kubectl. Forcing the level to CRITICAL suppresses all of them at the -// source. When the user asked for verbose output we instead switch on the SDK's -// kubectl-login diagnostics channel, which writes to stderr by design. +// 1. Loggers. The SDK logger is constructed with log.New(os.Stdout, ...) and +// resolves its level from IDSEC_LOG_LEVEL on every call, so +// `grant --verbose k8s exec-credential` would interleave log lines with the +// credential JSON. Forcing CRITICAL silences every such logger at the source. +// 2. Interactive auth prompts. On a cache miss with stdin available we can reach +// Authenticate, and the SDK prints its browser-redirect message to os.Stdout +// unless config.IsStdoutReservedForData() is true — see +// pkg/auth/identity/idsec_identity.go:546-556. ReserveStdoutForData exists +// for exactly this case and flips that message to stderr. +// +// When the user asked for verbose output we switch on the SDK's kubectl-login +// diagnostics channel instead, which writes to stderr by design. +// +// Concurrency note: IDSEC_LOG_LEVEL, IDSEC_KUBELOGIN_LOG_LEVEL and the SDK's +// stdout reservation are all process-global. That is safe here because the CLI +// runs a single command per process (same assumption as the `verbose` var in +// root.go). One caveat survives it: a goroutine abandoned by +// k8s.runWithContext can outlive this restore and log late, after the level has +// gone back to INFO. That would land on stdout — but only after the command has +// already written its JSON and returned, so kubectl has its response either way. func reserveStdoutForProtocol(wantDiagnostics bool) func() { prevLevel, hadLevel := os.LookupEnv(sdkconfig.IdsecLogLevelEnvVar) sdkconfig.DisableVerboseLogging() + // Route the SDK's interactive browser/IdP prompts to stderr. + sdkconfig.ReserveStdoutForData() + prevKube, hadKube := os.LookupEnv(sdkk8s.KubectlLoginLogLevelEnvVar) if wantDiagnostics { _ = os.Setenv(sdkk8s.KubectlLoginLogLevelEnvVar, "INFO") } return func() { + sdkconfig.ReleaseStdoutForData() restoreEnv(sdkconfig.IdsecLogLevelEnvVar, prevLevel, hadLevel) if wantDiagnostics { restoreEnv(sdkk8s.KubectlLoginLogLevelEnvVar, prevKube, hadKube) @@ -178,6 +197,10 @@ func runK8sExecCredential( // Cache first, before any authentication. if credCache != nil { + // Rejecting an entry degrades into a fresh login; say why, on stderr. + credCache.Warn = func(msg string) { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %s\n", msg) + } if cred, ok := credCache.Get(key); ok { execDiag(cmd, "reusing cached cluster credential for %s", flags.fqdn) return writeExecCredential(cmd, cred, info.APIVersion) @@ -240,7 +263,10 @@ func defaultExecCredentialDeps(interactive bool) (*execCredentialDeps, error) { ispAuth := auth.NewIdsecISPAuth(true) // LoadAuthentication reads the keyring and may refresh over the network, but - // never prompts. It reports a missing/expired token as (nil, nil). + // never prompts. It signals "no usable session" in more than one way: an + // absent auth profile yields (nil, nil) (pkg/auth/idsec_auth.go:326), while + // unusable refresh state yields an error (pkg/auth/idsec_isp_auth.go:144). + // Both, plus an empty token string, mean the same thing here. token, err := ispAuth.LoadAuthentication(profile, true) if err != nil || token == nil || strings.TrimSpace(token.Token) == "" { if !interactive { diff --git a/cmd/k8s_exec_credential_test.go b/cmd/k8s_exec_credential_test.go index 47bccaf..d595176 100644 --- a/cmd/k8s_exec_credential_test.go +++ b/cmd/k8s_exec_credential_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "os" "path/filepath" @@ -14,6 +15,7 @@ import ( "github.com/aaearon/grant-cli/internal/k8s" "github.com/cyberark/idsec-sdk-golang/pkg/common" + sdkconfig "github.com/cyberark/idsec-sdk-golang/pkg/config" "github.com/spf13/cobra" ) @@ -116,11 +118,25 @@ func TestExecCredentialStdoutIsOnlyJSON(t *testing.T) { // runs. Every SDK logger re-reads it on each call, so this is the value that // decides whether SDK log lines hit stdout mid-protocol. var levelDuringFlow string + var stdoutReservedDuringFlow bool provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} resolveDeps := func(bool) (*execCredentialDeps, error) { levelDuringFlow = os.Getenv("IDSEC_LOG_LEVEL") + stdoutReservedDuringFlow = sdkconfig.IsStdoutReservedForData() + // Anything the SDK or grant logs at this point must not reach stdout. log.Info("this line must never appear on stdout") + + // Reproduce the exact branch the SDK takes when it prints the browser + // redirect message during interactive authentication + // (pkg/auth/identity/idsec_identity.go:546-556). This is reachable from + // here: a cache miss with spec.interactive:true can call Authenticate. + promptOut := io.Writer(os.Stdout) + if sdkconfig.IsStdoutReservedForData() { + promptOut = os.Stderr + } + fmt.Fprintf(promptOut, "\nYou are now being redirected from your browser...\n") + return &execCredentialDeps{provider: provider, elevateToken: "isp-jwt"}, nil } @@ -154,10 +170,18 @@ func TestExecCredentialStdoutIsOnlyJSON(t *testing.T) { if levelDuringFlow != "CRITICAL" { t.Errorf("IDSEC_LOG_LEVEL during the credential flow = %q, want CRITICAL so no SDK logger writes to stdout", levelDuringFlow) } - // The caller's setting is restored once the command is done. + if !stdoutReservedDuringFlow { + t.Error("config.IsStdoutReservedForData() was false during the credential flow; " + + "the SDK would print its browser-redirect message to stdout and corrupt the protocol") + } + + // Both process-global switches are restored once the command is done. if got := os.Getenv("IDSEC_LOG_LEVEL"); got != "INFO" { t.Errorf("IDSEC_LOG_LEVEL = %q after the command, want it restored to INFO", got) } + if sdkconfig.IsStdoutReservedForData() { + t.Error("the stdout reservation leaked past the command") + } trimmed := strings.TrimSpace(stdout) if !strings.HasPrefix(trimmed, "{") || !strings.HasSuffix(trimmed, "}") { diff --git a/internal/k8s/execcred_cache.go b/internal/k8s/execcred_cache.go index 31438d0..64e2973 100644 --- a/internal/k8s/execcred_cache.go +++ b/internal/k8s/execcred_cache.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "strings" @@ -41,6 +42,11 @@ type CredentialKey struct { type CredentialCache struct { dir string now func() time.Time + + // Warn, when set, receives a message whenever an entry is rejected for a + // security reason. A rejection silently degrades into a fresh login, so the + // user deserves to know why. Ordinary misses and expiries are not reported. + Warn func(msg string) } // NewCredentialCache creates a credential cache rooted at dir. @@ -48,6 +54,13 @@ func NewCredentialCache(dir string) *CredentialCache { return &CredentialCache{dir: dir, now: time.Now} } +func (c *CredentialCache) warn(format string, args ...any) { + if c.Warn == nil { + return + } + c.Warn(fmt.Sprintf(format, args...)) +} + // pathFor returns the on-disk path for a key. func (c *CredentialCache) pathFor(key CredentialKey) string { raw := strings.Join([]string{ @@ -67,21 +80,15 @@ func (c *CredentialCache) Get(key CredentialKey) (*k8smodels.IdsecSCAK8sExecCred path := c.pathFor(key) if err := c.checkDirSecure(); err != nil { + c.warn("ignoring the credential cache: %s is not usable (%v); re-authenticating instead", c.dir, err) return nil, false } - // Lstat, not Stat: a symlink here must be rejected, not followed. - fi, err := os.Lstat(path) - if err != nil { - return nil, false - } - if err := checkCredentialFileSecure(fi); err != nil { - _ = os.Remove(path) - return nil, false - } - - data, err := os.ReadFile(path) //nolint:gosec // path is derived from a hashed key inside the cache dir + data, err := c.readEntry(path) if err != nil { + if !os.IsNotExist(err) { + c.warn("ignoring cached credential %s (%v); re-authenticating instead", path, err) + } return nil, false } @@ -123,9 +130,12 @@ func (c *CredentialCache) Put(key CredentialKey, cred *k8smodels.IdsecSCAK8sExec return fmt.Errorf("failed to create credential cache directory: %w", err) } // Tighten a pre-existing cache directory rather than writing secrets into a - // world-readable one. - if err := os.Chmod(c.dir, 0o700); err != nil { - return fmt.Errorf("failed to secure credential cache directory: %w", err) + // world-readable one. Skipped on Windows, where Chmod only toggles the + // read-only attribute and would make the directory unwritable. + if posixPermissions { + if err := os.Chmod(c.dir, 0o700); err != nil { + return fmt.Errorf("failed to secure credential cache directory: %w", err) + } } data, err := json.Marshal(cred) @@ -174,38 +184,60 @@ func writeAndSecure(f *os.File, data []byte) error { return nil } -// checkDirSecure rejects a cache directory that is a symlink, is not a -// directory, or is accessible beyond the owner. -func (c *CredentialCache) checkDirSecure() error { - fi, err := os.Lstat(c.dir) +// readEntry opens the entry and validates it through the resulting file +// descriptor, so the file that is checked is exactly the file that is read. +// A path-based Lstat followed by a read would leave a window for the entry to +// be swapped for a symlink in between. +// +// Entries that fail validation are removed. os.Remove on a symlink removes the +// link, never its target. +func (c *CredentialCache) readEntry(path string) ([]byte, error) { + f, err := os.OpenFile(path, os.O_RDONLY|openNoFollowFlag, 0) //nolint:gosec // path is a hashed key inside the cache dir if err != nil { - return err + // O_NOFOLLOW turns a symlink into ELOOP rather than opening the target. + if !os.IsNotExist(err) { + _ = os.Remove(path) + return nil, errors.New("it is not a regular file, or is a symlink") + } + return nil, err } - if !fi.IsDir() { - return errors.New("credential cache path is not a directory") + defer func() { _ = f.Close() }() + + fi, err := f.Stat() + if err != nil { + return nil, err } - if fi.Mode()&os.ModeSymlink != 0 { - return errors.New("credential cache directory is a symlink") + if !fi.Mode().IsRegular() { + _ = os.Remove(path) + return nil, errors.New("it is not a regular file") } - if fi.Mode().Perm()&0o077 != 0 { - return errors.New("credential cache directory is accessible beyond its owner") + if posixPermissions { + if err := checkPrivateToCurrentUser(fi); err != nil { + _ = os.Remove(path) + return nil, err + } } - return checkOwnedByCurrentUser(fi) + + return io.ReadAll(f) } -// checkCredentialFileSecure rejects anything that is not a private regular file -// owned by the current user. -func checkCredentialFileSecure(fi os.FileInfo) error { +// checkDirSecure rejects a cache directory that is a symlink, is not a +// directory, or is accessible beyond the owner. +func (c *CredentialCache) checkDirSecure() error { + fi, err := os.Lstat(c.dir) + if err != nil { + return err + } if fi.Mode()&os.ModeSymlink != 0 { - return errors.New("credential cache entry is a symlink") + return errors.New("it is a symlink") } - if !fi.Mode().IsRegular() { - return errors.New("credential cache entry is not a regular file") + if !fi.IsDir() { + return errors.New("it is not a directory") } - if fi.Mode().Perm()&0o077 != 0 { - return errors.New("credential cache entry is accessible beyond its owner") + if !posixPermissions { + return nil } - return checkOwnedByCurrentUser(fi) + return checkPrivateToCurrentUser(fi) } // isUsableCredential rejects a decoded credential that carries no actual diff --git a/internal/k8s/execcred_cache_test.go b/internal/k8s/execcred_cache_test.go index 5af1339..bcbacf9 100644 --- a/internal/k8s/execcred_cache_test.go +++ b/internal/k8s/execcred_cache_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "time" @@ -393,6 +394,87 @@ func TestCredentialCacheKeyIncludesOrganization(t *testing.T) { } } +// Failing safe is right; failing silently into a login prompt is not. +func TestCredentialCacheWarnsWhenRejectingAnEntry(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX file modes are not meaningful on Windows") + } + + dir := t.TempDir() + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + c := NewCredentialCache(dir) + + var warnings []string + c.Warn = func(msg string) { warnings = append(warnings, msg) } + + if err := c.Put(testKey(), credWithExpiry(time.Now().Add(time.Hour))); err != nil { + t.Fatalf("Put: %v", err) + } + path := c.pathFor(testKey()) + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + + if _, ok := c.Get(testKey()); ok { + t.Fatal("expected a miss for a world-readable entry") + } + if len(warnings) != 1 { + t.Fatalf("got %d warnings, want 1: %v", len(warnings), warnings) + } + if !strings.Contains(warnings[0], path) { + t.Errorf("warning should name the rejected path: %q", warnings[0]) + } + if !strings.Contains(warnings[0], "re-authenticating") { + t.Errorf("warning should say what happens next: %q", warnings[0]) + } +} + +func TestCredentialCacheWarnsWhenDirectoryIsRejected(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX file modes are not meaningful on Windows") + } + + dir := t.TempDir() + c := NewCredentialCache(dir) + if err := c.Put(testKey(), credWithExpiry(time.Now().Add(time.Hour))); err != nil { + t.Fatalf("Put: %v", err) + } + if err := os.Chmod(dir, 0o755); err != nil { + t.Fatal(err) + } + + var warnings []string + c.Warn = func(msg string) { warnings = append(warnings, msg) } + + if _, ok := c.Get(testKey()); ok { + t.Fatal("expected a miss") + } + if len(warnings) != 1 || !strings.Contains(warnings[0], dir) { + t.Errorf("expected one warning naming %s, got %v", dir, warnings) + } +} + +// An ordinary miss is not a security event and must stay quiet. +func TestCredentialCacheDoesNotWarnOnOrdinaryMiss(t *testing.T) { + dir := t.TempDir() + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + c := NewCredentialCache(dir) + + var warnings []string + c.Warn = func(msg string) { warnings = append(warnings, msg) } + + if _, ok := c.Get(testKey()); ok { + t.Fatal("expected a miss") + } + if len(warnings) != 0 { + t.Errorf("an absent entry must not warn, got %v", warnings) + } +} + func TestCredentialCacheMissOnUnwritableDir(t *testing.T) { c := NewCredentialCache(t.TempDir()) if _, ok := c.Get(testKey()); ok { diff --git a/internal/k8s/execcred_cache_windows_test.go b/internal/k8s/execcred_cache_windows_test.go new file mode 100644 index 0000000..cc3ee74 --- /dev/null +++ b/internal/k8s/execcred_cache_windows_test.go @@ -0,0 +1,38 @@ +//go:build windows + +package k8s + +import ( + "testing" + "time" +) + +// TestCredentialCacheRoundTripOnWindows guards a regression that made the cache +// permanently unusable on Windows. +// +// Go synthesizes FileMode permission bits on Windows from a single read-only +// attribute: every ordinary file reports 0666 and every directory 0777 +// (os/types_windows.go). A POSIX `perm&0o077 != 0` check therefore rejects +// everything, so every Get missed and kubectl re-authenticated on every single +// call. The permission and ownership checks are now gated behind +// posixPermissions. +func TestCredentialCacheRoundTripOnWindows(t *testing.T) { + if posixPermissions { + t.Fatal("posixPermissions must be false on Windows") + } + + c := NewCredentialCache(t.TempDir()) + key := testKey() + + if err := c.Put(key, credWithExpiry(time.Now().Add(time.Hour))); err != nil { + t.Fatalf("Put: %v", err) + } + + cred, ok := c.Get(key) + if !ok { + t.Fatal("cache miss on Windows: the credential cache is unusable and kubectl will re-authenticate every call") + } + if cred.Status.Token != "tok" { + t.Errorf("token = %q", cred.Status.Token) + } +} diff --git a/internal/k8s/kubeconfig.go b/internal/k8s/kubeconfig.go index c6802a7..58795c7 100644 --- a/internal/k8s/kubeconfig.go +++ b/internal/k8s/kubeconfig.go @@ -3,6 +3,7 @@ package k8s import ( "errors" "fmt" + "io" "os" "path/filepath" "strings" @@ -383,6 +384,13 @@ func writeTempFile(tmp *os.File, data []byte, perm os.FileMode) error { // existing narrower mode is preserved, and an existing group/world-accessible // mode is tightened with a warning. func targetFileMode(path string) (perm os.FileMode, warnings []string) { + // Windows synthesizes permission bits from a read-only attribute (every + // ordinary file reads as 0666), so a POSIX 0077 check there would warn on + // every run and mean nothing. + if !posixPermissions { + return 0o600, nil + } + fi, err := os.Stat(path) if err != nil { return 0o600, nil @@ -405,18 +413,30 @@ func targetFileMode(path string) (perm os.FileMode, warnings []string) { // each other's copy, and the source must be a regular file — a symlinked or // special kubeconfig is refused rather than dereferenced into a new file. func BackupOnce(path string) (bool, error) { - fi, err := os.Lstat(path) + // Open first, then validate through the descriptor, so the file that is + // checked is the file that is read. A path-based Lstat followed by a read + // would let the path be swapped in between, and unlike the credential cache + // this directory has no privacy guarantee to lean on. + src, err := os.OpenFile(path, os.O_RDONLY|openNoFollowFlag, 0) //nolint:gosec // path is the user's chosen kubeconfig if err != nil { if os.IsNotExist(err) { return false, nil } + // O_NOFOLLOW surfaces a symlinked target as an open error rather than + // silently dereferencing it. + return false, fmt.Errorf("failed to open %s for backup (it must be a regular file, not a symlink): %w", path, err) + } + defer func() { _ = src.Close() }() + + fi, err := src.Stat() + if err != nil { return false, fmt.Errorf("failed to inspect %s: %w", path, err) } if !fi.Mode().IsRegular() { return false, fmt.Errorf("refusing to back up %s: not a regular file", path) } - data, err := os.ReadFile(path) //nolint:gosec // path is the user's chosen kubeconfig + data, err := io.ReadAll(src) if err != nil { return false, fmt.Errorf("failed to read %s for backup: %w", path, err) } diff --git a/internal/k8s/ownership_unix.go b/internal/k8s/ownership_unix.go index e9c1b2a..34700b0 100644 --- a/internal/k8s/ownership_unix.go +++ b/internal/k8s/ownership_unix.go @@ -4,21 +4,34 @@ package k8s import ( "errors" + "fmt" "os" "syscall" ) -// checkOwnedByCurrentUser rejects a path owned by another user. Root (uid 0) is -// accepted as an owner so a sudo-created cache is still usable by root itself. -func checkOwnedByCurrentUser(fi os.FileInfo) error { +// posixPermissions reports whether os.FileMode permission bits carry real +// access-control meaning on this platform. +const posixPermissions = true + +// openNoFollowFlag makes an open() refuse to traverse a final symlink, so a +// path can be opened and then inspected via its file descriptor without a +// window for the path to be swapped underneath. +const openNoFollowFlag = syscall.O_NOFOLLOW + +// checkPrivateToCurrentUser rejects a file or directory that is accessible to +// anyone but its owner, or that is owned by another user. +func checkPrivateToCurrentUser(fi os.FileInfo) error { + if perm := fi.Mode().Perm(); perm&0o077 != 0 { + return fmt.Errorf("mode %o is accessible beyond its owner", perm) + } + stat, ok := fi.Sys().(*syscall.Stat_t) if !ok { - // Unknown filesystem metadata: fall back to the permission-bit check the - // caller already performed rather than failing closed on every platform. + // Unknown filesystem metadata: the permission-bit check above still applied. return nil } if int(stat.Uid) != os.Getuid() { - return errors.New("path is owned by another user") + return errors.New("owned by another user") } return nil } diff --git a/internal/k8s/ownership_windows.go b/internal/k8s/ownership_windows.go index 7c7acd1..64006aa 100644 --- a/internal/k8s/ownership_windows.go +++ b/internal/k8s/ownership_windows.go @@ -4,8 +4,21 @@ package k8s import "os" -// checkOwnedByCurrentUser is a no-op on Windows: POSIX uid semantics do not -// apply, and the ACL-based equivalent would need golang.org/x/sys/windows, -// which grant does not depend on. Windows users rely on the default per-user -// profile directory ACLs on %USERPROFILE%\.grant. -func checkOwnedByCurrentUser(os.FileInfo) error { return nil } +// posixPermissions is false on Windows: Go synthesizes FileMode permission bits +// from a single read-only attribute, reporting every ordinary file as 0666 and +// every directory as 0777 (os/types_windows.go). Those bits say nothing about +// who can actually read the file, and os.Chmod only toggles the read-only +// attribute — it has no ACL semantics. Applying a POSIX 0077 check here would +// reject every file unconditionally. +const posixPermissions = false + +// openNoFollowFlag has no Windows equivalent; opens are unmodified. +const openNoFollowFlag = 0 + +// checkPrivateToCurrentUser is a no-op on Windows. +// +// Confidentiality of the credential cache relies on the ACLs Windows applies to +// the user profile directory that contains %USERPROFILE%\.grant. Enforcing it +// here would need golang.org/x/sys/windows to read the security descriptor, +// which grant does not depend on. +func checkPrivateToCurrentUser(os.FileInfo) error { return nil } From cd0019852c2d6bb1be02db6af4fb5d3ed2fe3405 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Fri, 14 Aug 2026 11:03:30 +0200 Subject: [PATCH 5/8] fix(k8s): give exec-credential ownership of the whole stdout boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kubectl's exec-credential protocol requires stdout to carry the ExecCredential JSON and nothing else. The previous fix called sdkconfig.ReserveStdoutForData(), which moves exactly one SDK message — the browser redirect — to stderr. The SDK also drives Survey prompts for PIN entry, MFA-method selection, OOB verification and username/password, whose default Stdio.Out is os.Stdout and which consult no reservation flag at all. All four are reachable on a cache miss via Authenticate. Enumerating writers is the wrong shape for this problem: the list is not knowable from here and grows with every SDK release. This takes the boundary instead. The command duplicates the descriptor behind stdout, re-points that descriptor at stderr, sets os.Stdout = os.Stderr, and writes the JSON to the saved descriptor. Two layers, because they catch different writers: the os.Stdout swap contains everything that resolves os.Stdout when it runs, and the descriptor swap also contains writers that captured it at init (github.com/pkg/browser holds such a var) and subprocesses that inherit it. Release is deferred, so it runs on panic. The descriptor layer is POSIX-only. A Windows handle cannot be re-pointed and SetStdHandle does not affect an already-built *os.File, so there the guarantee is the os.Stdout swap alone; the comments say that rather than implying parity. No module was added: dup/dup2/dup3 come from syscall. The old test reproduced the browser-message branch specifically, which is why it stayed green while four prompts walked past it. The new test asserts the property instead: an arbitrary write to os.Stdout from inside the credential flow does not reach the process's real stdout, and what does reach it parses as exactly one ExecCredential. It runs without cmd.SetOut so the JSON travels the production route. --- cmd/k8s_exec_credential.go | 51 +++++++----- cmd/stdout_guard.go | 80 ++++++++++++++++++ cmd/stdout_guard_dup2.go | 8 ++ cmd/stdout_guard_dup3.go | 10 +++ cmd/stdout_guard_nofd.go | 23 ++++++ cmd/stdout_guard_test.go | 162 +++++++++++++++++++++++++++++++++++++ cmd/stdout_guard_unix.go | 41 ++++++++++ 7 files changed, 356 insertions(+), 19 deletions(-) create mode 100644 cmd/stdout_guard.go create mode 100644 cmd/stdout_guard_dup2.go create mode 100644 cmd/stdout_guard_dup3.go create mode 100644 cmd/stdout_guard_nofd.go create mode 100644 cmd/stdout_guard_test.go create mode 100644 cmd/stdout_guard_unix.go diff --git a/cmd/k8s_exec_credential.go b/cmd/k8s_exec_credential.go index 183574d..ebf40a4 100644 --- a/cmd/k8s_exec_credential.go +++ b/cmd/k8s_exec_credential.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "strings" @@ -107,31 +108,26 @@ func defaultCredentialCache() *k8s.CredentialCache { return k8s.NewCredentialCache(dir) } -// reserveStdoutForProtocol keeps everything except the ExecCredential JSON off -// os.Stdout for the duration of this command. There are two independent writers -// to shut up, and both must be handled: +// quietenSDKChannels is the second-line defense behind the stdout guard. // -// 1. Loggers. The SDK logger is constructed with log.New(os.Stdout, ...) and -// resolves its level from IDSEC_LOG_LEVEL on every call, so -// `grant --verbose k8s exec-credential` would interleave log lines with the -// credential JSON. Forcing CRITICAL silences every such logger at the source. -// 2. Interactive auth prompts. On a cache miss with stdin available we can reach -// Authenticate, and the SDK prints its browser-redirect message to os.Stdout -// unless config.IsStdoutReservedForData() is true — see -// pkg/auth/identity/idsec_identity.go:546-556. ReserveStdoutForData exists -// for exactly this case and flips that message to stderr. +// The guard in stdout_guard.go is what actually makes the protocol safe: no +// writer reaches the real standard output once it is installed. These switches +// exist so the SDK's own chatter does not merely get rerouted onto the user's +// stderr in a torrent, and so the SDK's browser-redirect message takes the +// stderr branch it already knows about. Nothing here is load-bearing for +// protocol correctness — do not re-introduce per-writer silencing as if it +// were, which is how stdout contamination survived two review cycles. // // When the user asked for verbose output we switch on the SDK's kubectl-login -// diagnostics channel instead, which writes to stderr by design. +// diagnostics channel, which writes to stderr by design. // // Concurrency note: IDSEC_LOG_LEVEL, IDSEC_KUBELOGIN_LOG_LEVEL and the SDK's // stdout reservation are all process-global. That is safe here because the CLI // runs a single command per process (same assumption as the `verbose` var in -// root.go). One caveat survives it: a goroutine abandoned by -// k8s.runWithContext can outlive this restore and log late, after the level has -// gone back to INFO. That would land on stdout — but only after the command has -// already written its JSON and returned, so kubectl has its response either way. -func reserveStdoutForProtocol(wantDiagnostics bool) func() { +// root.go). A goroutine abandoned by k8s.runWithContext can outlive this +// restore and log late — with the guard in place that lands on stderr, not on +// kubectl's stdout. +func quietenSDKChannels(wantDiagnostics bool) func() { prevLevel, hadLevel := os.LookupEnv(sdkconfig.IdsecLogLevelEnvVar) sdkconfig.DisableVerboseLogging() @@ -171,7 +167,24 @@ func runK8sExecCredential( credCache *k8s.CredentialCache, rawExecInfo string, ) error { - restore := reserveStdoutForProtocol(verbose) + // Decide this before the guard moves os.Stdout: a caller that supplied its + // own writer (tests, or a future embedding) keeps it. + usesProcessStdout := cmd.OutOrStdout() == io.Writer(os.Stdout) + + // Take the stdout boundary before anything else runs. Everything below — + // including any SDK authentication prompt — is inside the guard. + // + // The protocol payload has to be written to the descriptor the guard saved, + // not to cmd.OutOrStdout(), which now resolves to stderr. Redirect the + // command's own writer at it, unless a caller already supplied one. + guard := reserveStdout() + defer guard.Release() + if usesProcessStdout { + cmd.SetOut(guard.Data) + defer cmd.SetOut(nil) + } + + restore := quietenSDKChannels(verbose) defer restore() info, err := parseExecInfo(rawExecInfo) diff --git a/cmd/stdout_guard.go b/cmd/stdout_guard.go new file mode 100644 index 0000000..f6c40cd --- /dev/null +++ b/cmd/stdout_guard.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "io" + "os" +) + +// stdoutGuard gives a command exclusive ownership of the process's standard +// output for the duration of its run. +// +// It exists for commands that speak a machine protocol on stdout — today only +// `grant k8s exec-credential`, whose contract with kubectl is that stdout +// contains the ExecCredential JSON and absolutely nothing else. The danger is +// not grant's own printing; it is the SDK and the libraries underneath it. The +// SDK builds loggers with log.New(os.Stdout, ...), prints its browser-redirect +// message to os.Stdout, hands os.Stdout to subprocesses, and drives Survey +// prompts (PIN entry, MFA-method selection, OOB verification, username and +// password), whose default Stdio.Out is os.Stdout. Enumerating those writers +// and silencing them one by one is how this bug got shipped twice: the list is +// not knowable from here and grows with every SDK release. +// +// So the guard takes the boundary instead of the writers. Two layers: +// +// 1. os.Stdout is pointed at os.Stderr. Every writer that reads os.Stdout when +// it runs — the SDK logger, Survey's default Stdio, exec.Cmd.Stdout +// assignments, the browser-redirect message — follows it to stderr without +// knowing anything happened. +// 2. Where the platform allows it, the file descriptor behind stdout is itself +// redirected to stderr. That also catches writers that captured os.Stdout +// before the command started (github.com/pkg/browser holds exactly such a +// package-level var) and subprocesses that inherit the descriptor. +// +// Data is the one writer that still reaches the real standard output: a +// descriptor duplicated before the redirect. The command writes its protocol +// payload there and nowhere else. +// +// Layer 2 is unavailable on Windows, where a descriptor cannot be re-pointed at +// another file and a captured *os.File keeps writing to the handle it was built +// from. On Windows the guarantee is therefore layer 1 only: writers that +// consult os.Stdout at call time are contained, writers that captured it at +// init time are not. Every writer named above is in the first group. +type stdoutGuard struct { + // Data writes to the real standard output. It is only valid until Release. + Data io.Writer + + release func() +} + +// Release restores standard output. It is safe to call once, and must be +// deferred so it also runs when the command panics. +func (g *stdoutGuard) Release() { + if g == nil || g.release == nil { + return + } + g.release() + g.release = nil +} + +// reserveStdout is the seam tests replace to observe the guard. +var reserveStdout = defaultReserveStdout + +func defaultReserveStdout() *stdoutGuard { + original := os.Stdout + + data := io.Writer(original) + restoreFD := func() {} + if saved, restore, err := reserveStdoutFD(original, os.Stderr); err == nil { + data, restoreFD = saved, restore + } + + os.Stdout = os.Stderr + + return &stdoutGuard{ + Data: data, + release: func() { + os.Stdout = original + restoreFD() + }, + } +} diff --git a/cmd/stdout_guard_dup2.go b/cmd/stdout_guard_dup2.go new file mode 100644 index 0000000..cede2da --- /dev/null +++ b/cmd/stdout_guard_dup2.go @@ -0,0 +1,8 @@ +//go:build darwin || dragonfly || freebsd || netbsd || openbsd || solaris + +package cmd + +import "syscall" + +// dupTo points newFD at whatever oldFD refers to. +func dupTo(oldFD, newFD int) error { return syscall.Dup2(oldFD, newFD) } diff --git a/cmd/stdout_guard_dup3.go b/cmd/stdout_guard_dup3.go new file mode 100644 index 0000000..6a3b214 --- /dev/null +++ b/cmd/stdout_guard_dup3.go @@ -0,0 +1,10 @@ +//go:build linux + +package cmd + +import "syscall" + +// dupTo points newFD at whatever oldFD refers to. Linux dropped dup2 from the +// syscall table on the newer architectures (arm64 among them), so Dup3 with no +// flags is the portable spelling across every Linux target grant builds for. +func dupTo(oldFD, newFD int) error { return syscall.Dup3(oldFD, newFD, 0) } diff --git a/cmd/stdout_guard_nofd.go b/cmd/stdout_guard_nofd.go new file mode 100644 index 0000000..e1227a3 --- /dev/null +++ b/cmd/stdout_guard_nofd.go @@ -0,0 +1,23 @@ +//go:build !(linux || darwin || dragonfly || freebsd || netbsd || openbsd || solaris) + +package cmd + +import ( + "errors" + "os" +) + +// errStdoutFDUnsupported reports that this platform cannot re-point the +// descriptor behind standard output at another file. +// +// Windows is the case that matters. A handle cannot be replaced in place: +// SetStdHandle only changes what GetStdHandle returns for code that asks +// afterwards, and Go built os.Stdout from the handle at process start, so an +// *os.File that captured it keeps writing to the original console or pipe. +// The stdout guard therefore falls back to its os.Stdout swap alone, which +// still contains every writer that reads os.Stdout when it runs. +var errStdoutFDUnsupported = errors.New("stdout descriptor redirection is not supported on this platform") + +func reserveStdoutFD(target, sink *os.File) (*os.File, func(), error) { + return nil, nil, errStdoutFDUnsupported +} diff --git a/cmd/stdout_guard_test.go b/cmd/stdout_guard_test.go new file mode 100644 index 0000000..f99fc2e --- /dev/null +++ b/cmd/stdout_guard_test.go @@ -0,0 +1,162 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "strings" + "testing" + "time" +) + +// TestExecCredentialGuardsProcessStdout is the test the older one should have +// been. TestExecCredentialStdoutIsOnlyJSON reproduces one specific SDK branch — +// the browser-redirect message — and so proved only that that branch behaves. +// The Survey prompts the SDK drives for PIN entry, MFA-method selection, OOB +// verification and username/password default their Stdio.Out to os.Stdout and +// consult nothing at all, and they walked straight past it. +// +// This test asserts the property rather than a branch: an arbitrary write to +// os.Stdout from inside the credential flow does not reach the process's real +// standard output, and what does reach it is exactly the ExecCredential JSON. +// The command runs without cmd.SetOut, so the JSON travels the production route +// through the guard's saved descriptor instead of a test buffer. +func TestExecCredentialGuardsProcessStdout(t *testing.T) { + realStdout := os.Stdout + pipeR, pipeW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = pipeW + t.Cleanup(func() { os.Stdout = realStdout }) + + // Captured before the command runs, exactly as github.com/pkg/browser + // captures os.Stdout into a package-level var at init. Only the descriptor + // layer of the guard can contain a writer like this. + preCaptured := os.Stdout + fdLayer := stdoutFDReservationWorks(t) + + provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} + resolveDeps := func(bool) (*execCredentialDeps, error) { + // Layer 1, asserted directly: anything resolving os.Stdout right now + // gets stderr. On Windows this is the whole of the guarantee. + if os.Stdout != os.Stderr { + t.Errorf("os.Stdout was not redirected during the credential flow; got %v", os.Stdout) + } + + // Plain writes, of no particular kind. They stand in for every SDK and + // third-party writer, named or not, that resolves os.Stdout when it + // runs: Survey's default Stdio.Out, log.New(os.Stdout, ...), + // exec.Cmd.Stdout assignments, the browser-redirect message. + fmt.Fprintln(os.Stdout, "Enter your PIN:") + fmt.Fprint(os.Stdout, "Select an MFA method: ") + + if fdLayer { + fmt.Fprintln(preCaptured, "subprocess chatter through a captured descriptor") + } + + return &execCredentialDeps{provider: provider, elevateToken: "isp-jwt"}, nil + } + + execCmd := NewK8sExecCredentialCommandWithDeps(resolveDeps, nil, + execInfoJSON(t, "client.authentication.k8s.io/v1beta1", true)) + + var errBuf bytes.Buffer + execCmd.SetErr(&errBuf) + execCmd.SetArgs([]string{"--csp", "aws", "--fqdn", "prod.eks.example"}) + if err := execCmd.Execute(); err != nil { + t.Fatalf("execute: %v\nstderr: %s", err, errBuf.String()) + } + + if err := pipeW.Close(); err != nil { + t.Fatal(err) + } + onStdout, err := io.ReadAll(pipeR) + if err != nil { + t.Fatal(err) + } + + trimmed := strings.TrimSpace(string(onStdout)) + var got map[string]any + if err := json.Unmarshal([]byte(trimmed), &got); err != nil { + t.Fatalf("the process stdout is not exactly one ExecCredential document (%v); "+ + "something inside the credential flow escaped the stdout guard:\n%q", err, string(onStdout)) + } + if got["kind"] != "ExecCredential" { + t.Errorf("kind = %v, want ExecCredential", got["kind"]) + } + + // The guard hands os.Stdout back to whatever it found, not to the real one. + if os.Stdout != pipeW { + t.Errorf("os.Stdout was not restored after the command; got %v", os.Stdout) + } +} + +// TestStdoutGuardRestoresOnPanic asserts the reservation is not leaked when the +// guarded code panics. A leaked guard would leave the whole process writing its +// standard output to stderr. +func TestStdoutGuardRestoresOnPanic(t *testing.T) { + realStdout := os.Stdout + pipeR, pipeW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer func() { _ = pipeR.Close() }() + os.Stdout = pipeW + t.Cleanup(func() { os.Stdout = realStdout }) + + func() { + defer func() { + if recover() == nil { + t.Error("expected the panic to propagate") + } + }() + guard := reserveStdout() + defer guard.Release() + panic("boom") + }() + + if os.Stdout != pipeW { + t.Fatalf("os.Stdout was not restored after a panic; got %v", os.Stdout) + } + + // The descriptor is back as well: a write to os.Stdout reaches the pipe. + const marker = "stdout is usable again\n" + if _, err := fmt.Fprint(os.Stdout, marker); err != nil { + t.Fatal(err) + } + if err := pipeW.Close(); err != nil { + t.Fatal(err) + } + back, err := io.ReadAll(pipeR) + if err != nil { + t.Fatal(err) + } + if string(back) != marker { + t.Errorf("after release, stdout carried %q, want %q", string(back), marker) + } +} + +// stdoutFDReservationWorks reports whether this platform can re-point the +// descriptor behind standard output, which is what decides whether writers that +// captured os.Stdout before the guard was installed are contained. False on +// Windows, where the guard's guarantee is the os.Stdout swap alone. +func stdoutFDReservationWorks(t *testing.T) bool { + t.Helper() + probeR, probeW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer func() { + _ = probeR.Close() + _ = probeW.Close() + }() + _, restore, err := reserveStdoutFD(probeW, os.Stderr) + if err != nil { + return false + } + restore() + return true +} diff --git a/cmd/stdout_guard_unix.go b/cmd/stdout_guard_unix.go new file mode 100644 index 0000000..1157c96 --- /dev/null +++ b/cmd/stdout_guard_unix.go @@ -0,0 +1,41 @@ +//go:build linux || darwin || dragonfly || freebsd || netbsd || openbsd || solaris + +package cmd + +import ( + "fmt" + "os" + "syscall" +) + +// reserveStdoutFD duplicates the descriptor behind target, re-points that +// descriptor at sink, and returns a file writing to target's original +// destination plus a restore function. +// +// Working at the descriptor level is what makes the reservation total: after +// this returns, a write to descriptor 1 lands on sink no matter which *os.File +// or which process issued it. The duplicate is marked close-on-exec so a +// subprocess cannot inherit a second route to the protocol stream. +func reserveStdoutFD(target, sink *os.File) (*os.File, func(), error) { + targetFD := int(target.Fd()) + + savedFD, err := syscall.Dup(targetFD) + if err != nil { + return nil, nil, fmt.Errorf("failed to duplicate stdout: %w", err) + } + syscall.CloseOnExec(savedFD) + + if err := dupTo(int(sink.Fd()), targetFD); err != nil { + _ = syscall.Close(savedFD) + return nil, nil, fmt.Errorf("failed to redirect stdout: %w", err) + } + + saved := os.NewFile(uintptr(savedFD), "grant-reserved-stdout") + restore := func() { + // Put the original destination back before dropping the duplicate, + // otherwise the descriptor is left pointing at sink. + _ = dupTo(savedFD, targetFD) + _ = saved.Close() + } + return saved, restore, nil +} From de38b84e187953271b137b59554831553e379911 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Fri, 14 Aug 2026 11:03:42 +0200 Subject: [PATCH 6/8] fix(k8s): enforce the symlink refusal on Windows instead of skipping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openNoFollowFlag was syscall.O_NOFOLLOW on POSIX and 0 on Windows, so every symlink check on Windows was a no-op. The credential cache opened symlinks normally and f.Stat() then described the target rather than the link, with the ownership and mode validation skipped alongside; BackupOnce dereferenced a symlinked kubeconfig while its doc comment promised it refuses one. That regressed an earlier Lstat rejection. Mapping a security primitive to zero on a platform that lacks its spelling is not a port. Windows has the primitive. CreateFile with FILE_FLAG_OPEN_REPARSE_POINT opens the reparse point itself rather than its target, so the handle refers to the link; asking that handle for its attributes then reports whether we were handed one, with no window in between for the path to be swapped. That is the same descriptor-based discipline the POSIX path uses. The flag constant becomes openNoFollowRead(path), with isSymlinkOpenError to classify the failure. golang.org/x/sys moves from indirect to direct — it was already in the Windows build graph, so no module was added. The symlink tests skipped on Windows, which is how this survived: the contract was checked only where it happened to be true. They now run everywhere, skipping only when the machine itself will not create a symlink (Windows without Developer Mode or admin). Three lower-severity items from the same review, all in this code: - readEntry treated every non-ENOENT open failure as "symlink, or not a regular file", deleted the entry and discarded the real error, so descriptor exhaustion or a transient I/O fault would destroy a valid credential. Only a symlink earns removal now; everything else propagates. - Get warned about the cache being "not usable" when the directory did not exist, which is every first run on a new install — CacheDir() returns the path without creating it. A missing directory is now an ordinary quiet miss. The existing test missed this by creating the directory first. - BackupOnce could leave a partial .grant.bak after a failed write, and the next run treats any existing backup as complete and goes on to replace the kubeconfig. A failed write now removes it; the write step is a package var so the failure is testable. --- go.mod | 2 +- internal/k8s/execcred_cache.go | 26 +++++++-- internal/k8s/execcred_cache_test.go | 71 ++++++++++++++++++++--- internal/k8s/kubeconfig.go | 32 +++++++++-- internal/k8s/kubeconfig_test.go | 62 +++++++++++++++++--- internal/k8s/ownership_unix.go | 18 ++++-- internal/k8s/ownership_windows.go | 79 +++++++++++++++++++++++--- internal/k8s/symlink_test.go | 87 +++++++++++++++++++++++++++++ 8 files changed, 338 insertions(+), 39 deletions(-) create mode 100644 internal/k8s/symlink_test.go diff --git a/go.mod b/go.mod index fa7ff48..9054a76 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/mattn/go-isatty v0.0.24 github.com/minio/selfupdate v0.6.0 github.com/spf13/cobra v1.10.2 + golang.org/x/sys v0.47.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -56,7 +57,6 @@ require ( golang.org/x/image v0.26.0 // indirect golang.org/x/mobile v0.0.0-20250408133729-978277e7eaf7 // indirect golang.org/x/net v0.58.0 // indirect - golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.45.0 // indirect golang.org/x/text v0.41.0 // indirect gopkg.in/errgo.v1 v1.0.1 // indirect diff --git a/internal/k8s/execcred_cache.go b/internal/k8s/execcred_cache.go index 64e2973..887f425 100644 --- a/internal/k8s/execcred_cache.go +++ b/internal/k8s/execcred_cache.go @@ -80,7 +80,12 @@ func (c *CredentialCache) Get(key CredentialKey) (*k8smodels.IdsecSCAK8sExecCred path := c.pathFor(key) if err := c.checkDirSecure(); err != nil { - c.warn("ignoring the credential cache: %s is not usable (%v); re-authenticating instead", c.dir, err) + // A cache directory that does not exist yet is the first run, not a + // security event. Warning about it would greet every new install with a + // scary message on the very first kubectl call. + if !os.IsNotExist(err) { + c.warn("ignoring the credential cache: %s is not usable (%v); re-authenticating instead", c.dir, err) + } return nil, false } @@ -191,15 +196,24 @@ func writeAndSecure(f *os.File, data []byte) error { // // Entries that fail validation are removed. os.Remove on a symlink removes the // link, never its target. +// +// Only a symlink earns removal. An earlier version treated *every* non-ENOENT +// open failure as "symlink, or not a regular file" and deleted the entry, so +// descriptor exhaustion or a transient I/O error would destroy a perfectly good +// credential and discard the real cause with it. Those errors now propagate +// untouched; the caller reports them and re-authenticates for this one run. func (c *CredentialCache) readEntry(path string) ([]byte, error) { - f, err := os.OpenFile(path, os.O_RDONLY|openNoFollowFlag, 0) //nolint:gosec // path is a hashed key inside the cache dir + f, err := openNoFollowRead(path) if err != nil { - // O_NOFOLLOW turns a symlink into ELOOP rather than opening the target. - if !os.IsNotExist(err) { + switch { + case os.IsNotExist(err): + return nil, err + case isSymlinkOpenError(err): _ = os.Remove(path) - return nil, errors.New("it is not a regular file, or is a symlink") + return nil, errors.New("it is a symlink") + default: + return nil, err } - return nil, err } defer func() { _ = f.Close() }() diff --git a/internal/k8s/execcred_cache_test.go b/internal/k8s/execcred_cache_test.go index bcbacf9..6988ffa 100644 --- a/internal/k8s/execcred_cache_test.go +++ b/internal/k8s/execcred_cache_test.go @@ -308,11 +308,11 @@ func TestCredentialCacheRefusesLooseDirectory(t *testing.T) { } } +// TestCredentialCacheRefusesSymlinkedEntry runs on every platform, Windows +// included. It used to skip there, which is precisely how openNoFollowFlag = 0 +// went unnoticed: the contract said symlinked entries are refused, and the only +// test that checked it declined to run on the platform where it was false. func TestCredentialCacheRefusesSymlinkedEntry(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink semantics differ on Windows") - } - dir := t.TempDir() // t.TempDir() is 0755 on some systems; tighten it so this test exercises the // symlink check rather than the directory-permission check. @@ -327,9 +327,7 @@ func TestCredentialCacheRefusesSymlinkedEntry(t *testing.T) { if err := os.WriteFile(target, data, 0o600); err != nil { t.Fatal(err) } - if err := os.Symlink(target, path); err != nil { - t.Fatal(err) - } + mustSymlink(t, target, path) if _, ok := c.Get(testKey()); ok { t.Fatal("a symlinked cache entry must not be followed") @@ -475,6 +473,65 @@ func TestCredentialCacheDoesNotWarnOnOrdinaryMiss(t *testing.T) { } } +// TestCredentialCacheIsQuietOnFirstUse covers the case the test above misses: +// it creates the cache directory first, so it never exercises a fresh install. +// CacheDir() returns ~/.grant/cache without creating it, so the very first +// kubectl call on a new machine found no directory at all — and greeted the +// user with a security-flavored warning about their cache being "not usable". +func TestCredentialCacheIsQuietOnFirstUse(t *testing.T) { + // Never created: exactly what a fresh install looks like. + c := NewCredentialCache(filepath.Join(t.TempDir(), "grant", "cache")) + + var warnings []string + c.Warn = func(msg string) { warnings = append(warnings, msg) } + + if _, ok := c.Get(testKey()); ok { + t.Fatal("expected a miss") + } + if len(warnings) != 0 { + t.Errorf("a cache directory that does not exist yet is a first run, not a security event; got %v", warnings) + } +} + +// TestCredentialCacheKeepsEntryOnTransientOpenError pins the failure mode that +// used to delete valid credentials: readEntry treated every non-ENOENT open +// error as "symlink, or not a regular file", removed the entry, and threw the +// real cause away. Descriptor exhaustion or a blip on a network filesystem would +// destroy a perfectly good credential. +func TestCredentialCacheKeepsEntryOnTransientOpenError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("directory permissions do not gate opens on Windows") + } + if os.Getuid() == 0 { + t.Skip("root ignores the permission bits this test relies on") + } + + dir := t.TempDir() + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + c := NewCredentialCache(dir) + path := c.pathFor(testKey()) + + data, _ := json.Marshal(credWithExpiry(time.Now().Add(time.Hour))) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + // Unreadable, but present and perfectly valid — an EACCES on open, standing + // in for any transient I/O failure. + if err := os.Chmod(path, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(path, 0o600) }) + + if _, ok := c.Get(testKey()); ok { + t.Fatal("expected a miss when the entry cannot be read") + } + if _, err := os.Lstat(path); err != nil { + t.Fatalf("the entry was deleted over a transient open failure: %v", err) + } +} + func TestCredentialCacheMissOnUnwritableDir(t *testing.T) { c := NewCredentialCache(t.TempDir()) if _, ok := c.Get(testKey()); ok { diff --git a/internal/k8s/kubeconfig.go b/internal/k8s/kubeconfig.go index 58795c7..fbf3f87 100644 --- a/internal/k8s/kubeconfig.go +++ b/internal/k8s/kubeconfig.go @@ -417,14 +417,15 @@ func BackupOnce(path string) (bool, error) { // checked is the file that is read. A path-based Lstat followed by a read // would let the path be swapped in between, and unlike the credential cache // this directory has no privacy guarantee to lean on. - src, err := os.OpenFile(path, os.O_RDONLY|openNoFollowFlag, 0) //nolint:gosec // path is the user's chosen kubeconfig + src, err := openNoFollowRead(path) if err != nil { if os.IsNotExist(err) { return false, nil } - // O_NOFOLLOW surfaces a symlinked target as an open error rather than - // silently dereferencing it. - return false, fmt.Errorf("failed to open %s for backup (it must be a regular file, not a symlink): %w", path, err) + if isSymlinkOpenError(err) { + return false, fmt.Errorf("refusing to back up %s: it is a symlink, and grant will not dereference one", path) + } + return false, fmt.Errorf("failed to open %s for backup: %w", path, err) } defer func() { _ = src.Close() }() @@ -449,14 +450,33 @@ func BackupOnce(path string) (bool, error) { } return false, fmt.Errorf("failed to write %s: %w", backup, err) } - defer func() { _ = f.Close() }() - if _, err := f.Write(data); err != nil { + // A backup that exists is treated as complete by the next run (the O_EXCL + // above returns early on os.IsExist), and cmd/k8s_kubeconfig.go will then + // happily replace the kubeconfig believing a good copy is on disk. So a + // backup that was not written in full must not be left behind: remove it and + // report, rather than leaving a truncated file wearing the name of a backup. + if err := backupWrite(f, data); err != nil { + _ = os.Remove(backup) return false, fmt.Errorf("failed to write %s: %w", backup, err) } return true, nil } +// backupWrite writes and durably closes the backup file. It is a package var so +// tests can inject a mid-write failure, which is otherwise unreachable. +var backupWrite = func(f *os.File, data []byte) error { + if _, err := f.Write(data); err != nil { + _ = f.Close() + return err + } + if err := f.Sync(); err != nil { + _ = f.Close() + return err + } + return f.Close() +} + // --- yaml.Node helpers --- func mapGet(node *yaml.Node, key string) *yaml.Node { diff --git a/internal/k8s/kubeconfig_test.go b/internal/k8s/kubeconfig_test.go index a1dab2a..bd1866c 100644 --- a/internal/k8s/kubeconfig_test.go +++ b/internal/k8s/kubeconfig_test.go @@ -431,24 +431,72 @@ func TestBackupOnceIsExclusive(t *testing.T) { } } +// TestBackupOnceRefusesNonRegularSource runs on every platform, Windows +// included. BackupOnce documents that it refuses a symlinked kubeconfig rather +// than dereferencing it; that promise was false on Windows while this test +// skipped there. func TestBackupOnceRefusesNonRegularSource(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink semantics differ on Windows") - } - dir := t.TempDir() realPath := filepath.Join(dir, "real") link := filepath.Join(dir, "link") if err := os.WriteFile(realPath, []byte(existingKubeconfig), 0o600); err != nil { t.Fatal(err) } - if err := os.Symlink(realPath, link); err != nil { - t.Fatal(err) - } + mustSymlink(t, realPath, link) if _, err := BackupOnce(link); err == nil { t.Fatal("expected a symlinked kubeconfig to be refused") } + // The backup must not have been created from the dereferenced target. + if _, err := os.Lstat(link + ".grant.bak"); !os.IsNotExist(err) { + t.Error("a symlinked kubeconfig was backed up anyway") + } +} + +// TestBackupOnceLeavesNoPartialBackup covers the integrity gap: a backup that +// exists is treated as complete by every later run (BackupOnce returns early on +// os.IsExist, and the kubeconfig command then replaces the real file believing +// a good copy is on disk). A write that fails halfway must therefore leave +// nothing behind rather than a truncated file wearing the backup's name. +func TestBackupOnceLeavesNoPartialBackup(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + if err := os.WriteFile(path, []byte(existingKubeconfig), 0o600); err != nil { + t.Fatal(err) + } + + original := backupWrite + t.Cleanup(func() { backupWrite = original }) + backupWrite = func(f *os.File, data []byte) error { + // A short write, as a full disk or a killed NFS mount would produce. + _, _ = f.Write(data[:len(data)/2]) + _ = f.Close() + return errors.New("no space left on device") + } + + if _, err := BackupOnce(path); err == nil { + t.Fatal("expected the failed backup write to be reported") + } + if _, err := os.Lstat(path + ".grant.bak"); !os.IsNotExist(err) { + t.Fatal("a partial backup was left on disk; the next run would mistake it for a complete one") + } + + // And the next attempt, with a working writer, produces a complete backup. + backupWrite = original + created, err := BackupOnce(path) + if err != nil { + t.Fatalf("BackupOnce: %v", err) + } + if !created { + t.Fatal("expected the retry to create the backup") + } + data, err := os.ReadFile(path + ".grant.bak") + if err != nil { + t.Fatal(err) + } + if string(data) != existingKubeconfig { + t.Errorf("backup content = %q, want the full kubeconfig", string(data)) + } } func TestWriteAtomicCreatesFileWithSecureModes(t *testing.T) { diff --git a/internal/k8s/ownership_unix.go b/internal/k8s/ownership_unix.go index 34700b0..0bd50e6 100644 --- a/internal/k8s/ownership_unix.go +++ b/internal/k8s/ownership_unix.go @@ -13,10 +13,20 @@ import ( // access-control meaning on this platform. const posixPermissions = true -// openNoFollowFlag makes an open() refuse to traverse a final symlink, so a -// path can be opened and then inspected via its file descriptor without a -// window for the path to be swapped underneath. -const openNoFollowFlag = syscall.O_NOFOLLOW +// openNoFollowRead opens path for reading and refuses to traverse a final +// symlink, so the path can be inspected through the resulting descriptor +// without leaving a window for it to be swapped underneath. +func openNoFollowRead(path string) (*os.File, error) { + return os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0) //nolint:gosec // callers pass a path they own +} + +// isSymlinkOpenError reports whether an openNoFollowRead failure means the path +// was a symlink, as opposed to any other I/O failure. +// +// Linux reports ELOOP; the BSDs report EMLINK for O_NOFOLLOW specifically. +func isSymlinkOpenError(err error) bool { + return errors.Is(err, syscall.ELOOP) || errors.Is(err, syscall.EMLINK) +} // checkPrivateToCurrentUser rejects a file or directory that is accessible to // anyone but its owner, or that is owned by another user. diff --git a/internal/k8s/ownership_windows.go b/internal/k8s/ownership_windows.go index 64006aa..accc40f 100644 --- a/internal/k8s/ownership_windows.go +++ b/internal/k8s/ownership_windows.go @@ -2,7 +2,12 @@ package k8s -import "os" +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) // posixPermissions is false on Windows: Go synthesizes FileMode permission bits // from a single read-only attribute, reporting every ordinary file as 0666 and @@ -12,13 +17,71 @@ import "os" // reject every file unconditionally. const posixPermissions = false -// openNoFollowFlag has no Windows equivalent; opens are unmodified. -const openNoFollowFlag = 0 +// errSymlinkRefused reports that a path was a reparse point (a symlink, a +// junction or a mount point) and was refused rather than followed. +var errSymlinkRefused = errors.New("it is a symlink or other reparse point") + +// openNoFollowRead is the Windows counterpart of O_NOFOLLOW. +// +// An earlier version of this file defined the no-follow open flag as 0, which +// silently turned every symlink check on Windows into a no-op: the cache opened +// symlinks normally, f.Stat() then described the *target*, and BackupOnce +// dereferenced a symlinked kubeconfig despite documenting that it refuses one. +// Mapping a security primitive to zero on a platform that lacks it is not a +// port, it is a hole. +// +// Windows has the primitive, it is just spelled differently. +// FILE_FLAG_OPEN_REPARSE_POINT makes CreateFile open the reparse point itself +// instead of its target, so the handle refers to the link, never to what it +// points at. Asking that handle for its attributes then tells us whether we +// were handed a link, with no window in between for the path to be swapped — +// the same descriptor-based discipline the POSIX path relies on. +// FILE_FLAG_BACKUP_SEMANTICS is required for the call to accept directories. +func openNoFollowRead(path string) (*os.File, error) { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, &os.PathError{Op: "open", Path: path, Err: err} + } + + h, err := windows.CreateFile( + p, + windows.GENERIC_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS, + 0, + ) + if err != nil { + return nil, &os.PathError{Op: "open", Path: path, Err: err} + } + f := os.NewFile(uintptr(h), path) + + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(windows.Handle(h), &info); err != nil { + _ = f.Close() + return nil, &os.PathError{Op: "open", Path: path, Err: err} + } + if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + _ = f.Close() + return nil, &os.PathError{Op: "open", Path: path, Err: errSymlinkRefused} + } + return f, nil +} + +// isSymlinkOpenError reports whether an openNoFollowRead failure means the path +// was a symlink, as opposed to any other I/O failure. +func isSymlinkOpenError(err error) bool { return errors.Is(err, errSymlinkRefused) } -// checkPrivateToCurrentUser is a no-op on Windows. +// checkPrivateToCurrentUser is a no-op on Windows, and this is a real gap, not +// a platform equivalence. // -// Confidentiality of the credential cache relies on the ACLs Windows applies to -// the user profile directory that contains %USERPROFILE%\.grant. Enforcing it -// here would need golang.org/x/sys/windows to read the security descriptor, -// which grant does not depend on. +// On POSIX the credential cache verifies that each file is owned by the calling +// user and is unreadable by anyone else. Neither check is performed here. +// Confidentiality of %USERPROFILE%\.grant rests entirely on the ACLs Windows +// applies to the user profile directory by default. If a machine's profile ACLs +// have been loosened, or the cache directory was created by another account, +// grant will not notice. Closing it needs the file's security descriptor +// (GetSecurityInfo) compared against the process token's user SID, plus a DACL +// walk for the "no one else may read" half; that is not implemented. func checkPrivateToCurrentUser(os.FileInfo) error { return nil } diff --git a/internal/k8s/symlink_test.go b/internal/k8s/symlink_test.go new file mode 100644 index 0000000..6ced46a --- /dev/null +++ b/internal/k8s/symlink_test.go @@ -0,0 +1,87 @@ +package k8s + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +// mustSymlink creates a symlink, or skips the test when the platform will not +// let an unprivileged process create one. +// +// Windows requires either administrator rights or Developer Mode for +// os.Symlink. Skipping on *that* is honest — the machine cannot stage the +// attack — and is a different thing from skipping the whole test on Windows +// because symlink handling there was never implemented, which is what used to +// happen and is what let the no-follow regression through. +func mustSymlink(t *testing.T, target, link string) { + t.Helper() + if err := os.Symlink(target, link); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("this machine will not create symlinks (needs Developer Mode or admin): %v", err) + } + t.Fatal(err) + } +} + +// TestOpenNoFollowReadRefusesSymlink pins the platform primitive itself, one +// level below the cache and the backup that depend on it. A future port that +// defines the no-follow behavior as "do nothing" — as the Windows file once +// did with openNoFollowFlag = 0 — fails here first. +func TestOpenNoFollowReadRefusesSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + link := filepath.Join(dir, "link") + if err := os.WriteFile(target, []byte("secret"), 0o600); err != nil { + t.Fatal(err) + } + mustSymlink(t, target, link) + + f, err := openNoFollowRead(link) + if err == nil { + _ = f.Close() + t.Fatal("openNoFollowRead followed a symlink; every caller's symlink guarantee rests on it not doing that") + } + if !isSymlinkOpenError(err) { + t.Errorf("error %v is not classified as a symlink refusal, so callers will treat it as an I/O fault", err) + } + if os.IsNotExist(err) { + t.Error("a symlink refusal must not look like a missing file") + } +} + +// TestOpenNoFollowReadOpensRegularFile keeps the refusal from degenerating into +// "refuse everything". +func TestOpenNoFollowReadOpensRegularFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "plain") + if err := os.WriteFile(path, []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + + f, err := openNoFollowRead(path) + if err != nil { + t.Fatalf("openNoFollowRead: %v", err) + } + defer func() { _ = f.Close() }() + + fi, err := f.Stat() + if err != nil { + t.Fatal(err) + } + if !fi.Mode().IsRegular() { + t.Errorf("mode = %v, want a regular file", fi.Mode()) + } +} + +// TestOpenNoFollowReadReportsMissingFile keeps ENOENT distinguishable, which is +// what lets the cache treat an absent entry as an ordinary miss. +func TestOpenNoFollowReadReportsMissingFile(t *testing.T) { + _, err := openNoFollowRead(filepath.Join(t.TempDir(), "absent")) + if !os.IsNotExist(err) { + t.Fatalf("err = %v, want a not-exist error", err) + } + if isSymlinkOpenError(err) { + t.Error("a missing file must not be classified as a symlink") + } +} From ff6de858465a010b99530742976edaf5ce09885b Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Fri, 14 Aug 2026 11:03:50 +0200 Subject: [PATCH 7/8] docs(k8s): correct the guarantees CLAUDE.md and the selector hint claimed The non-interactive hint from the cluster selector told users to "pass a cluster name or --fqdn". grant k8s elevate has no --fqdn flag; the cluster is a positional argument and --fqdn exists only on the hidden exec-credential command. Flagged by Copilot on PR #55. CLAUDE.md described the stdout reservation as "two switches, both required" and the no-follow flag as "syscall.O_NOFOLLOW / 0", both of which described the defects rather than the behaviour. It now records the stdout guard and its honest per-platform guarantee, the Windows reparse point check and why golang.org/x/sys is a direct dependency, the backup-integrity and readEntry rules, and states plainly that checkPrivateToCurrentUser on Windows is a gap rather than an equivalent. --- CLAUDE.md | 9 +++++---- internal/ui/cluster_selector.go | 5 ++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ba91c42..f47e7d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,12 +69,13 @@ Custom `SCAAccessService` follows SDK conventions: - **Selector:** `internal/ui/cluster_selector.go` — Format/Build/Find/Select quartet mirroring `internal/ui/selector.go`. - **Fail closed on `connectionMethod`.** The evaluate API returns an unconstrained string. Anything that is not exactly `direct` or `proxy` is rejected **before elevation** — an empty or future value must never fall through to the direct flow and bypass the DPA proxy the tenant expects. - **Azure identity binding is mandatory.** `ExecCredentialParams.ElevateToken` (the Idira session JWT) is what makes the SDK's `validateAzureCLIIdentity` compare the SCA identity against the logged-in `az` account. It is only checked when non-empty, so `ExecCredential` **refuses** an Azure request without it rather than authenticating as whoever happens to be logged into the Azure CLI. -- **Kubeconfig write semantics** (`internal/k8s/kubeconfig.go`): MERGE, never overwrite. grant-owned entries are named `grant--` so ownership is decidable without extra state; colliding grant entries are replaced and reported on stderr; all other entries survive (a `yaml.Node` tree is edited in place, so comments and structure are preserved). `current-context` changes only with `--set-current-context`. Atomic write via a temp file **in the same directory** + `fsync` + `rename`. Mode `0600` (an existing narrower mode is preserved; an existing group/world-readable file is re-secured with a warning), parent dir `0700`. `BackupOnce` writes `.grant.bak` with `O_EXCL` (concurrency-safe) and refuses a non-regular/symlinked source. +- **Kubeconfig write semantics** (`internal/k8s/kubeconfig.go`): MERGE, never overwrite. grant-owned entries are named `grant--` so ownership is decidable without extra state; colliding grant entries are replaced and reported on stderr; all other entries survive (a `yaml.Node` tree is edited in place, so comments and structure are preserved). `current-context` changes only with `--set-current-context`. Atomic write via a temp file **in the same directory** + `fsync` + `rename`. Mode `0600` (an existing narrower mode is preserved; an existing group/world-readable file is re-secured with a warning), parent dir `0700`. `BackupOnce` writes `.grant.bak` with `O_EXCL` (concurrency-safe) and refuses a non-regular/symlinked source on **every** platform. A failed write removes the partial backup rather than leaving one: a backup that exists is treated as complete by the next run, which would then replace the kubeconfig believing a good copy is on disk. The write step is the `backupWrite` package var so that failure is testable. - **`$KUBECONFIG` resolution follows kubectl's write rule:** the first entry that **exists**, or the **last** entry when none do — never just the first, which would create a new file that shadows the user's real config. kubectl reads the whole chain with the first file winning, so this is also the only placement where grant's entries are the ones that resolve. grant merges into exactly one file and never rewrites the rest of the chain. - **Rewritten exec stanzas always carry `interactiveMode`.** client-go **requires** it for `client.authentication.k8s.io/v1` (optional only in v1beta1) and rejects the kubeconfig before grant is ever invoked when it is missing. `RewriteExecCommands` sets `IfAvailable` when absent and preserves an explicit value. -- **Exec-credential cache** (`internal/k8s/execcred_cache.go`): `~/.grant/cache/execcred_.json`. **Organization is part of the key** — the same cluster and role reached through different tenants must not share an entry. Written by creating a fresh `0600` temp file in the cache dir and renaming over the target, so a pre-existing loose file is *replaced*, never written into. Reads **open first, then validate via the descriptor** (`O_NOFOLLOW` + `fstat`), closing the TOCTOU window a path-based `Lstat`-then-read would leave; the same pattern is used by `BackupOnce`. Rejected: symlinks (the link is removed, its target untouched), non-regular files, a file or directory readable beyond the owner, a file owned by another user, and payloads carrying no actual credential material. Rejections are **reported on stderr** via `CredentialCache.Warn` — failing safe is right, failing silently into a login prompt is not; ordinary misses stay quiet. Expiry uses `status.expirationTimestamp` **verbatim** — the SDK bakes its early-refresh buffer in once, at the point the raw DPA/STS/AKS expiry is known, and grant never re-applies it. Absent/malformed expiry ⇒ not cacheable; more than `maxCredentialLifetime` (24h) ahead ⇒ clock skew, refused. -- **Permission checks are POSIX-only** (`internal/k8s/ownership_{unix,windows}.go`). Go synthesizes Windows `FileMode` bits from a single read-only attribute — every ordinary file reads as `0666`, every directory as `0777` — and `os.Chmod` there only toggles read-only, with no ACL semantics. A POSIX `perm&0o077` check therefore rejects *everything* on Windows, which once made the credential cache a permanent miss and forced re-authentication on every kubectl call. `posixPermissions` gates the mode, ownership and chmod logic (also in `targetFileMode`), and `openNoFollowFlag` is `syscall.O_NOFOLLOW` / `0`. Windows relies on user-profile-directory ACLs instead. `GOOS=windows go vet ./...` is part of the verify sequence, and `execcred_cache_windows_test.go` guards the regression. -- **`exec-credential` reserves stdout — two switches, both required.** (1) The SDK logger is built with `log.New(os.Stdout, …)` and re-reads `IDSEC_LOG_LEVEL` on every call, so `--verbose` would interleave log lines with the credential JSON; `reserveStdoutForProtocol` forces `IDSEC_LOG_LEVEL=CRITICAL` for the command's duration. (2) During interactive auth the SDK prints its browser-redirect message to `os.Stdout` **unless** `sdkconfig.IsStdoutReservedForData()` is true (`pkg/auth/identity/idsec_identity.go:546-556`) — reachable on a cache miss with `spec.interactive:true` — so the command calls **`sdkconfig.ReserveStdoutForData()`** and releases it on exit. When `--verbose` was asked for, the SDK's `IDSEC_KUBELOGIN_LOG_LEVEL` diagnostics channel is switched on instead; it writes to stderr by design. grant's own diagnostics use `execDiag` → stderr, never the package-level `log`. Both switches are process-global, which is safe because the CLI runs one command per process; the residual caveat is that a goroutine abandoned by `runWithContext` can log after the restore, but only once the JSON has already been written. +- **Exec-credential cache** (`internal/k8s/execcred_cache.go`): `~/.grant/cache/execcred_.json`. **Organization is part of the key** — the same cluster and role reached through different tenants must not share an entry. Written by creating a fresh `0600` temp file in the cache dir and renaming over the target, so a pre-existing loose file is *replaced*, never written into. Reads **open first, then validate via the descriptor** (`openNoFollowRead` + `fstat`), closing the TOCTOU window a path-based `Lstat`-then-read would leave; the same pattern is used by `BackupOnce`. Only a symlink earns deletion — any other open failure (descriptor exhaustion, a transient I/O fault) propagates untouched rather than destroying a valid credential. A cache directory that does not exist yet is a first run, not a security event, and stays quiet. Rejected: symlinks (the link is removed, its target untouched), non-regular files, a file or directory readable beyond the owner, a file owned by another user, and payloads carrying no actual credential material. Rejections are **reported on stderr** via `CredentialCache.Warn` — failing safe is right, failing silently into a login prompt is not; ordinary misses stay quiet. Expiry uses `status.expirationTimestamp` **verbatim** — the SDK bakes its early-refresh buffer in once, at the point the raw DPA/STS/AKS expiry is known, and grant never re-applies it. Absent/malformed expiry ⇒ not cacheable; more than `maxCredentialLifetime` (24h) ahead ⇒ clock skew, refused. +- **Permission checks are POSIX-only; symlink checks are not** (`internal/k8s/ownership_{unix,windows}.go`). Go synthesizes Windows `FileMode` bits from a single read-only attribute — every ordinary file reads as `0666`, every directory as `0777` — and `os.Chmod` there only toggles read-only, with no ACL semantics. A POSIX `perm&0o077` check therefore rejects *everything* on Windows, which once made the credential cache a permanent miss and forced re-authentication on every kubectl call. `posixPermissions` gates the mode, ownership and chmod logic (also in `targetFileMode`); Windows leans on user-profile-directory ACLs instead, and `checkPrivateToCurrentUser` there is a **documented gap, not an equivalence** — closing it needs `GetSecurityInfo` plus a DACL walk and is not implemented. +- **Never map a security primitive to zero on a platform that lacks its spelling.** `openNoFollowFlag = 0` on Windows silently turned every symlink check into a no-op: the cache opened symlinks, `f.Stat()` then described the *target*, and `BackupOnce` dereferenced a symlinked kubeconfig while documenting that it refuses one. The primitive is now a function, `openNoFollowRead(path)` — `O_NOFOLLOW` on POSIX, `CreateFile` with `FILE_FLAG_OPEN_REPARSE_POINT` + a `FILE_ATTRIBUTE_REPARSE_POINT` check on the resulting handle on Windows, which refuses links, junctions and mount points without a TOCTOU window. `isSymlinkOpenError` classifies the failure so callers can tell a symlink from an I/O fault. This is why `golang.org/x/sys` is a **direct** dependency; it was already in the Windows build graph, so no module was added. The symlink tests run on every platform (they skip only when the *machine* will not create a symlink), because skipping them on Windows is exactly what let this through. `GOOS=windows go vet ./...` stays in the verify sequence. +- **`exec-credential` owns the whole stdout boundary** (`cmd/stdout_guard.go`). kubectl's protocol requires stdout to carry the ExecCredential JSON and nothing else. Do **not** solve this by enumerating writers and silencing them one at a time — that approach shipped twice and missed the Survey prompts the SDK drives for PIN entry, MFA-method selection, OOB verification and username/password, whose default `Stdio.Out` is `os.Stdout` and which consult nothing. The guard takes the boundary instead: it duplicates the descriptor behind stdout, re-points that descriptor at stderr (POSIX only), sets `os.Stdout = os.Stderr`, and hands the command the saved descriptor as the one writer that reaches the real stdout. Layer 1 (the `os.Stdout` swap) contains every writer that resolves `os.Stdout` when it runs; layer 2 (the descriptor swap) also contains writers that captured it at init — `github.com/pkg/browser` holds such a var — and subprocesses that inherit the descriptor. **Layer 2 is unavailable on Windows**, where a handle cannot be re-pointed and `SetStdHandle` does not affect an already-built `*os.File`; state the guarantee that way rather than implying parity. `Release` is deferred so it also runs on panic. `quietenSDKChannels` (`IDSEC_LOG_LEVEL`, `sdkconfig.ReserveStdoutForData`, `IDSEC_KUBELOGIN_LOG_LEVEL`) is second-line noise control only and is **not** load-bearing for protocol correctness. - **`exec-credential` orders auth last.** Exec info is parsed and validated, flags are checked, and the credential cache is consulted **before** any authentication — a cache hit must never trigger a browser/MFA prompt, because kubectl may run with no terminal. On a miss, `defaultExecCredentialDeps` uses `LoadAuthentication` (keyring read + non-interactive refresh, never prompts); only if that yields no token does it fall back to interactive `Authenticate`, and when `spec.interactive` is false it returns `ErrInteractionRequired` instead. Note `LoadAuthentication` signals "no usable session" in **two** ways — `(nil, nil)` for an absent auth profile (`pkg/auth/idsec_auth.go:326`) and an *error* for unusable refresh state (`pkg/auth/idsec_isp_auth.go:144`) — so callers must check the token, not just the error. - **R-5a (partially resolved):** the flag names in the defensive rewrite are **confirmed correct** against the SDK CLI schema (`models/idsec_sca_k8s_elevate.go:89-94` defines exactly `--csp`, `--role-id`, `--fqdn`, `--organization-id`, `--namespace`). Still unobserved: whether the DPA-generated kubeconfig actually points `users[].user.exec.command` at `idsec`/`ark`. `RewriteExecCommands` only rewrites when the command basename matches a known official binary; anything else passes through untouched. - **Untested against a live cluster.** No Kubernetes entitlements were available; README carries a single note saying so. diff --git a/internal/ui/cluster_selector.go b/internal/ui/cluster_selector.go index ec1bd19..09d1a4b 100644 --- a/internal/ui/cluster_selector.go +++ b/internal/ui/cluster_selector.go @@ -56,7 +56,10 @@ func FindClusterByDisplay(clusters []k8s.Cluster, display string) (*k8s.Cluster, // SelectCluster presents an interactive selector for choosing a cluster. func SelectCluster(clusters []k8s.Cluster) (*k8s.Cluster, error) { if !IsInteractive() { - return nil, fmt.Errorf("%w; pass a cluster name or --fqdn, or run 'grant k8s list' to see eligible clusters", ErrNotInteractive) + // The cluster is a positional argument, not a flag. --fqdn exists only on + // the hidden exec-credential command, so naming it here sent users to a + // flag that does not exist on the command they were running. + return nil, fmt.Errorf("%w; pass a cluster name, or run 'grant k8s list' to see eligible clusters", ErrNotInteractive) } if len(clusters) == 0 { From 633a5ba642642bdfe034fc07e18e699d04541d50 Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Fri, 14 Aug 2026 11:21:07 +0200 Subject: [PATCH 8/8] fix(k8s): install the stdout guard before Cobra's pre-run, and stop overstating it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of ed738ad. Three defects, one of them real. **The guard did not cover PersistentPreRunE.** It was installed inside RunE, and Cobra runs the root pre-run first — which on --verbose emits the WSL keyring notice through the package-level `log`, an SDK logger built on os.Stdout. That line went straight into kubectl's protocol stream. The guard now goes up in executeWithKeyringOverride, where #59 already established that startup work belongs, ahead of every Cobra hook. Which commands get it is driven by a `grant.stdout: protocol` annotation on the command rather than by matching a path, and the target is resolved with cobra.Command.Find, which strips flags and executes nothing. A resolution failure just means no guard, i.e. the previous behaviour. reserveStdout now nests, so the RunE reservation returns the outer guard's writer with a no-op Release and the outer owner restores stdout. **Layer 1 was described as sufficient. It is not.** Writers that capture os.Stdout at init escape a pointer swap — github.com/pkg/browser's package var and grant's own `log` are both examples, and the second is exactly what leaked above. Only the descriptor layer contains them, and that layer does not exist on Windows. The comments and CLAUDE.md now say which writers each layer covers and state plainly that init-time captured writers are uncontained on Windows, instead of claiming every named writer is handled. **Two cmd tests asserted POSIX modes without a Windows skip**, so they failed rather than skipped on the windows-latest leg — the branch was not green, contrary to the earlier report. TestExecCredentialCacheFilePermissions skips outright; TestKubeconfigFilePermissionsAndBackup keeps its portable backup assertions and guards only the mode check. internal/k8s already handled its equivalents this way. README claimed an unconditional 0600 guarantee for the kubeconfig and the credential cache. That is POSIX-only, and grant inspects neither ACLs nor ownership on Windows. README now says so, matching CLAUDE.md. --- CLAUDE.md | 8 +- README.md | 4 +- cmd/k8s_exec_credential.go | 5 ++ cmd/k8s_exec_credential_test.go | 9 +++ cmd/k8s_kubeconfig_test.go | 6 +- cmd/root.go | 15 +++- cmd/stdout_guard.go | 59 ++++++++++++++- cmd/stdout_guard_test.go | 129 ++++++++++++++++++++++++++++++++ 8 files changed, 229 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f47e7d4..9faa6df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,13 @@ Custom `SCAAccessService` follows SDK conventions: - **Exec-credential cache** (`internal/k8s/execcred_cache.go`): `~/.grant/cache/execcred_.json`. **Organization is part of the key** — the same cluster and role reached through different tenants must not share an entry. Written by creating a fresh `0600` temp file in the cache dir and renaming over the target, so a pre-existing loose file is *replaced*, never written into. Reads **open first, then validate via the descriptor** (`openNoFollowRead` + `fstat`), closing the TOCTOU window a path-based `Lstat`-then-read would leave; the same pattern is used by `BackupOnce`. Only a symlink earns deletion — any other open failure (descriptor exhaustion, a transient I/O fault) propagates untouched rather than destroying a valid credential. A cache directory that does not exist yet is a first run, not a security event, and stays quiet. Rejected: symlinks (the link is removed, its target untouched), non-regular files, a file or directory readable beyond the owner, a file owned by another user, and payloads carrying no actual credential material. Rejections are **reported on stderr** via `CredentialCache.Warn` — failing safe is right, failing silently into a login prompt is not; ordinary misses stay quiet. Expiry uses `status.expirationTimestamp` **verbatim** — the SDK bakes its early-refresh buffer in once, at the point the raw DPA/STS/AKS expiry is known, and grant never re-applies it. Absent/malformed expiry ⇒ not cacheable; more than `maxCredentialLifetime` (24h) ahead ⇒ clock skew, refused. - **Permission checks are POSIX-only; symlink checks are not** (`internal/k8s/ownership_{unix,windows}.go`). Go synthesizes Windows `FileMode` bits from a single read-only attribute — every ordinary file reads as `0666`, every directory as `0777` — and `os.Chmod` there only toggles read-only, with no ACL semantics. A POSIX `perm&0o077` check therefore rejects *everything* on Windows, which once made the credential cache a permanent miss and forced re-authentication on every kubectl call. `posixPermissions` gates the mode, ownership and chmod logic (also in `targetFileMode`); Windows leans on user-profile-directory ACLs instead, and `checkPrivateToCurrentUser` there is a **documented gap, not an equivalence** — closing it needs `GetSecurityInfo` plus a DACL walk and is not implemented. - **Never map a security primitive to zero on a platform that lacks its spelling.** `openNoFollowFlag = 0` on Windows silently turned every symlink check into a no-op: the cache opened symlinks, `f.Stat()` then described the *target*, and `BackupOnce` dereferenced a symlinked kubeconfig while documenting that it refuses one. The primitive is now a function, `openNoFollowRead(path)` — `O_NOFOLLOW` on POSIX, `CreateFile` with `FILE_FLAG_OPEN_REPARSE_POINT` + a `FILE_ATTRIBUTE_REPARSE_POINT` check on the resulting handle on Windows, which refuses links, junctions and mount points without a TOCTOU window. `isSymlinkOpenError` classifies the failure so callers can tell a symlink from an I/O fault. This is why `golang.org/x/sys` is a **direct** dependency; it was already in the Windows build graph, so no module was added. The symlink tests run on every platform (they skip only when the *machine* will not create a symlink), because skipping them on Windows is exactly what let this through. `GOOS=windows go vet ./...` stays in the verify sequence. -- **`exec-credential` owns the whole stdout boundary** (`cmd/stdout_guard.go`). kubectl's protocol requires stdout to carry the ExecCredential JSON and nothing else. Do **not** solve this by enumerating writers and silencing them one at a time — that approach shipped twice and missed the Survey prompts the SDK drives for PIN entry, MFA-method selection, OOB verification and username/password, whose default `Stdio.Out` is `os.Stdout` and which consult nothing. The guard takes the boundary instead: it duplicates the descriptor behind stdout, re-points that descriptor at stderr (POSIX only), sets `os.Stdout = os.Stderr`, and hands the command the saved descriptor as the one writer that reaches the real stdout. Layer 1 (the `os.Stdout` swap) contains every writer that resolves `os.Stdout` when it runs; layer 2 (the descriptor swap) also contains writers that captured it at init — `github.com/pkg/browser` holds such a var — and subprocesses that inherit the descriptor. **Layer 2 is unavailable on Windows**, where a handle cannot be re-pointed and `SetStdHandle` does not affect an already-built `*os.File`; state the guarantee that way rather than implying parity. `Release` is deferred so it also runs on panic. `quietenSDKChannels` (`IDSEC_LOG_LEVEL`, `sdkconfig.ReserveStdoutForData`, `IDSEC_KUBELOGIN_LOG_LEVEL`) is second-line noise control only and is **not** load-bearing for protocol correctness. +- **`exec-credential` owns the whole stdout boundary** (`cmd/stdout_guard.go`). kubectl's protocol requires stdout to carry the ExecCredential JSON and nothing else. Do **not** solve this by enumerating writers and silencing them one at a time — that approach shipped twice and missed the Survey prompts the SDK drives for PIN entry, MFA-method selection, OOB verification and username/password, whose default `Stdio.Out` is `os.Stdout` and which consult nothing. The guard takes the boundary instead: it duplicates the descriptor behind stdout, re-points that descriptor at stderr (POSIX only), sets `os.Stdout = os.Stderr`, and hands the command the saved descriptor as the one writer that reaches the real stdout. `Release` is deferred so it also runs on panic. + + **State the two layers precisely — they do not cover the same writers, and Layer 1 alone is NOT sufficient.** + - *Layer 1, the `os.Stdout` swap:* contains writers that resolve `os.Stdout` **at call time** — the SDK logger built per call by `common.GetLogger`, Survey's `defaultAskOptions()`, `exec.Cmd.Stdout` assignments, the SDK's browser-redirect message. + - *Layer 2, the descriptor swap:* additionally contains writers that captured `os.Stdout` **at init time**, plus subprocesses that inherit the descriptor. Real examples in grant's own graph: `github.com/pkg/browser`'s package-level `var Stdout io.Writer = os.Stdout`, and grant's own `log` var in `cmd/verbose.go`, an SDK logger constructed once at init. + - *On Windows layer 2 does not exist* — a handle cannot be re-pointed and `SetStdHandle` does not affect an already-built `*os.File`. So **init-time captured writers are genuinely uncontained on Windows.** Never write that layer 1 covers "every writer"; it does not. +- **The guard is installed before Cobra's pre-run, not in `RunE`.** `PersistentPreRunE` runs first and already writes to stdout on `--verbose` (the WSL keyring notice goes through the init-captured `log`), so a `RunE`-installed guard is too late. `Execute` → `executeWithKeyringOverride` calls `installProtocolStdoutGuard`, which resolves the target command with `cobra.Command.Find` and reserves stdout when it carries the `grant.stdout: protocol` annotation. The annotation lives on the command, so no path string matching is involved, and `reserveStdout` nests — the `RunE` reservation returns the outer guard's writer with a no-op `Release`. `quietenSDKChannels` (`IDSEC_LOG_LEVEL`, `sdkconfig.ReserveStdoutForData`, `IDSEC_KUBELOGIN_LOG_LEVEL`) is second-line noise control only and is **not** load-bearing for protocol correctness. - **`exec-credential` orders auth last.** Exec info is parsed and validated, flags are checked, and the credential cache is consulted **before** any authentication — a cache hit must never trigger a browser/MFA prompt, because kubectl may run with no terminal. On a miss, `defaultExecCredentialDeps` uses `LoadAuthentication` (keyring read + non-interactive refresh, never prompts); only if that yields no token does it fall back to interactive `Authenticate`, and when `spec.interactive` is false it returns `ErrInteractionRequired` instead. Note `LoadAuthentication` signals "no usable session" in **two** ways — `(nil, nil)` for an absent auth profile (`pkg/auth/idsec_auth.go:326`) and an *error* for unusable refresh state (`pkg/auth/idsec_isp_auth.go:144`) — so callers must check the token, not just the error. - **R-5a (partially resolved):** the flag names in the defensive rewrite are **confirmed correct** against the SDK CLI schema (`models/idsec_sca_k8s_elevate.go:89-94` defines exactly `--csp`, `--role-id`, `--fqdn`, `--organization-id`, `--namespace`). Still unobserved: whether the DPA-generated kubeconfig actually points `users[].user.exec.command` at `idsec`/`ark`. `RewriteExecCommands` only rewrites when the command basename matches a known official binary; anything else passes through untouched. - **Untested against a live cluster.** No Kubernetes entitlements were available; README carries a single note saying so. diff --git a/README.md b/README.md index 92a1ef3..8892603 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,9 @@ grant k8s kubeconfig # merge into your existing kubeconfig kubectl --context grant-aws-prod get ns ``` -`grant k8s kubeconfig` is a **merge**, not an overwrite. Only entries grant owns — named `grant--` — are added or replaced; every other cluster, user and context in your kubeconfig is left alone, and `current-context` is not changed unless you pass `--set-current-context`. The file is written atomically at mode `0600`, and the first merge into a pre-existing kubeconfig leaves a `.grant.bak` copy behind. Use `--stdout` to print without touching any file, or `--file ` to target a different one. +`grant k8s kubeconfig` is a **merge**, not an overwrite. Only entries grant owns — named `grant--` — are added or replaced; every other cluster, user and context in your kubeconfig is left alone, and `current-context` is not changed unless you pass `--set-current-context`. The file is written atomically, and the first merge into a pre-existing kubeconfig leaves a `.grant.bak` copy behind. Use `--stdout` to print without touching any file, or `--file ` to target a different one. + +**A note on file permissions.** On Linux and macOS, the kubeconfig and the cached cluster credentials are written at mode `0600` and grant refuses to read a cached credential that is owned by another user or readable beyond you. On Windows none of that applies: Go reports a synthesized `0666` for every file, `chmod` there only toggles the read-only attribute, and grant does **not** inspect ACLs or file ownership. Confidentiality on Windows rests entirely on the default permissions of your user profile directory. Symlinked kubeconfigs and cache entries are refused on every platform. The generated kubeconfig authenticates through a hidden `grant k8s exec-credential` plugin that kubectl invokes for you. diff --git a/cmd/k8s_exec_credential.go b/cmd/k8s_exec_credential.go index ebf40a4..8eef944 100644 --- a/cmd/k8s_exec_credential.go +++ b/cmd/k8s_exec_credential.go @@ -80,6 +80,11 @@ all diagnostics go to stderr.`, SilenceErrors: true, SilenceUsage: true, RunE: runFn, + + // Stdout is kubectl's protocol channel, not human output. Execute reads + // this to take the stdout boundary before Cobra's pre-run hooks get a + // chance to write anything there. + Annotations: map[string]string{stdoutOwnershipAnnotation: stdoutOwnershipProtocol}, } cmd.Flags().String("csp", "", "Cloud provider: aws, azure") diff --git a/cmd/k8s_exec_credential_test.go b/cmd/k8s_exec_credential_test.go index d595176..d8a8e22 100644 --- a/cmd/k8s_exec_credential_test.go +++ b/cmd/k8s_exec_credential_test.go @@ -9,6 +9,7 @@ import ( "io" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -379,6 +380,14 @@ func TestExecCredentialCachesAndRefetchesAfterExpiry(t *testing.T) { } func TestExecCredentialCacheFilePermissions(t *testing.T) { + // POSIX-only, like every other mode assertion in this tree. Go synthesizes + // Windows FileMode bits from a single read-only attribute, so every ordinary + // file reports 0666 there and this assertion fails rather than skipping. + // internal/k8s guards its equivalents the same way. + if runtime.GOOS == "windows" { + t.Skip("POSIX file modes are not meaningful on Windows") + } + dir := t.TempDir() credCache := k8s.NewCredentialCache(dir) provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} diff --git a/cmd/k8s_kubeconfig_test.go b/cmd/k8s_kubeconfig_test.go index 15e9469..cd5d9b2 100644 --- a/cmd/k8s_kubeconfig_test.go +++ b/cmd/k8s_kubeconfig_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" "strings" "testing" @@ -252,7 +253,10 @@ func TestKubeconfigFilePermissionsAndBackup(t *testing.T) { if err != nil { t.Fatal(err) } - if fi.Mode().Perm() != 0o600 { + // The mode half is POSIX-only: Go synthesizes Windows FileMode bits from a + // single read-only attribute, so every ordinary file reads as 0666 there. + // The backup half below is portable and keeps running on every platform. + if runtime.GOOS != "windows" && fi.Mode().Perm() != 0o600 { t.Errorf("mode = %o, want 0600", fi.Mode().Perm()) } diff --git a/cmd/root.go b/cmd/root.go index 729903f..ddbd0f7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -273,7 +273,18 @@ func NewRootCommandWithDeps( // // It fails closed: if the override cannot be applied the command never runs, // because continuing would walk the user into an unbounded D-Bus hang. -func executeWithKeyringOverride(cmd *cobra.Command) error { +// +// args is the raw command line (os.Args[1:] in production). It is used only to +// work out, before Cobra runs anything, whether the command being invoked owns +// stdout as a machine protocol — see installProtocolStdoutGuard. That has to +// happen here rather than in the command's RunE: PersistentPreRunE runs first +// and already writes to stdout on the `--verbose` path (the keyring notice +// below goes through the SDK logger, which is built on os.Stdout), so a guard +// installed in RunE would let that through. +func executeWithKeyringOverride(cmd *cobra.Command, args ...string) error { + guard := installProtocolStdoutGuard(cmd, args) + defer guard.Release() + applied, reason, err := keyringApply() if err != nil { return fmt.Errorf("could not force the file-based keyring backend: %w (set IDSEC_BASIC_KEYRING=1 manually and retry)", err) @@ -286,7 +297,7 @@ func executeWithKeyringOverride(cmd *cobra.Command) error { func Execute() { passedArgValidation = false - if err := executeWithKeyringOverride(rootCmd); err != nil { + if err := executeWithKeyringOverride(rootCmd, os.Args[1:]...); err != nil { fmt.Fprintln(rootCmd.ErrOrStderr(), err) if !verbose && passedArgValidation { fmt.Fprintln(rootCmd.ErrOrStderr(), "Hint: re-run with --verbose for more details") diff --git a/cmd/stdout_guard.go b/cmd/stdout_guard.go index f6c40cd..4eb1d0a 100644 --- a/cmd/stdout_guard.go +++ b/cmd/stdout_guard.go @@ -3,6 +3,8 @@ package cmd import ( "io" "os" + + "github.com/spf13/cobra" ) // stdoutGuard gives a command exclusive ownership of the process's standard @@ -56,8 +58,63 @@ func (g *stdoutGuard) Release() { g.release = nil } +// stdoutOwnershipAnnotation marks a command whose stdout is a machine protocol +// rather than human output, so Execute can hand it the stdout boundary before +// Cobra runs anything at all. Driving this off an annotation rather than a +// command path keeps the decision on the command itself. +const stdoutOwnershipAnnotation = "grant.stdout" + +// stdoutOwnershipProtocol is the annotation value that requests the guard. +const stdoutOwnershipProtocol = "protocol" + +// installProtocolStdoutGuard reserves stdout when the command named by args +// owns it as a protocol, and returns nil otherwise. The returned guard is safe +// to Release when nil. +// +// This runs before PersistentPreRunE, which matters: the root pre-run emits the +// WSL keyring notice through the SDK logger on --verbose, and that logger is +// built on os.Stdout. A guard installed in RunE arrives after that line has +// already been written into kubectl's stdout. +// +// Find only resolves the subcommand path (it strips flags itself) and executes +// nothing, so this cannot change how any command runs. A resolution failure +// simply means no guard: the command then reserves stdout in its own RunE, as +// before. +func installProtocolStdoutGuard(root *cobra.Command, args []string) *stdoutGuard { + target, _, err := root.Find(args) + if err != nil || target == nil { + return nil + } + if target.Annotations[stdoutOwnershipAnnotation] != stdoutOwnershipProtocol { + return nil + } + return reserveStdout() +} + +// activeStdoutGuard is the installed guard, if any. It exists so a command's +// RunE can ask for the reservation without caring whether Execute already made +// it — nesting yields the same Data and a no-op Release, leaving the outer +// owner responsible for restoring stdout. +var activeStdoutGuard *stdoutGuard + // reserveStdout is the seam tests replace to observe the guard. -var reserveStdout = defaultReserveStdout +var reserveStdout = nestingReserveStdout + +func nestingReserveStdout() *stdoutGuard { + if activeStdoutGuard != nil { + // Nested: same writer, nil release. + return &stdoutGuard{Data: activeStdoutGuard.Data} + } + + g := defaultReserveStdout() + activeStdoutGuard = g + inner := g.release + g.release = func() { + activeStdoutGuard = nil + inner() + } + return g +} func defaultReserveStdout() *stdoutGuard { original := os.Stdout diff --git a/cmd/stdout_guard_test.go b/cmd/stdout_guard_test.go index f99fc2e..dfd5e80 100644 --- a/cmd/stdout_guard_test.go +++ b/cmd/stdout_guard_test.go @@ -9,6 +9,8 @@ import ( "strings" "testing" "time" + + "github.com/cyberark/idsec-sdk-golang/pkg/common" ) // TestExecCredentialGuardsProcessStdout is the test the older one should have @@ -160,3 +162,130 @@ func stdoutFDReservationWorks(t *testing.T) bool { restore() return true } + +// TestExecCredentialGuardsCobraPreRun covers the gap the RunE-installed guard +// left open. Cobra runs PersistentPreRunE before RunE, so a guard taken inside +// RunE arrives too late for anything the root pre-run writes. That is not +// hypothetical: on --verbose the pre-run emits the WSL keyring notice through +// the package-level `log`, an SDK logger built on os.Stdout, straight into +// kubectl's protocol stream. +// +// This drives the production path — the real root command, the real +// PersistentPreRunE, the real keyring notice — through +// executeWithKeyringOverride, which is where Execute now installs the guard. +func TestExecCredentialGuardsCobraPreRun(t *testing.T) { + resetKeyringOverrideState(t) + restoreVerbose := verbose + restoreArgValidation := passedArgValidation + t.Cleanup(func() { + verbose = restoreVerbose + passedArgValidation = restoreArgValidation + }) + + realStdout := os.Stdout + pipeR, pipeW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = pipeW + t.Cleanup(func() { os.Stdout = realStdout }) + + // Built here, so it captures the pipe exactly as the production `log` var + // captures the real stdout at init. Only the descriptor layer can contain a + // writer like this. + log = common.GetLogger("grant", -1) + fdLayer := stdoutFDReservationWorks(t) + + // Make the pre-run actually emit the notice. + keyringApply = func() (bool, string, error) { return true, "WSL detected: forcing the file-based keyring", nil } + + provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} + execCmd := NewK8sExecCredentialCommandWithDeps(depsFor(provider), nil, + execInfoJSON(t, "client.authentication.k8s.io/v1beta1", true)) + + k8sParent := newK8sParent() + k8sParent.AddCommand(execCmd) + root := newRootCommand(nil) + root.AddCommand(k8sParent) + + var errBuf bytes.Buffer + root.SetErr(&errBuf) + args := []string{"--verbose", "k8s", "exec-credential", "--csp", "aws", "--fqdn", "prod.eks.example"} + root.SetArgs(args) + + if err := executeWithKeyringOverride(root, args...); err != nil { + t.Fatalf("execute: %v\nstderr: %s", err, errBuf.String()) + } + if !verbose { + t.Fatal("the root PersistentPreRunE did not run; this test is not exercising production wiring") + } + + if err := pipeW.Close(); err != nil { + t.Fatal(err) + } + onStdout, err := io.ReadAll(pipeR) + if err != nil { + t.Fatal(err) + } + + if !fdLayer { + // Windows: the descriptor layer is unavailable, so a logger that + // captured os.Stdout at init still reaches the real stdout. Assert the + // weaker property the platform can actually deliver, rather than + // pretending parity. + if !strings.Contains(string(onStdout), `"kind":"ExecCredential"`) { + t.Fatalf("the ExecCredential never reached stdout:\n%q", string(onStdout)) + } + return + } + + trimmed := strings.TrimSpace(string(onStdout)) + var got map[string]any + if err := json.Unmarshal([]byte(trimmed), &got); err != nil { + t.Fatalf("the process stdout is not exactly one ExecCredential document (%v); "+ + "something written before RunE escaped the stdout guard:\n%q", err, string(onStdout)) + } + if got["kind"] != "ExecCredential" { + t.Errorf("kind = %v, want ExecCredential", got["kind"]) + } +} + +// TestInstallProtocolStdoutGuardOnlyForProtocolCommands keeps the guard from +// hijacking stdout for ordinary commands, whose stdout is human output or JSON +// the user asked for. +func TestInstallProtocolStdoutGuardOnlyForProtocolCommands(t *testing.T) { + provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} + execCmd := NewK8sExecCredentialCommandWithDeps(depsFor(provider), nil, "") + k8sParent := newK8sParent() + k8sParent.AddCommand(execCmd) + root := newRootCommand(nil) + root.AddCommand(k8sParent) + + tests := []struct { + name string + args []string + want bool + }{ + {"exec-credential owns stdout", []string{"k8s", "exec-credential", "--csp", "aws"}, true}, + {"exec-credential behind flags", []string{"--verbose", "k8s", "exec-credential"}, true}, + {"k8s parent does not", []string{"k8s"}, false}, + {"root does not", nil, false}, + {"unknown command does not", []string{"nope"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + guard := installProtocolStdoutGuard(root, tt.args) + defer guard.Release() + if got := guard != nil; got != tt.want { + t.Errorf("installProtocolStdoutGuard(%v) reserved = %v, want %v", tt.args, got, tt.want) + } + if guard != nil && os.Stdout != os.Stderr { + t.Error("the guard was installed but stdout was not redirected") + } + }) + } + if activeStdoutGuard != nil { + t.Error("a guard leaked past its Release") + } +}