Chio/Docs

BuildContainer Platformsnew

Kubernetes Admission Control

Use a ChioPolicy CRD and fail-closed admission webhooks to check signed capabilities before Kubernetes admits a pod.

Policy and admission verification

ChioPolicy is the declarative record an operator authors and kubectl surfaces. The webhook is the enforcement point. Its trust set and required-scope list come from its own environment; a namespace opts into enforcement with a label. The two use the same canonical scope grammar, so what a policy declares and what the webhook enforces are written the same way.
rendering…
A pod CREATE in an enforcing namespace is routed to the validating webhook. VerifyCapability runs the full flow against the configured trust set; a denial carries the reason back through the API server to the client.

The ChioPolicy CRD

ChioPolicy is a namespaced custom resource in the chio.world API group, served at v1alpha1. It declares the scopes pods in a namespace must present, an enforcement mode, an optional selector, and default sidecar wiring for matching pods.

chiopolicy.yaml
apiVersion: chio.world/v1alpha1
kind: ChioPolicy
metadata:
  name: payments-guardrail
  namespace: payments
spec:
  # Every admitted pod must present a capability covering all of these.
  requiredScopes:
    - "ledger:invoke"
    - "resource:receipts/*:read"
  # Optional: narrow the policy to matching pods. Omit to cover the namespace.
  selector:
    matchLabels:
      chio.world/governed: "true"
  # enforce (default) | audit | disabled
  enforcement: enforce
  sidecarConfig:
    image: ghcr.io/backbay-labs/chio-sidecar:latest
    upstream: http://localhost:8080
    specPath: /etc/chio/openapi.yaml
    receiptStore: bigquery://chio_receipts.payments
    autoInject: false

Install the definition before applying any policy. The CRD ships in the Chio repository at sdks/k8s/crds/chiopolicy-crd.yaml:

bash
$ kubectl apply -f sdks/k8s/crds/chiopolicy-crd.yaml
customresourcedefinition.apiextensions.k8s.io/chiopolicies.chio.world created

$ kubectl get chiopolicy -n payments
NAME                 SCOPES                                    ENFORCEMENT   AGE
payments-guardrail   [ledger:invoke resource:receipts/*:read] enforce       12s

The short name is chiop, and the printer columns show the required scopes and enforcement mode directly in kubectl get output. The status subresource carries observedGeneration and a standard conditions array.

FieldTypeMeaning
spec.requiredScopesstring[] (required, min 1)Canonical Chio scopes every governed pod's capability must cover.
spec.selectorlabel selectormatchLabels / matchExpressions (In, NotIn, Exists, DoesNotExist). Omitted means the whole namespace.
spec.enforcementenum, default enforceenforce rejects non-compliant pods; audit admits but logs violations; disabled skips enforcement.
spec.sidecarConfig.imagestringContainer image for the Chio sidecar governing matching pods.
spec.sidecarConfig.upstreamstringDefault upstream URL for the sidecar proxy.
spec.sidecarConfig.specPathstringPath to the OpenAPI spec the sidecar enforces.
spec.sidecarConfig.receiptStorestringReceipt storage backend URI.
spec.sidecarConfig.autoInjectbool, default falseInject the sidecar into matching pods without a per-pod annotation.

Install the Webhooks

Enforcement lives in two admission configurations, both shipped under sdks/k8s/webhooks. The validating webhook decides admission; the mutating webhook applies the same decision at CREATE so a mutation pass can never become a bypass.

ConfigurationWebhookPathOperations
ValidatingWebhookConfigurationvalidate.chio.world/validateCREATE, UPDATE
MutatingWebhookConfigurationmutate.chio.world/mutateCREATE

Both target pods at Namespaced scope, register only v1 in admissionReviewVersions, declare sideEffects: None, and set a 10-second timeout. Both use failurePolicy: Fail — if the webhook is unreachable, admission is denied rather than waved through.

validating-webhook.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: chio-validating-webhook
webhooks:
  - name: validate.chio.world
    admissionReviewVersions: ["v1"]
    clientConfig:
      service:
        name: chio-admission-controller
        namespace: chio-system
        path: /validate
        port: 443
      # caBundle injected by cert-manager or set manually.
    rules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
        scope: Namespaced
    failurePolicy: Fail
    sideEffects: None
    timeoutSeconds: 10
    namespaceSelector:
      matchLabels:
        chio.world/enforce: "true"

The webhook serves TLS on port 8443 behind a chio-admission-controller Service in the chio-system namespace, fronted by the Service's port 443. Provision the serving certificate the way you provision any admission-webhook cert — the manifests leave the caBundle for cert-manager to inject, or set it by hand. See Secrets & Signing Keys for cert and key handling.

The mutating webhook emits no patch

In the shipped binary the mutating handler runs the identical verification and returns an allow with no patch. It never relaxes the check: if the validating webhook would deny, the mutating webhook denies too. Sidecar wiring belongs to the injector that consumes spec.sidecarConfig, not to this handler.

Opt In with Namespace Labels

A namespace only reaches the webhooks when it carries the matching label. The validating webhook fires on chio.world/enforce: "true"; the mutating webhook fires on chio.world/inject: "true". Unlabeled namespaces are untouched, so rollout is namespace by namespace.

bash
$ kubectl label namespace payments chio.world/enforce=true
namespace/payments labeled

Configure the Trust Set

The webhook reads its verification policy from two environment variables, and only these two. There is no default: an unset or empty value fails closed, so a misconfigured deployment denies every pod rather than admitting on a permissive fallback.

VariableValueEmpty behavior
CHIO_WEBHOOK_TRUSTED_KERNEL_KEYSComma-separated Ed25519 issuer public keys, hex-encoded.Fail closed.
CHIO_WEBHOOK_REQUIRED_SCOPESComma-separated canonical scopes every capability must cover.Fail closed.

Each trusted key is validated at load time as a well-formed 32-byte Ed25519 public key; a malformed entry stops the process from starting. The required-scope strings share the grammar used in spec.requiredScopes, so a namespace's policy and the webhook's enforced list read the same.

deployment.env
env:
  - name: CHIO_WEBHOOK_TRUSTED_KERNEL_KEYS
    value: "25403c1e...f0,9a7b18d2...c4"   # kernel issuer public keys (hex)
  - name: CHIO_WEBHOOK_REQUIRED_SCOPES
    value: "ledger:invoke,resource:receipts/*:read"
  - name: TLS_CERT_FILE
    value: /etc/chio/tls/tls.crt
  - name: TLS_KEY_FILE
    value: /etc/chio/tls/tls.key

With TLS_CERT_FILE and TLS_KEY_FILE both set the server serves HTTPS with a TLS 1.2 floor; with neither set it logs a warning and serves plaintext for local development only. Override the listen port with PORT; the default is 8443. A GET /healthz endpoint returns ok for liveness and readiness probes.


What the Pod Carries

A governed pod presents its capability as canonical JSON in the chio.world/capability-token annotation. The token is the standard Chio capability shape: an issuer, a subject, a scope envelope, a validity window, an optional parameter binding, and an Ed25519 signature.

pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: ledger-writer
  namespace: payments
  labels:
    chio.world/governed: "true"
  annotations:
    chio.world/capability-token: |
      {"id":"cap-019dbbf8-...","issuer":"25403c1e...f0",
       "subject":"25403c1e...f0",
       "scope":{"grants":[{"server_id":"*","tool_name":"ledger",
         "operations":["invoke"]}],
         "resource_grants":[{"uri_pattern":"receipts/*",
         "operations":["read"]}]},
       "issued_at":1776975000,"expires_at":1776978600,
       "parameter_binding":{"operations":["CREATE"],
         "namespaces":["payments"],"resources":["pods"]},
       "signature":"7be63cdb..."}
spec:
  containers:
    - name: app
      image: myapp:latest

The signature covers a canonical serialization of every field except signature itself: keys sorted, no insignificant whitespace. The webhook reconstructs that byte string from the raw annotation, strips the signature field, and verifies against the issuer key — so re-ordering or reformatting the JSON does not change the verified bytes. For the capability model in full, see Capabilities.

Self-asserted policy is never trusted

A pod that sets chio.world/required-scopes or chio.world/exempt on itself is denied. Required scopes come from the webhook's configuration only, and the webhook honors no self-declared exemption — a workload cannot rewrite the operator's policy or opt itself out.

The Verification Flow

On every admission the webhook decodes the AdmissionReview, extracts the candidate token, and builds an admission context from the request: operation (upper-cased), namespace, and resource. It then runs the full flow, denying on the first failure with a typed reason that Kubernetes surfaces to the caller.

  1. Deny if no trusted kernel keys are configured.
  2. Deny if no required scopes are configured.
  3. Deny if the capability token is missing or malformed.
  4. Deny if the issuer is not in the trusted-key set.
  5. Deny if the Ed25519 signature does not verify.
  6. Deny if the token is not yet valid or has expired.
  7. Deny if the parameter binding does not cover the admission context.
  8. Deny if any required scope is not covered by the token's grants.

The validity window is compared against the request time in Unix seconds: a token is rejected while now < issued_at and once now >= expires_at. A token with no parameter_binding is permitted — scope coverage carries the authorization — but a binding that is present must match: its operations compare case-insensitively, its namespaces and resources exactly. Empty fields in a binding act as wildcards.

json
// A denied admission carries the reason in the response status.
{
  "apiVersion": "admission.k8s.io/v1",
  "kind": "AdmissionReview",
  "response": {
    "uid": "test-uid-001",
    "allowed": false,
    "status": {
      "message": "webhook denies admission: capability token does not cover required scope \"ledger:invoke\"",
      "code": 403
    }
  }
}

Scope Grammar

Required scopes and the grants inside a capability are written in the same colon-delimited grammar. The webhook accepts three forms and normalizes operation aliases before matching.

FormExpands toExample
<name>:<op>tool scope, any serverledger:invoke
<kind>:<name>:<op>resource or prompt scoperesource:receipts/*:read
tool:<server>:<name>:<op>fully-qualified tool scopetool:srv-files:read_file:invoke

A grant covers a required scope when the server and name patterns match and the operation is present. Patterns are exact, or a trailing * matches by prefix, or a bare * matches anything — so a grant on receipts/* covers a requirement on receipts/2026-07. Operation aliases fold together: call, exec, and execute normalize to invoke; watch to subscribe; result to read_result.


Governing Batch Jobs

Admission gating covers pods at the door. For batch Job workloads that need a grant minted at creation and receipts aggregated across their lifecycle, the Chio Kubernetes controller pairs with the webhook. It watches batch/v1 Jobs labeled chio.world/governed: "true", mints a capability through the sidecar at creation, harvests per-pod receipts, and releases the grant with a signed JobReceipt on completion or failure.

bash
# Controller manifests create the chio-system namespace, RBAC, and Deployment.
$ kubectl apply -f sdks/k8s/controller/config/manager/manager.yaml
$ kubectl apply -f sdks/k8s/controller/config/rbac/role.yaml

The controller ships as ghcr.io/backbay-labs/chio-k8s-controller and reaches the sidecar at CHIO_SIDECAR_URL. Its fail-closed configuration reinforces the webhook: if the sidecar is unreachable at mint time it requeues with backoff and persists no placeholder capability, so the Job's pods stay ungoverned and are rejected at admission rather than starting unsigned.

Where receipts land

The controller aggregates per-pod receipts into a JobReceipt and posts it to the sidecar's receipt store. Those receipts are the same cryptographic artifacts the rest of Chio emits — see Receipts for the format and verification path.

Next Steps

  • Capabilities for the token model the annotation carries: scope, delegation, and parameter binding.
  • Trust Model for what a trusted kernel key attests and how issuers are rotated.
  • Sidecar HTTP Service for the sidecar image referenced by spec.sidecarConfig.
  • Secrets & Signing Keys for provisioning the webhook's serving certificate.