--- # ============================================================================= # Two identities for the PM agent service, and the whole point is how little each holds. # Serves R-SEC-02 (least privilege for workload identities). # # pm-agent the dispatcher. Needs to create and observe worker Jobs in its OWN # namespace. Nothing else, anywhere. # pm-agent-worker the per-assignment worker. Holds NO Kubernetes rights at all — no # Role, no RoleBinding, not even a mounted token. # # THE ESCALATION THIS LAYOUT EXISTS TO BOUND, and why RBAC alone cannot close it. # # Kubernetes places NO `use` check on a Pod's `serviceAccountName`. So any principal that can # `create jobs` in a namespace can create a Job that runs as ANY ServiceAccount in that # namespace, with an arbitrary command. "Can create Jobs" is therefore equivalent to "can run # arbitrary code as the most privileged SA in the namespace", no matter how narrow the # creator's own Role looks. That is exactly the finding recorded in # conformance/incluster/rbac.yaml for the conformance trigger, and it applies here verbatim. # # This service answers it in two ways at once: # # 1. There is no privileged SA in the namespace to escalate TO. `pm-agent-worker` is bound # to nothing, so even a Job with a shell as its command and that SA attached gets no API # access — and `automountServiceAccountToken: false` in the committed Job template means # no token is projected into the pod either. The prize is empty. # 2. platform/kyverno/admission/pm-agent-job-shape.yaml constrains the SHAPE of what # `pm-agent` may create: the digest-pinned image, the pinned command with no args, that # SA, no token, no extra volume, no init or ephemeral container, and only the committed # env names. That policy is the security boundary; this file is the necessary half. # # DO NOT grant this Role without that policy reconciled and ENFORCING, and do not bind # anything to `pm-agent-worker`. If a worker ever genuinely needs to read something from the # API, that is a reviewed change to the boundary — and it should be questioned hard, because # a worker's job is to write git, not to touch the cluster. # # Namespaced Roles only. There is no ClusterRole here, deliberately: nothing this service # does is cluster-scoped, and the absence is the control. # ============================================================================= apiVersion: v1 kind: ServiceAccount metadata: name: pm-agent namespace: pm-agent labels: app.kubernetes.io/name: pm-agent app.kubernetes.io/part-of: pm-agent annotations: pm-agent.homelab.keithjackson.dev/audit: >- The dispatcher identity. It may create worker Jobs in this namespace and watch them. Its blast radius is bounded by the admission policy pm-agent-job-shape, NOT by this Role — widen neither without the other, and review both. --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: pm-agent namespace: pm-agent labels: app.kubernetes.io/name: pm-agent app.kubernetes.io/part-of: pm-agent rules: # Create one worker Job per assignment, and read back which Jobs are still running. # # `create` cannot be constrained by resourceNames (the name is not known until the object # exists), which is the structural reason the admission policy has to exist. # # `get`/`list` are load-bearing, not conveniences: the dispatcher's concurrency cap and its # "a worker is already on this issue" check are both answered by listing Jobs, and its # issue -> branch memory is the annotation it reads back off the Job it created. Without # them it would duplicate work on every poll. - apiGroups: [batch] resources: [jobs] verbs: [create, get, list, watch] # NOTHING ELSE, and three absences are deliberate: # no `delete` on jobs — a dispatcher that can delete can hide a failed run. Finished # Jobs are reaped by ttlSecondsAfterFinished in the template, by # the Job controller, on a clock nothing here can influence. # no `patch`/`update` — the shape admission approved must stay the shape that runs. # no pods, pods/log, # pods/exec, secrets, # configmaps — the dispatcher reads its own config from its own env and the # thread from Gitea. An execution channel into a worker pod, or a # read over Secrets, would serve nothing it actually does. --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: pm-agent namespace: pm-agent labels: app.kubernetes.io/name: pm-agent app.kubernetes.io/part-of: pm-agent roleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: pm-agent subjects: - kind: ServiceAccount name: pm-agent namespace: pm-agent --- # THE WORKER IDENTITY — deliberately bound to NOTHING. # # It exists only because a Pod must name a ServiceAccount, and because naming a dedicated # one is what lets the admission policy require it: a Job that tried to run as `pm-agent` # (which CAN create Jobs, and so could fork itself without limit) is DENIED. # # `automountServiceAccountToken: false` here as well as in the Job template. The template's # setting is what actually takes effect; this one is the fallback for any pod in this # namespace that forgets, and it documents the intent at the identity rather than only at # one use of it. apiVersion: v1 kind: ServiceAccount metadata: name: pm-agent-worker namespace: pm-agent labels: app.kubernetes.io/name: pm-agent-worker app.kubernetes.io/part-of: pm-agent annotations: pm-agent.homelab.keithjackson.dev/audit: >- INTENTIONALLY POWERLESS. No Role and no RoleBinding reference this ServiceAccount, and no token is mounted into the pods that use it. A worker runs model-authored code against a git repository; it holds a Gitea token and an Anthropic subscription token, and nothing else. Binding any Role to this SA is a change to the security boundary. automountServiceAccountToken: false