Compare commits

..

14 Commits

Author SHA1 Message Date
Volodymyr Stoiko
db6f7de8c7 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.
2026-06-16 20:55:22 +00:00
Volodymyr Stoiko
4abe675b74 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.
2026-06-16 20:03:43 +00:00
Volodymyr Stoiko
bec7ffae1c 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.
2026-06-16 19:55:41 +00:00
Volodymyr Stoiko
89979e3aec 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.
2026-06-16 19:16:16 +00:00
Volodymyr Stoiko
04d7b04c19 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.
2026-06-16 19:08:03 +00:00
Volodymyr Stoiko
a306678c7f 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.
2026-06-16 14:30:36 +00:00
Volodymyr Stoiko
a8422e3a4b Merge permissions-refactoring (#1942) into gated-auth
Brings the canonical AUTH_GROUP_MAPPING / AUTH_ROLES / defaultRole
permissions model. Conflicts resolved by taking #1942's config-map and
values.yaml auth block and re-layering the gated-auth phase 2a additions
(AUTH_CLI_SERVICE_ACCOUNTS, tap.auth.cli) on top. Drops the duplicate
AUTH_GROUP_MAPPING render in favor of #1942's.
2026-06-16 11:09:38 +00:00
Volodymyr Stoiko
7bfc43295a 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.
2026-06-03 18:01:08 +00:00
Volodymyr Stoiko
879b4b1e7b Merge branch 'master' into permissions-refactoring
One conflict in helm-chart/values.yaml::tap.auth — master still
carries the legacy AUTH_ROLES schema (per-role AuthorizedActions
struct with canDownloadPCAP / scriptingPermissions / etc).
permissions-refactoring replaced it with the post-#782 shape
(rolesClaim/defaultRole/groupMapping/roles).

Kept the permissions-refactoring shape — locked decision per
plans/permissions-decisions.md (Q1, Q2). Helm template still
renders AUTH_ROLES + AUTH_GROUP_MAPPING + AUTH_ROLES_CLAIM
correctly.
2026-06-01 10:14:13 +00:00
Volodymyr Stoiko
e948637f79 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.
2026-05-19 06:23:16 +00:00
Volodymyr Stoiko
c21e4c4276 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.
2026-05-18 22:11:34 +00:00
Volodymyr Stoiko
9445806002 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.
2026-05-18 21:35:13 +00:00
Volodymyr Stoiko
90a6fb3d40 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.
2026-05-17 21:34:50 +00:00
Volodymyr Stoiko
fd5bf8c1b5 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.
2026-05-14 21:04:48 +00:00
10 changed files with 300 additions and 98 deletions

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

@@ -159,34 +159,47 @@ type mcpServer struct {
cachedHubMCP *hubMCPResponse // Cached tools/prompts from Hub
cachedAt time.Time // When the cache was populated
hubMCPMu sync.Mutex
saToken string // ServiceAccount token for a gated Hub (phase 2a); empty falls back to License-Key
tokenSource func() string // hub SA token source (phase 2a); 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 != ""
// Best-effort: mint a scoped ServiceAccount token so the CLI authenticates
// to a gated Hub as kubeshark-cli. Falls back to License-Key when minting
// isn't possible (no kube access in --url mode, SA missing, or no RBAC).
saToken := ""
if !urlMode {
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()
}
}
// 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: utils.NewHubHTTPClientWithToken(30*time.Second, saToken, config.Config.License),
saToken: saToken,
httpClient: utils.NewHubHTTPClientWithTokenSource(30*time.Second, tokenSource, config.Config.License),
tokenSource: tokenSource,
stdin: os.Stdin,
stdout: os.Stdout,
setFlags: setFlags,
@@ -214,7 +227,7 @@ func (s *mcpServer) validateDirectURL() error {
s.directURL = urlStr
// Use a short timeout for validation
client := utils.NewHubHTTPClientWithToken(10*time.Second, s.saToken, config.Config.License)
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)
@@ -225,7 +238,7 @@ func (s *mcpServer) validateDirectURL() error {
defer func() { _ = resp.Body.Close() }()
if utils.IsAuthRequired(resp) {
return utils.ErrHubAuthRequired
return fmt.Errorf("%s", s.hubAuthErrorMessage())
}
// Try to parse the MCP response to validate it's a valid Kubeshark endpoint
@@ -305,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()
@@ -327,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
}
@@ -783,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
}
@@ -843,7 +877,8 @@ 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: utils.HubAuthTransportWithToken(s.saToken, config.Config.License, &http.Transport{
CheckRedirect: utils.StopOnSSORedirect,
Transport: utils.HubAuthTransportWithTokenSource(s.tokenSource, config.Config.License, &http.Transport{
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
}),
@@ -855,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
}
@@ -1082,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)
@@ -1094,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
}
@@ -1118,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
@@ -1167,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 := utils.NewHubHTTPClient(timeout, config.Config.License)
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")
@@ -1178,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

@@ -102,23 +102,10 @@ func CreateDefaultConfig() ConfigStruct {
},
},
Auth: configStructs.AuthConfig{
RolesClaim: "role",
Roles: map[string]configStructs.Role{
"admin": {
Filter: "",
CanDownloadPCAP: true,
CanUseScripting: true,
ScriptingPermissions: configStructs.ScriptingPermissions{
CanSave: true,
CanActivate: true,
CanDelete: true,
},
CanUpdateTargetedPods: true,
CanStopTrafficCapturing: true,
CanControlDissection: true,
ShowAdminConsoleLink: true,
},
},
RolesClaim: "groups",
DefaultRole: "kubeshark-viewer",
GroupMapping: map[string]string{},
Roles: map[string]configStructs.RoleConfig{},
},
EnabledDissectors: []string{
"amqp",

View File

@@ -155,23 +155,6 @@ type ProbeConfig struct {
FailureThreshold int `yaml:"failureThreshold" json:"failureThreshold" default:"3"`
}
type ScriptingPermissions struct {
CanSave bool `yaml:"canSave" json:"canSave" default:"true"`
CanActivate bool `yaml:"canActivate" json:"canActivate" default:"true"`
CanDelete bool `yaml:"canDelete" json:"canDelete" default:"true"`
}
type Role struct {
Filter string `yaml:"filter" json:"filter" default:""`
CanDownloadPCAP bool `yaml:"canDownloadPCAP" json:"canDownloadPCAP" default:"false"`
CanUseScripting bool `yaml:"canUseScripting" json:"canUseScripting" default:"false"`
ScriptingPermissions ScriptingPermissions `yaml:"scriptingPermissions" json:"scriptingPermissions"`
CanUpdateTargetedPods bool `yaml:"canUpdateTargetedPods" json:"canUpdateTargetedPods" default:"false"`
CanStopTrafficCapturing bool `yaml:"canStopTrafficCapturing" json:"canStopTrafficCapturing" default:"false"`
CanControlDissection bool `yaml:"canControlDissection" json:"canControlDissection" default:"false"`
ShowAdminConsoleLink bool `yaml:"showAdminConsoleLink" json:"showAdminConsoleLink" default:"false"`
}
type SamlConfig struct {
IdpMetadataUrl string `yaml:"idpMetadataUrl" json:"idpMetadataUrl"`
X509crt string `yaml:"x509crt" json:"x509crt"`
@@ -190,12 +173,40 @@ type AuthConfig struct {
// NOTE: prior releases routed `oidc` to Descope. If you were using `oidc`
// to mean Descope, switch to `descope` (or `default`). The rename is a
// breaking change documented in the release notes.
Type string `yaml:"type" json:"type" default:"saml"`
Roles map[string]Role `yaml:"roles" json:"roles"`
RolesClaim string `yaml:"rolesClaim" json:"rolesClaim"`
DefaultRole string `yaml:"defaultRole" json:"defaultRole"`
DefaultFilter string `yaml:"defaultFilter" json:"defaultFilter"`
Saml SamlConfig `yaml:"saml" json:"saml"`
Type string `yaml:"type" json:"type" default:"saml"`
RolesClaim string `yaml:"rolesClaim" json:"rolesClaim"`
// DefaultRole is applied when the authenticated user's SSO claim has no
// recognized group. Must be one of the four built-in roles
// (kubeshark-admin / kubeshark-realtime / kubeshark-snapshot /
// kubeshark-viewer), the name of an operator-defined role under
// `tap.auth.roles`, or empty for strict-deny.
DefaultRole string `yaml:"defaultRole" json:"defaultRole"`
// GroupMapping translates SSO group names into role names (built-in or
// operator-defined). Optional — groups whose name already matches a
// built-in role are identity-matched and don't need an entry here.
// Operator-defined role names MUST appear here to participate in
// resolution (identity-match is built-in-only).
GroupMapping map[string]string `yaml:"groupMapping" json:"groupMapping"`
// Roles is the operator-defined role catalogue, keyed by role name.
// Each role has its own capability set + namespace scope. Names with
// the `kubeshark-` prefix are reserved for built-ins and will be
// rejected at hub startup. Unknown capability strings are dropped
// with a warning; empty / "*" namespace specs mean deny-all-data and
// allow-all respectively.
Roles map[string]RoleConfig `yaml:"roles" json:"roles"`
Saml SamlConfig `yaml:"saml" json:"saml"`
}
// 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 /
// dissection:control / pods:target:write / settings:write); unknown
// capability strings are warn-dropped at hub startup. Namespaces is a
// comma-separated list with `*` (allow-all) and glob (`foo-*`, `*-bar`,
// `*mid*`) support; empty string means deny-all-data.
type RoleConfig struct {
Capabilities []string `yaml:"capabilities" json:"capabilities"`
Namespaces string `yaml:"namespaces" json:"namespaces"`
}
type IngressConfig struct {

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,11 +29,11 @@ data:
{{ (default false .Values.demoModeEnabled) | ternary "default" .Values.tap.auth.type }}
{{- end }}'
AUTH_SAML_IDP_METADATA_URL: '{{ .Values.tap.auth.saml.idpMetadataUrl }}'
AUTH_ROLES: '{{ .Values.tap.auth.roles | toJson }}'
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: '{{ .Values.tap.auth.groupMapping | default dict | toJson }}'
AUTH_GROUP_MAPPING: '{{ default (dict) .Values.tap.auth.groupMapping | toJson }}'
AUTH_ROLES: '{{ default (dict) .Values.tap.auth.roles | toJson }}'
AUTH_OIDC_ISSUER: '{{ default "not set" (((.Values.tap).auth).oidc).issuer }}'
AUTH_OIDC_REFRESH_TOKEN_LIFETIME: '{{ default "3960h" (((.Values.tap).auth).oidc).refreshTokenLifetime }}'
AUTH_OIDC_STATE_PARAM_EXPIRY: '{{ default "10m" (((.Values.tap).auth).oidc).oauth2StateParamExpiry }}'

View File

@@ -153,22 +153,10 @@ tap:
auth:
enabled: false
type: saml
roles:
admin:
filter: ""
canDownloadPCAP: true
canUseScripting: true
scriptingPermissions:
canSave: true
canActivate: true
canDelete: true
canUpdateTargetedPods: true
canStopTrafficCapturing: true
canControlDissection: true
showAdminConsoleLink: true
rolesClaim: role
defaultRole: ""
defaultFilter: ""
rolesClaim: groups
defaultRole: kubeshark-viewer
groupMapping: {}
roles: {}
# CLI ServiceAccount-token auth (gated-auth phase 2a). When enabled, the
# chart creates a `kubeshark-cli` ServiceAccount and a Role permitting
# `create` on its token, and the Hub allowlists it via

View File

@@ -3,6 +3,8 @@ package kubernetes
import (
"context"
"fmt"
"sync"
"time"
authenticationv1 "k8s.io/api/authentication/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -17,6 +19,13 @@ const (
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
@@ -25,6 +34,14 @@ const (
// 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,
@@ -38,7 +55,58 @@ func (provider *Provider) MintHubToken(ctx context.Context, namespace string) (s
metav1.CreateOptions{},
)
if err != nil {
return "", fmt.Errorf("minting hub token for serviceaccount %s/%s: %w", namespace, CLIServiceAccountName, err)
return "", time.Time{}, fmt.Errorf("minting hub token for serviceaccount %s/%s: %w", namespace, CLIServiceAccountName, err)
}
return tr.Status.Token, nil
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

@@ -29,10 +29,14 @@ var ErrHubAuthRequired = errors.New("hub requires authentication: set a valid li
// 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 {
saToken string
licenseKey string
base http.RoundTripper
saTokenFunc func() string
licenseKey string
base http.RoundTripper
}
func (rt *hubAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -40,11 +44,15 @@ func (rt *hubAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, err
if base == nil {
base = http.DefaultTransport
}
saToken := ""
if rt.saTokenFunc != nil {
saToken = rt.saTokenFunc()
}
switch {
case rt.saToken != "":
case saToken != "":
if req.Header.Get(CLI_AUTH_HEADER) == "" {
req = req.Clone(req.Context())
req.Header.Set(CLI_AUTH_HEADER, rt.saToken)
req.Header.Set(CLI_AUTH_HEADER, saToken)
}
case rt.licenseKey != "":
if req.Header.Get(LICENSE_KEY_HEADER) == "" {
@@ -55,22 +63,60 @@ func (rt *hubAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, err
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 (Phase 1).
func NewHubHTTPClient(timeout time.Duration, licenseKey string) *http.Client {
return &http.Client{
Timeout: timeout,
Transport: &hubAuthRoundTripper{licenseKey: licenseKey},
Timeout: timeout,
CheckRedirect: StopOnSSORedirect,
Transport: &hubAuthRoundTripper{licenseKey: licenseKey},
}
}
// NewHubHTTPClientWithToken returns an *http.Client that authenticates to the
// Hub with a ServiceAccount token when saToken is set, otherwise 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,
Transport: &hubAuthRoundTripper{saToken: saToken, licenseKey: licenseKey},
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},
}
}
@@ -81,16 +127,24 @@ func HubAuthTransport(licenseKey string, base http.RoundTripper) http.RoundTripp
return &hubAuthRoundTripper{licenseKey: licenseKey, base: base}
}
// HubAuthTransportWithToken is HubAuthTransport with a ServiceAccount token
// (preferred over the License-Key when set).
// 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{saToken: saToken, licenseKey: licenseKey, base: base}
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 redirect to an SSO login page.
// 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)
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.