Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions CLAUDE.md

Large diffs are not rendered by default.

33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -155,13 +156,45 @@ on stdout even on exit 1.
| `approve [id]` | Approve a pending request (approvers only); omit `<id>` in a TTY to pick from pending requests |
| `reject [id]` | Reject a pending request (approvers only); omit `<id>` 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 `<cluster>` 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-<provider>-<name>` — 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 `<target>.grant.bak` copy behind. Use `--stdout` to print without touching any file, or `--file <path>` 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`)

**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`

Expand Down
1 change: 1 addition & 0 deletions cmd/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ func init() {
NewUpdateCommand(),
NewListCommand(),
NewRequestCommand(),
NewK8sCommand(),
)
}
21 changes: 21 additions & 0 deletions cmd/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
101 changes: 101 additions & 0 deletions cmd/k8s.go
Original file line number Diff line number Diff line change
@@ -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)
}
184 changes: 184 additions & 0 deletions cmd/k8s_elevate.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading