-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathvalidatingadmissionpolicy.yaml
More file actions
367 lines (338 loc) · 19.6 KB
/
Copy pathvalidatingadmissionpolicy.yaml
File metadata and controls
367 lines (338 loc) · 19.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
# ValidatingAdmissionPolicy for ScheduledMachine resources.
#
# Enforces spec constraints at admission time (CREATE / UPDATE) so that invalid
# resources are rejected before the controller ever sees them — satisfying
# NIST 800-53 CM-5 (Access Restrictions for Change).
#
# Requires Kubernetes >= 1.26 (alpha), >= 1.28 (beta), >= 1.30 (GA).
# The policy mirrors the runtime validation in src/reconcilers/helpers.rs
# (parse_duration, validate_labels, validate_api_group) to create a defence-
# in-depth posture: invalid specs are caught at admission rather than
# only at reconciliation time.
#
# Apply with:
# kubectl apply -f deploy/admission/validatingadmissionpolicy.yaml
# kubectl apply -f deploy/admission/validatingadmissionpolicybinding.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: scheduledmachine-validation
labels:
app.kubernetes.io/name: 5spot
app.kubernetes.io/component: admission
app.kubernetes.io/managed-by: 5spot-controller
spec:
# Reject the request outright; do not silently accept invalid specs.
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: ["5spot.finos.org"]
apiVersions: ["v1alpha1"]
resources: ["scheduledmachines"]
operations: ["CREATE", "UPDATE"]
# Derived values reused by the RBAC authorizer checks below. The group is the
# part of apiVersion before '/'; the resource is the naive lowercase plural of
# kind (lowerAscii + 's'). This mirrors resource_plural() in
# src/reconcilers/helpers.rs so the permission checked here is exactly the one
# the controller will exercise when it creates the resource.
variables:
- name: bootstrapGroup
expression: "object.spec.bootstrapSpec.apiVersion.split('/')[0]"
- name: bootstrapResource
expression: "object.spec.bootstrapSpec.kind.lowerAscii() + 's'"
- name: infraGroup
expression: "object.spec.infrastructureSpec.apiVersion.split('/')[0]"
- name: infraResource
expression: "object.spec.infrastructureSpec.kind.lowerAscii() + 's'"
validations:
# ── 1. clusterName: non-empty ────────────────────────────────────────────
- expression: "object.spec.clusterName.size() > 0"
message: "spec.clusterName must not be empty"
reason: Invalid
# ── 1b. clusterName: length ≤ MAX_CLUSTER_NAME_LEN (63) ──────────────────
# Mirrors MAX_CLUSTER_NAME_LEN in src/constants.rs. 63 is the effective
# CAPI cluster-name cap because the value flows downstream into DNS
# labels (RFC-1123 limit) and into the
# `cluster.x-k8s.io/cluster-name` label value. Rejecting longer values
# at admission also bounds Prometheus label cardinality — the metrics
# pipeline emits cluster_name as a label on several counters.
- expression: "object.spec.clusterName.size() <= 63"
message: >-
spec.clusterName must be ≤ 63 characters (RFC-1123 DNS label limit;
the effective CAPI cluster-name cap)
reason: Invalid
# ── 1c. clusterName: safe character set ──────────────────────────────────
# Restricts clusterName to ASCII alphanumerics, '-', '.', '_'. Blocks
# log-injection via embedded newlines or NUL bytes and prevents exotic
# Unicode from bloating Prometheus label cardinality. Mirrors the
# runtime check in validate_cluster_name() (src/reconcilers/helpers.rs).
- expression: "object.spec.clusterName.matches('^[A-Za-z0-9][A-Za-z0-9._-]*$')"
message: >-
spec.clusterName must start with an ASCII alphanumeric and contain
only ASCII alphanumerics, '-', '.', or '_'
reason: Invalid
# ── 1d. killIfCommands: count ≤ MAX_KILL_IF_COMMANDS_COUNT (100) ─────────
# Mirrors MAX_KILL_IF_COMMANDS_COUNT in src/constants.rs. The per-node
# reclaim agent evaluates every pattern against every PID in /proc; an
# unbounded list lets a malicious CR pin agent CPU. Trivially true when
# killIfCommands is absent (optional field).
- expression: "!has(object.spec.killIfCommands) || object.spec.killIfCommands.size() <= 100"
message: >-
spec.killIfCommands must have at most 100 entries — each entry is
evaluated against every /proc/<pid> by the per-node reclaim agent
reason: Invalid
# ── 1e. killIfCommands: per-entry length ≤ MAX_KILL_IF_COMMAND_LEN (256) ─
# Mirrors MAX_KILL_IF_COMMAND_LEN in src/constants.rs. Caps both agent
# CPU per match attempt and the size of the per-node ConfigMap
# projection. Empty strings are also rejected — they would match every
# process and are almost certainly a mistake.
- expression: |
!has(object.spec.killIfCommands) ||
object.spec.killIfCommands.all(c, c.size() >= 1 && c.size() <= 256)
message: >-
spec.killIfCommands entries must be 1..=256 characters each
reason: Invalid
# ── 2. gracefulShutdownTimeout duration format ────────────────────────────
# Must be <positive-integer><unit> where unit ∈ {s, m, h}.
# Mirrors the parse_duration() check in src/reconcilers/helpers.rs.
- expression: "object.spec.gracefulShutdownTimeout.matches('^[0-9]+[smh]$')"
message: >-
spec.gracefulShutdownTimeout must be a duration string such as '5m', '30s', or '1h'
(format: <positive integer> followed by s, m, or h)
reason: Invalid
# ── 3. nodeDrainTimeout duration format ───────────────────────────────────
- expression: "object.spec.nodeDrainTimeout.matches('^[0-9]+[smh]$')"
message: >-
spec.nodeDrainTimeout must be a duration string such as '5m', '30s', or '1h'
(format: <positive integer> followed by s, m, or h)
reason: Invalid
# ── 4. Cron XOR explicit day/hour windows ────────────────────────────────
# Using cron together with daysOfWeek or hoursOfDay is ambiguous.
# The controller silently ignores daysOfWeek/hoursOfDay when cron is set;
# we reject the spec at admission to prevent silent data loss.
- expression: |
!has(object.spec.schedule.cron) ||
(object.spec.schedule.daysOfWeek.size() == 0 &&
object.spec.schedule.hoursOfDay.size() == 0)
message: >-
spec.schedule: cron is mutually exclusive with daysOfWeek and hoursOfDay —
set one or the other, not both
reason: Invalid
# ── 5. Without cron, both day and hour windows must be provided ───────────
- expression: |
has(object.spec.schedule.cron) ||
(object.spec.schedule.daysOfWeek.size() > 0 &&
object.spec.schedule.hoursOfDay.size() > 0)
message: >-
spec.schedule: when cron is not set, both daysOfWeek and hoursOfDay must be
non-empty to define an active window
reason: Invalid
# ── 6. daysOfWeek item format ─────────────────────────────────────────────
# Each element must be a day name (mon…sun), an inclusive range (mon-fri),
# or a comma-separated combination (mon-wed,fri-sun).
# Trivially true for empty arrays (cron path).
- expression: |
object.spec.schedule.daysOfWeek.all(d,
d.matches('^(mon|tue|wed|thu|fri|sat|sun)(-(mon|tue|wed|thu|fri|sat|sun))?(,(mon|tue|wed|thu|fri|sat|sun)(-(mon|tue|wed|thu|fri|sat|sun))?)*$'))
message: >-
spec.schedule.daysOfWeek items must be day names or ranges
(e.g. 'mon', 'mon-fri', 'mon-wed,fri-sun')
reason: Invalid
# ── 7. hoursOfDay item format ─────────────────────────────────────────────
# Each element must be a 1-2 digit hour, a range (9-17), or a comma-
# separated combination (0-9,18-23). Numeric bounds (0-23) are enforced
# at runtime by the schedule evaluator.
# Trivially true for empty arrays (cron path).
- expression: |
object.spec.schedule.hoursOfDay.all(h,
h.matches('^[0-9]{1,2}(-[0-9]{1,2})?(,[0-9]{1,2}(-[0-9]{1,2})?)*$'))
message: >-
spec.schedule.hoursOfDay items must be hours or ranges
(e.g. '9', '9-17', '0-9,18-23')
reason: Invalid
# ── 8. bootstrapSpec.apiVersion must use a namespaced API group ───────────
# Core API versions (e.g. "v1") have no '/' and must never be used for
# bootstrap resources — rejects the same case as validate_api_group() at runtime.
- expression: "object.spec.bootstrapSpec.apiVersion.contains('/')"
message: >-
spec.bootstrapSpec.apiVersion must use a namespaced API group
(e.g. 'bootstrap.cluster.x-k8s.io/v1beta1') — core API versions are not permitted
reason: Invalid
# ── 9. bootstrapSpec.apiVersion must be from an allowed provider group ────
# Mirrors ALLOWED_BOOTSTRAP_API_GROUPS in src/constants.rs.
- expression: |
object.spec.bootstrapSpec.apiVersion.startsWith('bootstrap.cluster.x-k8s.io/') ||
object.spec.bootstrapSpec.apiVersion.startsWith('k0smotron.io/')
message: >-
spec.bootstrapSpec.apiVersion must be from an allowed group:
bootstrap.cluster.x-k8s.io or k0smotron.io
reason: Invalid
# ── 10. bootstrapSpec.kind must not be empty ──────────────────────────────
- expression: "object.spec.bootstrapSpec.kind.size() > 0"
message: "spec.bootstrapSpec.kind must not be empty"
reason: Invalid
# ── 11. infrastructureSpec.apiVersion must use a namespaced API group ─────
- expression: "object.spec.infrastructureSpec.apiVersion.contains('/')"
message: >-
spec.infrastructureSpec.apiVersion must use a namespaced API group
(e.g. 'infrastructure.cluster.x-k8s.io/v1beta1') — core API versions are not permitted
reason: Invalid
# ── 12. infrastructureSpec.apiVersion must be from an allowed provider group
# Mirrors ALLOWED_INFRASTRUCTURE_API_GROUPS in src/constants.rs.
- expression: |
object.spec.infrastructureSpec.apiVersion.startsWith('infrastructure.cluster.x-k8s.io/') ||
object.spec.infrastructureSpec.apiVersion.startsWith('k0smotron.io/')
message: >-
spec.infrastructureSpec.apiVersion must be from an allowed group:
infrastructure.cluster.x-k8s.io or k0smotron.io
reason: Invalid
# ── 13. infrastructureSpec.kind must not be empty ─────────────────────────
- expression: "object.spec.infrastructureSpec.kind.size() > 0"
message: "spec.infrastructureSpec.kind must not be empty"
reason: Invalid
# ── 13c–13f. Embedded metadata: name/namespace are controller-owned ───────
# The controller owns the created resource's identity: it always names the
# bootstrap/infrastructure resource after the ScheduledMachine and creates
# it in the SM's own namespace (cross-namespace creation is forbidden —
# threat T1). A user-supplied metadata.name/metadata.namespace is therefore
# rejected, not silently ignored.
#
# NOTE: this only works because EmbeddedResource's `metadata` is marked
# `x-kubernetes-preserve-unknown-fields: true` in src/crd.rs. Without it the
# API server PRUNES unknown fields before admission policies run, so the
# field would be gone before this rule could see it. The runtime
# `validate_embedded_metadata()` in src/reconcilers/helpers.rs enforces the
# same rule as defence-in-depth. Only metadata.labels / metadata.annotations
# are user-settable (and are reserved-prefix-checked at reconcile time).
- expression: "!has(object.spec.bootstrapSpec.metadata) || !has(object.spec.bootstrapSpec.metadata.namespace)"
message: >-
spec.bootstrapSpec.metadata.namespace is not permitted — the 5Spot
controller always creates the bootstrap resource in the
ScheduledMachine's own namespace
reason: Forbidden
- expression: "!has(object.spec.bootstrapSpec.metadata) || !has(object.spec.bootstrapSpec.metadata.name)"
message: >-
spec.bootstrapSpec.metadata.name is not permitted — the 5Spot controller
names the bootstrap resource after the ScheduledMachine
reason: Forbidden
- expression: "!has(object.spec.infrastructureSpec.metadata) || !has(object.spec.infrastructureSpec.metadata.namespace)"
message: >-
spec.infrastructureSpec.metadata.namespace is not permitted — the 5Spot
controller always creates the infrastructure resource in the
ScheduledMachine's own namespace
reason: Forbidden
- expression: "!has(object.spec.infrastructureSpec.metadata) || !has(object.spec.infrastructureSpec.metadata.name)"
message: >-
spec.infrastructureSpec.metadata.name is not permitted — the 5Spot
controller names the infrastructure resource after the ScheduledMachine
reason: Forbidden
# ── 13a. RBAC: requesting user may create the bootstrapSpec resource ──────
# Privilege-escalation guard. The 5Spot controller runs with broad RBAC so
# it can create bootstrap/infrastructure/Machine objects on the user's
# behalf. Without this check, a user able to create a ScheduledMachine but
# NOT a K0sWorkerConfig could have the controller create one for them —
# escalating through the controller. We require the *requesting user* to
# independently hold `create` on the embedded bootstrap GVK in the target
# namespace, mirroring how CAPI's own webhooks gate templated resources.
# The controller-side equivalent (its own service account) is enforced at
# reconcile time by ensure_can_create() in src/reconcilers/helpers.rs.
- expression: >-
authorizer.group(variables.bootstrapGroup)
.resource(variables.bootstrapResource)
.namespace(object.metadata.namespace)
.check('create')
.allowed()
message: >-
user is not permitted to create the spec.bootstrapSpec resource type —
creating a ScheduledMachine requires 'create' permission on the
embedded bootstrap resource (RBAC) to prevent privilege escalation
through the 5Spot controller
reason: Forbidden
# ── 13b. RBAC: requesting user may create the infrastructureSpec resource ──
# Same privilege-escalation guard as 13a, applied to the embedded
# infrastructure GVK (e.g. RemoteMachine).
- expression: >-
authorizer.group(variables.infraGroup)
.resource(variables.infraResource)
.namespace(object.metadata.namespace)
.check('create')
.allowed()
message: >-
user is not permitted to create the spec.infrastructureSpec resource
type — creating a ScheduledMachine requires 'create' permission on the
embedded infrastructure resource (RBAC) to prevent privilege escalation
through the 5Spot controller
reason: Forbidden
# ── 14. nodeTaints: key format (RFC-1123 qualified name) ──────────────────
# Mirrors validate_taint_key() in src/crd.rs. The optional "<prefix>/" part
# is a DNS subdomain; the name part is a qualified name. Both capped at 63
# chars post-split. Core/v1 admission applies the same regex — we duplicate
# it here so bad input never reaches the reconciler.
# Trivially true when nodeTaints is absent or empty.
- expression: |
!has(object.spec.nodeTaints) ||
object.spec.nodeTaints.all(t,
t.key.matches('^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?$'))
message: >-
spec.nodeTaints[*].key must be an RFC-1123 qualified name
(optional DNS-1123 prefix '/' followed by a label of letters/digits/'-','_','.')
reason: Invalid
# ── 15. nodeTaints: key length ≤ 253 total, name-part ≤ 63 ────────────────
# Qualified-name rule: the name after the optional '/' is ≤ 63 chars.
# We check total length ≤ 253 which also bounds the name part since the
# prefix is ≤ 253 per DNS-1123. A tighter per-part cap would require CEL
# string splits that are verbose; the Rust-side validator does enforce
# the 63-char name-part cap as the authoritative guard.
- expression: |
!has(object.spec.nodeTaints) ||
object.spec.nodeTaints.all(t, t.key.size() <= 253)
message: "spec.nodeTaints[*].key must be ≤ 253 characters"
reason: Invalid
# ── 16. nodeTaints: value length ≤ 63 ─────────────────────────────────────
# Mirrors validate_taint_value() in src/crd.rs. Core/v1 enforces the same
# 63-char cap on taint values.
- expression: |
!has(object.spec.nodeTaints) ||
object.spec.nodeTaints.all(t, !has(t.value) || t.value.size() <= 63)
message: "spec.nodeTaints[*].value must be ≤ 63 characters"
reason: Invalid
# ── 17. nodeTaints: reserved 5spot.finos.org/ prefix ──────────────────────
# Our own controller owns this namespace for internal taints / annotations.
# Users must not set taints here via the CR; request routing for reserved
# keys is handled server-side.
- expression: |
!has(object.spec.nodeTaints) ||
object.spec.nodeTaints.all(t, !t.key.startsWith('5spot.finos.org/'))
message: >-
spec.nodeTaints[*].key must not start with '5spot.finos.org/' —
this prefix is reserved for 5Spot controller-owned taints
reason: Invalid
# ── 18. nodeTaints: reserved kubernetes.io / node.kubernetes.io /
# node-role.kubernetes.io prefixes ────────────────────────────────────────
# These prefixes carry special kubelet/scheduler semantics and must not
# be set via spec.nodeTaints — operators wanting control-plane role
# taints should use spec.machineTemplate instead.
- expression: |
!has(object.spec.nodeTaints) ||
object.spec.nodeTaints.all(t,
!t.key.startsWith('kubernetes.io/') &&
!t.key.startsWith('node.kubernetes.io/') &&
!t.key.startsWith('node-role.kubernetes.io/'))
message: >-
spec.nodeTaints[*].key must not start with 'kubernetes.io/',
'node.kubernetes.io/', or 'node-role.kubernetes.io/' —
use spec.machineTemplate for control-plane role taints
reason: Invalid
# ── 19. nodeTaints: no duplicate (key, effect) pairs ──────────────────────
# Mirrors the duplicate-check arm of validate_node_taints() in src/crd.rs.
# Identity is the tuple (key, effect); value is mutable. We check that
# every entry appears exactly once under (key, effect). O(n²) scan —
# acceptable because nodeTaints is typically ≤ 5 entries per CR.
- expression: |
!has(object.spec.nodeTaints) ||
object.spec.nodeTaints.all(t,
object.spec.nodeTaints.filter(x, x.key == t.key && x.effect == t.effect).size() == 1)
message: >-
spec.nodeTaints must not contain duplicate (key, effect) pairs —
taint identity is the tuple (key, effect); value is mutable
reason: Invalid