diff --git a/CHANGELOG.md b/CHANGELOG.md index e6d6ee1..2aea0b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ 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 +- `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 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 3487905..9faa6df 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,41 @@ 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 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`. +- **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 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** (`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. `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. + ## 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 +138,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`, `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 4070f39..8892603 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,32 @@ 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`) | +| `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, 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. + +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 +189,12 @@ 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 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/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..43c1a9d 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,26 @@ 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) +} + +// 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 new file mode 100644 index 0000000..526aafb --- /dev/null +++ b/cmd/k8s.go @@ -0,0 +1,101 @@ +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(), + NewK8sElevateCommand(), + NewK8sKubeconfigCommand(), + NewK8sExecCredentialCommand(), + ) + 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 +} + +// 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. +// resolveDeps is called only on a cache miss, so tests can assert that a cache +// hit never authenticates. +func NewK8sExecCredentialCommandWithDeps( + resolveDeps func(interactive bool) (*execCredentialDeps, error), + credCache *k8s.CredentialCache, + execInfo string, +) *cobra.Command { + return newK8sExecCredentialCommand(func(c *cobra.Command, _ []string) error { + return runK8sExecCredential(c, resolveDeps, credCache, execInfo) + }) +} + +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_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..8eef944 --- /dev/null +++ b/cmd/k8s_exec_credential.go @@ -0,0 +1,366 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + + "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" +) + +// 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 +} + +// 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)", + 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; +all diagnostics go to stderr.`, + Hidden: true, + Args: cobra.NoArgs, + 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") + 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 { + return runK8sExecCredential(cmd, resolveExecCredentialDeps, 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) +} + +// quietenSDKChannels is the second-line defense behind the stdout guard. +// +// 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, 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). 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() + + // 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) + } + } +} + +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, + resolveDeps func(interactive bool) (*execCredentialDeps, error), + credCache *k8s.CredentialCache, + rawExecInfo string, +) error { + // 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) + 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, + OrganizationID: flags.organizationID, + } + + // 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) + } + } + + deps, err := resolveDeps(info.Spec.Interactive) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) + defer cancel() + + 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, + }) + if err != nil { + return err + } + + if credCache != nil { + if err := credCache.Put(key, cred); err != nil { + 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 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 { + 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") + 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..d8a8e22 --- /dev/null +++ b/cmd/k8s_exec_credential_test.go @@ -0,0 +1,559 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "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" +) + +// 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 +} + +// 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{ + "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 +} + +// 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) { + 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 + 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 + } + + execCmd := NewK8sExecCredentialCommandWithDeps(resolveDeps, nil, + execInfoJSON(t, "client.authentication.k8s.io/v1beta1", true)) + + 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\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) + } + 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, "}") { + 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(depsFor(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(depsFor(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(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") + } +} + +func TestExecCredentialInteractiveModePropagated(t *testing.T) { + for _, interactive := range []bool{true, false} { + provider := &mockCredentialProvider{cred: sampleCredential(time.Now().Add(time.Hour))} + cmd := NewK8sExecCredentialCommandWithDeps(depsFor(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(depsFor(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(depsFor(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(depsFor(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(depsFor(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(depsFor(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) { + // 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))} + + 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) + } + + 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(depsFor(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(depsFor(provider), nil, info) + if _, _, err := runExecCred(t, cmd, tt.args...); err == nil { + t.Fatal("expected a validation error") + } + }) + } +} + +// 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 { + 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 new file mode 100644 index 0000000..ea6e605 --- /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", "elevate", "kubeconfig", "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_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..cd5d9b2 --- /dev/null +++ b/cmd/k8s_kubeconfig_test.go @@ -0,0 +1,385 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "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) + } + // 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()) + } + + 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.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..3717eb6 --- /dev/null +++ b/cmd/k8s_list_test.go @@ -0,0 +1,178 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/aaearon/grant-cli/internal/k8s" + "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{ + { + 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) { + setOutputFormat(t, "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) { + setOutputFormat(t, "text") + + 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..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"` @@ -103,3 +105,50 @@ 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"` +} + +// 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/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 new file mode 100644 index 0000000..4eb1d0a --- /dev/null +++ b/cmd/stdout_guard.go @@ -0,0 +1,137 @@ +package cmd + +import ( + "io" + "os" + + "github.com/spf13/cobra" +) + +// 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 +} + +// 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 = 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 + + 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..dfd5e80 --- /dev/null +++ b/cmd/stdout_guard_test.go @@ -0,0 +1,291 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/cyberark/idsec-sdk-golang/pkg/common" +) + +// 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 +} + +// 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") + } +} 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 +} diff --git a/go.mod b/go.mod index 5d7a83a..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 ) @@ -15,11 +16,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 +44,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 @@ -40,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/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/execcred.go b/internal/k8s/execcred.go new file mode 100644 index 0000000..bcba354 --- /dev/null +++ b/internal/k8s/execcred.go @@ -0,0 +1,268 @@ +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 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, + // 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 + } + + // 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 + } + 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..887f425 --- /dev/null +++ b/internal/k8s/execcred_cache.go @@ -0,0 +1,285 @@ +package k8s + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "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. +// +// 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 + OrganizationID 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 + + // 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. +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{ + 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 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) + + if err := c.checkDirSecure(); err != nil { + // 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 + } + + 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 + } + + var cred k8smodels.IdsecSCAK8sExecCredential + if err := json.Unmarshal(data, &cred); err != nil { + return nil, false + } + if !isUsableCredential(&cred) { + 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. +// +// 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. 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) + if err != nil { + return fmt.Errorf("failed to encode credential: %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 +} + +// 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. +// +// 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 := openNoFollowRead(path) + if err != nil { + switch { + case os.IsNotExist(err): + return nil, err + case isSymlinkOpenError(err): + _ = os.Remove(path) + return nil, errors.New("it is a symlink") + default: + return nil, err + } + } + defer func() { _ = f.Close() }() + + fi, err := f.Stat() + if err != nil { + return nil, err + } + if !fi.Mode().IsRegular() { + _ = os.Remove(path) + return nil, errors.New("it is not a regular file") + } + if posixPermissions { + if err := checkPrivateToCurrentUser(fi); err != nil { + _ = os.Remove(path) + return nil, err + } + } + + return io.ReadAll(f) +} + +// 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("it is a symlink") + } + if !fi.IsDir() { + return errors.New("it is not a directory") + } + if !posixPermissions { + return nil + } + return checkPrivateToCurrentUser(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) { + 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..6988ffa --- /dev/null +++ b/internal/k8s/execcred_cache_test.go @@ -0,0 +1,540 @@ +package k8s + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "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 + } +} + +// 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") + } +} + +// 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) { + 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) + } + mustSymlink(t, target, path) + + 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") + } +} + +// 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) + } +} + +// 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 { + t.Error("expected a miss on an empty cache") + } +} 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/execcred_test.go b/internal/k8s/execcred_test.go new file mode 100644 index 0000000..92e63ee --- /dev/null +++ b/internal/k8s/execcred_test.go @@ -0,0 +1,282 @@ +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) + } +} + +// 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 { + 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..fbf3f87 --- /dev/null +++ b/internal/k8s/kubeconfig.go @@ -0,0 +1,549 @@ +package k8s + +import ( + "errors" + "fmt" + "io" + "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")))) + 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 { + 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, 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 entries[len(entries)-1] +} + +// 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) { + // 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 + } + 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. +// +// 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) { + // 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 := openNoFollowRead(path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + 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() }() + + 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 := io.ReadAll(src) + if err != nil { + 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 write %s: %w", backup, err) + } + + // 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 { + 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..bd1866c --- /dev/null +++ b/internal/k8s/kubeconfig_test.go @@ -0,0 +1,709 @@ +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"]) + } +} + +// 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 + want string + }{ + {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, 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") + } +} + +// 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) { + 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) + } + 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) { + 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/ownership_unix.go b/internal/k8s/ownership_unix.go new file mode 100644 index 0000000..0bd50e6 --- /dev/null +++ b/internal/k8s/ownership_unix.go @@ -0,0 +1,47 @@ +//go:build !windows + +package k8s + +import ( + "errors" + "fmt" + "os" + "syscall" +) + +// posixPermissions reports whether os.FileMode permission bits carry real +// access-control meaning on this platform. +const posixPermissions = true + +// 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. +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: the permission-bit check above still applied. + return nil + } + if int(stat.Uid) != os.Getuid() { + return errors.New("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..accc40f --- /dev/null +++ b/internal/k8s/ownership_windows.go @@ -0,0 +1,87 @@ +//go:build windows + +package k8s + +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 +// 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 + +// 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, and this is a real gap, not +// a platform equivalence. +// +// 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/service.go b/internal/k8s/service.go new file mode 100644 index 0000000..f823b26 --- /dev/null +++ b/internal/k8s/service.go @@ -0,0 +1,396 @@ +// 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 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 ( + "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 + + // 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...) + 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 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 + 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/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") + } +} diff --git a/internal/ui/cluster_selector.go b/internal/ui/cluster_selector.go new file mode 100644 index 0000000..09d1a4b --- /dev/null +++ b/internal/ui/cluster_selector.go @@ -0,0 +1,83 @@ +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() { + // 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 { + 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") + } +}