RBAC: cluster-wide deployer-apps ClusterRole, drop per-service deployer Roles (zero per-service RBAC) #466

Closed
opened 2026-07-04 07:54:36 +00:00 by coilysiren · 2 comments
Owner

Problem

Every new service costs a hand-tuned RBAC block in deploy/forgejo-runner-deploy.yml: a per-namespace deployer Role (listing exactly which kinds that service deploys) plus a RoleBinding for the shared forgejo/deployer ServiceAccount. Commit be85c09 is the tax made visible - node-stats-mcp grew a Service/ConfigMap/Ingress/Middleware, so its Role had to be widened by hand or CD half-applied and denied the rest (Forbidden on services/configmaps/ingresses/middlewares). Kai does not want to add an RBAC block for every new service.

Decision (director surface session, 2026-07-04)

Go zero per-service RBAC via a cluster-wide grant, mirroring the pattern this repo already uses for the coarse grant. deployer-namespaces is already a ClusterRole + ClusterRoleBinding letting forgejo/deployer get/create namespaces cluster-wide. Extend that same shape to app kinds.

Implementation

  • Add a ClusterRole named deployer-apps holding the union of the app-deploy kinds the existing per-namespace Roles grant, with the standard get,list,watch,create,update,patch,delete verb set (pods stay read-only get,list,watch):
    • core: services, configmaps, persistentvolumeclaims, serviceaccounts, pods (read-only)
    • apps: deployments, replicasets
    • batch: cronjobs, jobs
    • networking.k8s.io: ingresses
    • traefik.io: middlewares
    • external-secrets.io: externalsecrets
  • Deliberately exclude secrets and rbac.authorization.k8s.io (roles/rolebindings) from the cluster-wide role. Cluster-wide Secret write and cluster-wide RBAC write are the two genuine escalations (read a kube-system secret or bind yourself more power). Keeping them out bounds the blast radius while still giving zero-RBAC for the read-only MCP fleet.
  • Add a ClusterRoleBinding named deployer-apps binding forgejo/deployer to that ClusterRole, modeled byte-for-byte on the existing deployer-namespaces ClusterRoleBinding block.
  • Remove the per-namespace deployer Role + RoleBinding blocks now made redundant by the cluster-wide grant (atlas, node-stats-mcp, coilysiren-eco-gnome, authelia, repo-recall, and any other whose Role only lists kinds now in deployer-apps).
  • Keep a small supplemental namespaced Role (secrets and/or roles/rolebindings only) for the namespaces whose current deployer Role lists secrets or roles/rolebindings - from the current file that is at least coilysiren-eco-app and open-webui (both list secrets, roles, rolebindings); check each namespace's block. Those keep exactly a secrets(+rbac)-scoped Role + RoleBinding until they migrate to ExternalSecrets. File a follow-up issue: "migrate eco-app/open-webui deployer off raw Secrets/RBAC to ExternalSecrets so their supplemental Roles can drop."
  • Leave the deployer-namespaces ClusterRole + ClusterRoleBinding untouched.

Verify

  • The rendered manifest still lets forgejo/deployer create the app kinds in an app namespace, and cannot touch secrets in kube-system. If you can reach a cluster: kubectl auth can-i --as=system:serviceaccount:forgejo:deployer create services -n node-stats-mcp -> yes, ... get secrets -n kube-system -> no.
  • Note in the WARD-OUTCOME that applying the RBAC to the live cluster is an operator step (a cluster-admin kubectl apply, as prior RBAC changes were applied by Kai per deploy#26). The engineer lands the manifest on main; the apply is Kai's.

Refs

  • The redundancy this removes: commit be85c09 (widen node-stats Role by hand).
  • The pattern to extend: the existing deployer-namespaces ClusterRole/ClusterRoleBinding in deploy/forgejo-runner-deploy.yml.
  • Fleet context: coilyco-bridge/deploy#28 (auth overlay), deploy#30 (personal-MCP fleet). This unblocks the reddit clean-clone (coilyco-bridge/deploy, blocked issue) needing zero RBAC.

Update docs/FEATURES.md if the RBAC model is inventoried there. Land on canonical Forgejo main.

Provenance: director surface session, 2026-07-04.

## Problem Every new service costs a hand-tuned RBAC block in `deploy/forgejo-runner-deploy.yml`: a per-namespace `deployer` `Role` (listing exactly which kinds that service deploys) plus a `RoleBinding` for the shared `forgejo/deployer` ServiceAccount. Commit `be85c09` is the tax made visible - node-stats-mcp grew a Service/ConfigMap/Ingress/Middleware, so its Role had to be widened by hand or CD half-applied and denied the rest (Forbidden on services/configmaps/ingresses/middlewares). Kai does not want to add an RBAC block for every new service. ## Decision (director surface session, 2026-07-04) Go **zero per-service RBAC** via a cluster-wide grant, mirroring the pattern this repo already uses for the coarse grant. `deployer-namespaces` is already a `ClusterRole` + `ClusterRoleBinding` letting `forgejo/deployer` get/create namespaces cluster-wide. Extend that same shape to app kinds. ## Implementation - Add a `ClusterRole` named `deployer-apps` holding the **union** of the app-deploy kinds the existing per-namespace Roles grant, with the standard `get,list,watch,create,update,patch,delete` verb set (pods stay read-only `get,list,watch`): - core: `services`, `configmaps`, `persistentvolumeclaims`, `serviceaccounts`, `pods` (read-only) - `apps`: `deployments`, `replicasets` - `batch`: `cronjobs`, `jobs` - `networking.k8s.io`: `ingresses` - `traefik.io`: `middlewares` - `external-secrets.io`: `externalsecrets` - **Deliberately exclude `secrets` and `rbac.authorization.k8s.io` (roles/rolebindings) from the cluster-wide role.** Cluster-wide Secret write and cluster-wide RBAC write are the two genuine escalations (read a kube-system secret or bind yourself more power). Keeping them out bounds the blast radius while still giving zero-RBAC for the read-only MCP fleet. - Add a `ClusterRoleBinding` named `deployer-apps` binding `forgejo/deployer` to that ClusterRole, modeled byte-for-byte on the existing `deployer-namespaces` ClusterRoleBinding block. - **Remove** the per-namespace `deployer` `Role` + `RoleBinding` blocks now made redundant by the cluster-wide grant (atlas, node-stats-mcp, coilysiren-eco-gnome, authelia, repo-recall, and any other whose Role only lists kinds now in `deployer-apps`). - **Keep a small supplemental namespaced Role** (secrets and/or roles/rolebindings only) for the namespaces whose current `deployer` Role lists `secrets` or `roles`/`rolebindings` - from the current file that is at least **coilysiren-eco-app** and **open-webui** (both list `secrets`, `roles`, `rolebindings`); check each namespace's block. Those keep exactly a `secrets`(+`rbac`)-scoped Role + RoleBinding until they migrate to ExternalSecrets. File a follow-up issue: "migrate eco-app/open-webui deployer off raw Secrets/RBAC to ExternalSecrets so their supplemental Roles can drop." - Leave the `deployer-namespaces` ClusterRole + ClusterRoleBinding untouched. ## Verify - The rendered manifest still lets `forgejo/deployer` create the app kinds in an app namespace, and cannot touch `secrets` in `kube-system`. If you can reach a cluster: `kubectl auth can-i --as=system:serviceaccount:forgejo:deployer create services -n node-stats-mcp` -> yes, `... get secrets -n kube-system` -> no. - Note in the WARD-OUTCOME that **applying** the RBAC to the live cluster is an operator step (a cluster-admin `kubectl apply`, as prior RBAC changes were applied by Kai per deploy#26). The engineer lands the manifest on main; the apply is Kai's. ## Refs - The redundancy this removes: commit `be85c09` (widen node-stats Role by hand). - The pattern to extend: the existing `deployer-namespaces` ClusterRole/ClusterRoleBinding in `deploy/forgejo-runner-deploy.yml`. - Fleet context: coilyco-bridge/deploy#28 (auth overlay), deploy#30 (personal-MCP fleet). This unblocks the reddit clean-clone (coilyco-bridge/deploy, blocked issue) needing zero RBAC. Update `docs/FEATURES.md` if the RBAC model is inventoried there. Land on canonical Forgejo main. Provenance: director surface session, 2026-07-04.
Member

🔒 Reserved by ward agent --driver claude — container engineer-claude-infrastructure-466 on host KAI-DESKTOP-TOWER is carrying this issue (reserved 2026-07-04T07:55:37Z). Concurrent ward agent runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); --force overrides.

Do not comment on or edit this issue to steer the run while it is reserved. The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a new issue, dispatched fresh — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494).

— Claude (she/her), via ward agent

<!-- ward-agent-reservation --> 🔒 Reserved by `ward agent --driver claude` — container `engineer-claude-infrastructure-466` on host `KAI-DESKTOP-TOWER` is carrying this issue (reserved 2026-07-04T07:55:37Z). Concurrent `ward agent` runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); `--force` overrides. **Do not comment on or edit this issue to steer the run while it is reserved.** The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a **new issue, dispatched fresh** — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494). <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Author
Owner

WARD-OUTCOME: done - deployer-apps ClusterRole + ClusterRoleBinding added, per-service Roles dropped (atlas/eco-app/open-webui keep secrets/RBAC-only supplementals); landed on main @5fd4481, CI green. Live kubectl apply is Kai's operator step.

The bulk of this was a satisfying delete: the giant per-namespace Role block collapsed into one cluster-wide grant plus three tiny supplementals, net -132 lines in the manifest. The one judgment call the issue flagged - 'check each namespace's block' - paid off: atlas also lists secrets (its tailnet sidecar ts-state), not just eco-app and open-webui, so it got a secrets-only supplemental too. Easy to have missed by trusting the 'at least eco-app and open-webui' shorthand.

What fought back was entirely off to the side of the actual RBAC: the code-comments hook rejects any YAML comment below the top header line, so my inline rationale had to move into docs/cd-runner.md - which then blew the 4000-char doc-size cap and took a few rounds of trimming to fit. And the pylint pre-commit hook is always_run and shells out to uv, which couldn't provision Python 3.13 into the root-owned /opt/uv/python; pointing UV_PYTHON_INSTALL_DIR at a writable dir fixed it and pylint passed 10/10. Worth flagging that env breakage as a container-image rough edge - a naive run would read it as a lint regression and reach for --no-verify.

Confidence is high on the manifest: it's the literal union of the old Roles, verb-for-verb, with the two real escalations (cluster-wide secrets + rbac) deliberately held namespaced. Couldn't run kubectl auth can-i (no cluster reach from here), so the can-i checks in the issue are unverified - but the shapes mirror the existing deployer-namespaces grant byte-for-byte. Follow-up filed as #467 to migrate the three supplementals onto ExternalSecrets so the last namespaced RBAC can drop.

WARD-OUTCOME: done - deployer-apps ClusterRole + ClusterRoleBinding added, per-service Roles dropped (atlas/eco-app/open-webui keep secrets/RBAC-only supplementals); landed on main @5fd4481, CI green. Live `kubectl apply` is Kai's operator step. The bulk of this was a satisfying delete: the giant per-namespace Role block collapsed into one cluster-wide grant plus three tiny supplementals, net -132 lines in the manifest. The one judgment call the issue flagged - 'check each namespace's block' - paid off: **atlas** also lists `secrets` (its tailnet sidecar `ts-state`), not just eco-app and open-webui, so it got a secrets-only supplemental too. Easy to have missed by trusting the 'at least eco-app and open-webui' shorthand. What fought back was entirely off to the side of the actual RBAC: the `code-comments` hook rejects any YAML comment below the top header line, so my inline rationale had to move into `docs/cd-runner.md` - which then blew the 4000-char doc-size cap and took a few rounds of trimming to fit. And the `pylint` pre-commit hook is `always_run` and shells out to `uv`, which couldn't provision Python 3.13 into the root-owned `/opt/uv/python`; pointing `UV_PYTHON_INSTALL_DIR` at a writable dir fixed it and pylint passed 10/10. Worth flagging that env breakage as a container-image rough edge - a naive run would read it as a lint regression and reach for `--no-verify`. Confidence is high on the manifest: it's the literal union of the old Roles, verb-for-verb, with the two real escalations (cluster-wide secrets + rbac) deliberately held namespaced. Couldn't run `kubectl auth can-i` (no cluster reach from here), so the can-i checks in the issue are unverified - but the shapes mirror the existing deployer-namespaces grant byte-for-byte. Follow-up filed as #467 to migrate the three supplementals onto ExternalSecrets so the last namespaced RBAC can drop.
Sign in to join this conversation.
No description provided.