From 34156e0eb59f77942ec3726e946361f0f66e778a Mon Sep 17 00:00:00 2001 From: aldredb Date: Thu, 23 Jul 2026 17:26:19 +0800 Subject: [PATCH 1/4] helm: use namespaced Role for manager-role when restrictWatchNamespaces is set When restrictWatchNamespaces is configured, the manager-role is now emitted as a namespace-scoped Role (one per watched namespace) instead of a ClusterRole. The RoleBinding references kind: Role accordingly. The two inherently cluster-scoped rules (namespaces get, subjectaccessreviews create) remain in the separate manager-cluster-role ClusterRole, which is unchanged. This eliminates the broad ClusterRole that customers with strict RBAC policies object to, while preserving identical effective permissions within the watched namespaces. Default behavior (empty restrictWatchNamespaces) is unchanged. --- .../templates/rbac.yaml | 114 +++++++++++++++++- helm/temporal-worker-controller/values.yaml | 8 +- 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/helm/temporal-worker-controller/templates/rbac.yaml b/helm/temporal-worker-controller/templates/rbac.yaml index cf3e9ba2..c8887e66 100644 --- a/helm/temporal-worker-controller/templates/rbac.yaml +++ b/helm/temporal-worker-controller/templates/rbac.yaml @@ -58,6 +58,117 @@ subjects: name: {{ .Values.serviceAccount.name | default (printf "%s-service-account" .Release.Name) }} namespace: {{ .Release.Namespace }} --- +{{- if .Values.rbac.restrictWatchNamespaces }} +# Namespace-scoped mode: the manager role is emitted as a namespaced Role in each +# watched namespace. Cluster-scoped rules (namespaces get, subjectaccessreviews +# create) are handled by the separate manager-cluster-role below. +{{- range .Values.rbac.restrictWatchNamespaces }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + app.kubernetes.io/component: rbac + {{- include "temporal-worker-controller.labels" $ | nindent 4 }} + name: {{ $.Release.Name }}-{{ $.Release.Namespace }}-manager-role + namespace: {{ . }} +rules: + - apiGroups: + - "" + resources: + - events + verbs: + - create + - patch + - apiGroups: + - "" + resources: + - secrets + verbs: + - get + - list + - watch + - apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - apps + resources: + - deployments/scale + verbs: + - update + - apiGroups: + - temporal.io + resources: + - connections + - temporalconnections + - workerresourcetemplates + verbs: + - get + - list + - patch + - update + - watch + - apiGroups: + - temporal.io + resources: + - connections/finalizers + - temporalconnections/finalizers + - temporalworkerdeployments/finalizers + - workerdeployments/finalizers + verbs: + - update + - apiGroups: + - temporal.io + resources: + - temporalconnections/status + - temporalworkerdeployments/status + - workerdeployments/status + - workerresourcetemplates/status + verbs: + - get + - patch + - update + - apiGroups: + - temporal.io + resources: + - temporalworkerdeployments + - workerdeployments + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + {{- range $.Values.workerResourceTemplate.allowedResources }} + - apiGroups: + {{- range .apiGroups }} + - {{ . | quote }} + {{- end }} + resources: + {{- range .resources }} + - {{ . | quote }} + {{- end }} + verbs: + - create + - delete + - get + - patch + - update + {{- end }} +{{- end }} +{{- else }} apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: @@ -173,6 +284,7 @@ rules: - patch - update {{- end }} +{{- end }} --- {{- if .Values.rbac.restrictWatchNamespaces }} # Namespace-scoped mode: the manager role is bound only within the watched @@ -230,7 +342,7 @@ metadata: namespace: {{ . }} roleRef: apiGroup: rbac.authorization.k8s.io - kind: ClusterRole + kind: Role name: {{ $.Release.Name }}-{{ $.Release.Namespace }}-manager-role subjects: - kind: ServiceAccount diff --git a/helm/temporal-worker-controller/values.yaml b/helm/temporal-worker-controller/values.yaml index 1c01d447..09f2cba3 100644 --- a/helm/temporal-worker-controller/values.yaml +++ b/helm/temporal-worker-controller/values.yaml @@ -42,10 +42,10 @@ rbac: create: true # restrictWatchNamespaces keeps the namespaces the controller watches and the # namespaces its RBAC grants access to in sync. Empty (the default) watches all - # namespaces with cluster-wide RBAC. When set, the controller watches only these - # namespaces (via WATCH_NAMESPACES) and is granted the manager role through a - # RoleBinding in each listed namespace, plus a minimal cluster-scoped role for the - # two grants that cannot be namespaced (namespaces get, subjectaccessreviews create). + # namespaces with cluster-wide RBAC (ClusterRole + ClusterRoleBinding). When set, + # the manager role is emitted as a namespaced Role (one per listed namespace) bound + # via a RoleBinding, plus a minimal cluster-scoped role for the two grants that + # cannot be namespaced (namespaces get, subjectaccessreviews create). restrictWatchNamespaces: [] serviceAccount: From 4b17a0caf9c0e1c93fd5fc34ae20510a87c8cdb8 Mon Sep 17 00:00:00 2001 From: aldredb Date: Thu, 23 Jul 2026 19:35:58 +0800 Subject: [PATCH 2/4] helm: drop release namespace from namespaced Role/RoleBinding names The namespace field already differentiates, so the release namespace prefix is redundant for namespace-scoped resources. Addresses review feedback. --- helm/temporal-worker-controller/templates/rbac.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/helm/temporal-worker-controller/templates/rbac.yaml b/helm/temporal-worker-controller/templates/rbac.yaml index c8887e66..cd3b3a57 100644 --- a/helm/temporal-worker-controller/templates/rbac.yaml +++ b/helm/temporal-worker-controller/templates/rbac.yaml @@ -70,7 +70,7 @@ metadata: labels: app.kubernetes.io/component: rbac {{- include "temporal-worker-controller.labels" $ | nindent 4 }} - name: {{ $.Release.Name }}-{{ $.Release.Namespace }}-manager-role + name: {{ $.Release.Name }}-manager-role namespace: {{ . }} rules: - apiGroups: @@ -338,12 +338,12 @@ metadata: labels: app.kubernetes.io/component: rbac {{- include "temporal-worker-controller.labels" $ | nindent 4 }} - name: {{ $.Release.Name }}-{{ $.Release.Namespace }}-manager-rolebinding + name: {{ $.Release.Name }}-manager-rolebinding namespace: {{ . }} roleRef: apiGroup: rbac.authorization.k8s.io kind: Role - name: {{ $.Release.Name }}-{{ $.Release.Namespace }}-manager-role + name: {{ $.Release.Name }}-manager-role subjects: - kind: ServiceAccount name: {{ $.Values.serviceAccount.name | default (printf "%s-service-account" $.Release.Name) }} From 35f251a8287868cd00975056eae5b80037c0132c Mon Sep 17 00:00:00 2001 From: aldredb Date: Sat, 25 Jul 2026 06:17:36 +0800 Subject: [PATCH 3/4] hack: update sync-rbac-rules.py to handle namespaced Role markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Helm template now has two marker pairs: - GENERATED RULES BEGIN/END — full rules for the ClusterRole (default mode) - GENERATED RULES (NAMESPACED) BEGIN/END — rules minus cluster-scoped entries (namespaces, subjectaccessreviews) for the namespaced Role The sync script parses config/rbac/role.yaml, filters out cluster-scoped rules for the namespaced block, and updates both marker pairs. Addresses review feedback about hack/sync-rbac-rules.py compatibility. --- hack/sync-rbac-rules.py | 117 ++++++++++++------ .../templates/rbac.yaml | 2 + 2 files changed, 78 insertions(+), 41 deletions(-) diff --git a/hack/sync-rbac-rules.py b/hack/sync-rbac-rules.py index f4a6d616..905a18c1 100644 --- a/hack/sync-rbac-rules.py +++ b/hack/sync-rbac-rules.py @@ -1,65 +1,100 @@ #!/usr/bin/env python3 -"""Sync rules from config/rbac/role.yaml into the Helm ClusterRole template. +"""Sync rules from config/rbac/role.yaml into the Helm RBAC template. -The Helm template must contain: - # GENERATED RULES BEGIN - ... +The Helm template contains two marker pairs: + + # GENERATED RULES BEGIN — full rules for the ClusterRole (default mode) # GENERATED RULES END -markers inside its ClusterRole rules section. Everything between the markers -is replaced with the rules from config/rbac/role.yaml (indented by two spaces). + + # GENERATED RULES (NAMESPACED) BEGIN — rules minus cluster-scoped entries for + # GENERATED RULES (NAMESPACED) END the namespaced Role (restrictWatchNamespaces) + +Both are updated from config/rbac/role.yaml. The namespaced block excludes +rules for cluster-scoped resources (namespaces, subjectaccessreviews) since +those are handled by the separate manager-cluster-role. """ import re import sys +import yaml + ROLE_YAML = "config/rbac/role.yaml" HELM_RBAC = "helm/temporal-worker-controller/templates/rbac.yaml" + BEGIN_MARKER = " # GENERATED RULES BEGIN" END_MARKER = " # GENERATED RULES END" +BEGIN_MARKER_NS = " # GENERATED RULES (NAMESPACED) BEGIN" +END_MARKER_NS = " # GENERATED RULES (NAMESPACED) END" +CLUSTER_SCOPED_RESOURCES = {"namespaces", "subjectaccessreviews"} -def extract_rules_text(path): - with open(path) as f: - content = f.read() - idx = content.find("\nrules:\n") - if idx == -1: - print(f"ERROR: 'rules:' not found in {path}", file=sys.stderr) - sys.exit(1) - rules_body = content[idx + len("\nrules:\n"):] - # Indent lines relative to the `rules:` key in the Helm template. - # controller-gen emits two indentation levels: - # col 0: outer list items (e.g. "- apiGroups:") → add 2 spaces - # col 2: inner list values (e.g. " - events") → add 4 spaces - # col 2: mapping keys (e.g. " resources:") → add 2 spaces - # The extra indent on inner list values matches the style used by the - # hand-authored rules in the Helm template. - lines = rules_body.splitlines(keepends=True) - result = [] - for line in lines: - if not line.strip(): - result.append(line) - elif line.startswith(" - "): - result.append(" " + line) # inner list value: 2 global + 2 extra - else: - result.append(" " + line) # outer list item or mapping key: 2 global - return "".join(result) - - -def update_helm(path, rules_text): + +def extract_rules(path): + """Return the list of rule dicts from config/rbac/role.yaml.""" with open(path) as f: content = f.read() + docs = list(yaml.safe_load_all(content)) + for doc in docs: + if doc and doc.get("kind") == "ClusterRole" and "rules" in doc: + return doc["rules"] + print(f"ERROR: ClusterRole with rules not found in {path}", file=sys.stderr) + sys.exit(1) + + +def rules_to_text(rules): + """Convert a list of rule dicts to indented YAML text for the Helm template.""" + lines = [] + for rule in rules: + lines.append(" - apiGroups:") + for ag in rule.get("apiGroups", []): + lines.append(f' - "{ag}"' if ag == "" else f" - {ag}") + lines.append(" resources:") + for res in rule.get("resources", []): + lines.append(f" - {res}") + if "resourceNames" in rule: + lines.append(" resourceNames:") + for rn in rule["resourceNames"]: + lines.append(f" - {rn}") + lines.append(" verbs:") + for verb in rule.get("verbs", []): + lines.append(f" - {verb}") + return "\n".join(lines) + "\n" + + +def filter_namespaced(rules): + """Remove rules that reference cluster-scoped resources.""" + return [ + r for r in rules + if not CLUSTER_SCOPED_RESOURCES.intersection(r.get("resources", [])) + ] + + +def replace_between_markers(content, begin, end, replacement): pattern = re.compile( - r"(" + re.escape(BEGIN_MARKER) + r"[^\n]*\n)(.*?)(" + re.escape(END_MARKER) + r")", + r"(" + re.escape(begin) + r"[^\n]*\n)(.*?)(" + re.escape(end) + r")", re.DOTALL, ) if not pattern.search(content): - print(f"ERROR: markers not found in {path}", file=sys.stderr) + print(f"ERROR: markers {begin!r} not found", file=sys.stderr) sys.exit(1) - updated = pattern.sub(r"\g<1>" + rules_text + r"\g<3>", content) - with open(path, "w") as f: - f.write(updated) + return pattern.sub(r"\g<1>" + replacement + r"\g<3>", content) + + +def main(): + rules = extract_rules(ROLE_YAML) + + with open(HELM_RBAC) as f: + content = f.read() + + content = replace_between_markers(content, BEGIN_MARKER, END_MARKER, rules_to_text(rules)) + content = replace_between_markers( + content, BEGIN_MARKER_NS, END_MARKER_NS, rules_to_text(filter_namespaced(rules)) + ) + + with open(HELM_RBAC, "w") as f: + f.write(content) print(f"Synced RBAC rules from {ROLE_YAML} → {HELM_RBAC}") if __name__ == "__main__": - rules = extract_rules_text(ROLE_YAML) - update_helm(HELM_RBAC, rules) + main() diff --git a/helm/temporal-worker-controller/templates/rbac.yaml b/helm/temporal-worker-controller/templates/rbac.yaml index cd3b3a57..88706e38 100644 --- a/helm/temporal-worker-controller/templates/rbac.yaml +++ b/helm/temporal-worker-controller/templates/rbac.yaml @@ -73,6 +73,7 @@ metadata: name: {{ $.Release.Name }}-manager-role namespace: {{ . }} rules: + # GENERATED RULES (NAMESPACED) BEGIN - do not edit; run 'make manifests' to update - apiGroups: - "" resources: @@ -151,6 +152,7 @@ rules: - patch - update - watch + # GENERATED RULES (NAMESPACED) END {{- range $.Values.workerResourceTemplate.allowedResources }} - apiGroups: {{- range .apiGroups }} From 9ea524f86ce0743c7abf53761e5cb5f8a65bb1b0 Mon Sep 17 00:00:00 2001 From: aldredb Date: Sat, 25 Jul 2026 06:28:04 +0800 Subject: [PATCH 4/4] hack: update sync-rbac-rules.py for namespaced Role markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for the GENERATED RULES (NAMESPACED) BEGIN/END marker pair. The script now updates both blocks: full rules for the ClusterRole, and filtered rules (excluding namespaces and subjectaccessreviews) for the namespaced Role. No external dependencies — stdlib only. --- hack/sync-rbac-rules.py | 96 +++++++++++++++++++++++------------------ 1 file changed, 55 insertions(+), 41 deletions(-) diff --git a/hack/sync-rbac-rules.py b/hack/sync-rbac-rules.py index 905a18c1..810fec6c 100644 --- a/hack/sync-rbac-rules.py +++ b/hack/sync-rbac-rules.py @@ -16,8 +16,6 @@ import re import sys -import yaml - ROLE_YAML = "config/rbac/role.yaml" HELM_RBAC = "helm/temporal-worker-controller/templates/rbac.yaml" @@ -29,44 +27,60 @@ CLUSTER_SCOPED_RESOURCES = {"namespaces", "subjectaccessreviews"} -def extract_rules(path): - """Return the list of rule dicts from config/rbac/role.yaml.""" +def extract_rules_text(path): + """Extract the raw rules text from config/rbac/role.yaml.""" with open(path) as f: content = f.read() - docs = list(yaml.safe_load_all(content)) - for doc in docs: - if doc and doc.get("kind") == "ClusterRole" and "rules" in doc: - return doc["rules"] - print(f"ERROR: ClusterRole with rules not found in {path}", file=sys.stderr) - sys.exit(1) - - -def rules_to_text(rules): - """Convert a list of rule dicts to indented YAML text for the Helm template.""" - lines = [] - for rule in rules: - lines.append(" - apiGroups:") - for ag in rule.get("apiGroups", []): - lines.append(f' - "{ag}"' if ag == "" else f" - {ag}") - lines.append(" resources:") - for res in rule.get("resources", []): - lines.append(f" - {res}") - if "resourceNames" in rule: - lines.append(" resourceNames:") - for rn in rule["resourceNames"]: - lines.append(f" - {rn}") - lines.append(" verbs:") - for verb in rule.get("verbs", []): - lines.append(f" - {verb}") - return "\n".join(lines) + "\n" - - -def filter_namespaced(rules): - """Remove rules that reference cluster-scoped resources.""" - return [ - r for r in rules - if not CLUSTER_SCOPED_RESOURCES.intersection(r.get("resources", [])) - ] + idx = content.find("\nrules:\n") + if idx == -1: + print(f"ERROR: 'rules:' not found in {path}", file=sys.stderr) + sys.exit(1) + rules_body = content[idx + len("\nrules:\n"):] + lines = rules_body.splitlines(keepends=True) + result = [] + for line in lines: + if not line.strip(): + result.append(line) + elif line.startswith(" - "): + result.append(" " + line) + else: + result.append(" " + line) + return "".join(result) + + +def filter_namespaced(rules_text): + """Remove rule blocks that reference cluster-scoped resources. + + Splits on top-level list items (lines starting with ' - ') and drops + any block whose 'resources:' section contains a cluster-scoped resource. + """ + blocks = [] + current = [] + for line in rules_text.splitlines(keepends=True): + if line.startswith(" - ") and current: + blocks.append("".join(current)) + current = [line] + else: + current.append(line) + if current: + blocks.append("".join(current)) + + filtered = [] + for block in blocks: + resources = set() + in_resources = False + for line in block.splitlines(): + stripped = line.strip() + if stripped == "resources:": + in_resources = True + elif in_resources and stripped.startswith("- "): + resources.add(stripped[2:].strip()) + elif in_resources and not stripped.startswith("- "): + in_resources = False + if not CLUSTER_SCOPED_RESOURCES.intersection(resources): + filtered.append(block) + + return "".join(filtered) def replace_between_markers(content, begin, end, replacement): @@ -81,14 +95,14 @@ def replace_between_markers(content, begin, end, replacement): def main(): - rules = extract_rules(ROLE_YAML) + rules_text = extract_rules_text(ROLE_YAML) with open(HELM_RBAC) as f: content = f.read() - content = replace_between_markers(content, BEGIN_MARKER, END_MARKER, rules_to_text(rules)) + content = replace_between_markers(content, BEGIN_MARKER, END_MARKER, rules_text) content = replace_between_markers( - content, BEGIN_MARKER_NS, END_MARKER_NS, rules_to_text(filter_namespaced(rules)) + content, BEGIN_MARKER_NS, END_MARKER_NS, filter_namespaced(rules_text) ) with open(HELM_RBAC, "w") as f: