Skip to content
Open
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
219 changes: 140 additions & 79 deletions internal/k8s/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,29 +73,37 @@ type PermissionCheckResult struct {

// Capabilities represents the features available based on RBAC permissions
type Capabilities struct {
Exec bool `json:"exec"` // Can create pods/exec (terminal feature)
LocalTerminal bool `json:"localTerminal"` // Local terminal available (not in-cluster, not disabled)
Logs bool `json:"logs"` // Can get pods/log (log viewer)
PortForward bool `json:"portForward"` // Can create pods/portforward
Secrets bool `json:"secrets"` // Can list secrets
SecretsUpdate bool `json:"secretsUpdate"` // Can update secrets (inline editing)
HelmWrite bool `json:"helmWrite"` // Helm write ops (detected via secrets/create as sentinel RBAC check)
NodeWrite bool `json:"nodeWrite"` // Can patch nodes (cordon/uncordon/drain)
MCPEnabled bool `json:"mcpEnabled"` // MCP server is running
Deployment DeploymentInfo `json:"deployment"` // How / where this Radar binary is running. Tells the UI which chrome to render or suppress (e.g. embedded mode hides the cluster headline + local-MCP card because the hub already renders both).
AuthEnabled bool `json:"authEnabled,omitempty"` // Auth is enabled on the server
Username string `json:"username,omitempty"` // Authenticated username (when auth enabled)
Resources *ResourcePermissions `json:"resources,omitempty"` // Per-resource-type permissions
Visibility *VisibilitySummary `json:"visibility,omitempty"` // Present when resource visibility is limited enough to make diagnostics incomplete
Exec bool `json:"exec"` // Can create pods/exec (terminal feature)
LocalTerminal bool `json:"localTerminal"` // Local terminal available (not in-cluster, not disabled)
Logs bool `json:"logs"` // Can get pods/log (log viewer)
PortForward bool `json:"portForward"` // Can create pods/portforward
Secrets bool `json:"secrets"` // Can list secrets
SecretsUpdate bool `json:"secretsUpdate"` // Can update secrets (inline editing)
HelmWrite bool `json:"helmWrite"` // Helm write ops (detected via secrets/create as sentinel RBAC check)
NodeWrite bool `json:"nodeWrite"` // Can patch nodes (cordon/uncordon/drain)
WorkloadWrites WorkloadWritePermissions `json:"workloadWrites"` // Can patch workload kinds (restart/scale controls)
MCPEnabled bool `json:"mcpEnabled"` // MCP server is running
Deployment DeploymentInfo `json:"deployment"` // How / where this Radar binary is running. Tells the UI which chrome to render or suppress (e.g. embedded mode hides the cluster headline + local-MCP card because the hub already renders both).
AuthEnabled bool `json:"authEnabled,omitempty"` // Auth is enabled on the server
Username string `json:"username,omitempty"` // Authenticated username (when auth enabled)
Resources *ResourcePermissions `json:"resources,omitempty"` // Per-resource-type permissions
Visibility *VisibilitySummary `json:"visibility,omitempty"` // Present when resource visibility is limited enough to make diagnostics incomplete
}

// NamespaceCapabilities holds the effective exec/logs/portForward capabilities
// for a specific namespace. When global checks deny these capabilities,
// namespace-scoped RBAC re-checks may grant them.
// WorkloadWritePermissions indicates which workload resources the user can patch.
type WorkloadWritePermissions struct {
Deployments bool `json:"deployments"`
DaemonSets bool `json:"daemonSets"`
StatefulSets bool `json:"statefulSets"`
Rollouts bool `json:"rollouts"`
}

// NamespaceCapabilities holds the effective capabilities for a specific namespace.
type NamespaceCapabilities struct {
Exec bool `json:"exec"`
Logs bool `json:"logs"`
PortForward bool `json:"portForward"`
Exec bool `json:"exec"`
Logs bool `json:"logs"`
PortForward bool `json:"portForward"`
WorkloadWrites WorkloadWritePermissions `json:"workloadWrites"`
}

// DeploymentInfo describes how / where this Radar binary is running.
Expand Down Expand Up @@ -208,20 +216,26 @@ func CheckCapabilities(ctx context.Context) (*Capabilities, error) {
var hadErrors atomic.Bool

type capCheck struct {
resource string
verb string
result *bool
group string
resource string
verb string
result *bool
namespaceFallback bool
}

caps := &Capabilities{}
checks := []capCheck{
{"pods/exec", "create", &caps.Exec},
{"pods/log", "get", &caps.Logs},
{"pods/portforward", "create", &caps.PortForward},
{"secrets", "list", &caps.Secrets},
{"secrets", "update", &caps.SecretsUpdate},
{"secrets", "create", &caps.HelmWrite},
{"nodes", "patch", &caps.NodeWrite},
{resource: "pods/exec", verb: "create", result: &caps.Exec, namespaceFallback: true},
{resource: "pods/log", verb: "get", result: &caps.Logs, namespaceFallback: true},
{resource: "pods/portforward", verb: "create", result: &caps.PortForward, namespaceFallback: true},
{resource: "secrets", verb: "list", result: &caps.Secrets, namespaceFallback: true},
{resource: "secrets", verb: "update", result: &caps.SecretsUpdate, namespaceFallback: true},
{resource: "secrets", verb: "create", result: &caps.HelmWrite, namespaceFallback: true},
{resource: "nodes", verb: "patch", result: &caps.NodeWrite, namespaceFallback: true},
{group: "apps", resource: "deployments", verb: "patch", result: &caps.WorkloadWrites.Deployments},
{group: "apps", resource: "daemonsets", verb: "patch", result: &caps.WorkloadWrites.DaemonSets},
{group: "apps", resource: "statefulsets", verb: "patch", result: &caps.WorkloadWrites.StatefulSets},
{group: "argoproj.io", resource: "rollouts", verb: "patch", result: &caps.WorkloadWrites.Rollouts},
}

var wg sync.WaitGroup
Expand All @@ -230,13 +244,13 @@ func CheckCapabilities(ctx context.Context) (*Capabilities, error) {
for _, check := range checks {
go func(c capCheck) {
defer wg.Done()
allowed, apiErr := canI(checkCtx, "", "", c.resource, c.verb)
allowed, apiErr := canI(checkCtx, "", c.group, c.resource, c.verb)
if allowed {
*c.result = true
return
}
if fallbackNs != "" {
allowed, nsApiErr := canI(checkCtx, fallbackNs, "", c.resource, c.verb)
if fallbackNs != "" && c.namespaceFallback {
allowed, nsApiErr := canI(checkCtx, fallbackNs, c.group, c.resource, c.verb)
if allowed {
*c.result = true
return
Expand Down Expand Up @@ -311,23 +325,13 @@ func InvalidateCapabilitiesCache() {
nsCapMu.Unlock()
}

// CheckNamespaceCapabilities performs namespace-scoped RBAC checks for capabilities
// that were denied by global checks (cluster-wide + effective-namespace fallback).
// This enables lazy re-checking when a user views a resource in a specific namespace —
// they may have namespace-scoped RoleBindings that grant exec/logs/portForward in
// namespaces other than the kubeconfig default.
//
// Returns nil if no namespace-scoped re-check is needed (all capabilities already allowed).
func CheckNamespaceCapabilities(ctx context.Context, namespace string, globalCaps *Capabilities) (*NamespaceCapabilities, error) {
// CheckNamespaceCapabilities performs namespace-scoped RBAC checks for
// capabilities that drive resource-level controls.
func CheckNamespaceCapabilities(ctx context.Context, namespace string) (*NamespaceCapabilities, error) {
if namespace == "" {
return nil, nil
}

// If all three are already allowed globally, no need for namespace check
if globalCaps.Exec && globalCaps.Logs && globalCaps.PortForward {
return nil, nil
}

// Check namespace cache
nsCapMu.RLock()
if nsCapCache != nil {
Expand All @@ -346,41 +350,35 @@ func CheckNamespaceCapabilities(ctx context.Context, namespace string, globalCap
checkCtx, cancel := NewOperationContext(10 * time.Second)
defer cancel()

result := &NamespaceCapabilities{
Exec: globalCaps.Exec,
Logs: globalCaps.Logs,
PortForward: globalCaps.PortForward,
}
result := &NamespaceCapabilities{}

// Only re-check capabilities that were denied globally
type capCheck struct {
group string
resource string
verb string
result *bool
}

var checks []capCheck
if !globalCaps.Exec && !ForceDisableExec {
checks = append(checks, capCheck{"pods/exec", "create", &result.Exec})
}
if !globalCaps.Logs {
checks = append(checks, capCheck{"pods/log", "get", &result.Logs})
}
if !globalCaps.PortForward {
checks = append(checks, capCheck{"pods/portforward", "create", &result.PortForward})
}

if len(checks) == 0 {
return result, nil
}
if !ForceDisableExec {
checks = append(checks, capCheck{resource: "pods/exec", verb: "create", result: &result.Exec})
}
checks = append(checks,
capCheck{resource: "pods/log", verb: "get", result: &result.Logs},
capCheck{resource: "pods/portforward", verb: "create", result: &result.PortForward},
capCheck{group: "apps", resource: "deployments", verb: "patch", result: &result.WorkloadWrites.Deployments},
capCheck{group: "apps", resource: "daemonsets", verb: "patch", result: &result.WorkloadWrites.DaemonSets},
capCheck{group: "apps", resource: "statefulsets", verb: "patch", result: &result.WorkloadWrites.StatefulSets},
capCheck{group: "argoproj.io", resource: "rollouts", verb: "patch", result: &result.WorkloadWrites.Rollouts},
)

var hadErrors atomic.Bool
var wg sync.WaitGroup
wg.Add(len(checks))
for _, check := range checks {
go func(c capCheck) {
defer wg.Done()
allowed, apiErr := canI(checkCtx, namespace, "", c.resource, c.verb)
allowed, apiErr := canI(checkCtx, namespace, c.group, c.resource, c.verb)
if allowed {
*c.result = true
}
Expand Down Expand Up @@ -448,20 +446,26 @@ func CheckCapabilitiesForUser(ctx context.Context, username string, groups []str
defer cancel()

type capCheck struct {
resource string
verb string
result *bool
group string
resource string
verb string
result *bool
namespaceFallback bool
}

caps := &Capabilities{}
checks := []capCheck{
{"pods/exec", "create", &caps.Exec},
{"pods/log", "get", &caps.Logs},
{"pods/portforward", "create", &caps.PortForward},
{"secrets", "list", &caps.Secrets},
{"secrets", "update", &caps.SecretsUpdate},
{"secrets", "create", &caps.HelmWrite},
{"nodes", "patch", &caps.NodeWrite},
{resource: "pods/exec", verb: "create", result: &caps.Exec, namespaceFallback: true},
{resource: "pods/log", verb: "get", result: &caps.Logs, namespaceFallback: true},
{resource: "pods/portforward", verb: "create", result: &caps.PortForward, namespaceFallback: true},
{resource: "secrets", verb: "list", result: &caps.Secrets, namespaceFallback: true},
{resource: "secrets", verb: "update", result: &caps.SecretsUpdate, namespaceFallback: true},
{resource: "secrets", verb: "create", result: &caps.HelmWrite, namespaceFallback: true},
{resource: "nodes", verb: "patch", result: &caps.NodeWrite, namespaceFallback: true},
{group: "apps", resource: "deployments", verb: "patch", result: &caps.WorkloadWrites.Deployments},
{group: "apps", resource: "daemonsets", verb: "patch", result: &caps.WorkloadWrites.DaemonSets},
{group: "apps", resource: "statefulsets", verb: "patch", result: &caps.WorkloadWrites.StatefulSets},
{group: "argoproj.io", resource: "rollouts", verb: "patch", result: &caps.WorkloadWrites.Rollouts},
}

var wg sync.WaitGroup
Expand All @@ -470,14 +474,14 @@ func CheckCapabilitiesForUser(ctx context.Context, username string, groups []str
for _, check := range checks {
go func(c capCheck) {
defer wg.Done()
allowed, _ := canIAs(checkCtx, k8sClient, username, groups, "", "", c.resource, c.verb)
allowed, _ := canIAs(checkCtx, k8sClient, username, groups, "", c.group, c.resource, c.verb)
if allowed {
*c.result = true
return
}
// Try namespace-scoped fallback
if fallbackNs := GetEffectiveNamespace(); fallbackNs != "" {
allowed, _ = canIAs(checkCtx, k8sClient, username, groups, fallbackNs, "", c.resource, c.verb)
if fallbackNs := GetEffectiveNamespace(); fallbackNs != "" && c.namespaceFallback {
allowed, _ = canIAs(checkCtx, k8sClient, username, groups, fallbackNs, c.group, c.resource, c.verb)
if allowed {
*c.result = true
}
Expand All @@ -500,6 +504,63 @@ func CheckCapabilitiesForUser(ctx context.Context, username string, groups []str
return caps, nil
}

// CheckNamespaceCapabilitiesForUser runs namespace-scoped SubjectAccessReview
// checks as the authenticated user.
func CheckNamespaceCapabilitiesForUser(ctx context.Context, username string, groups []string, namespace string) (*NamespaceCapabilities, error) {
if namespace == "" {
return nil, nil
}

k8sClient := GetClient()
if k8sClient == nil {
return nil, nil
}

if GetConnectionStatus().State == StateDisconnected {
return nil, nil
}

checkCtx, cancel := NewOperationContext(10 * time.Second)
defer cancel()

result := &NamespaceCapabilities{}

type capCheck struct {
group string
resource string
verb string
result *bool
}

var checks []capCheck
if !ForceDisableExec {
checks = append(checks, capCheck{resource: "pods/exec", verb: "create", result: &result.Exec})
}
checks = append(checks,
capCheck{resource: "pods/log", verb: "get", result: &result.Logs},
capCheck{resource: "pods/portforward", verb: "create", result: &result.PortForward},
capCheck{group: "apps", resource: "deployments", verb: "patch", result: &result.WorkloadWrites.Deployments},
capCheck{group: "apps", resource: "daemonsets", verb: "patch", result: &result.WorkloadWrites.DaemonSets},
capCheck{group: "apps", resource: "statefulsets", verb: "patch", result: &result.WorkloadWrites.StatefulSets},
capCheck{group: "argoproj.io", resource: "rollouts", verb: "patch", result: &result.WorkloadWrites.Rollouts},
)

var wg sync.WaitGroup
wg.Add(len(checks))
for _, check := range checks {
go func(c capCheck) {
defer wg.Done()
allowed, _ := canIAs(checkCtx, k8sClient, username, groups, namespace, c.group, c.resource, c.verb)
if allowed {
*c.result = true
}
}(check)
}
wg.Wait()

return result, nil
}

// canIAs checks if a specific user can perform an action using SubjectAccessReview.
// Unlike canI which uses SelfSubjectAccessReview (checks the ServiceAccount),
// this checks on behalf of a specific user.
Expand Down
16 changes: 10 additions & 6 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -729,19 +729,23 @@ func (s *Server) handleCapabilities(w http.ResponseWriter, r *http.Request) {
caps.Username = user.Username
}

// Namespace-scoped re-check: when exec/logs/portForward are denied by the
// initial RBAC checks (cluster-wide + effective-namespace fallback), re-check
// scoped to the specific namespace the user is viewing. Users with
// namespace-scoped RoleBindings may have these permissions in namespaces
// other than the kubeconfig default.
// Namespace-scoped re-check for controls whose permission can differ by
// namespace. This keeps action visibility aligned with the namespace the
// user is viewing rather than only the kubeconfig default namespace.
if ns := r.URL.Query().Get("namespace"); ns != "" {
nsCaps, err := k8s.CheckNamespaceCapabilities(r.Context(), ns, caps)
var nsCaps *k8s.NamespaceCapabilities
if user := auth.UserFromContext(r.Context()); user != nil {
nsCaps, err = k8s.CheckNamespaceCapabilitiesForUser(r.Context(), user.Username, user.Groups, ns)
} else {
nsCaps, err = k8s.CheckNamespaceCapabilities(r.Context(), ns)
}
if err != nil {
log.Printf("[capabilities] namespace-scoped check for %q failed: %v", ns, err)
} else if nsCaps != nil {
caps.Exec = nsCaps.Exec
caps.Logs = nsCaps.Logs
caps.PortForward = nsCaps.PortForward
caps.WorkloadWrites = nsCaps.WorkloadWrites
}
}

Expand Down
19 changes: 18 additions & 1 deletion internal/server/server_smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1115,7 +1115,8 @@ func TestSmokeCapabilitiesShape(t *testing.T) {
}

var body struct {
Resources map[string]bool `json:"resources"`
Resources map[string]bool `json:"resources"`
WorkloadWrites map[string]bool `json:"workloadWrites"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
Expand All @@ -1141,6 +1142,22 @@ func TestSmokeCapabilitiesShape(t *testing.T) {
tag, field.Name)
}
}

if body.WorkloadWrites == nil {
t.Fatal("capabilities response missing 'workloadWrites' field")
}
workloadWritesType := reflect.TypeOf(k8s.WorkloadWritePermissions{})
for i := 0; i < workloadWritesType.NumField(); i++ {
field := workloadWritesType.Field(i)
tag := field.Tag.Get("json")
if tag == "" {
t.Errorf("WorkloadWritePermissions.%s has no json tag", field.Name)
continue
}
if _, ok := body.WorkloadWrites[tag]; !ok {
t.Errorf("capabilities.workloadWrites missing key %q for WorkloadWritePermissions.%s", tag, field.Name)
}
}
}

// --- requireConnected guard (table-driven) ---
Expand Down
Loading
Loading