cli + helm: gated-Hub authentication (License-Key → ServiceAccount-token, phases 1+2a) + MCP token lifecycle (#1944)

* auth: drop AUTH_ROLES; add AUTH_GROUP_MAPPING + built-in defaultRole

Companion to kubeshark/hub#permissions-refactoring. Aligns the CLI
config struct, chart values, and rendered ConfigMap with the
post-refactor hub.

config/configStructs/tapConfig.go:
  - Drop AuthConfig.Roles (admin-authored map[string]Role) and the
    Role + ScriptingPermissions structs they referenced.
  - Drop AuthConfig.DefaultFilter (no namespace scoping in v1).
  - Add AuthConfig.GroupMapping (map[string]string) — SSO group name
    → built-in role translation.
  - Tighten DefaultRole godoc to reference the four built-in role
    constants (kubeshark-admin / kubeshark-realtime /
    kubeshark-snapshot / kubeshark-viewer) and the strict-deny
    semantics on empty.

config/configStruct.go:
  - Drop the legacy "admin" entry from the AuthConfig default —
    operators now configure DefaultRole + GroupMapping instead.
  - Default RolesClaim is now "groups" (Okta/OIDC convention; was
    "role"), matching the hub's runtime default.

helm-chart/templates/12-config-map.yaml:
  - Drop AUTH_ROLES emission (key no longer read by hub).
  - Add AUTH_GROUP_MAPPING emission from tap.auth.groupMapping (JSON
    map; hub validates each value against the built-in role names at
    sync time).

helm-chart/values.yaml: regenerated from the Go config — drops the
tap.auth.roles block, adds tap.auth.groupMapping with the new
documentation header for DefaultRole.

Breaking change: deployments carrying tap.auth.roles in their values
will silently lose those role definitions. Migration is to remove the
roles: block and either (a) name their SSO groups to match the four
built-in role constants, or (b) populate tap.auth.groupMapping with
explicit translations.

* auth: set helm default role to kubeshark-viewer

Per round-2 permissions clarifications: SSO users whose claim doesn't
match any built-in role and isn't in AUTH_GROUP_MAPPING should fall
back to a read-only baseline instead of strict-deny ("").

defaultRole="" causes the dashboard to 403-storm gated endpoints from
unmatched users; viewer (snapshot:read only) gives them a sensible
read-only UX while still preventing any state change.

* auth: add tap.auth.roles operator-defined role catalogue

Chart-side companion to hub commit 67162b2e (Phase C of the permissions
refactor). Operators can now declare named roles with their own
capability set + namespace scope under tap.auth.roles; the
12-config-map renders these as AUTH_ROLES JSON for the hub to consume.

  - config/configStructs: AuthConfig.Roles map[string]RoleConfig with
    Capabilities + Namespaces; doc comments updated for groupMapping +
    defaultRole to reflect that user-defined names are now accepted.
  - config/configStruct.go: zero-value initializer for Roles so
    `kubeshark config` renders `roles: {}` consistently.
  - helm-chart/templates/12-config-map.yaml: AUTH_ROLES emits the
    full roles map as JSON; hub-side syncAuthRoles validates names
    (kubeshark-* prefix reserved) and capabilities (unknown caps
    warn-dropped).
  - helm-chart/values.yaml: regenerated. Diff is the single `roles: {}`
    line under tap.auth.

Spot-checked the rendered ConfigMap:

  AUTH_ROLES: '{"payments-viewer":{"capabilities":["snapshot:read",
                                                   "dissection:live"],
                                    "namespaces":"payments"}}'

which is exactly the shape the hub parser expects.

* auth: emit CHART_VERSION into hub ConfigMap (Phase V)

Hub commit 51abc954 reads this key on first SyncConfig and warns when
its embedded version.Ver disagrees with the chart at the major
component. Provided alongside as the chart-side companion to keep
both sides on one PR per repo on the permissions-refactoring branch.

* Revert "auth: emit CHART_VERSION into hub ConfigMap (Phase V)"

This reverts c21e4c42. Companion to hub revert 61a275b6 — the hub no
longer reads CHART_VERSION, so emitting it serves no purpose.

* chore: drop internal plan ref from auth config comment

Plans live as local working docs, not in the repo — strip the cross-repo
plans/permissions-decisions.md reference from the AuthConfig.Roles comment.

* cli: authenticate Hub HTTP requests with License-Key (gated-auth phase 1)

Add a License-Key RoundTripper plus NewHubHTTPClient / HubAuthTransport
helpers in utils, and route the MCP runner's Hub clients (connect, --url
validate, file download, tools list) through them so the CLI keeps working
once the Hub enforces auth. License-Key is a custom header, so it survives
the kube API-server service proxy (a bearer token would not). Surface
ErrHubAuthRequired on a 401/302 from the Hub.

console already sends License-Key on its ws connection. pprof is not
covered here: it shells out to 'go tool pprof <hubUrl>' / opens a browser,
external fetches that can't carry a custom header (follow-up).

* cli: mint a ServiceAccount token for gated Hub auth (phase 2a)

Provider.MintHubToken requests a short-lived token (audience kubeshark-hub)
for the kubeshark-cli ServiceAccount via the TokenRequest API. The Hub
client now prefers that token via the X-Kubeshark-Authorization custom
header (which survives the kube API-server proxy), falling back to
License-Key when minting isn't possible (--url mode, SA missing, or no
RBAC to mint). The MCP runner mints once at startup.

Pairs with hub branch cli-sa-auth. Needs the helm kubeshark-cli SA + RBAC
and the hub AUTH_CLI_SERVICE_ACCOUNTS allowlist to validate end-to-end.

* cli: authenticate console ws with the ServiceAccount token (phase 2a)

console mints the kubeshark-cli token like the MCP runner and sends it via
X-Kubeshark-Authorization, falling back to License-Key when minting isn't
possible.

* helm: kubeshark-cli ServiceAccount + token-minter RBAC + allowlist (phase 2a)

When tap.auth.cli.enabled, create the kubeshark-cli ServiceAccount and a
Role granting 'create' on serviceaccounts/token for it (bound to
configurable tap.auth.cli.subjects), and set AUTH_CLI_SERVICE_ACCOUNTS so
the Hub allowlists it. Binding the Role to a subject is what grants that
subject CLI access to a gated Hub.

* helm: render AUTH_GROUP_MAPPING in the hub config-map

The local chart never rendered AUTH_GROUP_MAPPING, so tap.auth.groupMapping
was silently dropped — SSO groups and the kubeshark-cli SA fell back to
AUTH_DEFAULT_ROLE instead of their mapped roles.

* helm: project kubeshark-hub SA token onto worker for internalauth (gated-auth)

The worker seeds its name-resolution map and capture targets via plain
HTTP GETs to the hub (/resolver/history, /pods/targeted, /pods/all). With
auth enabled these routes sit behind Auth() and return 401, leaving the
worker with an empty resolver map (all traffic shows unresolved).

Project a short-lived SA token (audience kubeshark-hub) onto the sniffer
container and expose its path via HUB_INTERNAL_TOKEN_PATH so the worker
presents it as a Bearer to the hub's internalauth gate. Gated on
tap.auth.enabled; auth-disabled clusters are unchanged.

* cli/mcp: auto-renew hub token in proxy mode; explicit token + clear expiry in --url mode

The MCP server minted a hub SA token once at startup and reused it for
the life of the process, so a long-running server 401'd silently after
the ~1h token TTL.

- utils/http: the hub round-tripper now sources the SA token via a
  func() string per request (static constructors preserved), so a
  renewing source keeps long-lived clients authenticated.
- kubernetes: add HubTokenRenewer — re-mints the kubeshark-cli token
  before expiry (using the TokenRequest expiry, 5m margin), concurrency-safe.
- mcp proxy mode: use the renewer so the token auto-renews.
- mcp --url mode: cannot mint (no kube access) — accept an explicit
  --token / KUBESHARK_HUB_TOKEN, and on 401 surface a clear 'token
  expired/invalid, re-mint and restart' message instead of an opaque
  API error.

console is intentionally untouched (non-functional pending the
Connect-RPC re-point); it gets renewal when that migration lands.

* cli/mcp: throttle hub-token re-mint on failure; accurate proxy 401 message

Review follow-up. HubTokenRenewer.Token() re-minted on every request while
the token was empty, so a cluster where minting can't succeed (no RBAC, SA
missing) paid a blocking ~10s CreateToken round-trip per request (under the
lock). Add a 30s retry throttle: on mint failure, back off and fall back to
the prior token / License-Key. Also correct the proxy-mode 401 message — a
token-bearing 401 means the minted token was rejected (allowlist/audience),
not an RBAC-to-mint failure.

* cli/mcp: authenticate --list-tools with the hub token source

fetchAndDisplayTools used a no-token client, so 'kubeshark mcp
--list-tools' 401'd against a gated Hub even with a valid --token. Factor
the proxy/url token-source selection into hubTokenSource() (reused by
runMCPWithConfig) and pass it through to fetchAndDisplayTools, which now
also reports a clear 401 instead of an opaque parse error.

* docs(mcp): document gated-Hub auth — --token for URL mode, proxy auto-renew

Explain that proxy mode mints and auto-renews the kubeshark-cli token, URL
mode needs an explicit --token / KUBESHARK_HUB_TOKEN (can't auto-renew) and
reports a clear 401 on expiry. Add --token to the CLI options table.

* cli/mcp: address Copilot review (token fallback, SSO redirects, messages)

- hubtoken: HubTokenRenewer.Token() no longer hands out an already-expired
  token on mint failure — returns "" so the round-tripper falls back to the
  License-Key instead of looping on 401s.
- utils/http: hub clients no longer follow SSO redirects (StopOnSSORedirect
  CheckRedirect on 302/303), and IsAuthRequired covers 302+303, so auth
  detection is reliable instead of silently following to an HTML login page.
- mcpRunner: fetchHubMCP/callHubTool/fetchAndDisplayTools/callDownloadFile
  use utils.IsAuthRequired (401 + unfollowed redirect) instead of bare 401;
  download path checks it before writing the body to disk; URL-mode auth
  message now also mentions the License-Key fallback.

* Merge branch 'master' into gated-auth

Conflicts were in the helm chart's auth config, where master's permissions
refactor (fcceed23) reordered the AUTH_* config-map keys and replaced the
inline tap.auth.roles tree with roles/rolesClaim/defaultRole/groupMapping —
directly alongside gated-auth's new CLI ServiceAccount-token auth. Neither
side removed the other's work, so both are kept.

Also promote tap.auth.cli to a real config field. It had been hand-written
into helm-chart/values.yaml, which is generated by `make generate-helm-values`
(bin/kubeshark__ config > helm-chart/values.yaml). The next regeneration would
have silently dropped the key, leaving 22-cli-auth.yaml unable to render the
ServiceAccount. Add CliAuthConfig/CliAuthSubject to AuthConfig and regenerate
values.yaml from source, so the block now round-trips.

Subject fields carry omitempty so User/Group entries don't emit an empty
namespace and ServiceAccount entries don't emit an empty apiGroup, keeping
the rendered RoleBinding idiomatic.

values.yaml is generated and cannot hold comments, so the tap.auth.cli docs
live in helm-chart/README.md alongside the other tap.auth.* rows. Drop the
internal plan phase labels from the CLI and utils comments; phases exist only
in local planning docs.

---------

Co-authored-by: Alon Girmonsky <1990761+alongir@users.noreply.github.com>
This commit is contained in:
Volodymyr Stoiko
2026-07-15 01:04:37 +03:00
committed by GitHub
parent ad5df664fc
commit f3b2b205f1
12 changed files with 470 additions and 10 deletions

View File

@@ -1,6 +1,7 @@
package cmd
import (
"context"
"fmt"
"net/http"
"net/url"
@@ -51,6 +52,18 @@ func init() {
func runConsoleWithoutProxy() {
log.Info().Msg("Starting scripting console ...")
time.Sleep(5 * time.Second)
// Best-effort: mint a scoped ServiceAccount token to authenticate to a
// gated Hub as kubeshark-cli; fall back to License-Key when not possible.
saToken := ""
if provider, err := getKubernetesProviderForCli(true, true); err == nil {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
if tok, terr := provider.MintHubToken(ctx, config.Config.Tap.Release.Namespace); terr == nil {
saToken = tok
}
cancel()
}
hubUrl := kubernetes.GetHubUrl()
for {
@@ -73,7 +86,11 @@ func runConsoleWithoutProxy() {
}
headers := http.Header{}
headers.Set(utils.X_KUBESHARK_CAPTURE_HEADER_KEY, utils.X_KUBESHARK_CAPTURE_HEADER_IGNORE_VALUE)
headers.Set("License-Key", config.Config.License)
if saToken != "" {
headers.Set(utils.CLI_AUTH_HEADER, saToken)
} else {
headers.Set(utils.LICENSE_KEY_HEADER, config.Config.License)
}
c, _, err := websocket.DefaultDialer.Dial(u.String(), headers)
if err != nil {

View File

@@ -10,6 +10,7 @@ var mcpKubeconfig string
var mcpListTools bool
var mcpConfig bool
var mcpAllowDestructive bool
var mcpToken string
var mcpCmd = &cobra.Command{
Use: "mcp",
@@ -118,6 +119,7 @@ func init() {
rootCmd.AddCommand(mcpCmd)
mcpCmd.Flags().StringVar(&mcpURL, "url", "", "Direct URL to Kubeshark (e.g., https://kubeshark.example.com). When set, connects directly without kubectl/proxy and disables start/stop tools.")
mcpCmd.Flags().StringVar(&mcpToken, "token", "", "ServiceAccount/bearer token for a gated Hub in --url mode (e.g. from 'kubectl create token kubeshark-cli --audience kubeshark-hub'); also read from KUBESHARK_HUB_TOKEN. Ignored without --url, where the token is minted and auto-renewed from kube access.")
mcpCmd.Flags().StringVar(&mcpKubeconfig, "kubeconfig", "", "Path to kubeconfig file (e.g., /Users/me/.kube/config)")
mcpCmd.Flags().BoolVar(&mcpListTools, "list-tools", false, "List available MCP tools and exit")
mcpCmd.Flags().BoolVar(&mcpConfig, "mcp-config", false, "Print MCP client configuration JSON and exit")

View File

@@ -20,6 +20,7 @@ import (
"github.com/kubeshark/kubeshark/internal/connect"
"github.com/kubeshark/kubeshark/kubernetes"
"github.com/kubeshark/kubeshark/misc"
"github.com/kubeshark/kubeshark/utils"
"github.com/rs/zerolog"
)
@@ -158,21 +159,52 @@ type mcpServer struct {
cachedHubMCP *hubMCPResponse // Cached tools/prompts from Hub
cachedAt time.Time // When the cache was populated
hubMCPMu sync.Mutex
tokenSource func() string // hub SA token source; proxy mode auto-renews, URL mode is the static --token. nil → License-Key
}
const hubMCPCacheTTL = 5 * time.Minute
// hubTokenSource builds the SA-token source used to authenticate to a gated
// Hub. Proxy mode (kube access) returns an auto-renewing minter so long-lived
// processes keep working past the ~1h token TTL; URL mode returns the static
// --token / KUBESHARK_HUB_TOKEN (it can't mint without kube access). Returns
// nil when no token is available, so callers fall back to the License-Key.
func hubTokenSource(urlMode bool) func() string {
if urlMode {
staticTok := mcpToken
if staticTok == "" {
staticTok = os.Getenv("KUBESHARK_HUB_TOKEN")
}
if staticTok == "" {
return nil
}
return func() string { return staticTok }
}
provider, err := getKubernetesProviderForCli(true, true)
if err != nil {
return nil
}
return kubernetes.NewHubTokenRenewer(provider, config.Config.Tap.Release.Namespace).Token
}
func runMCPWithConfig(setFlags []string, directURL string, allowDestructive bool) {
// Disable zerolog output to stderr (MCP uses stdio)
zerolog.SetGlobalLevel(zerolog.Disabled)
urlMode := directURL != ""
// Hub SA-token source: auto-renewing in proxy mode, the static --token in
// URL mode; nil falls back to the License-Key. See hubTokenSource.
tokenSource := hubTokenSource(urlMode)
server := &mcpServer{
httpClient: &http.Client{Timeout: 30 * time.Second},
httpClient: utils.NewHubHTTPClientWithTokenSource(30*time.Second, tokenSource, config.Config.License),
tokenSource: tokenSource,
stdin: os.Stdin,
stdout: os.Stdout,
setFlags: setFlags,
directURL: directURL,
urlMode: directURL != "",
urlMode: urlMode,
allowDestructive: allowDestructive,
}
@@ -195,7 +227,7 @@ func (s *mcpServer) validateDirectURL() error {
s.directURL = urlStr
// Use a short timeout for validation
client := &http.Client{Timeout: 10 * time.Second}
client := utils.NewHubHTTPClientWithTokenSource(10*time.Second, s.tokenSource, config.Config.License)
// Try to reach the MCP API base endpoint which returns tool definitions
testURL := fmt.Sprintf("%s/api/mcp", urlStr)
@@ -205,6 +237,10 @@ func (s *mcpServer) validateDirectURL() error {
}
defer func() { _ = resp.Body.Close() }()
if utils.IsAuthRequired(resp) {
return fmt.Errorf("%s", s.hubAuthErrorMessage())
}
// Try to parse the MCP response to validate it's a valid Kubeshark endpoint
var mcpInfo hubMCPResponse
if err := json.NewDecoder(resp.Body).Decode(&mcpInfo); err != nil {
@@ -282,6 +318,20 @@ func (s *mcpServer) ensureBackendConnection() string {
// fetchHubMCP fetches tools and prompts from the Hub's /api/mcp endpoint
// Returns nil if Hub is not available or returns an error
// hubAuthErrorMessage returns a clear, user-facing explanation for an
// auth-required hub response (401, or an unfollowed SSO 302/303 redirect),
// distinguishing a missing/expired credential from a generic API error. URL
// mode can't auto-renew, so it points the user at re-minting.
func (s *mcpServer) hubAuthErrorMessage() string {
if s.urlMode {
return "Hub requires authentication (401/SSO redirect) and no accepted credential was presented. " +
"In URL mode pass a token via --token / KUBESHARK_HUB_TOKEN (mint with " +
"`kubectl create token kubeshark-cli --audience kubeshark-hub`) — it can't auto-renew, so re-mint and " +
"restart if it expired — or set a valid license (License-Key) if the deployment uses one."
}
return "Hub rejected the request (401 Unauthorized): the minted kubeshark-cli token was not accepted (check the Hub's AUTH_CLI_SERVICE_ACCOUNTS allowlist and the token audience), or no credential was available — if you lack RBAC to mint the token the CLI falls back to the License-Key, so set a valid license."
}
func (s *mcpServer) fetchHubMCP() *hubMCPResponse {
s.hubMCPMu.Lock()
defer s.hubMCPMu.Unlock()
@@ -304,6 +354,10 @@ func (s *mcpServer) fetchHubMCP() *hubMCPResponse {
}
defer func() { _ = resp.Body.Close() }()
if utils.IsAuthRequired(resp) {
fmt.Fprintf(os.Stderr, "[kubeshark-mcp] %s\n", s.hubAuthErrorMessage())
return nil
}
if resp.StatusCode >= 400 {
return nil
}
@@ -760,6 +814,9 @@ func (s *mcpServer) callHubTool(toolName string, args map[string]any) (string, b
return fmt.Sprintf("Error reading response: %v", err), true
}
if utils.IsAuthRequired(resp) {
return s.hubAuthErrorMessage(), true
}
if resp.StatusCode >= 400 {
return fmt.Sprintf("Hub API error (%d): %s", resp.StatusCode, string(body)), true
}
@@ -820,10 +877,11 @@ func (s *mcpServer) callDownloadFile(args map[string]any) (string, bool) {
// The default s.httpClient has a 30s total timeout which would fail for large files (up to 10GB).
// This client sets only connection-level timeouts and lets the body stream without a deadline.
downloadClient := &http.Client{
Transport: &http.Transport{
CheckRedirect: utils.StopOnSSORedirect,
Transport: utils.HubAuthTransportWithTokenSource(s.tokenSource, config.Config.License, &http.Transport{
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
},
}),
}
resp, err := downloadClient.Get(fullURL)
@@ -832,6 +890,11 @@ func (s *mcpServer) callDownloadFile(args map[string]any) (string, bool) {
}
defer func() { _ = resp.Body.Close() }()
// Catch auth-required (401 / unfollowed SSO redirect) before treating the
// body as a file, so we don't write an HTML login page to disk.
if utils.IsAuthRequired(resp) {
return s.hubAuthErrorMessage(), true
}
if resp.StatusCode >= 400 {
return fmt.Sprintf("Error downloading file: HTTP %d", resp.StatusCode), true
}
@@ -1059,6 +1122,10 @@ func listMCPTools(directURL string) {
fmt.Println("=========")
fmt.Println()
// Same hub credential as the running server: renewing minter in proxy
// mode, static --token in URL mode (nil → License-Key).
tokenSource := hubTokenSource(directURL != "")
// URL mode - no cluster management, connect directly
if directURL != "" {
fmt.Printf("URL Mode: %s\n\n", directURL)
@@ -1071,7 +1138,7 @@ func listMCPTools(directURL string) {
fmt.Println()
hubURL := strings.TrimSuffix(directURL, "/") + "/api/mcp"
fetchAndDisplayTools(hubURL, 30*time.Second)
fetchAndDisplayTools(hubURL, 30*time.Second, tokenSource)
return
}
@@ -1095,7 +1162,7 @@ func listMCPTools(directURL string) {
}
fmt.Printf("Connected to: %s\n\n", hubURL)
fetchAndDisplayTools(hubURL, 30*time.Second)
fetchAndDisplayTools(hubURL, 30*time.Second, tokenSource)
}
// establishProxyConnection sets up proxy to Kubeshark and returns the hub URL
@@ -1144,8 +1211,8 @@ func establishProxyConnection(timeout time.Duration) (string, error) {
}
// fetchAndDisplayTools fetches tools from the Kubeshark API and displays them
func fetchAndDisplayTools(hubURL string, timeout time.Duration) {
client := &http.Client{Timeout: timeout}
func fetchAndDisplayTools(hubURL string, timeout time.Duration, tokenSource func() string) {
client := utils.NewHubHTTPClientWithTokenSource(timeout, tokenSource, config.Config.License)
// Fetch tools list from /api/mcp endpoint
resp, err := client.Get(strings.TrimSuffix(hubURL, "/mcp") + "/mcp")
@@ -1155,6 +1222,11 @@ func fetchAndDisplayTools(hubURL string, timeout time.Duration) {
}
defer func() { _ = resp.Body.Close() }()
if utils.IsAuthRequired(resp) {
fmt.Println("Kubeshark API: 401 Unauthorized — the Hub requires a valid credential. In --url mode pass --token (or KUBESHARK_HUB_TOKEN); in proxy mode ensure you have RBAC to mint the kubeshark-cli token.")
return
}
// Parse the response using the Hub MCP response format
var mcpInfo hubMCPResponse
if err := json.NewDecoder(resp.Body).Decode(&mcpInfo); err != nil {

View File

@@ -194,9 +194,39 @@ type AuthConfig struct {
// with a warning; empty / "*" namespace specs mean deny-all-data and
// allow-all respectively.
Roles map[string]RoleConfig `yaml:"roles" json:"roles"`
Cli CliAuthConfig `yaml:"cli" json:"cli"`
Saml SamlConfig `yaml:"saml" json:"saml"`
}
// CliAuthConfig gates ServiceAccount-token auth for the CLI. When enabled, the
// chart creates a `kubeshark-cli` ServiceAccount plus a Role permitting
// `create` on its token, and the hub allowlists it via the
// AUTH_CLI_SERVICE_ACCOUNTS env var. The CLI mints a short-lived token for
// that SA to authenticate to a gated hub. Map `kubeshark-cli` to a role via
// GroupMapping (or DefaultRole); without a mapping it falls back to
// DefaultRole.
type CliAuthConfig struct {
Enabled bool `yaml:"enabled" json:"enabled" default:"false"`
// Subjects are the RBAC subjects permitted to mint the kubeshark-cli
// token — i.e. who may use the CLI against a gated hub. Rendered verbatim
// into the kubeshark-cli-token-minter RoleBinding; empty means the Role is
// created but bound to nobody.
Subjects []CliAuthSubject `yaml:"subjects" json:"subjects"`
}
// CliAuthSubject is one entry under tap.auth.cli.subjects, mirroring
// rbacv1.Subject: Kind is User, Group or ServiceAccount; ApiGroup is
// rbac.authorization.k8s.io for User and Group and must be empty for
// ServiceAccount; Namespace applies to ServiceAccount only. The empty-able
// fields are omitted when unset so the rendered RoleBinding stays valid for
// every kind.
type CliAuthSubject struct {
Kind string `yaml:"kind" json:"kind"`
Name string `yaml:"name" json:"name"`
ApiGroup string `yaml:"apiGroup,omitempty" json:"apiGroup,omitempty"`
Namespace string `yaml:"namespace,omitempty" json:"namespace,omitempty"`
}
// RoleConfig is an operator-defined role declared under tap.auth.roles.
// Capabilities is the closed vocabulary documented in the hub project
// (snapshot:read / snapshot:write / snapshot:dissection / dissection:live /

View File

@@ -218,6 +218,8 @@ Example for overriding image names:
| `tap.auth.rolesClaim` | Name of the JWT claim (OIDC) or SAML attribute carrying role memberships. | `role` |
| `tap.auth.defaultRole` | Optional role name inside `tap.auth.roles` applied as fallback when an authenticated user has no matching role. Empty string = no fallback, zero-valued permissions. | `""` |
| `tap.auth.roles` | Backend-neutral role map shared by SAML and OIDC. Each role's `namespaces` is a comma-separated list controlling which Kubernetes namespaces the role's users see traffic for: `""` = deny all, `"*"` = allow all, `"foo"` = literal namespace, `"foo,bar"` = OR over literals, `"foo-*"` = glob expansion against the cluster's known namespaces. Empty/unset `tap.auth.roles` grants nothing — admins opt into elevated access by populating this map. | `{"admin":{"namespaces":"*","canDownloadPCAP":true,"canUpdateTargetedPods":true,"canUseScripting":true,"scriptingPermissions":{"canSave":true,"canActivate":true,"canDelete":true},"canStopTrafficCapturing":true,"canControlDissection":true,"showAdminConsoleLink":true}}` |
| `tap.auth.cli.enabled` | Enable ServiceAccount-token auth for the CLI. Creates a `kubeshark-cli` ServiceAccount plus a Role permitting `create` on its token, and allowlists it on the hub via `AUTH_CLI_SERVICE_ACCOUNTS`. The CLI mints a short-lived token for that SA to authenticate to a gated hub. Map `kubeshark-cli` to a role via `tap.auth.groupMapping`; without a mapping it falls back to `tap.auth.defaultRole`. | `false` |
| `tap.auth.cli.subjects` | RBAC subjects permitted to mint the `kubeshark-cli` token — i.e. who may use the CLI against a gated hub. Each entry mirrors an `rbacv1.Subject`: `kind` (`User`, `Group` or `ServiceAccount`), `name`, `apiGroup` (`rbac.authorization.k8s.io` for `User`/`Group`, omitted for `ServiceAccount`) and `namespace` (`ServiceAccount` only). Empty means the Role is created but bound to nobody. | `[]` |
| `tap.auth.saml.idpMetadataUrl` | SAML IDP metadata URL <br/>(effective, if `tap.auth.type = saml`) | `` |
| `tap.auth.saml.x509crt` | A self-signed X.509 `.cert` contents <br/>(effective, if `tap.auth.type = saml`) | `` |
| `tap.auth.saml.x509key` | A self-signed X.509 `.key` contents <br/>(effective, if `tap.auth.type = saml`) | `` |

View File

@@ -146,6 +146,10 @@ spec:
value: '{{ (include "sentry.enabled" .) }}'
- name: SENTRY_ENVIRONMENT
value: '{{ .Values.tap.sentry.environment }}'
{{- if (((.Values.tap).auth).enabled) }}
- name: HUB_INTERNAL_TOKEN_PATH
value: /var/run/secrets/kubeshark/hub-token/token
{{- end }}
resources:
limits:
{{ if ne (toString .Values.tap.resources.sniffer.limits.cpu) "0" }}
@@ -235,6 +239,11 @@ spec:
{{- if .Values.tap.persistentStorage }}
subPathExpr: $(NODE_NAME)
{{- end }}
{{- if (((.Values.tap).auth).enabled) }}
- mountPath: /var/run/secrets/kubeshark/hub-token
name: hub-internal-token
readOnly: true
{{- end }}
{{- if .Values.tap.tls }}
- command:
- ./tracer
@@ -430,3 +439,12 @@ spec:
emptyDir:
sizeLimit: {{ .Values.tap.storageLimit }}
{{- end }}
{{- if (((.Values.tap).auth).enabled) }}
- name: hub-internal-token
projected:
sources:
- serviceAccountToken:
path: token
audience: kubeshark-hub
expirationSeconds: 3600
{{- end }}

View File

@@ -29,6 +29,7 @@ data:
{{ (default false .Values.demoModeEnabled) | ternary "default" .Values.tap.auth.type }}
{{- end }}'
AUTH_SAML_IDP_METADATA_URL: '{{ .Values.tap.auth.saml.idpMetadataUrl }}'
AUTH_CLI_SERVICE_ACCOUNTS: '{{ if (((.Values.tap).auth).cli).enabled }}{{ .Release.Namespace }}:kubeshark-cli{{ end }}'
AUTH_ROLES_CLAIM: '{{ .Values.tap.auth.rolesClaim }}'
AUTH_DEFAULT_ROLE: '{{ default "" .Values.tap.auth.defaultRole }}'
AUTH_GROUP_MAPPING: '{{ default (dict) .Values.tap.auth.groupMapping | toJson }}'

View File

@@ -0,0 +1,44 @@
{{- if (((.Values.tap).auth).cli).enabled }}
---
# ServiceAccount the CLI mints a short-lived token for (TokenRequest API) to
# authenticate to a gated Hub. Its name must match the Hub's
# AUTH_CLI_SERVICE_ACCOUNTS allowlist and the CLI's kubeshark-cli constant.
apiVersion: v1
kind: ServiceAccount
metadata:
labels:
{{- include "kubeshark.labels" . | nindent 4 }}
name: kubeshark-cli
namespace: {{ .Release.Namespace }}
---
# Permission to mint a token for the kubeshark-cli SA. Binding this Role to a
# subject is what grants that subject CLI access to a gated Hub.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
labels:
{{- include "kubeshark.labels" . | nindent 4 }}
name: kubeshark-cli-token-minter
namespace: {{ .Release.Namespace }}
rules:
- apiGroups: [""]
resources: ["serviceaccounts/token"]
resourceNames: ["kubeshark-cli"]
verbs: ["create"]
{{- with .Values.tap.auth.cli.subjects }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
labels:
{{- include "kubeshark.labels" $ | nindent 4 }}
name: kubeshark-cli-token-minter
namespace: {{ $.Release.Namespace }}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: kubeshark-cli-token-minter
subjects:
{{- toYaml . | nindent 2 }}
{{- end }}
{{- end }}

View File

@@ -157,6 +157,9 @@ tap:
defaultRole: kubeshark-viewer
groupMapping: {}
roles: {}
cli:
enabled: false
subjects: []
saml:
idpMetadataUrl: ""
x509crt: ""

112
kubernetes/hubtoken.go Normal file
View File

@@ -0,0 +1,112 @@
package kubernetes
import (
"context"
"fmt"
"sync"
"time"
authenticationv1 "k8s.io/api/authentication/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const (
// HubTokenAudience must match the hub's internalauth TokenAudience.
HubTokenAudience = "kubeshark-hub"
// CLIServiceAccountName is the ServiceAccount the CLI mints a token for;
// must match the SA the helm chart creates and the hub's
// AUTH_CLI_SERVICE_ACCOUNTS allowlist.
CLIServiceAccountName = "kubeshark-cli"
hubTokenExpirySeconds = 3600
// hubTokenRenewMargin re-mints this long before expiry so requests in
// flight always carry a comfortably valid token.
hubTokenRenewMargin = 5 * time.Minute
// hubTokenMintRetryInterval throttles re-minting after a failure so a
// cluster where minting can't succeed (no RBAC, SA missing) doesn't pay a
// blocking CreateToken round-trip on every request.
hubTokenMintRetryInterval = 30 * time.Second
)
// MintHubToken requests a short-lived ServiceAccount token (audience
// HubTokenAudience) for the CLI ServiceAccount via the TokenRequest API. The
// hub validates it with TokenReview and maps the SA to a role. The caller's
// kube RBAC must permit `create` on serviceaccounts/token for that SA — which
// is what gates who may use the CLI against a gated Hub.
func (provider *Provider) MintHubToken(ctx context.Context, namespace string) (string, error) {
tok, _, err := provider.mintHubToken(ctx, namespace)
return tok, err
}
// mintHubToken is MintHubToken plus the token's expiry (from the TokenRequest
// response, so a cluster that caps the duration below the request is respected;
// falls back to the requested TTL if the API omits it).
func (provider *Provider) mintHubToken(ctx context.Context, namespace string) (string, time.Time, error) {
exp := int64(hubTokenExpirySeconds)
tr, err := provider.clientSet.CoreV1().ServiceAccounts(namespace).CreateToken(
ctx,
CLIServiceAccountName,
&authenticationv1.TokenRequest{
Spec: authenticationv1.TokenRequestSpec{
Audiences: []string{HubTokenAudience},
ExpirationSeconds: &exp,
},
},
metav1.CreateOptions{},
)
if err != nil {
return "", time.Time{}, fmt.Errorf("minting hub token for serviceaccount %s/%s: %w", namespace, CLIServiceAccountName, err)
}
expireAt := tr.Status.ExpirationTimestamp.Time
if expireAt.IsZero() {
expireAt = time.Now().Add(time.Duration(hubTokenExpirySeconds) * time.Second)
}
return tr.Status.Token, expireAt, nil
}
// HubTokenRenewer hands out a hub SA token, transparently re-minting it before
// it expires. Safe for concurrent use; intended for long-lived CLI processes
// (mcp/console proxy mode) that would otherwise 401 after the ~1h token TTL.
type HubTokenRenewer struct {
provider *Provider
namespace string
mu sync.Mutex
token string
expireAt time.Time
nextAttempt time.Time // earliest time to retry minting after a failure
}
// NewHubTokenRenewer builds a renewer that mints kubeshark-cli tokens in the
// given namespace.
func NewHubTokenRenewer(provider *Provider, namespace string) *HubTokenRenewer {
return &HubTokenRenewer{provider: provider, namespace: namespace}
}
// Token returns a currently-valid hub token, minting or re-minting as needed.
// On a mint failure it returns the last token (possibly "") so the caller can
// fall back to the License-Key rather than hard-failing.
func (r *HubTokenRenewer) Token() string {
r.mu.Lock()
defer r.mu.Unlock()
needsMint := r.token == "" || time.Now().After(r.expireAt.Add(-hubTokenRenewMargin))
if needsMint && time.Now().After(r.nextAttempt) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
tok, expireAt, err := r.provider.mintHubToken(ctx, r.namespace)
cancel()
if err != nil {
// Throttle retries so a failing cluster doesn't block every request
// on a doomed mint; fall through to the expiry check below.
r.nextAttempt = time.Now().Add(hubTokenMintRetryInterval)
} else {
r.token, r.expireAt, r.nextAttempt = tok, expireAt, time.Time{}
}
}
// Never hand out an already-expired token: return "" so the auth
// round-tripper falls back to the License-Key instead of looping on 401s
// (it prefers any non-empty SA token over the License-Key).
if r.token != "" && !r.expireAt.IsZero() && time.Now().After(r.expireAt) {
return ""
}
return r.token
}

View File

@@ -88,6 +88,30 @@ Connect directly to an existing Kubeshark deployment:
}
```
For a **gated Hub** (`AUTH_ENABLED=true`), URL mode can't mint a token (no kube
access), so supply one explicitly via `--token` (or the `KUBESHARK_HUB_TOKEN`
env var). Mint it from a machine that has cluster access:
```bash
kubectl create token kubeshark-cli -n <release-namespace> --audience kubeshark-hub
```
```json
{
"mcpServers": {
"kubeshark": {
"command": "kubeshark",
"args": ["mcp", "--url", "https://kubeshark.example.com", "--token", "<token>"]
}
}
}
```
The token is short-lived (~1h) and URL mode **cannot auto-renew** it; when it
expires the server reports a clear `401 ... token expired/invalid` message — re-mint
and restart. Proxy mode (default, with kube access) mints the `kubeshark-cli`
token automatically and **auto-renews** it, so long-running sessions don't expire.
#### With Destructive Operations
```json
@@ -179,6 +203,7 @@ Found 5 services connecting to postgres:5432:
| Option | Description |
|--------|-------------|
| `--url` | Direct URL to Kubeshark Hub |
| `--token` | Hub SA/bearer token for `--url` mode against a gated Hub (also `KUBESHARK_HUB_TOKEN`); ignored in proxy mode, which mints and auto-renews the token |
| `--kubeconfig` | Path to kubeconfig file |
| `--allow-destructive` | Enable start/stop operations |
| `--list-tools` | List available tools and exit |

View File

@@ -2,17 +2,151 @@ package utils
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const (
X_KUBESHARK_CAPTURE_HEADER_KEY = "X-Kubeshark-Capture"
X_KUBESHARK_CAPTURE_HEADER_IGNORE_VALUE = "ignore"
LICENSE_KEY_HEADER = "License-Key"
// CLI_AUTH_HEADER carries a ServiceAccount bearer token to the Hub.
// A custom (non-Authorization) header so it survives the kube
// API-server service proxy, which consumes Authorization.
CLI_AUTH_HEADER = "X-Kubeshark-Authorization"
)
// ErrHubAuthRequired indicates the Hub rejected the request because auth is
// enabled but no valid credential was presented (missing/expired license).
var ErrHubAuthRequired = errors.New("hub requires authentication: set a valid license (config 'license') or credentials")
// hubAuthRoundTripper attaches the CLI's Hub credential to every request so any
// client built with it authenticates to a gated Hub. It prefers a scoped
// ServiceAccount token (CLI_AUTH_HEADER) when present, falling back to the
// License-Key (admin/transitional). Both are custom headers so they survive
// the kube API-server service proxy, unlike an Authorization bearer.
//
// The SA token is sourced via saTokenFunc on every request rather than captured
// once, so a caller with cluster access (proxy mode) can hand in a renewing
// source and long-lived clients keep working past the token's ~1h expiry.
type hubAuthRoundTripper struct {
saTokenFunc func() string
licenseKey string
base http.RoundTripper
}
func (rt *hubAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
base := rt.base
if base == nil {
base = http.DefaultTransport
}
saToken := ""
if rt.saTokenFunc != nil {
saToken = rt.saTokenFunc()
}
switch {
case saToken != "":
if req.Header.Get(CLI_AUTH_HEADER) == "" {
req = req.Clone(req.Context())
req.Header.Set(CLI_AUTH_HEADER, saToken)
}
case rt.licenseKey != "":
if req.Header.Get(LICENSE_KEY_HEADER) == "" {
req = req.Clone(req.Context())
req.Header.Set(LICENSE_KEY_HEADER, rt.licenseKey)
}
}
return base.RoundTrip(req)
}
// staticToken adapts a fixed token string to the saTokenFunc source, returning
// nil for an empty token so the round-tripper falls back to the License-Key.
func staticToken(saToken string) func() string {
if saToken == "" {
return nil
}
return func() string { return saToken }
}
// StopOnSSORedirect is an *http.Client CheckRedirect that does NOT follow
// SSO-style auth redirects (302 Found / 303 See Other) — it returns the
// redirect response so callers can detect auth-required via IsAuthRequired
// instead of silently following it to an HTML login page (and, for downloads,
// writing that page to disk). Other redirects (301/307/308) are still followed.
func StopOnSSORedirect(req *http.Request, _ []*http.Request) error {
if req.Response != nil {
switch req.Response.StatusCode {
case http.StatusFound, http.StatusSeeOther:
return http.ErrUseLastResponse
}
}
return nil
}
// NewHubHTTPClient returns an *http.Client that authenticates to the Hub with
// the License-Key header.
func NewHubHTTPClient(timeout time.Duration, licenseKey string) *http.Client {
return &http.Client{
Timeout: timeout,
CheckRedirect: StopOnSSORedirect,
Transport: &hubAuthRoundTripper{licenseKey: licenseKey},
}
}
// NewHubHTTPClientWithToken returns an *http.Client that authenticates to the
// Hub with a fixed ServiceAccount token when saToken is set, otherwise the
// License-Key.
func NewHubHTTPClientWithToken(timeout time.Duration, saToken, licenseKey string) *http.Client {
return &http.Client{
Timeout: timeout,
CheckRedirect: StopOnSSORedirect,
Transport: &hubAuthRoundTripper{saTokenFunc: staticToken(saToken), licenseKey: licenseKey},
}
}
// NewHubHTTPClientWithTokenSource is NewHubHTTPClientWithToken with a token
// source consulted per request, so a renewing source keeps the client
// authenticated past the token's expiry. A nil source falls back to the
// License-Key.
func NewHubHTTPClientWithTokenSource(timeout time.Duration, saTokenFunc func() string, licenseKey string) *http.Client {
return &http.Client{
Timeout: timeout,
CheckRedirect: StopOnSSORedirect,
Transport: &hubAuthRoundTripper{saTokenFunc: saTokenFunc, licenseKey: licenseKey},
}
}
// HubAuthTransport wraps base so requests carry the License-Key header. Use
// when a client needs custom transport settings (e.g. streaming downloads)
// but must still authenticate to the Hub.
func HubAuthTransport(licenseKey string, base http.RoundTripper) http.RoundTripper {
return &hubAuthRoundTripper{licenseKey: licenseKey, base: base}
}
// HubAuthTransportWithToken is HubAuthTransport with a fixed ServiceAccount
// token (preferred over the License-Key when set).
func HubAuthTransportWithToken(saToken, licenseKey string, base http.RoundTripper) http.RoundTripper {
return &hubAuthRoundTripper{saTokenFunc: staticToken(saToken), licenseKey: licenseKey, base: base}
}
// HubAuthTransportWithTokenSource is HubAuthTransportWithToken with a token
// source consulted per request (see NewHubHTTPClientWithTokenSource).
func HubAuthTransportWithTokenSource(saTokenFunc func() string, licenseKey string, base http.RoundTripper) http.RoundTripper {
return &hubAuthRoundTripper{saTokenFunc: saTokenFunc, licenseKey: licenseKey, base: base}
}
// IsAuthRequired reports whether the response indicates the Hub demanded
// authentication — a 401, or a 302/303 redirect to an SSO login page (the
// hub clients are configured not to follow those; see StopOnSSORedirect).
func IsAuthRequired(resp *http.Response) bool {
return resp != nil && (resp.StatusCode == http.StatusUnauthorized ||
resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusSeeOther)
}
// Get - When err is nil, resp always contains a non-nil resp.Body.
// Caller should close resp.Body when done reading from it.
func Get(url string, client *http.Client) (*http.Response, error) {