Skip to content

Commit bcceefb

Browse files
committed
Feat: add ValidatingAdmissionPolicy to protect kagenti.io/type label
Prevents manual labeling of Deployments and StatefulSets with kagenti.io/type in both metadata and pod template spec. Only the operator SA may set this label via an AgentRuntime CR. Includes both kustomize (config/vap/) and Helm chart templates. Signed-off-by: Daniels Nagornuks <dnagornu@redhat.com> Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
1 parent f8c06b9 commit bcceefb

8 files changed

Lines changed: 229 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{{- if .Values.vap.agentLabelProtection.enable }}
2+
apiVersion: admissionregistration.k8s.io/v1
3+
kind: ValidatingAdmissionPolicyBinding
4+
metadata:
5+
name: agent-label-protection
6+
labels:
7+
{{- include "chart.labels" . | nindent 4 }}
8+
app.kubernetes.io/component: admission
9+
spec:
10+
policyName: agent-label-protection
11+
validationActions: [Deny]
12+
{{- end }}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
{{- if .Values.vap.agentLabelProtection.enable }}
2+
apiVersion: admissionregistration.k8s.io/v1
3+
kind: ValidatingAdmissionPolicy
4+
metadata:
5+
name: agent-label-protection
6+
labels:
7+
{{- include "chart.labels" . | nindent 4 }}
8+
app.kubernetes.io/component: admission
9+
spec:
10+
failurePolicy: Fail
11+
matchConstraints:
12+
resourceRules:
13+
- apiGroups: ["apps"]
14+
apiVersions: ["v1"]
15+
operations: ["CREATE", "UPDATE"]
16+
resources: ["deployments", "statefulsets"]
17+
18+
matchConditions:
19+
- name: 'has-agent-type-label'
20+
expression: >-
21+
(has(object.metadata.labels) && 'kagenti.io/type' in object.metadata.labels)
22+
|| (has(object.spec.template.metadata)
23+
&& has(object.spec.template.metadata.labels)
24+
&& 'kagenti.io/type' in object.spec.template.metadata.labels)
25+
26+
- name: 'not-exempt-service-account'
27+
expression: >-
28+
!(request.userInfo.username == 'system:serviceaccount:{{ .Release.Namespace }}:{{ .Values.controllerManager.serviceAccountName }}')
29+
{{- range .Values.vap.agentLabelProtection.exemptServiceAccounts }}
30+
&& !(request.userInfo.username == 'system:serviceaccount:{{ . }}')
31+
{{- end }}
32+
33+
variables:
34+
- name: metaLabelUnchanged
35+
expression: >-
36+
!('kagenti.io/type' in object.metadata.labels)
37+
|| (request.operation == 'UPDATE'
38+
&& 'kagenti.io/type' in oldObject.metadata.labels
39+
&& oldObject.metadata.labels['kagenti.io/type'] == object.metadata.labels['kagenti.io/type'])
40+
41+
- name: podTemplateLabelUnchanged
42+
expression: >-
43+
!(has(object.spec.template.metadata)
44+
&& has(object.spec.template.metadata.labels)
45+
&& 'kagenti.io/type' in object.spec.template.metadata.labels)
46+
|| (request.operation == 'UPDATE'
47+
&& has(oldObject.spec.template.metadata)
48+
&& has(oldObject.spec.template.metadata.labels)
49+
&& 'kagenti.io/type' in oldObject.spec.template.metadata.labels
50+
&& oldObject.spec.template.metadata.labels['kagenti.io/type'] == object.spec.template.metadata.labels['kagenti.io/type'])
51+
52+
validations:
53+
- expression: "variables.metaLabelUnchanged"
54+
messageExpression: >-
55+
'The kagenti.io/type label on '
56+
+ object.metadata.namespace + '/' + object.metadata.name
57+
+ ' can only be applied by the kagenti-operator via an AgentRuntime CR.'
58+
+ ' Create an AgentRuntime targeting this workload instead of manually setting the label.'
59+
reason: Forbidden
60+
- expression: "variables.podTemplateLabelUnchanged"
61+
messageExpression: >-
62+
'The kagenti.io/type label in the pod template of '
63+
+ object.metadata.namespace + '/' + object.metadata.name
64+
+ ' can only be applied by the kagenti-operator via an AgentRuntime CR.'
65+
+ ' Create an AgentRuntime targeting this workload instead of manually setting the label.'
66+
reason: Forbidden
67+
{{- end }}

charts/kagenti-operator/values.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ metrics:
7676
webhook:
7777
enable: true
7878

79+
# [VAP]: ValidatingAdmissionPolicy for agent label protection.
80+
# Prevents manual application of the kagenti.io/type label on Deployments and
81+
# StatefulSets (both top-level metadata and pod template spec). Only the operator
82+
# SA (derived from controllerManager.serviceAccountName and the release namespace)
83+
# is exempt.
84+
vap:
85+
agentLabelProtection:
86+
enable: true
87+
7988
# [PROMETHEUS]: To enable a ServiceMonitor to export metrics to Prometheus set true
8089
prometheus:
8190
enable: false

kagenti-operator/config/default/kustomization.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ resources:
2323
- ../webhook
2424
# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required.
2525
- ../certmanager
26+
# [VAP] ValidatingAdmissionPolicy that prevents manual application of kagenti.io/type labels.
27+
- ../vap
2628
# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'.
2729
#- ../prometheus
2830
# [METRICS] Expose the controller manager metrics service.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
resources:
2+
- validating-admission-policy.yaml
3+
- validating-admission-policy-binding.yaml
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
apiVersion: admissionregistration.k8s.io/v1
2+
kind: ValidatingAdmissionPolicyBinding
3+
metadata:
4+
name: agent-label-protection
5+
labels:
6+
app.kubernetes.io/name: kagenti-operator
7+
app.kubernetes.io/component: admission
8+
spec:
9+
policyName: agent-label-protection
10+
validationActions: [Deny]
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# ValidatingAdmissionPolicy: prevent manual application of the kagenti.io/type
2+
# label on Deployments and StatefulSets. Only the kagenti-operator controller
3+
# (via an AgentRuntime CR) is allowed to set this label. Users who need the
4+
# label must create an AgentRuntime targeting their workload instead.
5+
#
6+
# The policy protects the label in TWO locations:
7+
# 1. object.metadata.labels — top-level workload label
8+
# 2. object.spec.template.metadata.labels — pod template label (propagated
9+
# to every Pod the workload creates)
10+
#
11+
# The policy is split into two layers:
12+
#
13+
# matchConditions — skip evaluation entirely when:
14+
# 1. The label is absent from BOTH locations (irrelevant request), OR
15+
# 2. The request originates from a trusted service account.
16+
# Two operator SA identities are listed because kustomize (make deploy)
17+
# and Helm use different namespaces and SA names:
18+
# - kagenti-operator-system/kagenti-operator-controller-manager (kustomize)
19+
# - kagenti-system/controller-manager (Helm)
20+
#
21+
# validations — for everyone else, allow only UPDATE requests where the
22+
# label already existed on the previous revision with the same value in
23+
# whichever location(s) it appears.
24+
#
25+
# CREATE requests that carry the label are always rejected for non-operator
26+
# callers, because the "label unchanged" check evaluates to false on CREATE
27+
# (there is no oldObject).
28+
apiVersion: admissionregistration.k8s.io/v1
29+
kind: ValidatingAdmissionPolicy
30+
metadata:
31+
name: agent-label-protection
32+
labels:
33+
app.kubernetes.io/name: kagenti-operator
34+
app.kubernetes.io/component: admission
35+
spec:
36+
failurePolicy: Fail
37+
matchConstraints:
38+
resourceRules:
39+
- apiGroups: ["apps"]
40+
apiVersions: ["v1"]
41+
operations: ["CREATE", "UPDATE"]
42+
resources: ["deployments", "statefulsets"]
43+
44+
matchConditions:
45+
- name: 'has-agent-type-label'
46+
expression: >-
47+
(has(object.metadata.labels) && 'kagenti.io/type' in object.metadata.labels)
48+
|| (has(object.spec.template.metadata)
49+
&& has(object.spec.template.metadata.labels)
50+
&& 'kagenti.io/type' in object.spec.template.metadata.labels)
51+
52+
- name: 'not-operator-service-account'
53+
expression: >-
54+
!(request.userInfo.username == 'system:serviceaccount:kagenti-operator-system:kagenti-operator-controller-manager')
55+
&& !(request.userInfo.username == 'system:serviceaccount:kagenti-system:controller-manager')
56+
57+
variables:
58+
- name: metaLabelUnchanged
59+
expression: >-
60+
!('kagenti.io/type' in object.metadata.labels)
61+
|| (request.operation == 'UPDATE'
62+
&& 'kagenti.io/type' in oldObject.metadata.labels
63+
&& oldObject.metadata.labels['kagenti.io/type'] == object.metadata.labels['kagenti.io/type'])
64+
65+
- name: podTemplateLabelUnchanged
66+
expression: >-
67+
!(has(object.spec.template.metadata)
68+
&& has(object.spec.template.metadata.labels)
69+
&& 'kagenti.io/type' in object.spec.template.metadata.labels)
70+
|| (request.operation == 'UPDATE'
71+
&& has(oldObject.spec.template.metadata)
72+
&& has(oldObject.spec.template.metadata.labels)
73+
&& 'kagenti.io/type' in oldObject.spec.template.metadata.labels
74+
&& oldObject.spec.template.metadata.labels['kagenti.io/type'] == object.spec.template.metadata.labels['kagenti.io/type'])
75+
76+
validations:
77+
- expression: "variables.metaLabelUnchanged"
78+
messageExpression: >-
79+
'The kagenti.io/type label on '
80+
+ object.metadata.namespace + '/' + object.metadata.name
81+
+ ' can only be applied by the kagenti-operator via an AgentRuntime CR.'
82+
+ ' Create an AgentRuntime targeting this workload instead of manually setting the label.'
83+
reason: Forbidden
84+
- expression: "variables.podTemplateLabelUnchanged"
85+
messageExpression: >-
86+
'The kagenti.io/type label in the pod template of '
87+
+ object.metadata.namespace + '/' + object.metadata.name
88+
+ ' can only be applied by the kagenti-operator via an AgentRuntime CR.'
89+
+ ' Create an AgentRuntime targeting this workload instead of manually setting the label.'
90+
reason: Forbidden

kagenti-operator/docs/architecture.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ The Kagenti Operator is a Kubernetes controller that implements the [Operator Pa
8282
- **AgentCard Validator**: Ensures `targetRef` is set on AgentCards. Rejects duplicate `targetRef` entries (prevents multiple AgentCards targeting the same workload in a namespace).
8383
- **AgentRuntime Validator**: Rejects duplicate `targetRef` entries (prevents multiple AgentRuntime CRs targeting the same workload in a namespace). Uses authoritative API server reads to eliminate informer cache-lag races.
8484

85+
#### Admission Policies
86+
- **Agent Label Protection (ValidatingAdmissionPolicy)**: Prevents manual application of the `kagenti.io/type` label on Deployments and StatefulSets. Only the operator's service account (via an AgentRuntime CR) is allowed to set this label. Users who attempt to add the label directly are rejected with a message directing them to create an AgentRuntime instead. The policy allows non-operator users to update workloads that already carry the label, as long as they don't change its value.
87+
8588
#### Signature Providers
8689
- **X5CProvider**: Validates `x5c` certificate chains against the SPIRE X.509 trust bundle and verifies JWS signatures using the leaf public key
8790

@@ -101,11 +104,13 @@ graph TB
101104
subgraph "Kagenti Operator"
102105
ValidationWebhook[Validating Webhooks]
103106
InjectionWebhook[AuthBridge Mutating Webhook]
107+
VAP[Agent Label Protection VAP]
104108
CardController[AgentCard Controller]
105109
SyncController[AgentCardSync Controller]
106110
RuntimeController[AgentRuntime Controller]
107111
CardCR -->|Validates| ValidationWebhook
108112
RuntimeCR -->|Validates| ValidationWebhook
113+
Deployment -->|CREATE/UPDATE with kagenti.io/type| VAP
109114
110115
ValidationWebhook -->|Valid CR| CardController
111116
end
@@ -142,6 +147,7 @@ graph TB
142147
style RuntimeCR fill:#e1f5fe
143148
style ValidationWebhook fill:#fff3e0
144149
style InjectionWebhook fill:#fff3e0
150+
style VAP fill:#fff3e0
145151
style CardController fill:#ffe0b2
146152
style SyncController fill:#ffe0b2
147153
style Deployment fill:#d1c4e9
@@ -357,6 +363,36 @@ Source: `internal/controller/agentcard_networkpolicy_controller.go`
357363
- The AgentRuntime controller reads ConfigMaps from `kagenti-system` (cluster defaults) regardless of mode — this requires cross-namespace read access
358364
- Namespace defaults ConfigMaps are read from the workload's own namespace
359365

366+
### Admission Control — Agent Label Protection
367+
368+
The operator deploys a `ValidatingAdmissionPolicy` (VAP) that prevents direct application of the `kagenti.io/type` label on Deployments and StatefulSets. This label is the entry point for the entire kagenti platform (webhook injection, agent discovery, client registration), so it must only be set through the official enrollment path — creating an AgentRuntime CR.
369+
370+
#### How It Works
371+
372+
| Layer | Purpose |
373+
|-------|---------|
374+
| **matchConstraints** | Targets CREATE and UPDATE of `apps/v1` Deployments and StatefulSets |
375+
| **matchConditions** | Skips evaluation when the object doesn't have `kagenti.io/type` or when the request comes from the operator's service account |
376+
| **validation** | On UPDATE, allows the request only if `kagenti.io/type` was already present with the same value (user is modifying other fields). On CREATE, always rejects since the label should not be set manually. |
377+
378+
#### Scenarios
379+
380+
| Action | Result |
381+
|--------|--------|
382+
| User creates Deployment with `kagenti.io/type: agent` | **Rejected** — create an AgentRuntime instead |
383+
| User adds `kagenti.io/type` to existing Deployment | **Rejected** — create an AgentRuntime instead |
384+
| User changes `kagenti.io/type` from `agent` to `tool` | **Rejected** — update the AgentRuntime instead |
385+
| User updates Deployment that already has the label (label unchanged) | **Allowed** |
386+
| User removes `kagenti.io/type` from Deployment | **Allowed** (matchCondition skips — new object has no label) |
387+
| Operator controller applies label via AgentRuntime | **Allowed** (service account exemption) |
388+
389+
#### Resources
390+
391+
The VAP is deployed as part of the operator's kustomize manifests (`config/vap/`):
392+
393+
- `ValidatingAdmissionPolicy``agent-label-protection`
394+
- `ValidatingAdmissionPolicyBinding` — binds with `validationActions: [Deny]`
395+
360396
### Secret Management
361397

362398
## Reconciliation Loops

0 commit comments

Comments
 (0)