Securing a Cluster
A Wasm component on Cosmonic Control starts with no access to anything: no network, no filesystem, no host interfaces. Everything it can reach is granted in its manifest: the hosts it may call, the volumes it mounts, the interfaces it imports, and the hosts it runs on. Whoever can create a WorkloadDeployment in a namespace decides those grants for that namespace.
On a shared cluster, that makes the manifest the place to enforce limits. This page sets up tenant isolation in two layers:
- Namespace isolation. Each tenant gets a namespace with its own hostgroup, Pod Security enforcement, and object quotas.
- Admission policies. Policies cap what a tenant can grant its own workloads, whatever its RBAC allows it to create.
Every manifest on this page is tested against a live cluster, with both policy engines.
Namespace isolation
Multitenancy maps each tenant to a Kubernetes namespace, and Tenant RBAC limits each team to its own. A tenant's boundary is made of these pieces together:
- A hostgroup in the tenant's namespace. A hostgroup reports its namespace as its environment, so the tenant's workloads have hosts of their own. See Host Groups.
- Placement pinned to that hostgroup. By default, a workload that names no environment can be placed on any matching host in the cluster, and one that names another namespace's environment runs on that namespace's hosts. Set
operator.allowSharedHosts=falseon the Cosmonic Control chart to have the operator keep each workload on its own namespace's hosts, and add theCOSMONIC-PLACEMENTpolicy below to refuse workloads that ask for anything else. - Pod Security on the namespace, which governs the host pods.
- A ResourceQuota on the Cosmonic objects the tenant can create.
- The NetworkPolicies the Cosmonic Control charts install.
Components from tenants that share a hostgroup run in the same host process. The Wasm sandbox still isolates them from each other, but the namespace no longer separates them at the process or node level. Give each tenant that needs that separation its own hostgroup.
Pod Security
Wasm components run inside the host pod rather than in pods of their own, so Pod Security Admission applies to the host pods and the control plane. The Cosmonic Control and hostgroup charts run under the restricted level by default.
Label each namespace before installing anything into it. Enforcement applies to pods as they are admitted, so labeling a namespace after the install leaves its running pods unchecked.
kubectl create namespace cosmonic-system
kubectl label namespace cosmonic-system \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/audit=restrictedThe one exception is the bundled Traefik ingress, which binds host ports 80 and 443. The baseline and restricted levels both forbid host ports. To run it under either, set ingress.traefik.hostPorts=false, and Traefik stays reachable through its NodePort Service:
helm install cosmonic-control oci://ghcr.io/cosmonic/cosmonic-control \
--namespace cosmonic-system \
--set ingress.traefik.hostPorts=falseingress.traefik.hostPorts is available in Cosmonic Control v0.14.0 and later.
Resource quotas
A ResourceQuota can count Cosmonic objects like any other resource. Capping workloads.runtime.wasmcloud.dev caps the replicas a tenant runs across all of its deployments, since every replica is a Workload.
The tenant namespace
This manifest creates a tenant namespace with all of the above, plus the label and ConfigMap the admission policies below read. Replace tenant-a with the tenant's namespace, then install the tenant's hostgroup into it.
# A tenant namespace for the policies in ../vap or ../kyverno.
#
# Apply this before installing the tenant's hostgroup: Pod Security only
# checks pods admitted after the label is set. When adding the policy-profile
# label to a namespace that already exists, create the cosmonic-policy
# ConfigMap first. The parameterized policies deny every write in a labeled
# namespace that has none.
apiVersion: v1
kind: Namespace
metadata:
name: tenant-a
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/audit: restricted
cosmonic.io/policy-profile: restricted
---
apiVersion: v1
kind: ConfigMap
metadata:
name: cosmonic-policy
namespace: tenant-a
data:
# Comma-separated. Domains also admit their subdomains.
allowedEgressDomains: 'api.example.com'
allowedRegistries: 'ghcr.io/wasmcloud/, registry.example.com/tenant-a/'
allowedInterfaces: 'wasi:http, wasi:config, wasi:logging, wasi:keyvalue'
ingressDomain: 'tenant-a.apps.example.com'
maxReplicas: '5'
maxPoolSize: '10'
maxConcurrency: '16'
---
# Workloads counts every replica the tenant runs, across all deployments.
apiVersion: v1
kind: ResourceQuota
metadata:
name: cosmonic-objects
namespace: tenant-a
spec:
hard:
count/workloaddeployments.runtime.wasmcloud.dev: '20'
count/workloads.runtime.wasmcloud.dev: '50'
count/httptriggers.control.cosmonic.io: '20'
count/artifacts.runtime.wasmcloud.dev: '20'Admission policies
The policies come in two equivalent sets:
- ValidatingAdmissionPolicy (VAP) is built into Kubernetes 1.30 and later and needs nothing else installed. Start here unless you already run Kyverno.
- Kyverno
ValidatingPolicy(Kyverno 1.18 and later) is for clusters that already run Kyverno and want these policies in its reports and exceptions.
Both sets use the same CEL expressions. Each rejection message starts with the policy's ID, such as [COSMONIC-HOSTPATH], whichever engine refused it.
| ID | Refuses | ConfigMap keys |
|---|---|---|
COSMONIC-HOSTPATH | hostPath volumes | |
COSMONIC-LOOPBACK | Access to the host's loopback interface | |
COSMONIC-EGRESS | Outbound hosts and DNS lookups outside an allowlist | allowedEgressDomains |
COSMONIC-REGISTRY | Images from untrusted registries | allowedRegistries |
COSMONIC-DIGEST | Images not pinned by digest | |
COSMONIC-INTERFACES | Host interfaces the namespace was not granted | allowedInterfaces |
COSMONIC-PLACEMENT | Workloads that run outside the namespace's own hosts | |
COSMONIC-LIMITS | Replica, pool, and concurrency settings above a cap | maxReplicas, maxPoolSize, maxConcurrency |
COSMONIC-INGRESS | HTTPTrigger hostnames outside the namespace's domain | ingressDomain |
COSMONIC-PARAMS | Changes to cosmonic-policy by anyone who cannot update the namespace |
How the policies are scoped
The policies only act in namespaces labeled cosmonic.io/policy-profile: restricted. Policies with parameters read them from a ConfigMap named cosmonic-policy in the namespace of the object being checked, so each tenant can have its own allowlists.
A labeled namespace without that ConfigMap refuses every workload, so the policies fail closed rather than open. When adding the label to a namespace that already exists, create the ConfigMap first.
Every kind that carries a workload
A workload's spec appears in three kinds. You write a WorkloadDeployment; the operator creates a WorkloadReplicaSet from it, and a Workload for each replica. A tenant who can create any of the three can run a workload, so a policy that checks only WorkloadDeployment is bypassed by creating a Workload directly. Each policy below checks all three, and the image policies also check Artifact.
Checking the WorkloadDeployment also gives a better error. If a policy only refuses the Workload the operator creates, the refusal appears on the WorkloadReplicaSet's ScaleUp condition, and the WorkloadDeployment just never becomes ready.
Existing workloads
Admission policies also run on updates, including the operator's routine updates and the finalizer removal that lets a deleted workload go away. A policy that checked those against a workload created before the policy would leave the workload stuck: it could not be updated, and could not finish deleting.
Each policy therefore skips objects that are being deleted, and updates that leave spec unchanged. An existing workload that breaks a policy keeps running, and is refused the next time its spec changes.
To find those workloads before enforcing, roll the policies out in stages:
- ValidatingAdmissionPolicy
- Kyverno
Set each binding's validationActions to ["Warn", "Audit"]. Violations come back as warnings to whoever applies the object and land in the API server's audit log, without refusing anything. Once they stop, switch to ["Deny"].
Set each policy's validationActions to ["Audit"]. Kyverno's background scan reports existing violations as PolicyReports (kubectl get policyreports -A) without refusing anything. Once they are resolved, switch to ["Deny"].
Applying the policies
- ValidatingAdmissionPolicy
- Kyverno
The Cosmonic Control chart (v0.14.0 and later) installs every policy below with its binding when policies.enabled is set. It needs Kubernetes 1.30 or later, and refuses to render on an older cluster:
helm upgrade cosmonic-control oci://ghcr.io/cosmonic/cosmonic-control \
--version 0.14.0 \
--namespace cosmonic-system \
-f cosmonic-control-values.yaml \
--set policies.enabled=trueThe chart installs each binding with validationActions: ['Deny'] and offers no value to change it. For the staged rollout in Existing workloads, apply the files yourself instead: each file below holds a ValidatingAdmissionPolicy and its binding, so edit the binding and apply it with kubectl apply -f.
Each file below holds a ValidatingPolicy. Apply them with kubectl apply -f, along with this ClusterRole:
# Lets Kyverno's reports and background controllers read Cosmonic resources.
# Admission works without this, because the object arrives in the request, but
# background scans and Audit results for these kinds come back empty and Kyverno
# only says so at a high log verbosity.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kyverno:cosmonic:view
labels:
rbac.kyverno.io/aggregate-to-reports-controller: 'true'
rbac.kyverno.io/aggregate-to-background-controller: 'true'
rules:
- apiGroups: ['runtime.wasmcloud.dev']
resources: ['workloads', 'workloadreplicasets', 'workloaddeployments', 'artifacts']
verbs: ['get', 'list', 'watch']
- apiGroups: ['control.cosmonic.io']
resources: ['httptriggers']
verbs: ['get', 'list', 'watch']Kyverno's own Helm chart does not grant its controllers access to custom resources, and without that access its background scans and Audit results for Cosmonic objects come back empty. Refusing objects at admission works either way.
Kyverno's admission webhook fails closed (failurePolicy: Fail), as these policies set it: while Kyverno is unavailable, Cosmonic objects in labeled namespaces cannot be created or changed. Setting failurePolicy: Ignore keeps the cluster writable during an outage, at the cost of admitting objects unchecked.
COSMONIC-HOSTPATH
A hostPath volume mounts a directory from the host's filesystem into the component, read-write unless the mount sets readOnly. Since v0.14.0, a host accepts a hostPath volume only inside a directory its HostGroup lists in hostVolumes.allowedHostPaths, which is empty by default (see hostPath volumes need an allowlist). That list is host-wide: once a directory is on it, every workload on the host group can mount it. This policy refuses hostPath volumes in tenant namespaces whatever the HostGroup allows; ephemeral volumes, which give a component scratch space of its own, stay allowed.
- ValidatingAdmissionPolicy
- Kyverno
# COSMONIC-HOSTPATH: no host filesystem mounts.
# A hostPath volume mounts any directory the host process can see, read-write
# unless the mount sets readOnly. Ephemeral volumes stay allowed.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: cosmonic-hostpath
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: ['runtime.wasmcloud.dev']
apiVersions: ['v1alpha1']
operations: ['CREATE', 'UPDATE']
resources: ['workloads', 'workloadreplicasets', 'workloaddeployments']
# Without these, objects that predate the policy could no longer be updated
# by the operator or have their finalizer removed. On create, oldObject is
# null under VAP and empty under Kyverno.
matchConditions:
- name: not-being-deleted
expression: '!has(object.metadata.deletionTimestamp)'
- name: spec-changed
expression: 'oldObject == null || !has(oldObject.spec) || object.spec != oldObject.spec'
variables:
- name: ws
expression: 'object.spec.?template.?spec.orValue(object.spec)'
validations:
- expression: '!variables.ws.?volumes.orValue([]).exists(v, has(v.hostPath))'
messageExpression: >-
'[COSMONIC-HOSTPATH] ' + object.kind + '/' + object.metadata.name +
': hostPath volumes are not allowed; use an ephemeral volume'
reason: Forbidden
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: cosmonic-hostpath
spec:
policyName: cosmonic-hostpath
validationActions: ['Deny']
matchResources:
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restricted# COSMONIC-HOSTPATH: no host filesystem mounts.
# A hostPath volume mounts any directory the host process can see, read-write
# unless the mount sets readOnly. Ephemeral volumes stay allowed.
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: cosmonic-hostpath
spec:
failurePolicy: Fail
validationActions: ['Deny']
matchConstraints:
resourceRules:
- apiGroups: ['runtime.wasmcloud.dev']
apiVersions: ['v1alpha1']
operations: ['CREATE', 'UPDATE']
resources: ['workloads', 'workloadreplicasets', 'workloaddeployments']
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restricted
# Without these, objects that predate the policy could no longer be updated
# by the operator or have their finalizer removed. On create, oldObject is
# null under VAP and empty under Kyverno.
matchConditions:
- name: not-being-deleted
expression: '!has(object.metadata.deletionTimestamp)'
- name: spec-changed
expression: 'oldObject == null || !has(oldObject.spec) || object.spec != oldObject.spec'
variables:
- name: ws
expression: 'object.spec.?template.?spec.orValue(object.spec)'
validations:
- expression: '!variables.ws.?volumes.orValue([]).exists(v, has(v.hostPath))'
messageExpression: >-
'[COSMONIC-HOSTPATH] ' + object.kind + '/' + object.metadata.name +
': hostPath volumes are not allowed; use an ephemeral volume'
reason: ForbiddenA refused workload looks like this:
Error from server (Forbidden): workloaddeployments.runtime.wasmcloud.dev "app" is forbidden:
ValidatingAdmissionPolicy 'cosmonic-hostpath' with binding 'cosmonic-hostpath' denied request:
[COSMONIC-HOSTPATH] WorkloadDeployment/app: hostPath volumes are not allowed; use an ephemeral volumeCOSMONIC-LOOPBACK
allowedHostLoopbackPorts lets a component connect to services listening on the host's own loopback interface, through host.wasmcloud.internal. Hosts only honor it when started with host loopback enabled, which is off by default, so this policy guards against a host that has it turned on.
- ValidatingAdmissionPolicy
- Kyverno
# COSMONIC-LOOPBACK: no access to the host's loopback interface.
# allowedHostLoopbackPorts reaches whatever listens on the host's localhost
# through host.wasmcloud.internal. It only works on hosts started with
# allow_host_loopback, so this policy is defense in depth.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: cosmonic-loopback
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: ['runtime.wasmcloud.dev']
apiVersions: ['v1alpha1']
operations: ['CREATE', 'UPDATE']
resources: ['workloads', 'workloadreplicasets', 'workloaddeployments']
# Without these, objects that predate the policy could no longer be updated
# by the operator or have their finalizer removed. On create, oldObject is
# null under VAP and empty under Kyverno.
matchConditions:
- name: not-being-deleted
expression: '!has(object.metadata.deletionTimestamp)'
- name: spec-changed
expression: 'oldObject == null || !has(oldObject.spec) || object.spec != oldObject.spec'
variables:
- name: ws
expression: 'object.spec.?template.?spec.orValue(object.spec)'
validations:
- expression: >-
variables.ws.?components.orValue([]).all(c,
size(c.?localResources.?allowedHostLoopbackPorts.orValue([])) == 0) &&
size(variables.ws.?service.?localResources.?allowedHostLoopbackPorts.orValue([])) == 0
messageExpression: >-
'[COSMONIC-LOOPBACK] ' + object.kind + '/' + object.metadata.name +
': allowedHostLoopbackPorts is not allowed'
reason: Forbidden
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: cosmonic-loopback
spec:
policyName: cosmonic-loopback
validationActions: ['Deny']
matchResources:
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restricted# COSMONIC-LOOPBACK: no access to the host's loopback interface.
# allowedHostLoopbackPorts reaches whatever listens on the host's localhost
# through host.wasmcloud.internal. It only works on hosts started with
# allow_host_loopback, so this policy is defense in depth.
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: cosmonic-loopback
spec:
failurePolicy: Fail
validationActions: ['Deny']
matchConstraints:
resourceRules:
- apiGroups: ['runtime.wasmcloud.dev']
apiVersions: ['v1alpha1']
operations: ['CREATE', 'UPDATE']
resources: ['workloads', 'workloadreplicasets', 'workloaddeployments']
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restricted
# Without these, objects that predate the policy could no longer be updated
# by the operator or have their finalizer removed. On create, oldObject is
# null under VAP and empty under Kyverno.
matchConditions:
- name: not-being-deleted
expression: '!has(object.metadata.deletionTimestamp)'
- name: spec-changed
expression: 'oldObject == null || !has(oldObject.spec) || object.spec != oldObject.spec'
variables:
- name: ws
expression: 'object.spec.?template.?spec.orValue(object.spec)'
validations:
- expression: >-
variables.ws.?components.orValue([]).all(c,
size(c.?localResources.?allowedHostLoopbackPorts.orValue([])) == 0) &&
size(variables.ws.?service.?localResources.?allowedHostLoopbackPorts.orValue([])) == 0
messageExpression: >-
'[COSMONIC-LOOPBACK] ' + object.kind + '/' + object.metadata.name +
': allowedHostLoopbackPorts is not allowed'
reason: ForbiddenCOSMONIC-EGRESS
allowedHosts lists the hosts a component may make outbound requests to, and allowedIpNameLookups the names it may resolve. A bare * in either allows everything. This policy requires every entry to name a domain in the namespace's allowedEgressDomains, or a subdomain of one. api.example.com in the allowlist admits https://api.example.com, eu.api.example.com:443, and *.api.example.com, but not api.example.com.evil.org or evilapi.example.com.
- ValidatingAdmissionPolicy
- Kyverno
# COSMONIC-EGRESS: outbound hosts and DNS lookups stay inside an allowlist.
# Every allowedHosts and allowedIpNameLookups entry must name a domain listed
# in the namespace's cosmonic-policy ConfigMap (allowedEgressDomains), or a
# subdomain of one. A bare `*` never passes.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: cosmonic-egress
spec:
failurePolicy: Fail
paramKind:
apiVersion: v1
kind: ConfigMap
matchConstraints:
resourceRules:
- apiGroups: ['runtime.wasmcloud.dev']
apiVersions: ['v1alpha1']
operations: ['CREATE', 'UPDATE']
resources: ['workloads', 'workloadreplicasets', 'workloaddeployments']
# Without these, objects that predate the policy could no longer be updated
# by the operator or have their finalizer removed. On create, oldObject is
# null under VAP and empty under Kyverno.
matchConditions:
- name: not-being-deleted
expression: '!has(object.metadata.deletionTimestamp)'
- name: spec-changed
expression: 'oldObject == null || !has(oldObject.spec) || object.spec != oldObject.spec'
variables:
- name: ws
expression: 'object.spec.?template.?spec.orValue(object.spec)'
- name: domains
expression: >-
params.data.?allowedEgressDomains.orValue('').split(',')
.map(d, d.trim()).filter(d, d != '')
# Every resource block the workload declares: each component's and the service's.
- name: resources
expression: >-
variables.ws.?components.orValue([]).map(c, c.?localResources.orValue({})) +
[variables.ws.?service.?localResources.orValue({})]
# allowedHosts entries may carry a scheme, path, and port; only the host
# left after removing them is compared.
- name: allowed
expression: >-
variables.resources.all(r,
r.?allowedHosts.orValue([]).all(e,
variables.domains.exists(d,
(e.contains('://') ? e.split('://')[1] : e).split('/')[0].split(':')[0] == d ||
(e.contains('://') ? e.split('://')[1] : e).split('/')[0].split(':')[0].endsWith('.' + d))) &&
r.?allowedIpNameLookups.orValue([]).all(e,
variables.domains.exists(d, e == d || e.endsWith('.' + d))))
validations:
- expression: 'variables.allowed'
messageExpression: >-
'[COSMONIC-EGRESS] ' + object.kind + '/' + object.metadata.name +
': allowedHosts and allowedIpNameLookups may only name these domains or their subdomains: ' +
variables.domains.join(', ')
reason: Forbidden
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: cosmonic-egress
spec:
policyName: cosmonic-egress
validationActions: ['Deny']
paramRef:
name: cosmonic-policy
parameterNotFoundAction: Deny
matchResources:
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restricted# COSMONIC-EGRESS: outbound hosts and DNS lookups stay inside an allowlist.
# Every allowedHosts and allowedIpNameLookups entry must name a domain listed
# in the namespace's cosmonic-policy ConfigMap (allowedEgressDomains), or a
# subdomain of one. A bare `*` never passes.
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: cosmonic-egress
spec:
failurePolicy: Fail
validationActions: ['Deny']
matchConstraints:
resourceRules:
- apiGroups: ['runtime.wasmcloud.dev']
apiVersions: ['v1alpha1']
operations: ['CREATE', 'UPDATE']
resources: ['workloads', 'workloadreplicasets', 'workloaddeployments']
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restricted
# Without these, objects that predate the policy could no longer be updated
# by the operator or have their finalizer removed. On create, oldObject is
# null under VAP and empty under Kyverno.
matchConditions:
- name: not-being-deleted
expression: '!has(object.metadata.deletionTimestamp)'
- name: spec-changed
expression: 'oldObject == null || !has(oldObject.spec) || object.spec != oldObject.spec'
variables:
# Kyverno has no paramKind; the policy reads the ConfigMap itself.
- name: params
expression: >-
resource.Get('v1', 'configmaps', object.metadata.namespace, 'cosmonic-policy')
- name: ws
expression: 'object.spec.?template.?spec.orValue(object.spec)'
- name: domains
expression: >-
variables.params.data.?allowedEgressDomains.orValue('').split(',')
.map(d, d.trim()).filter(d, d != '')
# Every resource block the workload declares: each component's and the service's.
- name: resources
expression: >-
variables.ws.?components.orValue([]).map(c, c.?localResources.orValue({})) +
[variables.ws.?service.?localResources.orValue({})]
# allowedHosts entries may carry a scheme, path, and port; only the host
# left after removing them is compared.
- name: allowed
expression: >-
variables.resources.all(r,
r.?allowedHosts.orValue([]).all(e,
variables.domains.exists(d,
(e.contains('://') ? e.split('://')[1] : e).split('/')[0].split(':')[0] == d ||
(e.contains('://') ? e.split('://')[1] : e).split('/')[0].split(':')[0].endsWith('.' + d))) &&
r.?allowedIpNameLookups.orValue([]).all(e,
variables.domains.exists(d, e == d || e.endsWith('.' + d))))
validations:
- expression: 'variables.allowed'
messageExpression: >-
'[COSMONIC-EGRESS] ' + object.kind + '/' + object.metadata.name +
': allowedHosts and allowedIpNameLookups may only name these domains or their subdomains: ' +
variables.domains.join(', ')
reason: ForbiddenCOSMONIC-REGISTRY
This policy requires every component image, service image, and Artifact image to start with a prefix in the namespace's allowedRegistries. End each prefix with /: ghcr.io/acme would also admit ghcr.io/acme-evil/app.
- ValidatingAdmissionPolicy
- Kyverno
# COSMONIC-REGISTRY: component images come only from trusted registries.
# Every component, service, and Artifact image must start with a prefix listed
# in the namespace's cosmonic-policy ConfigMap (allowedRegistries). End each
# prefix with `/` so `ghcr.io/acme/` does not also admit `ghcr.io/acme-evil/`.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: cosmonic-registries
spec:
failurePolicy: Fail
paramKind:
apiVersion: v1
kind: ConfigMap
matchConstraints:
resourceRules:
- apiGroups: ['runtime.wasmcloud.dev']
apiVersions: ['v1alpha1']
operations: ['CREATE', 'UPDATE']
resources: ['workloads', 'workloadreplicasets', 'workloaddeployments', 'artifacts']
# Without these, objects that predate the policy could no longer be updated
# by the operator or have their finalizer removed. On create, oldObject is
# null under VAP and empty under Kyverno.
matchConditions:
- name: not-being-deleted
expression: '!has(object.metadata.deletionTimestamp)'
- name: spec-changed
expression: 'oldObject == null || !has(oldObject.spec) || object.spec != oldObject.spec'
variables:
- name: ws
expression: 'object.spec.?template.?spec.orValue(object.spec)'
- name: prefixes
expression: >-
params.data.?allowedRegistries.orValue('').split(',')
.map(p, p.trim()).filter(p, p != '')
- name: images
expression: >-
(object.kind == 'Artifact' ? [object.spec.image] : []) +
variables.ws.?components.orValue([]).map(c, c.image) +
(has(variables.ws.service) ? [variables.ws.service.image] : [])
- name: untrusted
expression: >-
variables.images.filter(i, !variables.prefixes.exists(p, i.startsWith(p)))
validations:
- expression: 'size(variables.untrusted) == 0'
messageExpression: >-
'[COSMONIC-REGISTRY] ' + object.kind + '/' + object.metadata.name + ': ' +
variables.untrusted.join(', ') + ' is not from an allowed registry (' +
variables.prefixes.join(', ') + ')'
reason: Forbidden
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: cosmonic-registries
spec:
policyName: cosmonic-registries
validationActions: ['Deny']
paramRef:
name: cosmonic-policy
parameterNotFoundAction: Deny
matchResources:
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restricted# COSMONIC-REGISTRY: component images come only from trusted registries.
# Every component, service, and Artifact image must start with a prefix listed
# in the namespace's cosmonic-policy ConfigMap (allowedRegistries). End each
# prefix with `/` so `ghcr.io/acme/` does not also admit `ghcr.io/acme-evil/`.
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: cosmonic-registries
spec:
failurePolicy: Fail
validationActions: ['Deny']
matchConstraints:
resourceRules:
- apiGroups: ['runtime.wasmcloud.dev']
apiVersions: ['v1alpha1']
operations: ['CREATE', 'UPDATE']
resources: ['workloads', 'workloadreplicasets', 'workloaddeployments', 'artifacts']
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restricted
# Without these, objects that predate the policy could no longer be updated
# by the operator or have their finalizer removed. On create, oldObject is
# null under VAP and empty under Kyverno.
matchConditions:
- name: not-being-deleted
expression: '!has(object.metadata.deletionTimestamp)'
- name: spec-changed
expression: 'oldObject == null || !has(oldObject.spec) || object.spec != oldObject.spec'
variables:
# Kyverno has no paramKind; the policy reads the ConfigMap itself.
- name: params
expression: >-
resource.Get('v1', 'configmaps', object.metadata.namespace, 'cosmonic-policy')
- name: ws
expression: 'object.spec.?template.?spec.orValue(object.spec)'
- name: prefixes
expression: >-
variables.params.data.?allowedRegistries.orValue('').split(',')
.map(p, p.trim()).filter(p, p != '')
- name: images
expression: >-
(object.kind == 'Artifact' ? [object.spec.image] : []) +
variables.ws.?components.orValue([]).map(c, c.image) +
(has(variables.ws.service) ? [variables.ws.service.image] : [])
- name: untrusted
expression: >-
variables.images.filter(i, !variables.prefixes.exists(p, i.startsWith(p)))
validations:
- expression: 'size(variables.untrusted) == 0'
messageExpression: >-
'[COSMONIC-REGISTRY] ' + object.kind + '/' + object.metadata.name + ': ' +
variables.untrusted.join(', ') + ' is not from an allowed registry (' +
variables.prefixes.join(', ') + ')'
reason: ForbiddenCOSMONIC-DIGEST
A tag can be moved to point at different content after the workload that uses it was reviewed; a digest cannot. This policy requires every image to be pinned by digest, as image@sha256:....
- ValidatingAdmissionPolicy
- Kyverno
# COSMONIC-DIGEST: images are pinned by digest.
# A tag can be moved to different content after review; a digest cannot.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: cosmonic-digests
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: ['runtime.wasmcloud.dev']
apiVersions: ['v1alpha1']
operations: ['CREATE', 'UPDATE']
resources: ['workloads', 'workloadreplicasets', 'workloaddeployments', 'artifacts']
# Without these, objects that predate the policy could no longer be updated
# by the operator or have their finalizer removed. On create, oldObject is
# null under VAP and empty under Kyverno.
matchConditions:
- name: not-being-deleted
expression: '!has(object.metadata.deletionTimestamp)'
- name: spec-changed
expression: 'oldObject == null || !has(oldObject.spec) || object.spec != oldObject.spec'
variables:
- name: ws
expression: 'object.spec.?template.?spec.orValue(object.spec)'
- name: images
expression: >-
(object.kind == 'Artifact' ? [object.spec.image] : []) +
variables.ws.?components.orValue([]).map(c, c.image) +
(has(variables.ws.service) ? [variables.ws.service.image] : [])
- name: unpinned
expression: "variables.images.filter(i, !i.contains('@sha256:'))"
validations:
- expression: 'size(variables.unpinned) == 0'
messageExpression: >-
'[COSMONIC-DIGEST] ' + object.kind + '/' + object.metadata.name + ': ' +
variables.unpinned.join(', ') + ' must be pinned by digest (image@sha256:...)'
reason: Forbidden
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: cosmonic-digests
spec:
policyName: cosmonic-digests
validationActions: ['Deny']
matchResources:
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restricted# COSMONIC-DIGEST: images are pinned by digest.
# A tag can be moved to different content after review; a digest cannot.
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: cosmonic-digests
spec:
failurePolicy: Fail
validationActions: ['Deny']
matchConstraints:
resourceRules:
- apiGroups: ['runtime.wasmcloud.dev']
apiVersions: ['v1alpha1']
operations: ['CREATE', 'UPDATE']
resources: ['workloads', 'workloadreplicasets', 'workloaddeployments', 'artifacts']
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restricted
# Without these, objects that predate the policy could no longer be updated
# by the operator or have their finalizer removed. On create, oldObject is
# null under VAP and empty under Kyverno.
matchConditions:
- name: not-being-deleted
expression: '!has(object.metadata.deletionTimestamp)'
- name: spec-changed
expression: 'oldObject == null || !has(oldObject.spec) || object.spec != oldObject.spec'
variables:
- name: ws
expression: 'object.spec.?template.?spec.orValue(object.spec)'
- name: images
expression: >-
(object.kind == 'Artifact' ? [object.spec.image] : []) +
variables.ws.?components.orValue([]).map(c, c.image) +
(has(variables.ws.service) ? [variables.ws.service.image] : [])
- name: unpinned
expression: "variables.images.filter(i, !i.contains('@sha256:'))"
validations:
- expression: 'size(variables.unpinned) == 0'
messageExpression: >-
'[COSMONIC-DIGEST] ' + object.kind + '/' + object.metadata.name + ': ' +
variables.unpinned.join(', ') + ' must be pinned by digest (image@sha256:...)'
reason: ForbiddenCOSMONIC-INTERFACES
hostInterfaces are the capabilities a workload imports from the host, such as wasi:http or wasmcloud:messaging. This policy grants capabilities per namespace: each entry's namespace:package must be listed in the namespace's allowedInterfaces. See Principle of Least Authority for why capabilities are granted this way.
- ValidatingAdmissionPolicy
- Kyverno
# COSMONIC-INTERFACES: workloads only import capabilities the namespace was granted.
# Each hostInterfaces entry's namespace:package must be listed in the
# namespace's cosmonic-policy ConfigMap (allowedInterfaces).
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: cosmonic-interfaces
spec:
failurePolicy: Fail
paramKind:
apiVersion: v1
kind: ConfigMap
matchConstraints:
resourceRules:
- apiGroups: ['runtime.wasmcloud.dev']
apiVersions: ['v1alpha1']
operations: ['CREATE', 'UPDATE']
resources: ['workloads', 'workloadreplicasets', 'workloaddeployments']
# Without these, objects that predate the policy could no longer be updated
# by the operator or have their finalizer removed. On create, oldObject is
# null under VAP and empty under Kyverno.
matchConditions:
- name: not-being-deleted
expression: '!has(object.metadata.deletionTimestamp)'
- name: spec-changed
expression: 'oldObject == null || !has(oldObject.spec) || object.spec != oldObject.spec'
variables:
- name: ws
expression: 'object.spec.?template.?spec.orValue(object.spec)'
- name: granted
expression: >-
params.data.?allowedInterfaces.orValue('').split(',')
.map(i, i.trim()).filter(i, i != '')
- name: ungranted
expression: >-
variables.ws.?hostInterfaces.orValue([])
.map(i, i.namespace + ':' + i.package)
.filter(i, !(i in variables.granted))
validations:
- expression: 'size(variables.ungranted) == 0'
messageExpression: >-
'[COSMONIC-INTERFACES] ' + object.kind + '/' + object.metadata.name + ': ' +
variables.ungranted.join(', ') + ' is not granted to this namespace (' +
variables.granted.join(', ') + ')'
reason: Forbidden
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: cosmonic-interfaces
spec:
policyName: cosmonic-interfaces
validationActions: ['Deny']
paramRef:
name: cosmonic-policy
parameterNotFoundAction: Deny
matchResources:
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restricted# COSMONIC-INTERFACES: workloads only import capabilities the namespace was granted.
# Each hostInterfaces entry's namespace:package must be listed in the
# namespace's cosmonic-policy ConfigMap (allowedInterfaces).
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: cosmonic-interfaces
spec:
failurePolicy: Fail
validationActions: ['Deny']
matchConstraints:
resourceRules:
- apiGroups: ['runtime.wasmcloud.dev']
apiVersions: ['v1alpha1']
operations: ['CREATE', 'UPDATE']
resources: ['workloads', 'workloadreplicasets', 'workloaddeployments']
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restricted
# Without these, objects that predate the policy could no longer be updated
# by the operator or have their finalizer removed. On create, oldObject is
# null under VAP and empty under Kyverno.
matchConditions:
- name: not-being-deleted
expression: '!has(object.metadata.deletionTimestamp)'
- name: spec-changed
expression: 'oldObject == null || !has(oldObject.spec) || object.spec != oldObject.spec'
variables:
# Kyverno has no paramKind; the policy reads the ConfigMap itself.
- name: params
expression: >-
resource.Get('v1', 'configmaps', object.metadata.namespace, 'cosmonic-policy')
- name: ws
expression: 'object.spec.?template.?spec.orValue(object.spec)'
- name: granted
expression: >-
variables.params.data.?allowedInterfaces.orValue('').split(',')
.map(i, i.trim()).filter(i, i != '')
- name: ungranted
expression: >-
variables.ws.?hostInterfaces.orValue([])
.map(i, i.namespace + ':' + i.package)
.filter(i, !(i in variables.granted))
validations:
- expression: 'size(variables.ungranted) == 0'
messageExpression: >-
'[COSMONIC-INTERFACES] ' + object.kind + '/' + object.metadata.name + ': ' +
variables.ungranted.join(', ') + ' is not granted to this namespace (' +
variables.granted.join(', ') + ')'
reason: ForbiddenCOSMONIC-PLACEMENT
With the chart's default operator.allowSharedHosts=true, a workload that names no environment can be placed on any matching host in the cluster, and one that names another namespace's environment runs on that namespace's hosts. hostId pins a workload to one specific host. This policy requires environment to be the workload's own namespace and refuses hostId, so a tenant's workloads only run on the tenant's own hosts.
Setting operator.allowSharedHosts=false has the operator apply the same environment rule when it schedules. Use both: the policy refuses a misplaced workload when it is applied, with a message saying why, where the operator can only leave it unscheduled.
- ValidatingAdmissionPolicy
- Kyverno
# COSMONIC-PLACEMENT: workloads run only on their own namespace's hosts.
# A hostgroup reports its namespace as its environment, and the scheduler only
# places a workload on hosts in the environment it names. Requiring that to be
# the workload's own namespace, and refusing a pinned hostId, keeps a tenant
# off every other tenant's hosts.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: cosmonic-placement
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: ['runtime.wasmcloud.dev']
apiVersions: ['v1alpha1']
operations: ['CREATE', 'UPDATE']
resources: ['workloads', 'workloadreplicasets', 'workloaddeployments']
# Without these, objects that predate the policy could no longer be updated
# by the operator or have their finalizer removed. On create, oldObject is
# null under VAP and empty under Kyverno.
matchConditions:
- name: not-being-deleted
expression: '!has(object.metadata.deletionTimestamp)'
- name: spec-changed
expression: 'oldObject == null || !has(oldObject.spec) || object.spec != oldObject.spec'
variables:
- name: ws
expression: 'object.spec.?template.?spec.orValue(object.spec)'
validations:
- expression: "variables.ws.?environment.orValue('') == namespaceObject.metadata.name"
messageExpression: >-
'[COSMONIC-PLACEMENT] ' + object.kind + '/' + object.metadata.name +
': environment must be set to this namespace (' + namespaceObject.metadata.name + ')'
reason: Forbidden
- expression: "variables.ws.?hostId.orValue('') == ''"
messageExpression: >-
'[COSMONIC-PLACEMENT] ' + object.kind + '/' + object.metadata.name +
': hostId may not pin a workload to a specific host'
reason: Forbidden
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: cosmonic-placement
spec:
policyName: cosmonic-placement
validationActions: ['Deny']
matchResources:
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restricted# COSMONIC-PLACEMENT: workloads run only on their own namespace's hosts.
# A hostgroup reports its namespace as its environment, and the scheduler only
# places a workload on hosts in the environment it names. Requiring that to be
# the workload's own namespace, and refusing a pinned hostId, keeps a tenant
# off every other tenant's hosts.
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: cosmonic-placement
spec:
failurePolicy: Fail
validationActions: ['Deny']
matchConstraints:
resourceRules:
- apiGroups: ['runtime.wasmcloud.dev']
apiVersions: ['v1alpha1']
operations: ['CREATE', 'UPDATE']
resources: ['workloads', 'workloadreplicasets', 'workloaddeployments']
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restricted
# Without these, objects that predate the policy could no longer be updated
# by the operator or have their finalizer removed. On create, oldObject is
# null under VAP and empty under Kyverno.
matchConditions:
- name: not-being-deleted
expression: '!has(object.metadata.deletionTimestamp)'
- name: spec-changed
expression: 'oldObject == null || !has(oldObject.spec) || object.spec != oldObject.spec'
variables:
- name: ws
expression: 'object.spec.?template.?spec.orValue(object.spec)'
validations:
- expression: "variables.ws.?environment.orValue('') == namespaceObject.metadata.name"
messageExpression: >-
'[COSMONIC-PLACEMENT] ' + object.kind + '/' + object.metadata.name +
': environment must be set to this namespace (' + namespaceObject.metadata.name + ')'
reason: Forbidden
- expression: "variables.ws.?hostId.orValue('') == ''"
messageExpression: >-
'[COSMONIC-PLACEMENT] ' + object.kind + '/' + object.metadata.name +
': hostId may not pin a workload to a specific host'
reason: ForbiddenCOSMONIC-LIMITS
Workloads from many deployments share each host, so one oversized deployment can starve the others. This policy caps replicas, poolSize (warm instances kept per component), and maxConcurrency (calls one instance serves at once) at the namespace's maxReplicas, maxPoolSize, and maxConcurrency. A key left out of the ConfigMap is not capped.
- ValidatingAdmissionPolicy
- Kyverno
# COSMONIC-LIMITS: caps on how much of a shared host one workload can claim.
# maxReplicas, maxPoolSize, and maxConcurrency come from the namespace's
# cosmonic-policy ConfigMap; a key left out there is not capped. Unset fields on
# the workload take their defaults (1 replica, no pool, 1 concurrent call), so
# only fields that are set need checking.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: cosmonic-limits
spec:
failurePolicy: Fail
paramKind:
apiVersion: v1
kind: ConfigMap
matchConstraints:
resourceRules:
- apiGroups: ['runtime.wasmcloud.dev']
apiVersions: ['v1alpha1']
operations: ['CREATE', 'UPDATE']
resources: ['workloads', 'workloadreplicasets', 'workloaddeployments']
# Without these, objects that predate the policy could no longer be updated
# by the operator or have their finalizer removed. On create, oldObject is
# null under VAP and empty under Kyverno.
matchConditions:
- name: not-being-deleted
expression: '!has(object.metadata.deletionTimestamp)'
- name: spec-changed
expression: 'oldObject == null || !has(oldObject.spec) || object.spec != oldObject.spec'
variables:
- name: ws
expression: 'object.spec.?template.?spec.orValue(object.spec)'
- name: components
expression: 'variables.ws.?components.orValue([])'
validations:
- expression: >-
!has(params.data.maxReplicas) ||
dyn(object.spec).?replicas.orValue(1) <= int(params.data.maxReplicas)
messageExpression: >-
'[COSMONIC-LIMITS] ' + object.kind + '/' + object.metadata.name +
': replicas may not exceed ' + params.data.maxReplicas
reason: Forbidden
- expression: >-
!has(params.data.maxPoolSize) ||
variables.components.all(c, c.?poolSize.orValue(0) <= int(params.data.maxPoolSize))
messageExpression: >-
'[COSMONIC-LIMITS] ' + object.kind + '/' + object.metadata.name +
': poolSize may not exceed ' + params.data.maxPoolSize
reason: Forbidden
- expression: >-
!has(params.data.maxConcurrency) ||
variables.components.all(c, c.?maxConcurrency.orValue(1) <= int(params.data.maxConcurrency))
messageExpression: >-
'[COSMONIC-LIMITS] ' + object.kind + '/' + object.metadata.name +
': maxConcurrency may not exceed ' + params.data.maxConcurrency
reason: Forbidden
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: cosmonic-limits
spec:
policyName: cosmonic-limits
validationActions: ['Deny']
paramRef:
name: cosmonic-policy
parameterNotFoundAction: Deny
matchResources:
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restricted# COSMONIC-LIMITS: caps on how much of a shared host one workload can claim.
# maxReplicas, maxPoolSize, and maxConcurrency come from the namespace's
# cosmonic-policy ConfigMap; a key left out there is not capped. Unset fields on
# the workload take their defaults (1 replica, no pool, 1 concurrent call), so
# only fields that are set need checking.
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: cosmonic-limits
spec:
failurePolicy: Fail
validationActions: ['Deny']
matchConstraints:
resourceRules:
- apiGroups: ['runtime.wasmcloud.dev']
apiVersions: ['v1alpha1']
operations: ['CREATE', 'UPDATE']
resources: ['workloads', 'workloadreplicasets', 'workloaddeployments']
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restricted
# Without these, objects that predate the policy could no longer be updated
# by the operator or have their finalizer removed. On create, oldObject is
# null under VAP and empty under Kyverno.
matchConditions:
- name: not-being-deleted
expression: '!has(object.metadata.deletionTimestamp)'
- name: spec-changed
expression: 'oldObject == null || !has(oldObject.spec) || object.spec != oldObject.spec'
variables:
# Kyverno has no paramKind; the policy reads the ConfigMap itself.
- name: params
expression: >-
resource.Get('v1', 'configmaps', object.metadata.namespace, 'cosmonic-policy')
- name: ws
expression: 'object.spec.?template.?spec.orValue(object.spec)'
- name: components
expression: 'variables.ws.?components.orValue([])'
validations:
- expression: >-
!has(variables.params.data.maxReplicas) ||
dyn(object.spec).?replicas.orValue(1) <= int(variables.params.data.maxReplicas)
messageExpression: >-
'[COSMONIC-LIMITS] ' + object.kind + '/' + object.metadata.name +
': replicas may not exceed ' + variables.params.data.maxReplicas
reason: Forbidden
- expression: >-
!has(variables.params.data.maxPoolSize) ||
variables.components.all(c, c.?poolSize.orValue(0) <= int(variables.params.data.maxPoolSize))
messageExpression: >-
'[COSMONIC-LIMITS] ' + object.kind + '/' + object.metadata.name +
': poolSize may not exceed ' + variables.params.data.maxPoolSize
reason: Forbidden
- expression: >-
!has(variables.params.data.maxConcurrency) ||
variables.components.all(c, c.?maxConcurrency.orValue(1) <= int(variables.params.data.maxConcurrency))
messageExpression: >-
'[COSMONIC-LIMITS] ' + object.kind + '/' + object.metadata.name +
': maxConcurrency may not exceed ' + variables.params.data.maxConcurrency
reason: ForbiddenCOSMONIC-INGRESS
An HTTPTrigger claims hostnames on the shared ingress. Without a check, a tenant can claim a hostname another tenant serves and receive its traffic. This policy requires every hostname to be the namespace's ingressDomain or a subdomain of it, and refuses wildcard hostnames.
- ValidatingAdmissionPolicy
- Kyverno
# COSMONIC-INGRESS: HTTPTriggers only claim hostnames the namespace owns.
# Every hostname must sit under the ingressDomain in the namespace's
# cosmonic-policy ConfigMap, so one tenant cannot route another tenant's
# traffic to itself. Wildcard hostnames are refused outright.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: cosmonic-ingress
spec:
failurePolicy: Fail
paramKind:
apiVersion: v1
kind: ConfigMap
matchConstraints:
resourceRules:
- apiGroups: ['control.cosmonic.io']
apiVersions: ['v1alpha2']
operations: ['CREATE', 'UPDATE']
resources: ['httptriggers']
# Without these, objects that predate the policy could no longer be updated
# by the operator or have their finalizer removed. On create, oldObject is
# null under VAP and empty under Kyverno.
matchConditions:
- name: not-being-deleted
expression: '!has(object.metadata.deletionTimestamp)'
- name: spec-changed
expression: 'oldObject == null || !has(oldObject.spec) || object.spec != oldObject.spec'
variables:
- name: domain
expression: "params.data.?ingressDomain.orValue('')"
- name: unowned
expression: >-
object.spec.?hostnames.orValue([]).filter(h,
h.startsWith('*') || variables.domain == '' ||
!(h == variables.domain || h.endsWith('.' + variables.domain)))
validations:
- expression: 'size(variables.unowned) == 0'
messageExpression: >-
'[COSMONIC-INGRESS] ' + object.kind + '/' + object.metadata.name + ': ' +
variables.unowned.join(', ') + ' is not under this namespace\'s domain (' +
variables.domain + ')'
reason: Forbidden
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: cosmonic-ingress
spec:
policyName: cosmonic-ingress
validationActions: ['Deny']
paramRef:
name: cosmonic-policy
parameterNotFoundAction: Deny
matchResources:
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restricted# COSMONIC-INGRESS: HTTPTriggers only claim hostnames the namespace owns.
# Every hostname must sit under the ingressDomain in the namespace's
# cosmonic-policy ConfigMap, so one tenant cannot route another tenant's
# traffic to itself. Wildcard hostnames are refused outright.
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: cosmonic-ingress
spec:
failurePolicy: Fail
validationActions: ['Deny']
matchConstraints:
resourceRules:
- apiGroups: ['control.cosmonic.io']
apiVersions: ['v1alpha2']
operations: ['CREATE', 'UPDATE']
resources: ['httptriggers']
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restricted
# Without these, objects that predate the policy could no longer be updated
# by the operator or have their finalizer removed. On create, oldObject is
# null under VAP and empty under Kyverno.
matchConditions:
- name: not-being-deleted
expression: '!has(object.metadata.deletionTimestamp)'
- name: spec-changed
expression: 'oldObject == null || !has(oldObject.spec) || object.spec != oldObject.spec'
variables:
# Kyverno has no paramKind; the policy reads the ConfigMap itself.
- name: params
expression: >-
resource.Get('v1', 'configmaps', object.metadata.namespace, 'cosmonic-policy')
- name: domain
expression: "variables.params.data.?ingressDomain.orValue('')"
- name: unowned
expression: >-
object.spec.?hostnames.orValue([]).filter(h,
h.startsWith('*') || variables.domain == '' ||
!(h == variables.domain || h.endsWith('.' + variables.domain)))
validations:
- expression: 'size(variables.unowned) == 0'
messageExpression: >-
'[COSMONIC-INGRESS] ' + object.kind + '/' + object.metadata.name + ': ' +
variables.unowned.join(', ') + ' is not under this namespace\'s domain (' +
variables.domain + ')'
reason: ForbiddenCOSMONIC-PARAMS
The cosmonic-policy ConfigMap sets what every other policy allows, and the namespace's standard edit and admin roles can write ConfigMaps. Without a check, a tenant could add its own registry to allowedRegistries, or any other key, and then deploy what that admits. This policy lets only a user who can update or patch the namespace create or change the ConfigMap. That user could remove the cosmonic.io/policy-profile label anyway, so the policy takes nothing from them. Deleting the ConfigMap stays allowed: without it, the other policies refuse every workload in the namespace, so a tenant who deletes it only locks itself out.
- ValidatingAdmissionPolicy
- Kyverno
# COSMONIC-PARAMS: tenants cannot loosen their own policy parameters.
# The cosmonic-policy ConfigMap sets what the other policies allow, and a
# namespace's edit and admin roles can write ConfigMaps. Only someone who may
# change the namespace, and so could remove its policy-profile label anyway,
# may create or change it. Deleting it is left alone: the other policies then
# refuse every workload in the namespace.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: cosmonic-params
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: ['']
apiVersions: ['v1']
operations: ['CREATE', 'UPDATE']
resources: ['configmaps']
matchConditions:
# A request with no name, such as a generateName create, would otherwise
# fail the expression and be refused.
- name: policy-configmap
expression: "has(request.name) && request.name == 'cosmonic-policy'"
variables:
- name: ns
expression: "authorizer.group('').resource('namespaces').name(request.namespace)"
validations:
- expression: >-
variables.ns.check('update').allowed() ||
variables.ns.check('patch').allowed()
messageExpression: >-
'[COSMONIC-PARAMS] ConfigMap/cosmonic-policy: only those who can update namespace ' +
request.namespace + ' may change its policy parameters'
reason: Forbidden
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: cosmonic-params
spec:
policyName: cosmonic-params
validationActions: ['Deny']
matchResources:
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restrictedKyverno has no authorizer, so this copy asks the API server with a SubjectAccessReview, and skips background scans, which have no request to judge.
# COSMONIC-PARAMS: tenants cannot loosen their own policy parameters.
# The cosmonic-policy ConfigMap sets what the other policies allow, and a
# namespace's edit and admin roles can write ConfigMaps. Only someone who may
# change the namespace, and so could remove its policy-profile label anyway,
# may create or change it. Deleting it is left alone: the other policies then
# refuse every workload in the namespace.
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: cosmonic-params
spec:
failurePolicy: Fail
validationActions: ['Deny']
# Who may change the ConfigMap is a question about a request; a background
# scan has none.
evaluation:
background:
enabled: false
matchConstraints:
resourceRules:
- apiGroups: ['']
apiVersions: ['v1']
operations: ['CREATE', 'UPDATE']
resources: ['configmaps']
namespaceSelector:
matchLabels:
cosmonic.io/policy-profile: restricted
matchConditions:
# A request with no name, such as a generateName create, would otherwise
# fail the expression and be refused.
- name: policy-configmap
expression: "has(request.name) && request.name == 'cosmonic-policy'"
# Kyverno has no authorizer; ask the API server with a SubjectAccessReview.
variables:
- name: canChangeNamespace
expression: >-
['update', 'patch'].exists(verb,
resource.Post('authorization.k8s.io/v1', 'subjectaccessreviews', {
'apiVersion': dyn('authorization.k8s.io/v1'),
'kind': dyn('SubjectAccessReview'),
'spec': dyn({
'user': dyn(request.userInfo.username),
'uid': dyn(request.userInfo.?uid.orValue('')),
'groups': dyn(request.userInfo.?groups.orValue([])),
'extra': dyn(request.userInfo.?extra.orValue({})),
'resourceAttributes': dyn({'verb': verb, 'resource': 'namespaces', 'name': request.namespace})
})
}).?status.?allowed.orValue(false))
validations:
- expression: 'variables.canChangeNamespace'
messageExpression: >-
'[COSMONIC-PARAMS] ConfigMap/cosmonic-policy: only those who can update namespace ' +
request.namespace + ' may change its policy parameters'
reason: ForbiddenChecking a manifest
A server-side dry run sends a manifest through admission without creating anything, which makes it a quick way to check a workload against the policies, or to test a policy change:
kubectl apply --dry-run=server -n tenant-a -f workload.yamlWriting your own policies
The policies above are a starting point. When writing more, keep the conventions that make these safe to roll out:
- Match
workloads,workloadreplicasets, andworkloaddeployments, and read the workload spec withobject.spec.?template.?spec.orValue(object.spec), which finds it in all three. - Keep the two
matchConditions, so existing objects can still be updated and deleted. - Name API versions explicitly rather than with
*. Kyverno's reports controller cannot resolve a wildcard version for custom resources, and silently scans nothing. - Read the namespace from
namespaceObject.metadata.name. On Kubernetes 1.30, CEL does not exposemetadata.namespacefor custom resources.