Skip to content

Commit 5b9ca00

Browse files
authored
fix: server-side apply, idempotent Helm install, RBAC pre-flight (SKY-843) (#605)
1 parent 9e6d544 commit 5b9ca00

15 files changed

Lines changed: 982 additions & 42 deletions

deploy/helm/radar/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ Disabled by default for security:
9999
| Terminal | `rbac.podExec: true` | Shell access to pods |
100100
| Port Forward | `rbac.portForward: true` | Port forwarding to pods |
101101
| Logs | `rbac.podLogs: true` | View pod logs (**enabled by default**) |
102+
| Helm Write | `rbac.helm: true` | Install/upgrade/rollback/uninstall Helm releases. Under auth or cloud-mode, also emits a split helm add-on ClusterRole — `radar-helm` (member-safe: CRDs, storage, namespaces) and `radar-helm-admin` (owner-only: RBAC, webhooks, ApiServices) |
103+
| RBAC view | `rbac.viewRBAC: true` | Show ClusterRoles, ClusterRoleBindings, Roles, RoleBindings in the resource browser. Off by default — cache-served reads bypass per-user RBAC, so this exposes the cluster's authorization graph to every authenticated Radar user |
102104

103105
### In-app Agent Upgrades (opt-in, for Radar Cloud users)
104106

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{{- /*
2+
Bind Helm add-on ClusterRoles to cloud tiers. radar-helm goes to owner +
3+
member; radar-helm-admin goes to owner only — see clusterrole-helm-admin.yaml
4+
for the rationale. Viewer is intentionally not bound; viewers never install.
5+
*/}}
6+
{{- if and .Values.cloud.enabled .Values.cloud.defaultRbac.create .Values.rbac.create .Values.rbac.helm (ne .Values.auth.mode "none") -}}
7+
{{- if .Values.cloud.defaultRbac.owner }}
8+
apiVersion: rbac.authorization.k8s.io/v1
9+
kind: ClusterRoleBinding
10+
metadata:
11+
name: {{ include "radar.fullname" . }}-cloud-owner-helm
12+
labels:
13+
{{- include "radar.labels" . | nindent 4 }}
14+
roleRef:
15+
apiGroup: rbac.authorization.k8s.io
16+
kind: ClusterRole
17+
name: {{ include "radar.fullname" . }}-helm
18+
subjects:
19+
- kind: Group
20+
name: cloud:owner
21+
apiGroup: rbac.authorization.k8s.io
22+
---
23+
apiVersion: rbac.authorization.k8s.io/v1
24+
kind: ClusterRoleBinding
25+
metadata:
26+
name: {{ include "radar.fullname" . }}-cloud-owner-helm-admin
27+
labels:
28+
{{- include "radar.labels" . | nindent 4 }}
29+
roleRef:
30+
apiGroup: rbac.authorization.k8s.io
31+
kind: ClusterRole
32+
name: {{ include "radar.fullname" . }}-helm-admin
33+
subjects:
34+
- kind: Group
35+
name: cloud:owner
36+
apiGroup: rbac.authorization.k8s.io
37+
{{- end }}
38+
{{- if .Values.cloud.defaultRbac.member }}
39+
---
40+
apiVersion: rbac.authorization.k8s.io/v1
41+
kind: ClusterRoleBinding
42+
metadata:
43+
name: {{ include "radar.fullname" . }}-cloud-member-helm
44+
labels:
45+
{{- include "radar.labels" . | nindent 4 }}
46+
roleRef:
47+
apiGroup: rbac.authorization.k8s.io
48+
kind: ClusterRole
49+
name: {{ include "radar.fullname" . }}-helm
50+
subjects:
51+
- kind: Group
52+
name: cloud:member
53+
apiGroup: rbac.authorization.k8s.io
54+
{{- end }}
55+
{{- end -}}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{{- /*
2+
Cluster-admin-equivalent add-on ClusterRole. Emitted under the same gate as
3+
radar-helm but bound only to cloud:owner — never cloud:member.
4+
5+
The verbs in this role are individually sufficient for self-promotion to
6+
cluster-admin or for bypassing admission control:
7+
- rbac writes: bind your own user to cluster-admin in one apply
8+
- webhook writes: delete the policy that blocks privileged pods
9+
- apiservices: register a rogue aggregated API
10+
Granting these to a tier weaker than owner collapses the owner/member
11+
distinction. Charts that bundle their own RBAC, webhooks, or APIServices
12+
require an owner identity to install.
13+
*/}}
14+
{{- if and .Values.rbac.create .Values.rbac.helm (or (ne .Values.auth.mode "none") .Values.cloud.enabled) -}}
15+
apiVersion: rbac.authorization.k8s.io/v1
16+
kind: ClusterRole
17+
metadata:
18+
name: {{ include "radar.fullname" . }}-helm-admin
19+
labels:
20+
{{- include "radar.labels" . | nindent 4 }}
21+
rules:
22+
- apiGroups: ["rbac.authorization.k8s.io"]
23+
resources: ["clusterroles", "clusterrolebindings", "roles", "rolebindings"]
24+
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
25+
- apiGroups: ["admissionregistration.k8s.io"]
26+
resources:
27+
- validatingwebhookconfigurations
28+
- mutatingwebhookconfigurations
29+
- validatingadmissionpolicies
30+
- validatingadmissionpolicybindings
31+
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
32+
- apiGroups: ["apiregistration.k8s.io"]
33+
resources: ["apiservices"]
34+
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
35+
{{- end }}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{{- /*
2+
Helm add-on ClusterRole — member-safe tier. Covers CRDs, storage classes,
3+
runtime/priority classes, PDBs, namespaces. Cluster-admin-equivalent verbs
4+
(RBAC writes, webhooks, APIServices) live in radar-helm-admin; see that
5+
template for the owner-only rationale.
6+
*/}}
7+
{{- if and .Values.rbac.create .Values.rbac.helm (or (ne .Values.auth.mode "none") .Values.cloud.enabled) -}}
8+
apiVersion: rbac.authorization.k8s.io/v1
9+
kind: ClusterRole
10+
metadata:
11+
name: {{ include "radar.fullname" . }}-helm
12+
labels:
13+
{{- include "radar.labels" . | nindent 4 }}
14+
rules:
15+
- apiGroups: ["apiextensions.k8s.io"]
16+
resources: ["customresourcedefinitions"]
17+
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
18+
- apiGroups: ["storage.k8s.io"]
19+
resources: ["storageclasses", "csidrivers", "volumeattachments"]
20+
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
21+
- apiGroups: ["scheduling.k8s.io"]
22+
resources: ["priorityclasses"]
23+
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
24+
- apiGroups: ["node.k8s.io"]
25+
resources: ["runtimeclasses"]
26+
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
27+
- apiGroups: ["policy"]
28+
resources: ["poddisruptionbudgets"]
29+
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
30+
- apiGroups: [""]
31+
resources: ["namespaces"]
32+
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
33+
{{- end }}

deploy/helm/radar/templates/clusterrole.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,20 @@ rules:
7676
- selfsubjectaccessreviews
7777
verbs: ["create"]
7878

79+
{{- if .Values.rbac.viewRBAC }}
80+
# RBAC objects: opt-in read for the resource browser. Off by default —
81+
# cache-served reads are not gated by per-user RBAC, so granting this
82+
# exposes the cluster's authorization graph to anyone who can reach
83+
# Radar's API regardless of their own permissions.
84+
- apiGroups: ["rbac.authorization.k8s.io"]
85+
resources:
86+
- roles
87+
- rolebindings
88+
- clusterroles
89+
- clusterrolebindings
90+
verbs: ["get", "list", "watch"]
91+
{{- end }}
92+
7993
{{- if .Values.rbac.podLogs }}
8094
# Pod logs (opt-in - enables log viewer)
8195
- apiGroups: [""]

deploy/helm/radar/values.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ rbac:
4040
# Allow reading secrets (shows secrets in resource list)
4141
secrets: false
4242

43+
# Allow reading RBAC objects (Roles, RoleBindings, ClusterRoles, ClusterRoleBindings)
44+
# so the resource browser can list them. Off by default: anyone reaching Radar's
45+
# API sees the full RBAC graph through the shared informer cache regardless of
46+
# their own K8s permissions, which is reconnaissance value for a low-trust user.
47+
# Enable for clusters where Radar's audience is trusted with cluster RBAC visibility.
48+
viewRBAC: false
49+
4350
# Allow pod exec (enables terminal feature)
4451
podExec: false
4552

docs/authentication.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ User → [Auth Layer] → Radar Backend → K8s API (as user, via impersonation)
1111
```
1212

1313
1. **Authentication** identifies the user (proxy headers or OIDC login)
14-
2. **Reads** are filtered — Radar discovers which namespaces the user can access via `SubjectAccessReview` and only returns resources from those namespaces
14+
2. **Reads** are filtered by namespace — Radar discovers which namespaces the user can access via `SubjectAccessReview` and only returns resources from those namespaces. Cluster-scoped resources (Nodes, ClusterRoles when `rbac.viewRBAC` is on, StorageClasses, etc.) are served from the ServiceAccount-populated informer cache without per-user RBAC re-checks, so anyone reaching Radar's API sees them regardless of their own K8s permissions on those kinds.
1515
3. **Writes** use K8s impersonation — Radar makes the K8s API call as the authenticated user, so K8s RBAC decides whether it's allowed
1616
4. **UI adapts** — capability checks run per-user, so buttons (exec, restart, scale, Helm) only appear if the user has permission
1717

@@ -165,6 +165,14 @@ Under cloud-mode (`RADAR_CLOUD_MODE=true`, set automatically by the chart when `
165165
- Ships three default ClusterRoleBindings mapping Cloud's `cloud:owner` / `cloud:member` / `cloud:viewer` groups to the standard K8s `admin` / `edit` / `view` ClusterRoles. Configurable via `cloud.defaultRbac.*` in `values.yaml`.
166166
- Hardens the listener (no `/debug/pprof/*`, narrower exempt paths).
167167

168+
<a id="cloud-mode-helm-bindings"></a>
169+
**Helm-specific bindings (when `rbac.helm=true`).** Helm's pre-flight existence check needs cluster-scoped reads/writes that the K8s built-in `admin`/`edit`/`view` ClusterRoles don't grant. The chart emits two add-on ClusterRoles, split by trust tier:
170+
171+
- `radar-helm` — CRDs, StorageClasses, RuntimeClasses, PriorityClasses, PodDisruptionBudgets, Namespaces. Bound to `cloud:owner` AND `cloud:member`.
172+
- `radar-helm-admin` — RBAC objects (Roles/Bindings, Cluster variants), validating/mutating webhooks, ApiServices. Bound to `cloud:owner` ONLY. Granting these to a tier weaker than owner would let a member self-promote to cluster-admin in one `ClusterRoleBinding` write, collapsing the owner/member distinction.
173+
174+
A `cloud:member` attempting to install a chart that bundles its own RBAC will get a typed `rbac_preflight` 403 with an actionable "ask an owner" message. Day-to-day app charts and operator-CRD installs still work for members.
175+
168176
Customer-facing documentation for Radar Cloud lives on [radarhq.io](https://radarhq.io). The authoritative reference for the Cloud-mode chart values is the comment block in [`deploy/helm/radar/values.yaml`](../deploy/helm/radar/values.yaml) under `cloud:`.
169177

170178
## Setting Up User Permissions

docs/in-cluster.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,8 @@ Some features require additional permissions. Most are disabled by default for s
135135
| Terminal | `rbac.podExec: true` | `false` | Shell access to pods |
136136
| Port Forward | `rbac.portForward: true` | `false` | Port forwarding to pods/services |
137137
| Logs | `rbac.podLogs: true` | `true` | View pod logs |
138-
| Helm Write | `rbac.helm: true` | `false` | Install/upgrade/rollback/uninstall Helm releases (grants broad write access; auto-enables secrets) |
138+
| Helm Write | `rbac.helm: true` | `false` | Install/upgrade/rollback/uninstall Helm releases (grants broad write access; auto-enables secrets). When auth or cloud is on, also emits a split helm add-on: `radar-helm` (CRDs/storage/PDBs/namespaces, bound to owner+member) and `radar-helm-admin` (RBAC/webhooks/APIServices, owner-only) — see [authentication.md](authentication.md#cloud-mode-helm-bindings) |
139+
| RBAC view | `rbac.viewRBAC: true` | `false` | Show ClusterRoles, ClusterRoleBindings, Roles, RoleBindings in the resource browser. Off by default: cache-served reads bypass per-user RBAC, so granting this exposes the cluster's authorization graph to every authenticated Radar user |
139140
| Traffic TLS | `rbac.traffic: true` | `true` | Read Hubble relay TLS certs for Cilium traffic observation |
140141

141142
> **Node management** (cordon, uncordon, drain) is available via the MCP server and API. These operations require `patch` on nodes, `list` on pods, and `create` on `pods/eviction`, which are not included in the default ClusterRole. Add them via `rbac.additionalRules` or use [per-user authentication](authentication.md) so each user's own RBAC governs node operations.
@@ -283,6 +284,7 @@ See [Helm Chart README](../deploy/helm/radar/README.md) for all available values
283284
| `rbac.portForward` | Enable port forwarding | `false` |
284285
| `rbac.secrets` | Show secrets in resource list | `false` |
285286
| `rbac.helm` | Enable Helm write operations | `false` |
287+
| `rbac.viewRBAC` | Show RBAC objects in resource browser | `false` |
286288
| `rbac.traffic` | Read Hubble TLS certs | `true` |
287289
| `rbac.crdGroups.all` | Wildcard CRD read access | `false` |
288290

internal/helm/client.go

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1885,28 +1885,27 @@ func (c *Client) installWith(actionConfig *action.Configuration, req *InstallReq
18851885
}
18861886
}
18871887

1888-
// Create install action
1889-
installAction := action.NewInstall(actionConfig)
1890-
installAction.ReleaseName = req.ReleaseName
1891-
installAction.Namespace = req.Namespace
1892-
installAction.CreateNamespace = req.CreateNamespace
1893-
installAction.Timeout = 120 * time.Second
1894-
installAction.Version = req.Version
1888+
mode, err := preInstallCheck(actionConfig, req.ReleaseName, req.Namespace)
1889+
if err != nil {
1890+
return nil, err
1891+
}
18951892

1896-
// Locate/download chart
1897-
cp, err := installAction.ChartPathOptions.LocateChart(chartURL, c.settings)
1893+
// action.Install carries ChartPathOptions; instantiated here as a locator only.
1894+
locator := action.NewInstall(actionConfig)
1895+
locator.Version = req.Version
1896+
cp, err := locator.ChartPathOptions.LocateChart(chartURL, c.settings)
18981897
if err != nil {
18991898
return nil, fmt.Errorf("failed to locate chart: %w", err)
19001899
}
1901-
1902-
// Load chart
19031900
chart, err := loader.Load(cp)
19041901
if err != nil {
19051902
return nil, fmt.Errorf("failed to load chart: %w", err)
19061903
}
19071904

1908-
// Run install
1909-
rel, err := installAction.Run(chart, req.Values)
1905+
if mode != installFresh {
1906+
log.Printf("[helm] install %q/%q: prior release record exists, recovering via %s", req.Namespace, req.ReleaseName, recoveryMode(mode))
1907+
}
1908+
rel, err := runInstallOrUpgrade(actionConfig, req, chart, mode)
19101909
if err != nil {
19111910
return nil, fmt.Errorf("install failed: %w", err)
19121911
}
@@ -2071,6 +2070,14 @@ func (c *Client) installWithProgressUsing(actionConfig *action.Configuration, re
20712070
}
20722071
}
20732072

2073+
// Pre-flight before downloading: a deployed/pending release is knowable
2074+
// from local Helm storage and we shouldn't waste bandwidth + show
2075+
// "Downloading..." progress to a user who'll get a 409 anyway.
2076+
mode, err := preInstallCheck(actionConfig, req.ReleaseName, req.Namespace)
2077+
if err != nil {
2078+
return nil, err
2079+
}
2080+
20742081
sendProgress("downloading", fmt.Sprintf("Downloading chart %s-%s...", req.ChartName, req.Version), chartURL)
20752082

20762083
// Download the chart archive directly via HTTP, bypassing the Helm SDK's
@@ -2106,19 +2113,19 @@ func (c *Client) installWithProgressUsing(actionConfig *action.Configuration, re
21062113
return nil, fmt.Errorf("failed to load chart: %w", err)
21072114
}
21082115

2109-
installAction := action.NewInstall(actionConfig)
2110-
installAction.ReleaseName = req.ReleaseName
2111-
installAction.Namespace = req.Namespace
2112-
installAction.CreateNamespace = req.CreateNamespace
2113-
installAction.Timeout = 120 * time.Second
2114-
2115-
sendProgress("installing", fmt.Sprintf("Installing %s to namespace %s...", req.ReleaseName, req.Namespace), "")
2116-
2117-
if req.CreateNamespace {
2118-
sendProgress("installing", fmt.Sprintf("Creating namespace %s if needed...", req.Namespace), "")
2116+
switch mode {
2117+
case installFresh:
2118+
sendProgress("installing", fmt.Sprintf("Installing %s to namespace %s...", req.ReleaseName, req.Namespace), "")
2119+
if req.CreateNamespace {
2120+
sendProgress("installing", fmt.Sprintf("Creating namespace %s if needed...", req.Namespace), "")
2121+
}
2122+
case installReplace:
2123+
sendProgress("installing", fmt.Sprintf("Replacing prior uninstalled release %s in %s...", req.ReleaseName, req.Namespace), "")
2124+
case installUpgrade:
2125+
sendProgress("installing", fmt.Sprintf("Recovering prior failed release %s in %s...", req.ReleaseName, req.Namespace), "")
21192126
}
21202127

2121-
rel, err := installAction.Run(chart, req.Values)
2128+
rel, err := runInstallOrUpgrade(actionConfig, req, chart, mode)
21222129
if err != nil {
21232130
return nil, fmt.Errorf("install failed: %w", err)
21242131
}

internal/helm/handlers.go

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -822,11 +822,8 @@ func (h *Handlers) handleInstall(w http.ResponseWriter, r *http.Request) {
822822
release, installErr = client.Install(&req)
823823
}
824824
if err := installErr; err != nil {
825-
if IsForbiddenError(err) {
826-
writeError(w, http.StatusForbidden, "insufficient permissions to install Helm release")
827-
return
828-
}
829-
writeError(w, http.StatusInternalServerError, err.Error())
825+
log.Printf("[helm] install %q/%q (chart=%q repo=%q) failed: %v", req.Namespace, req.ReleaseName, req.ChartName, req.Repository, err)
826+
writeInstallError(w, err)
830827
return
831828
}
832829

@@ -924,11 +921,8 @@ func (h *Handlers) handleInstallStream(w http.ResponseWriter, r *http.Request) {
924921

925922
case result := <-resultCh:
926923
if result.err != nil {
927-
event := map[string]any{
928-
"type": "error",
929-
"message": result.err.Error(),
930-
}
931-
data, _ := json.Marshal(event)
924+
log.Printf("[helm] install %q/%q (chart=%q repo=%q) failed: %v", req.Namespace, req.ReleaseName, req.ChartName, req.Repository, result.err)
925+
data, _ := json.Marshal(installStreamErrorEvent(result.err))
932926
w.Write([]byte("data: " + string(data) + "\n\n"))
933927
} else {
934928
event := map[string]any{

0 commit comments

Comments
 (0)