This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Enforce

Enforcement policies and restrictions on a per-Namespace basis with Rules

Namespace rules can enforce admission behavior for selected resources in Tenant namespaces. Each enforce block can define an action and one or more matchers.

Each policy chapter identifies its API configuration path, then opens with Rule Composition: Actions and order, followed by Admission scope and limits. Policy sections explain configuration and examples. The final Reference section provides complete Tenant examples. Use the policy reference to choose a chapter.

See Order and scope for how mutation precedes enforcement, and Conditions for conditional enforcement. Audience and match expressions are documented under Rules.

Rule Composition

Actions and order

Rules are evaluated in declaration order. If multiple allow or deny rules match the same evaluated value, the last matching allow or deny rule wins. If at least one allow rule is configured for a matcher and no allow or deny rule matches the evaluated value, Capsule denies the request. In other words, allow rules create an allow-list for that matcher. audit rules are purely observational: they never influence the allow/deny decision, and report matches through Kubernetes events. Some policies also add admission warnings; see the reporting behavior documented for each policy.

Each enforce block supports an action field:

ActionBehavior
allowAllows the matching request and enables allow-list behavior for the matcher. If at least one allow rule exists and no allow or deny rule matches a value, Capsule denies that value. Additional constraints, such as image pull policy, must also be satisfied.
denyDenies the matching request. A later matching allow rule can override it.
auditReports matching values without allowing or denying the request. NetworkPolicy CIDR rules emit Kubernetes events; other policies may also return admission warnings.

If action is omitted, Capsule treats the rule as deny.

Storage volume rules use these actions only for additional access to PVs without a Tenant ownership label. They preserve existing Tenant-owned volume access, and cannot override another Tenant’s PV label. A storage audit match never grants access.

Allow-list behavior is evaluated per matcher and per evaluated value. For example, if a registry allow rule exists for harbor/.*, a Pod image from docker.io/library/nginx:latest is denied unless another later or earlier allow rule also matches that image. Audit rules do not satisfy this allow-list requirement.

This precedence model allows both broad defaults and specific exceptions. For example, you can allow all Harbor images but deny a customer path afterwards:

rules:
  - enforce:
      action: allow
      workloads:
        registries:
          - exp: "harbor/.*"

  - enforce:
      action: deny
      workloads:
        registries:
          - exp: "harbor/customer/.*"

In this example, harbor/nginx:1.14.2 is allowed, while harbor/customer/app:1.0.0 is denied because the later, more specific deny rule also matches.

You can also deny broadly and allow a more specific exception afterwards:

rules:
  - enforce:
      action: deny
      workloads:
        registries:
          - exp: "harbor/customer/.*"

  - enforce:
      action: allow
      workloads:
        registries:
          - exp: "harbor/customer/prod-image/.*"

In this example, harbor/customer/test-image/app:1.0.0 is denied, while harbor/customer/prod-image/app:1.0.0 is allowed.

Audit

Use action: audit to observe workload usage without directly blocking the request. Audit rules emit Kubernetes events and add warnings to the admission response, but they do not allow or deny the request. If an allow-list is active for the same matcher and no allow rule matches the evaluated value, the request is still denied even when an audit rule matches.

For registry enforcement:

---
apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  ...
  rules:
    - enforce:
        action: audit
        workloads:
          targets:
            - pod/containers
          registries:
            - exp: "docker.io/.*"

Applying a Pod with docker.io/library/nginx:latest succeeds in this audit-only example because no registry allow-list is configured. The API server response contains an admission warning and Capsule emits a related event for the Pod.

For QoS enforcement:

rules:
  - enforce:
      action: audit
      workloads:
        qosClasses:
          - Burstable

Applying a Burstable Pod succeeds in this audit-only example because no QoS allow-list is configured. Capsule emits an event and returns an admission warning.

For scheduler enforcement:

rules:
  - enforce:
      action: audit
      workloads:
        placement:
          schedulers:
            - exact:
                - custom-scheduler

Applying a Pod with spec.schedulerName: custom-scheduler succeeds in this audit-only example because no scheduler allow-list is configured. Capsule emits an audit event and returns an admission warning.

When audit rules are used together with allow rules, the matching value must still be allowed explicitly. For example, an audited registry reference that does not match any registry allow rule is denied by the allow-list, but Capsule still emits the audit event before denying the request.

Admission scope and limits

Enforcement runs during admission for resources in the namespaces selected by the rule. Audience filters the caller, and conditions decide whether an enforcement block participates. Each policy chapter lists its supported resources, operations, subresources, and handling of missing values.

Mutation runs before validation. An allow decision applies to its evaluated policy and value; other applicable checks, RBAC, and Kubernetes API validation must still pass. In particular, an enclosing action: audit does not disable resource defaulting or mutation.

Enforcement evaluates incoming writes. Changing a rule or namespace profile does not by itself repair existing resources or revoke established bindings. Storage ownership, resource mutation, and checks involving related resources have specific limits documented in their chapters.

Reference

The following fields belong to spec.rules[].enforce. Each policy page includes a complete Tenant example and explains its supported resources, matching behavior, and admission scope.

PolicyField under enforceComplete Tenant example
WorkloadsworkloadsWorkload reference
MetadatametadataMetadata reference
ServicesservicesService reference
IngressingressIngress reference
Network Policiesnetwork.policies.ingress.cidrs, network.policies.egress.cidrsNetworkPolicy reference
Storagestorage.volumesStorage reference

To disallow CIDR-based ingress and egress grants in every namespace of a Tenant:

rules:
  - enforce:
      action: deny
      network:
        policies:
          ingress:
            cidrs:
              - 0.0.0.0/0
              - ::/0
          egress:
            cidrs:
              - 0.0.0.0/0
              - ::/0

Place this rules block under Tenant.spec. The rule rejects grants from or to any IPv4 or IPv6 CIDR, as well as unrestricted ingress or egress rules with omitted peers. Selector-based peers and empty grant lists pass their CIDR check. Each direction is independent; omit ingress or egress to leave that direction unconstrained. The NetworkPolicy reference shows how later rules can permit approved ranges in selected namespaces.

1 - Workloads

Workload enforcement

Workload enforcement is configured under spec.rules[].enforce.workloads. Use targets to select native workload kinds or locations inside their Pod specs. A targets-only rule matches the selected kinds themselves. When workload policies are present, targets scope those policies: resource management, OCI registries, scheduler names, QoS, placement, security profiles, and PodDisruptionBudgets.

Rules use the shared namespace selection, audiences, conditions, and actions and order. See Reference for a complete Tenant example.

Rule Composition

Actions and order

Rules are evaluated in declaration order after namespace selection, audience, and condition filtering. The last matching allow or deny decision wins for each evaluated value. An omitted action defaults to deny.

ActionBehavior
allowCreates an allow-list for the evaluated matcher. A value must match an applicable allow or deny rule; otherwise an active allow-list rejects it.
denyRejects matching values, unless a later matching allow rule overrides the decision.
auditReports matches without allowing or denying them. Audit matches never satisfy an allow-list.

Each policy and value is evaluated independently. Allowing a registry does not bypass a scheduler, security, placement, or other applicable constraint.

For constraints on requests and limits and PodDisruptionBudgets, deny rejects violations and allow requires compliance. Their policy sections explain which values constitute a match and how audit events and admission warnings are reported.

Resource policies also have a mutation phase. The last applicable policy is effective for each target, resource, and request or limit field. The enclosing action: audit does not disable resource mutation. See Rule order and policy overrides for how Preserve and Default reset earlier constraints.

Admission scope and limits

Workload rules govern admission of the selected resources and Pod-spec locations. Workload Targets maps kinds to their Pod specs; the controller and Pod tables show where each policy takes effect.

ScopeBehavior
Targets omittedPolicies use their supported Pod locations. Controller templates require explicit controller targets.
Creation and updatesValidation checks the incoming resource on supported write paths. Policy-specific subresource handling is described below.
Resource mutationRequest and limit policies mutate Pods on creation only. With explicit controller targets, they also mutate controller templates on creation and update.
Pod subresourcesResource validation skips Pod subresources, including ephemeralcontainers. Registry and security policies use their supported container targets.
PDB relationshipsChecks can run on workload writes, PDB writes, and supported controller replica changes, including /scale. See Evaluation for triggers and limits.
Existing resourcesRule or namespace-profile changes affect subsequent relevant admissions; they do not rewrite existing workloads.

A controller write and the Pods it later creates are separate admissions, often with different callers. Include the required targets and audiences for both paths. Conditions inspect the actual incoming resource, which can also be a PDB or a Scale object.

Kubernetes validation, LimitRanges, ResourceQuotas, and other admission policies still apply after Capsule’s checks. See Admission lifecycle for the resource policy stages and Interaction with Kubernetes resource controls for their combined behavior.

Workload Targets

Configure targets under spec.rules[].enforce.workloads.targets. The same list selects workloads for resource, registry, scheduler, QoS, placement, security, and PodDisruptionBudget policies; individual policies do not have separate target lists.

  • With no workload policies, targets apply the rule’s action to the selected workload kinds. For example, targets: [daemonset] with action: deny rejects DaemonSets.
  • With workload policies, targets scope those policies to the selected workloads and Pod-spec locations. Adding a policy means the rule checks that property instead of restricting the kind itself. Use separate rule entries when you need both kind restrictions and property checks.

Omitted or empty targets preserve the default Pod scopes described below and do not enable controller checks. An empty workload block has no effect. Namespace selectors, audiences, and enforce.conditions apply to both forms.

Each target selects the object being admitted. With workload policies configured, Capsule reads the Pod spec at the following location:

TargetNative API group / kindPod spec evaluated on that object
podcore / Podspec
deploymentapps / Deploymentspec.template.spec
statefulsetapps / StatefulSetspec.template.spec
daemonsetapps / DaemonSetspec.template.spec
replicasetapps / ReplicaSetspec.template.spec
replicationcontrollercore / ReplicationControllerspec.template.spec
jobbatch / Jobspec.template.spec
cronjobbatch / CronJobspec.jobTemplate.spec.template.spec

For example, cronjob/containers checks regular containers at spec.jobTemplate.spec.template.spec.containers[] on the CronJob itself. It does not select a Job or Pod by following its CronJob owner.

Requests match their own API group and kind, without following owner references. A custom resource named DaemonSet in another API group is not a native target. Targets use the exact lowercase values shown here; wildcards such as pod/*, deployment/*, and * are not supported.

Migration: targets-only rules previously had no effect. They now apply the action to the selected kinds, and an omitted action defaults to deny. Remove unused targets-only entries before upgrading if they were placeholders.

Controller targets

Controller targets check the stored Pod template during controller creation and main-resource updates. They do not wait for a Pod to be created. A denial rejects the controller write; an accepted template supplies the values Kubernetes later uses to create child workloads. Existing children are not rewritten by admission.

The table uses cronjob as an example. The same suffixes and policy scopes apply to every controller kind listed above, using that kind’s Pod-spec path. Paths in this table are relative to spec.jobTemplate.spec.template.spec. ✅ indicates support; ❌ means the policy does not apply to that target. Only policies configured in the rule are evaluated.

Policycronjobcronjob/containerscronjob/initcontainerscronjob/volumes
Resource policies✅ Pod-level resources and regular/init-container resources✅ containers[].resources✅ initContainers[].resources❌
Registry policies✅ Regular/init-container images and image volumes✅ Regular container images✅ Init container images✅ Image volumes under volumes[].image
Placement policies✅ Pod placement fields❌❌❌
Seccomp / AppArmor✅ Effective regular/init-container profiles✅ Effective regular-container profiles✅ Effective init-container profiles❌
PDB policies✅ Template labels: overlap and unhealthy eviction policy❌❌❌

For example, deployment/initcontainers has the same scope as cronjob/initcontainers, but reads spec.template.spec.initContainers[]. Controller templates do not support /ephemeralcontainers. Resource policies cannot be combined with volume targets.

The placement row covers node selectors, tolerations, topology spread, and affinity. Scheduler and QoS policies always evaluate the whole selected Pod spec, even with a container or volume suffix. For example, cronjob/initcontainers with a scheduler policy checks spec.jobTemplate.spec.template.spec.schedulerName.

A whole-controller target includes container resources and image references; the explicit pod target has the narrower scope in the Pod table. For security profiles, the effective value is the container override or its inherited Pod default. A whole-controller target does not independently require a Pod-level default when every container supplies its own allowed profile.

Whole-controller PDB checks use the Pod template’s labels to find matching budgets. CronJobs, Jobs, and DaemonSets support overlap and unhealthy eviction policy checks, but not evictableReplicas. See the PDB target table for the complete mapping.

Resource request/limit policies also mutate the selected template locations on controller creation and updates. Targets do not extend mutate[].workloads to controllers; mutations configured there remain limited to Pod creation. See Admission lifecycle.

Conditions see the whole admitted controller: use object.spec.template.spec for a Deployment and object.spec.jobTemplate.spec.template.spec for a CronJob. Events concern that controller object. Namespace selection, audiences, and conditions must match each request independently.

Example: CronJob admission

This rule denies a forbidden scheduler or image in Deployment and CronJob templates while permitting compliant ones:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        workloads:
          targets: [deployment, cronjob]
          placement:
            schedulers:
              - exact: [forbidden-scheduler]
          registries:
            - exact: ['example.com/blocked/app:v1']

For the CronJob path, assuming the namespace, audience, and conditions match:

Admission requestDoes this rule run?Effect
Create or update the CronJob✅ cronjob is selectedChecks the scheduler and image references under spec.jobTemplate.spec.template.spec. A match rejects the CronJob write.
The CronJob controller creates a Job❌ job is not selectedThe Job inherits the template, but this rule does not check its admission. Add job to check it.
The Job controller creates a Pod❌ No Pod target is selectedThe Pod inherits the template, but this rule does not check its admission. Add the relevant Pod targets to check it.

To check all three stages, include cronjob, job, and the Pod targets needed for the policy. For registry checks, use pod/containers, pod/initcontainers, pod/ephemeralcontainers, and/or pod/volumes for the image locations you want to cover; pod alone does not select images. Controller-created Jobs and Pods can have different callers, so include those identities in any audience filter.

With no workload policies, targets: [cronjob] instead acts on the kind: deny rejects CronJobs, while allow establishes a workload-kind allow-list. A targets-only allow-list must also allow job and pod for scheduled workloads to run. See Select Workloads.

Pod targets

When workload policies are present, Pod targets select these locations. ✅ indicates support; ❌ means the policy does not apply to that target.

TargetResource policiesRegistry policiesPlacement policiesSeccomp / AppArmorPDB policies
pod✅ Pod-level spec.resources❌✅ Pod placement fields✅ Pod default only✅ Overlap and unhealthy eviction policy
pod/containers✅ spec.containers[].resources✅ Regular container images❌✅ Effective regular-container profiles❌
pod/initcontainers✅ spec.initContainers[].resources✅ Init container images❌✅ Effective init-container profiles❌
pod/ephemeralcontainers❌✅ Ephemeral container images❌✅ Effective ephemeral-container profiles❌
pod/volumes❌✅ Image volumes under spec.volumes[].image❌❌❌

For seccomp and AppArmor, an effective profile resolves a container override before the Pod default. Init containers include restartable sidecars. Selecting pod alone checks the default and leaves container overrides unchecked. See Target security profiles for a complete Tenant and a comparison of these scopes.

This table describes enforcement. Profile mutations under mutate[].workloads set Pod-level defaults on Pod creation, using pod or omitted mutation targets. Neither merge nor replace modifies container profile overrides. For security.readOnlyRootFilesystem and registries.imagePullPolicy mutations, pod selects all container groups and container targets can narrow the selection. See the separate mutation targets table. Enforcement targets do not control mutation.

Scheduler and QoS policies always evaluate the whole Pod selected by any Pod target. For example, pod/containers does not limit QoS calculation to regular containers. Resource policies cannot be combined with targets for ephemeral containers or volumes.

Omitting targets, or setting it to [], selects all compatible Pod locations: Pod-level resources, regular and init container resources, regular/init/ephemeral container images, image volumes, and Pod placement fields. Scheduler and QoS checks also apply. Profile policies check effective regular, init, and ephemeral container profiles. PDB overlap and unhealthy eviction policies check the Pod; eviction-count bounds require an explicit supported controller target. Resource-name compatibility is described under Targeting resource locations.

An explicit targets: [pod] is narrower than omitted targets: it selects Pod-level resources, placement, and security-profile defaults, together with scheduler and QoS checks. It does not select container resources or any image references. List the desired Pod parts explicitly when narrowing container, image, or profile policies.

For example, deny matching images only in init containers:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        workloads:
          targets: [pod/initcontainers]
          registries:
            - exp: "harbor/init-only/.*"

The same image reference in a regular container, ephemeral container, or image volume is unaffected by this rule. Combine targets to check multiple locations:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        workloads:
          targets: [pod/containers, pod/ephemeralcontainers]
          registries:
            - exp: "debug/.*"

This checks regular and ephemeral container images. It does not check init containers or image volumes.

Targets without policies match the whole kind. A targets-only rule with targets: [pod/containers] and action: deny rejects Pods, just like targets: [pod]. A part suffix narrows property checks only when workload policies are configured.

Target security profiles

This complete Tenant gives namespaces two different profile policies. Namespaces labeled profile-scope: pod-only require RuntimeDefault in the Pod’s security context. Namespaces labeled profile-scope: containers require RuntimeDefault for every effective regular, init, and ephemeral container profile. Namespaces with neither label are unaffected by these rules.

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - namespaceSelector:
        matchLabels:
          profile-scope: pod-only
      enforce:
        action: allow
        workloads:
          targets: [pod]
          security:
            seccompProfiles:
              - types: [RuntimeDefault]
            appArmorProfiles:
              - types: [RuntimeDefault]
    - namespaceSelector:
        matchLabels:
          profile-scope: containers
      enforce:
        action: allow
        workloads:
          targets:
            - pod/containers
            - pod/initcontainers
            - pod/ephemeralcontainers
          security:
            seccompProfiles:
              - types: [RuntimeDefault]
            appArmorProfiles:
              - types: [RuntimeDefault]

These rules supply no defaults. For Linux Pods, each mechanism is checked independently:

Submitted profile configurationprofile-scope: pod-onlyprofile-scope: containers
Pod RuntimeDefault; all containers inherit it✅ Allowed✅ Allowed
Pod RuntimeDefault; regular container overrides it with Unconfined✅ Allowed❌ Rejected
Pod RuntimeDefault; init container overrides it with Unconfined✅ Allowed❌ Rejected
Pod RuntimeDefault; ephemeral container overrides it with Unconfined✅ Allowed❌ Rejected
No Pod profile; every container explicitly uses RuntimeDefault❌ Rejected✅ Allowed
No profile at either level❌ Rejected❌ Rejected

List only pod/initcontainers to restrict the profile checks to init containers. Combine pod with the three container targets to require both an allowed Pod default and allowed effective container profiles. Omitting targets checks all three container groups without independently requiring a Pod default.

The security reference combines Pod-level defaulting with container enforcement. A container override survives mutation and must then pass the selected enforcement checks, including on pods/ephemeralcontainers updates.

Select Workloads

A targets-only allow rule establishes an allow-list across the eight supported native workload kinds. Other resource kinds remain outside this allow-list. Allow controller-created children explicitly:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: allow
        workloads:
          targets: [deployment, replicaset, pod]

This allows Deployments, ReplicaSets, and Pods while rejecting the other supported native workload kinds. Allowing a Deployment alone does not allow its ReplicaSets or Pods: each request is evaluated separately. Kind restrictions apply to creation and main-resource updates, not deletion or subresources.

Deny DaemonSets

Add a targets-only rule under Tenant.spec.rules to deny DaemonSets in namespaces labeled env: test:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - namespaceSelector:
        matchLabels:
          env: test
      enforce:
        action: deny
        workloads:
          targets: [daemonset]

This rejects DaemonSet creation and main-resource updates in the selected namespaces. It does not remove existing DaemonSets or block deletion or subresources. Other workload kinds and non-selected namespaces are unaffected by this rule. To cover all namespaces in the Tenant, omit namespaceSelector.

Use action: audit to record matching requests without blocking them. For kind restrictions, the last matching allow or deny rule wins; audit does not change that decision.

Resource Management

Configure CPU, memory, and other resource requests and limits, together with Pod QoS policies.

Requests and Limits

Resource policies let a Tenant administrator normalize and enforce the requests and limits of Pods created in Tenant namespaces. They cover common requirements that a Kubernetes LimitRange cannot express directly, including:

  • always removing a resource limit;
  • making a limit equal to its request;
  • defaulting a missing request or limit without replacing an explicit value;
  • deriving a missing limit from a request using a maximum ratio; and
  • rejecting or auditing explicit limits that exceed that ratio.

Resource policies are configured under spec.rules[].enforce.workloads.resources. The requests and limits maps are keyed by Kubernetes resource name. Each map entry contains a case-sensitive policy and, for policies that need one, a value.

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  rules:
    - enforce:
        action: deny
        workloads:
          resources:
            limits:
              cpu:
                policy: Remove
              memory:
                policy: MatchRequest

At every compatible resource location, this example produces the following behavior:

  • CPU limits are removed, even when the submitted Pod defines them;
  • memory limits are set to the corresponding memory requests; and
  • because targets is omitted, the policy applies to Pod-level resources, regular containers, and init containers.

Before and after admission

Consider this submitted Pod:

apiVersion: v1
kind: Pod
metadata:
  name: api
spec:
  containers:
    - name: api
      image: registry.example.com/api:1.0.0
      resources:
        requests:
          cpu: 250m
          memory: 512Mi
        limits:
          cpu: "1"
          memory: 1Gi

Capsule admits it with the equivalent resource configuration:

resources:
  requests:
    cpu: 250m
    memory: 512Mi
  limits:
    memory: 512Mi

The CPU request is untouched, the CPU limit is removed, and the memory limit is replaced with the memory request. Because targets is omitted, Capsule applies the same policy independently to spec.resources, every regular container, and every init container in the Pod. The submitted example has no Pod-level or init container resources, so those locations remain empty.

If MatchRequest finds neither a request nor a limit, the resource is compliant and remains undefined. If it finds a limit without a corresponding request, it cannot derive a replacement and the final state violates the policy. With action: deny the Pod is rejected; with action: audit Capsule admits it and emits an audit event and admission warning.

Policy reference

After starting with the common example above, use this reference to choose the precise behavior for each request and limit.

Requests

The following policies are supported under resources.requests:

PolicyvalueMutation behaviorValidation behavior
PreserveNot allowedLeaves an existing or missing request unchanged.Adds no constraint and clears earlier constraints for the same target and resource.
DefaultRequiredSets the configured quantity only when the request is absent. An explicit request is preserved.Adds no constraint and clears earlier constraints for the same target and resource.
RemoveNot allowedRemoves the request when it is present.Requires the request to be absent in the final Pod.

Default a request without overriding tenant workloads that already specify it:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        workloads:
          resources:
            requests:
              cpu:
                policy: Default
                value: 100m
              memory:
                policy: Default
                value: 256Mi

If a container requests 500m CPU, Capsule preserves 500m. If the CPU request is missing, Capsule adds 100m.

Remove an extended resource request:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        workloads:
          targets:
            - pod/containers
          resources:
            requests:
              example.com/temporary-device:
                policy: Remove

Resource names must be valid Kubernetes qualified names. Custom and extended resource names can be used for container and init-container policies, subject to the normal Kubernetes rules for that resource.

Limits

The following policies are supported under resources.limits:

PolicyvalueMutation behaviorValidation behavior
PreserveNot allowedLeaves an existing or missing limit unchanged.Adds no constraint and clears earlier constraints for the same target and resource.
DefaultRequiredSets the configured quantity only when the limit is absent. An explicit limit is preserved.Adds no constraint and clears earlier constraints for the same target and resource.
RemoveNot allowedRemoves the limit when it is present.Requires the limit to be absent in the final Pod.
MatchRequestNot allowedIf a request exists, sets the limit to exactly the request, replacing a different explicit limit. If no request exists, it does not add a limit.Requires request and limit to be equal. When the request is absent, the limit must also be absent.
RatioRequiredIf a positive request exists and the limit is absent, sets the limit to request * value. An explicit limit is preserved for validation.Requires a positive request and a limit no greater than request * value.

Default is a fill-only policy. It does not mean that every limit must equal the configured value. Use MatchRequest when Capsule should own the limit and keep it equal to the request, or Ratio when explicit tenant values are allowed within a bounded range.

Ratio limits

Ratio defines the maximum permitted limit as a multiple of the request. It is available only for limits and supports these resource names:

ResourceCalculation precision
cpuExact decimal arithmetic, rounded down to the nearest millicore.
memoryExact decimal arithmetic, rounded down to a whole byte.
ephemeral-storageExact decimal arithmetic, rounded down to a whole byte.

The ratio must be greater than or equal to 1. Quote fractional ratios in YAML for clarity, for example "1.5".

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        workloads:
          targets:
            - pod/containers
          resources:
            limits:
              memory:
                policy: Ratio
                value: "1.5"

For a memory request of 1Gi, the maximum limit is 1536Mi:

Submitted limitMutationResult with action: deny
MissingCapsule adds 1536Mi.Admitted.
1GiExplicit value is preserved.Admitted because it is below the maximum.
1536MiExplicit value is preserved.Admitted because it equals the maximum.
2GiExplicit value is preserved.Denied because it exceeds the maximum.

An explicit limit is never reduced by Ratio. This is deliberate: preserving the submitted value allows allow, deny, and audit to decide how a ratio violation should be handled. Use MatchRequest when the limit should always be rewritten instead.

Ratio requires a positive request. When the request is missing or zero, Capsule cannot calculate a default limit. The missing or non-positive request is therefore a policy violation. A deny action blocks it, an audit action reports it, and an allow action treats it as an allow-list miss and blocks it.

Capsule calculates ratios without floating-point arithmetic and rounds down so that the generated limit never exceeds the configured factor. For example, a CPU request of 101m with a ratio of 1.5 produces a limit of 151m, not 152m.

Targeting resource locations

Resource policies reuse enforce.workloads.targets to select resource locations. See Pod targets for the location table and Workload targets for controller targets. Ephemeral containers and volumes cannot be selected for resource policies.

When targets is omitted or empty, each resource policy applies to every compatible location: Pod-level spec.resources, regular containers, and init containers. An explicit targets list narrows that default.

Compatibility is evaluated per resource name. Kubernetes Pod-level resources support only cpu, memory, and huge-page resources. With omitted targets, those names apply at all three locations, while other valid names such as ephemeral-storage and extended resources apply only to regular and init containers. Capsule skips those incompatible names at spec.resources; it does not reject the rule or discard their container policies.

Only regular containers:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        workloads:
          targets:
            - pod/containers
          resources:
            limits:
              cpu:
                policy: Remove

Only init containers:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        workloads:
          targets:
            - pod/initcontainers
          resources:
            requests:
              cpu:
                policy: Default
                value: 25m

Both regular and init containers, explicitly:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        workloads:
          targets:
            - pod/containers
            - pod/initcontainers
          resources:
            limits:
              memory:
                policy: MatchRequest

Only Pod-level resources:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        workloads:
          targets:
            - pod
          resources:
            requests:
              cpu:
                policy: Default
                value: 500m
              memory:
                policy: Default
                value: 1Gi
            limits:
              cpu:
                policy: Ratio
                value: "2"
              memory:
                policy: Ratio
                value: "1.5"

The Kubernetes API server must support spec.resources for Pod-level resource policies to be useful. Pod-level policies support cpu, memory, and huge-page resources. Because Ratio itself supports only CPU, memory, and ephemeral storage, a pod-level ratio can be configured for CPU or memory, but not for huge pages. Use an explicit pod target when the policy must not affect containers.

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  # Valid: resource and registry policies use separate target scopes.
  rules:
    - enforce:
        action: deny
        workloads:
          targets:
            - pod/containers
          resources:
            limits:
              cpu:
                policy: Remove

    - enforce:
        action: deny
        workloads:
          targets:
            - pod/ephemeralcontainers
            - pod/volumes
          registries:
            - exp: "untrusted.example.com/.*"

Advanced behavior

The following concepts are mainly relevant when combining multiple rules, selectors, admission actions, or Kubernetes resource-management components.

Admission lifecycle

Resource policy admission has a mutation phase and a validation phase. Knowing which phase a policy uses is important when choosing a policy and an action.

Admission phaseOperationsBehavior
MutationPod CREATECapsule applies the effective Default, Remove, MatchRequest, or Ratio mutation to the incoming Pod. Preserve does not change the field.
ValidationPod CREATE and normal UPDATECapsule checks the final resource values for Remove, MatchRequest, and Ratio. Preserve and Default do not add a validation constraint.
Pod subresourcesAnyResource validation is skipped for Pod subresources, including ephemeralcontainers. Resource policies do not mutate ephemeral containers.

For Pods, resource mutation is limited to creation. Capsule does not try to rewrite resource fields on existing Pods, where Kubernetes immutability rules would normally reject the change. Normal Pod updates are still validated so that the policy describes the accepted final state.

With omitted targets or Pod targets, Capsule mutates and validates the resulting Pods without rewriting controller templates. With explicit controller targets, resource request/limit policies also mutate and validate the stored Pod template on controller creation and updates. Defaults therefore appear directly in the controller. Other workload mutations under mutate[].workloads remain Pod-only.

Actions and compliance

The action belongs to the enclosing enforce block and applies to validation constraints created by Remove, MatchRequest, and Ratio:

ActionWhen the final value compliesWhen the final value violates the policy
denyNo deny decision is produced.The Pod is denied.
allowThe policy produces an allow decision.The value does not satisfy the resource allow-list and the Pod is denied unless a later matching rule allows it.
auditNo audit is emitted.The Pod is admitted by this rule, and Capsule emits a Kubernetes event and admission warning. Other rules can still deny it.

As with other enforcement matchers, the last matching allow or deny decision wins. Audit decisions never override allow or deny decisions.

The following example denies memory limits above 1.5 times the request by default but permits up to 2 times the request in namespaces labeled burst-memory=true:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        workloads:
          targets:
            - pod/containers
          resources:
            limits:
              memory:
                policy: Ratio
                value: "1.5"

    - namespaceSelector:
        matchLabels:
          burst-memory: "true"
      enforce:
        action: allow
        workloads:
          targets:
            - pod/containers
          resources:
            limits:
              memory:
                policy: Ratio
                value: "2"

A container with a 1Gi request and a 1792Mi limit violates the first rule but complies with the later allow rule. It is admitted only in a namespace that matches the selector on the second rule.

Audit explicit ratio violations without rejecting them:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: audit
        workloads:
          targets:
            - pod/containers
          resources:
            limits:
              ephemeral-storage:
                policy: Ratio
                value: "2"

Remember that a missing limit is still defaulted during create. The audit is emitted only when the final Pod remains noncompliant, such as when it contains an explicit excessive limit or a limit without a positive request.

Rule order and policy overrides

Rules are processed in declaration order after namespaceSelector and audience filtering. Resource policies are resolved independently for every combination of target, resource name, and field (request or limit). This allows CPU and memory, or requests and limits, to be managed independently.

For mutation, the last applicable policy for a target, resource name, and field is effective. A later Preserve therefore prevents an earlier mutation:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        workloads:
          resources:
            limits:
              cpu:
                policy: Remove

    - namespaceSelector:
        matchLabels:
          preserve-cpu-limit: "true"
      enforce:
        action: allow
        workloads:
          targets:
            - pod/containers
            - pod/initcontainers
          resources:
            limits:
              cpu:
                policy: Preserve

In matching namespaces, the later Preserve policy leaves CPU limits intact. In other namespaces, the earlier Remove policy remains effective.

For validation, Preserve and Default also clear earlier constraints for the same target, field, and resource. Constraints declared after that reset point are still evaluated. This makes Preserve useful as a namespace-specific escape hatch and Default useful when switching from enforcement back to fill-only behavior.

For the request identity of controller-created Pods, see Audience.

Configuration validation

Capsule validates resource policy configuration before using it. Invalid rules are reported on the RuleStatus and are not silently accepted.

The following requirements apply:

  • At least one non-empty requests or limits map is required.
  • Policy names are case-sensitive.
  • value is required only by request Default, limit Default, and limit Ratio.
  • value must not be supplied to Preserve, Remove, or MatchRequest.
  • Default quantities must not be negative. A zero default is accepted, although Kubernetes or another admission policy can impose a stricter requirement.
  • A ratio must be greater than or equal to 1.
  • Ratio supports only cpu, memory, and ephemeral-storage.
  • pod/ephemeralcontainers, pod/volumes, and the deprecated pod/images target cannot be used in a workload block containing resource policies.
  • A request cannot use Remove while the same rule uses MatchRequest or Ratio for that resource’s limit, because those limit policies require the request.
  • When the pod target is explicitly present, resource names in that block are restricted to cpu, memory, and huge-page resources supported by Kubernetes pod-level resources.
  • When targets is omitted, Pod-incompatible resource names remain valid and are applied only to regular and init containers.

For example, this configuration is invalid:

resources:
  requests:
    memory:
      policy: Remove
  limits:
    memory:
      policy: Ratio
      value: "1.5"

The following is also invalid because value is not accepted by Remove:

resources:
  limits:
    cpu:
      policy: Remove
      value: "1"
Interaction with Kubernetes resource controls

Resource policies complement Kubernetes resource controls; they do not replace or disable them.

  • LimitRange can still apply its own defaults and min/max validation. Configure its constraints consistently with Capsule resource policies to avoid admission behavior that depends on webhook and admission-plugin order.
  • ResourceQuota, Global Resource Quota, and Resource Pools evaluate the resulting Pod resources according to their own semantics.
  • Other mutating webhooks can also change resources. Capsule’s generic mutating webhook requests reinvocation when another mutator changes the Pod, and the validating webhook checks the final object it receives.
  • Kubernetes performs its own resource validation after mutation. A resource configuration accepted by a Capsule policy can still be rejected by the API server or another admission policy.
  • A Vertical Pod Autoscaler or another controller that creates replacement Pods is subject to the policy when those Pods are admitted. Normal updates are validated but not mutated.

To inspect the effective resources after admission, query the Pod rather than only its workload-controller template:

kubectl get pod api -n solar-apps \
  -o jsonpath='{.spec.containers[*].resources}'

If admission is denied, Capsule’s error identifies the target path, resource field, and failed requirement. Audit violations appear as admission warnings and Kubernetes events associated with the Pod and Tenant.

QoS Classes

For example, reject BestEffort Pods in the Tenant namespaces:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  rules:
    - enforce:
        action: deny
        workloads:
          qosClasses:
            - BestEffort

QoS class enforcement allows administrators to allow, deny, or audit Pods based on their computed Kubernetes QoS class.

QoS rules are configured under enforce.workloads.qosClasses.

Supported QoS classes are:

QoS classDescription
GuaranteedThe Pod has CPU and memory requests and limits set so that requests equal limits.
BurstableThe Pod has at least one CPU or memory request or limit, but does not qualify as Guaranteed.
BestEffortThe Pod has no CPU or memory requests or limits.

Capsule evaluates the QoS class of the incoming Pod during create and update admission. If Kubernetes has already populated status.qosClass, Capsule can use that value; otherwise it computes the QoS class from the Pod specification.

Deny BestEffort Pods:

---
apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  ...
  rules:
    - enforce:
        action: deny
        workloads:
          qosClasses:
            - BestEffort

With this rule, a Pod without CPU or memory requests and limits is denied:

apiVersion: v1
kind: Pod
metadata:
  name: best-effort
spec:
  containers:
    - name: shell
      image: harbor/platform/debian:latest
      command: ["sleep", "infinity"]

Example rejection:

Error from server (Forbidden): error when creating "pod.yaml": admission webhook "pods.projectcapsule.dev" denied the request: QoS class "BestEffort" at status.qosClass is denied by namespace rule

Audit Burstable Pods:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: audit
        workloads:
          qosClasses:
            - Burstable

A matching Pod is admitted in this audit-only example, but Capsule emits an event and the API server response contains an admission warning. If a QoS allow-list is also configured and the Pod’s QoS class is not allowed, the Pod is denied while the audit event is still emitted.

Allow BestEffort only for selected namespaces:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        workloads:
          qosClasses:
            - BestEffort

    - namespaceSelector:
        matchLabels:
          allow-best-effort: "true"
      enforce:
        action: allow
        workloads:
          qosClasses:
            - BestEffort

Because later matching allow or deny rules take precedence, namespaces labeled allow-best-effort=true can run BestEffort Pods, while other namespaces cannot.

Placement

Configure scheduling matchers under enforce.workloads.placement, including schedulers, nodeSelector, tolerations, topologySpreadConstraints, and affinity. targets remains at enforce.workloads.targets and scopes all configured workload policies.

Configure placement policies under spec.rules[].enforce.workloads. Each property has its own matcher list:

PropertyEvaluated valueMutation
Scheduler namesspec.schedulerNameSet the scheduler
Node selectorsEach key/value pair in spec.nodeSelectorSet node selectors
TolerationsEach complete entry in spec.tolerationsSet tolerations
Topology spread constraintsEach complete entry in spec.topologySpreadConstraintsSet spread constraints
AffinityEach complete required or preferred affinity termSet affinity

Read Order and scope for how mutation precedes enforcement, and Conditions to apply checks conditionally. The Reference combines all placement policies with resource and image rules. To configure mutations alongside enforcement, see the complete mutation example.

Scheduler policies evaluate one scheduler name. Node selectors, tolerations, topology spread constraints, and affinity are evaluated entry by entry. For these structured entries, fields within a matcher are combined with AND, and entries in the matcher list are alternatives. A match requires one complete matcher to match the entry; different matchers cannot authorize separate parts of the same affinity term or spread constraint.

The action rules apply to each evaluated value: allow enables an allow-list, deny rejects matches, and audit only records them. The last matching allow or deny wins. Every supplied entry must pass its policy; a single rejected entry denies the request. Mutated values and Kubernetes-injected values are evaluated in the same way.

Allow rules do not require a placement property to be present. Omitted properties and empty lists have no entries to validate. Use workload mutation to establish settings before enforcement. An omitted policy list or [] adds no matchers. For node selectors, tolerations, spread constraints, and affinity, [{}] matches every present entry. Scheduler matchers require a match expression instead.

Those four structured properties use the pod target or a whole-controller target such as deployment. Omitting targets includes Pod placement checks; selecting only container or volume targets skips them. Scheduler checks apply to the whole selected Pod spec even when a part is selected. See Workload targets for all target scopes.

For those four properties, main-resource Pod updates check changed values and recheck affinity and spread selectors when labels change. Subresources skip these checks.

Matcher fields

Scheduler matchers and the key, values, topologyKey, and namespaces fields use the common match expression structure: exact, exp, and optional negate.

Omit a matcher field to leave it unrestricted. A supplied expression must contain exact or exp; key: {} is invalid. Use exp: '^$' to match an empty string. The property references below list the additional fields supported by each matcher, such as operators, effects, and numeric bounds.

Selector policies

Topology-spread and Pod-affinity matchers use the following policy structure for labelSelector and, where supported, namespaceSelector:

FieldBehavior
requiredWhen true, require at least one effective selector requirement.
requirementsAllowlist for individual selector requirements. Omission or an empty list leaves requirements unrestricted.

Each requirement matcher supports key, values, and operators. Label selectors support In, NotIn, Exists, and DoesNotExist. Every actual requirement must match one complete entry, and every supplied value must match its values expression. matchLabels entries are evaluated as singleton In requirements.

Exists and DoesNotExist have no values to test. Permitting those operators permits their valueless form; use operators: [In] when allowed values must restrict the selection. A requirement allowlist constrains supplied requirements; it does not require every listed key to appear.

Dynamic matchLabelKeys and mismatchLabelKeys are checked as In and NotIn requirements using the incoming Pod’s label values. Missing dynamic labels are ignored, matching Kubernetes behavior. required: true uses this effective selector, including any dynamic requirements.

Scheduler names

Configure scheduler matchers under enforce.workloads.placement.schedulers. Capsule checks spec.schedulerName during create and update admission.

Kubernetes defaults an omitted or empty name to default-scheduler before Pod admission. Enforcement checks that name unless a mutation changes it; the matcher itself does not fill an empty value. A conditional scheduler default can replace the built-in scheduler while preserving custom names.

Allow only these schedulers:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: allow
        workloads:
          placement:
            schedulers:
              - exact: [tenant-scheduler, batch-scheduler]
Pod scheduler name at enforcementResult
tenant-scheduler or batch-schedulerAllowed
other-schedulerDenied
default-scheduler, including a name defaulted by KubernetesDenied

To allow a scheduler family as well as fixed names, combine exact and exp. Either one can satisfy this matcher:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: allow
        workloads:
          placement:
            schedulers:
              - exact: [default-scheduler, batch-scheduler]
                exp: '^tenant-[a-z0-9-]+$'

Deny one scheduler while leaving other names unrestricted by this rule:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        workloads:
          placement:
            schedulers:
              - exact: [unsafe-scheduler]

Alternatively, negate a trusted set to deny all other names:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        workloads:
          placement:
            schedulers:
              - exact: [default-scheduler, tenant-scheduler]
                negate: true

To record usage without changing the admission decision, use audit:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: audit
        workloads:
          placement:
            schedulers:
              - exact: [custom-scheduler]

This emits an event and an admission warning for custom-scheduler. Other policies still apply; an audit match cannot satisfy a scheduler allow-list.

Node selectors

Each matcher evaluates one key/value pair in spec.nodeSelector.

FieldMatches
keyThe node-label key.
valuesThe node-label value, including an explicitly empty value.
apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: allow
        workloads:
          placement:
            nodeSelector:
              - key:
                  exact: [kubernetes.io/os]
                values:
                  exact: [linux]
              - key:
                  exp: '^placement\.example\.com/[a-z0-9-]+$'
                values:
                  exact: [shared, batch]

This allows kubernetes.io/os: linux and keys such as placement.example.com/pool: shared. It rejects kubernetes.io/os: windows, placement.example.com/pool: dedicated, and unlisted keys. A Pod with no node selector is allowed by this rule.

Tolerations

Each matcher evaluates one complete toleration in spec.tolerations.

FieldMatches
keyTaint key expression. An empty Pod key is a literal empty string.
valuesToleration value expression.
operatorsEqual or Exists. An omitted Pod operator is Equal.
effectsNoSchedule, PreferNoSchedule, NoExecute, or the literal empty string "".
tolerationSecondsInclusive min and max bounds, with optional allowUnlimited.

An empty toleration key or effect has broad Kubernetes semantics. It does not match an allowlist of specific keys or effects. To allow an empty effect, include "" explicitly in effects.

An absent tolerationSeconds means unlimited. Bounds apply to finite durations; allowUnlimited defaults to true. Set it to false to require a finite duration:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: allow
        workloads:
          placement:
            tolerations:
              - key:
                  exact: [node.kubernetes.io/not-ready, node.kubernetes.io/unreachable]
                operators: [Exists]
                effects: [NoExecute]
                tolerationSeconds:
                  max: 600
                  allowUnlimited: false

This permits only the two listed NoExecute tolerations, each with a finite duration of at most 600 seconds. Add other matchers for any additional tolerations needed by your workloads or injected by Kubernetes.

Disallow every toleration

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        workloads:
          placement:
            tolerations:
              - {}

The empty matcher rejects every toleration, including injected ones. Kubernetes normally adds not-ready and unreachable tolerations, so this rule rejects ordinary Pods with those entries too. Use an allowlist when system tolerations must remain permitted. A Pod with no tolerations has no entry for this deny rule to match.

Topology spread constraints

Each matcher evaluates one complete entry in spec.topologySpreadConstraints.

FieldMatches
topologyKeyTopology-key expression.
whenUnsatisfiableList containing DoNotSchedule or ScheduleAnyway.
maxSkewInclusive min and max bounds.
minDomainsInclusive bounds; an omitted Pod value is evaluated as 1.
nodeAffinityPolicyHonor or Ignore; an omitted Pod value is Honor.
nodeTaintsPolicyHonor or Ignore; an omitted Pod value is Ignore.
labelSelectorSelector policy for the Pods counted by the constraint.
apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: allow
        workloads:
          placement:
            topologySpreadConstraints:
              - topologyKey:
                  exact: [topology.kubernetes.io/zone, kubernetes.io/hostname]
                whenUnsatisfiable: [DoNotSchedule, ScheduleAnyway]
                maxSkew:
                  min: 1
                  max: 3
                labelSelector:
                  required: true
                  requirements:
                    - key:
                        exact: [app]
                      operators: [In]
                      values:
                        exact: [checkout]

This allows zone or host spreading with a skew of 1–3 and a selector for app: checkout. A constraint for another topology key, a skew of 4, or a selector using another key is rejected. The rule constrains supplied entries; it does not add a spread constraint or require one to be present.

Affinity

All three affinity types use one flat list under enforce.workloads.placement.affinity. Each matcher evaluates a complete required or preferred term from spec.affinity.

FieldApplies toBehavior
typesAllSelect nodeAffinity, podAffinity, or podAntiAffinity. Omission selects all compatible types.
modesAllSelect required, preferred, or both. Omission selects both.
weightPreferred termsInclusive min and max bounds within 1–100. Requires modes: [preferred].
requirementsNode affinityMatch matchExpressions using key, values, and operators.
fieldRequirementsNode affinityMatch matchFields using the same requirement structure.
topologyKeyPod affinity and anti-affinityTopology-key expression.
labelSelectorPod affinity and anti-affinitySelector policy for matching Pods.
namespaceScopePod affinity and anti-affinitySameNamespace or Any. Omission imposes no scope restriction.
namespacesPod affinity and anti-affinityExpression matched against every explicitly supplied namespace.
namespaceSelectorPod affinity and anti-affinitySelector policy for matching namespaces.
apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: allow
        workloads:
          placement:
            affinity:
              - types: [nodeAffinity]
                modes: [required, preferred]
                requirements:
                  - key:
                      exact: [topology.kubernetes.io/zone]
                    operators: [In]
                    values:
                      exact: [zone-a, zone-b]
              - types: [podAffinity, podAntiAffinity]
                modes: [preferred]
                topologyKey:
                  exact: [kubernetes.io/hostname]
                namespaceScope: SameNamespace
                weight:
                  min: 1
                  max: 100

This allows node affinity using the listed zone requirements and preferred Pod affinity or anti-affinity within the Pod’s namespace, grouped by host. Required Pod affinity and anti-affinity are rejected. The second matcher leaves Pod-label selectors unrestricted; add labelSelector to constrain them.

Node requirements also support Gt and Lt. The value matcher checks the literal numeric operand; it does not query node labels. When either node requirement list constrains a term, requirements from the other source must be explicitly allowed if present. For example, fieldRequirements: [{}] permits any native-valid field requirement alongside constrained requirements.

SameNamespace permits an omitted namespaces list or explicit references to the Pod’s own namespace, and requires the Pod term’s namespaceSelector to be absent. Even namespaceSelector: {} selects more than the current namespace and does not match this scope. Any leaves namespace scope unrestricted. A namespaces expression alone does not restrict namespaces selected through a namespaceSelector.

Type-specific fields only match types on which they are meaningful. Explicitly incompatible combinations are rejected when the policy is saved. Matching examines the submitted terms without listing nodes, Pods, or namespaces.

Pod Disruption Budgets

Use enforce.workloads.disruptionBudgets to constrain PodDisruptionBudgets (PDBs) covering selected workloads in a namespace. These policies can prevent overlapping budgets, require room for voluntary disruption at the desired replica count, and allow eviction of unhealthy Pods during maintenance.

The policies check existing PDBs; they do not create a PDB or require one to exist.

For example, require matching PDBs to leave room for at least one voluntary eviction per Deployment or StatefulSet with a nonzero desired replica count. This rule applies to every namespace in the Tenant:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: allow
        workloads:
          targets: [deployment, statefulset]
          disruptionBudgets:
            evictableReplicas:
              min: 1

For three desired replicas, a PDB with minAvailable: 2 passes, while minAvailable: 3 is rejected. This checks the configured allowance; actual eviction still depends on workload health.

The Reference includes all three properties in the combined Tenant. For maintenance scenarios, see workload best practices.

The shared workloads.targets list selects the Pods and controller templates whose PDBs are checked. Capsule also checks PDB writes that affect those workloads in the same namespace. A controller need not have running Pods for its template to be checked.

TargetallowOverlapunhealthyPodEvictionPoliciesevictableReplicas
Omitted or pod✅ Pod labels✅ PDBs selecting the Pod❌
deployment, statefulset, replicaset, replicationcontroller✅ Template labels✅ PDBs selecting the template✅ Desired spec.replicas, including /scale
daemonset, job, cronjob✅ Template labels✅ PDBs selecting the template❌ No supported desired replica count
Container or volume part targets❌❌❌

Controller targets are explicit opt-ins. Include pod as well to check labels on actual Pods, including those created by controllers. CronJobs use the Pod template inside their Job template. Part targets such as pod/containers and deployment/containers do not participate in PDB checks.

An evictableReplicas policy must explicitly select at least one of the four supported controller targets. Other targets in the same rule still participate in overlap and unhealthy-policy checks.

Evictable replicas

evictableReplicas.min and .max define inclusive bounds on the configured number of replicas that a PDB would allow to be evicted from a selected controller at its desired size, assuming those replicas are healthy. Both bounds are nonnegative integers. An omitted bound is unrestricted; for example, min: 1 requires room for at least one disruption without setting an upper limit.

Capsule calculates this allowance from the PDB and the controller’s desired spec.replicas:

PDB size fieldConfigured allowance
Integer minAvailableDesired replicas minus minAvailable, with a minimum of zero
Percentage minAvailableDesired replicas minus the rounded-up percentage of desired replicas, with a minimum of zero
Integer maxUnavailablemaxUnavailable, capped at the desired replica count
Percentage maxUnavailableRounded-up percentage of desired replicas, capped at the desired replica count
Both size fields omittedZero

Both percentage forms use Kubernetes’ upward rounding. For an allow rule with evictableReplicas.min: 1:

PDB configurationDesired replicasAllowanceAdmission
minAvailable: "75%"30❌ Rejected
minAvailable: "75%"41✅ Allowed
minAvailable: 231✅ Allowed
maxUnavailable: "25%"11✅ Allowed; the only replica can be evicted
maxUnavailable: 040❌ Rejected

Replica changes are checked through both the controller resource and its /scale subresource, including requests from kubectl scale and autoscalers. With the first two rows above, scaling from four replicas down to three is rejected.

Zero replicas are exempt from the replica-count check. Scaling a controller to zero remains possible. Scaling back above zero checks the resulting allowance. Overlap and unhealthy-policy checks still apply to zero-replica templates.

Each matching PDB is checked against each selected controller independently. Capsule does not add replicas from multiple controllers or calculate a combined live PDB allowance. This can be stricter than Kubernetes for a PDB that covers multiple workloads. The max bound applies to this per-controller calculation; it does not cap aggregate live disruptions across multiple controllers. Use a PDB selector that covers the complete intended controller rather than an arbitrary subset of its Pods.

Unhealthy Pods

unhealthyPodEvictionPolicies matches the effective value of the PDB’s spec.unhealthyPodEvictionPolicy:

ValueBehavior for unhealthy Running Pods
IfHealthyBudgetEviction requires the application’s healthy Pod count to satisfy its budget. This is also the effective value when the PDB omits the field.
AlwaysAllowUnhealthy Running Pods may be evicted even when the healthy budget is not satisfied. Healthy Pods still obey the PDB.

An allow rule listing only AlwaysAllow rejects a matching PDB that omits this field or sets IfHealthyBudget. Alternatively, a deny rule listing IfHealthyBudget rejects that value, including omission. An omitted or empty policy list adds no restriction. These rules validate the field; they do not populate it. See Kubernetes’ unhealthy Pod eviction policies for their effect during a drain.

Overlap

allowOverlap: false requires at most one PDB to select each Pod or selected controller template. Kubernetes accepts overlapping PDBs, but an eviction can fail when more than one PDB selects the Pod. See API-initiated eviction.

Matching uses Pod or Pod-template labels and each PDB’s matchLabels and matchExpressions in the same namespace. With policy/v1, a null selector matches no Pods, while an empty selector {} matches every Pod in the namespace. An omitted allowOverlap field adds no constraint. A later allow rule with allowOverlap: true can permit overlap, but it does not change Kubernetes' eviction behavior.

For example, two PDBs selecting app: frontend can be created before any matching Pod or selected controller template exists. Creating a matching workload is then rejected. If the workload already exists, creating the second PDB is rejected instead. Deleting a conflicting PDB or changing selectors to remove the overlap allows subsequent writes.

Evaluation

Checks run on both sides of the relationship:

RequestPDB checks
Selected Pod creation or changed labelsMatching PDB overlap and unhealthy eviction policies; label changes through Pod /status are also checked
Selected controller creation or changed template labelsMatching PDB policies for that controller target
Supported controller replica change with an applicable evictableReplicas policy, including /scaleResulting configured eviction allowance and the other applicable PDB policies
PDB creation or relevant spec changePolicies for existing selected Pods and controller templates that its selector matches
Unchanged relevant fieldsNo new PDB check
DeletionAllowed by these policies, so a conflicting PDB can be removed

A PDB with no matching selected workload can be created. The workload is checked when it is later admitted, allowing either creation order without making PDB existence a requirement.

Actions differ between numeric/boolean constraints and the list of policy values:

Propertyallowdenyaudit
allowOverlap and evictableReplicasRequires complianceRejects violationsReports violations
unhealthyPodEvictionPoliciesAccepts listed valuesRejects listed valuesReports listed values

An omitted action defaults to deny. Normal rule ordering applies independently to each property: the last matching allow/deny decision wins. Allowing one property does not bypass a failure of another. Audit rules emit events without changing the admission decision. Omitted properties never override an earlier rule.

Namespace selection, audiences, and enforcement conditions use the usual rules scope. Conditions inspect the actual incoming object: a PDB during a PDB write, a controller during a controller write, and an autoscaling/v1 Scale during a /scale update. Include all relevant actors and write paths when using these gates; a condition restricted to Pod creation does not protect controller, PDB, or scale updates.

Changing a rule or namespace profile affects subsequent relevant admissions; it does not repair existing PDBs or immediately revalidate stored workloads. Concurrent PDB and workload writes can race because admission across separate resources is not atomic. Monitor actual PDB status and workload health when planning maintenance.

Security

Configure seccompProfiles and appArmorProfiles under enforce.workloads.security. The workload-level targets field selects the Pod or container locations to check.

seccompProfiles and appArmorProfiles allow, deny, or audit profile choices for Linux workloads. The Seccomp and AppArmor sections below describe each mechanism and its local-profile matching. Use security mutations to supply Pod defaults before enforcement. These checks complement namespace Pod Security labels; they skip Pods and templates declaring spec.os.name: windows.

Both matchers use these fields and the usual rule order:

FieldMeaning
typesRequired list of 1–3 entries containing RuntimeDefault, Localhost, or Unconfined.
localhostProfilesOptional list of up to 64 exact, exp, and negate expressions for local profile paths or names. Requires Localhost in types.

Each enforcement rule supports up to 64 seccompProfiles matchers and up to 64 appArmorProfiles matchers. Oversized lists are rejected when the policy is saved, limiting nested matching work during workload admission.

Multiple matchers are alternatives. The expressions in localhostProfiles constrain only Localhost; other types in the same matcher still match normally. Omitting these expressions permits any non-empty Localhost path or name. Audit reports matching admission choices through Capsule events; it does not configure kernel syscall logging or AppArmor learning modes.

The shared workload targets select where checks apply. Omitted targets check effective profiles for regular, init, and ephemeral containers. Explicit pod checks only the Pod default, so it does not prevent container overrides. Controller targets inspect effective profiles in their templates, including CronJobs’ nested templates. Targeted templates must declare allowed profiles themselves because profile mutation applies only to Pod creation.

Validation runs on Pod and controller create/update requests, and on pods/ephemeralcontainers updates. It does not run on status, resize, or delete requests and does not reconfigure existing containers. Policy changes apply to subsequent admissions, so a previously admitted Pod may fail a later update under a stricter policy.

Seccomp

enforce.workloads.security.seccompProfiles evaluates a container’s explicit securityContext.seccompProfile, falling back to the Pod’s spec.securityContext.seccompProfile. Regular containers, init containers (including restartable sidecars), and ephemeral containers use this resolution. A Pod default is optional when every selected container declares an allowed profile of its own.

Effective typeWhat the matcher checks
RuntimeDefaultThe workload selects the container runtime’s default seccomp profile.
LocalhostThe workload selects a local seccomp file. localhostProfiles matches its relative path.
UnconfinedThe workload explicitly disables seccomp, or the selected container is privileged.

Privileged containers are evaluated as Unconfined even if their manifest names a different profile. When neither the container nor Pod supplies a profile, Capsule evaluates it as Unset. An allow-list rejects this value; Capsule does not infer the kubelet’s seccomp default. Unset appears only in diagnostics and cannot be listed in types. A deny rule for Unconfined alone therefore permits an unset profile. Pair an allow-list with seccomp mutation when a declared profile is required.

Match local seccomp paths

For Localhost, localhostProfiles matches the value of localhostProfile, such as profiles/solar.json, relative to the kubelet’s seccomp directory. It does not match an absolute node path or inspect the file’s contents.

This complete Tenant allows RuntimeDefault, the exact local path profiles/solar.json, and files matching ^profiles/shared/[a-z0-9-]+\.json$ in the selected namespaces. A local path outside those matches, Unconfined, or an unset effective profile is denied. It does not add defaults; use seccomp mutation for that.

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - namespaceSelector:
        matchLabels:
          security-profile: confined
      enforce:
        action: allow
        workloads:
          security:
            seccompProfiles:
              - types: [RuntimeDefault, Localhost]
                localhostProfiles:
                  - exact: [profiles/solar.json]
                  - exp: '^profiles/shared/[a-z0-9-]+\.json$'

Admission checks the reference, while nodes apply the actual profile. Install approved files on eligible nodes before use. See the Kubernetes seccomp tutorial.

AppArmor

enforce.workloads.security.appArmorProfiles checks effective AppArmor profiles in this order: the container’s structured securityContext.appArmorProfile, a legacy per-container AppArmor annotation, then the Pod’s spec.securityContext.appArmorProfile. This also covers init, restartable sidecar, and ephemeral containers. A container override therefore remains subject to enforcement even when the Pod default is allowed.

Effective typeWhat the matcher checks
RuntimeDefaultThe workload selects the container runtime’s default AppArmor profile.
LocalhostThe workload selects a loaded AppArmor profile. localhostProfiles matches its name.
UnconfinedThe workload explicitly disables AppArmor, or the selected container is privileged.

A missing effective profile is reported as Unset and fails an allow-list. Capsule does not infer a runtime’s implicit AppArmor behavior. A deny rule matching only Unconfined permits an unset profile. Use AppArmor mutation with an allow-list to require a declared profile. Privileged containers evaluate as Unconfined regardless of the profile written in the manifest.

Match loaded AppArmor names

localhostProfiles matches a loaded profile name such as solar-confined. The value is not the filesystem path where the AppArmor definition is stored. For example, exact: [solar-confined] checks the name in localhostProfile; exp: '^solar-[a-z0-9-]+$' would permit any matching name.

This complete Tenant allows RuntimeDefault and only the named local profile in selected namespaces. Other Localhost names, Unconfined, and unset effective profiles are rejected. It supplies no defaults by itself.

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - namespaceSelector:
        matchLabels:
          security-profile: apparmor
      enforce:
        action: allow
        workloads:
          security:
            appArmorProfiles:
              - types: [RuntimeDefault, Localhost]
                localhostProfiles:
                  - exact: [solar-confined]

Capsule does not verify that AppArmor is supported, that the named profile is loaded, or that its contents are identical on all nodes. Configure eligible nodes before using these profiles. See the Kubernetes AppArmor guide.

Security reference

This complete Tenant defaults both profiles in selected namespaces. It allows RuntimeDefault and one approved Localhost profile for each mechanism. The same checks apply to explicitly targeted Deployment templates, which must supply their own profile configuration. Apply this example only with eligible Linux nodes supporting AppArmor and with the custom profiles installed before use.

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - namespaceSelector:
        matchLabels:
          security-profile: confined
      mutate:
        - action: merge
          workloads:
            security:
              seccompProfile:
                type: RuntimeDefault
              appArmorProfile:
                type: RuntimeDefault
      enforce:
        action: allow
        workloads:
          targets:
            - pod/containers
            - pod/initcontainers
            - pod/ephemeralcontainers
            - deployment
          security:
            seccompProfiles:
              - types: [RuntimeDefault, Localhost]
                localhostProfiles:
                  - exact: [profiles/solar.json]
            appArmorProfiles:
              - types: [RuntimeDefault, Localhost]
                localhostProfiles:
                  - exact: [solar-confined]
Container choiceResult
No override; Pod default is RuntimeDefaultAllowed through inheritance.
Approved Localhost profileAllowed and preserved by merge.
Unapproved Localhost profileDenied.
Explicit Unconfined or privileged containerDenied.
No profile at either level in a targeted templateDenied.
Ephemeral container overriding the Pod default with UnconfinedDenied.

Registries

Registry enforcement allows administrators to allow, deny, or audit Pod image references. Registry matchers are evaluated against the full OCI reference string, including registry, repository path, image name, tag, or digest.

Registry rules are configured under enforce.workloads.registries. The shared Workload targets field under enforce.workloads.targets selects image references in Pods or controller templates. See Pod targets for container and image-volume scopes and examples.

Registry matchers use the common match expression structure:

registries:
  - exact:
      - harbor/platform/debian:latest
      - harbor/platform/busybox:latest
  - exp: "harbor/platform/.*"

Use exact for a fixed list of complete references and exp for path or registry patterns. A single matcher may contain both fields:

registries:
  - exact:
      - harbor/platform/debian:latest
    exp: "harbor/shared/.*"

This matcher succeeds for harbor/platform/debian:latest or any reference matching harbor/shared/.*.

The following example allows Harbor images by default, denies a more specific customer path for regular containers and image volumes, allows and audits regular container images from an audit registry, and allows a production image path only for namespaces matching env=prod:

---
apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  ...
  rules:
    - enforce:
        action: allow
        workloads:
          registries:
            - exp: "harbor/.*"

    - enforce:
        action: deny
        workloads:
          targets:
            - pod/containers
            - pod/volumes
          registries:
            - exp: "harbor/customer/.*"

    - enforce:
        action: allow
        workloads:
          targets:
            - pod/containers
          registries:
            - exp: "audit/.*"

    - enforce:
        action: audit
        workloads:
          targets:
            - pod/containers
          registries:
            - exp: "audit/.*"

    - namespaceSelector:
        matchExpressions:
          - key: env
            operator: In
            values: ["prod"]
      enforce:
        action: allow
        workloads:
          targets:
            - pod/containers
            - pod/volumes
          registries:
            - exp: "harbor/customer/prod-image/.*"
              policy: ["Always"]

Apply the following Pod in namespace solar-test, which does not match the env=prod selector:

apiVersion: v1
kind: Pod
metadata:
  name: image-volume
spec:
  containers:
    - name: shell
      command: ["sleep", "infinity"]
      imagePullPolicy: IfNotPresent
      image: harbor/customer/test-image/debian:latest
      volumeMounts:
        - name: volume
          mountPath: /volume
  volumes:
    - name: volume
      image:
        reference: quay.io/crio/artifact:v2
        pullPolicy: IfNotPresent

The request is denied:

kubectl apply -f pod.yaml -n solar-test

Error from server (Forbidden): error when creating "pod.yaml": admission webhook "pods.projectcapsule.dev" denied the request: containers[0] reference "harbor/customer/test-image/debian:latest" is denied by registry rule "harbor/customer/.*"

The Pod is denied because the regular container image matches both harbor/.* and harbor/customer/.*. Since the deny rule is declared later, it has higher precedence.

The image volume reference is not denied by the shown deny rule because it does not match harbor/customer/.*. If the image volume used a matching reference, for example harbor/customer/volume-artifact:v1, the same deny rule would apply because it targets both pod/containers and pod/volumes.

In a namespace matching env=prod, the more specific production allow rule is also considered:

apiVersion: v1
kind: Pod
metadata:
  name: prod-image
spec:
  containers:
    - name: shell
      command: ["sleep", "infinity"]
      imagePullPolicy: Always
      image: harbor/customer/prod-image/debian:latest

The request is allowed because the namespace-specific rule matches later and allows harbor/customer/prod-image/.* with imagePullPolicy: Always.

Target-specific registry rules allow different behavior for different parts of the same Pod. For example, this rule denies the registry only for init containers:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        workloads:
          targets:
            - pod/initcontainers
          registries:
            - exp: "harbor/init-only/.*"

A matching reference under spec.initContainers is denied. The same reference under spec.containers is ignored by this rule.

PullPolicy

Define the allowed image pull policies for a matching registry rule. Supported policies are:

  • Always: The image is always pulled.
  • IfNotPresent: The image is pulled only if it is not already present on the node.
  • Never: The image is never pulled. If the image is not present on the node, the Pod fails to start.

The policy field is optional. If no policy is specified, all image pull policies are accepted for the matching registry rule.

To set the policy before validation, use mutate[].workloads.registries.imagePullPolicy. The configured mutation must produce a policy accepted by the matching enforcement rule.

---
apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  ...
  rules:
    - enforce:
        action: allow
        workloads:
          targets:
            - pod/containers
          registries:
            - exp: "harbor/v2/customer-registry/.*"
              policy: ["IfNotPresent", "Always"]

If the final matching registry decision is allow and that matching registry rule defines policy, the Pod must use one of the configured pull policies. For example, this rule allows the registry but only with Always:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: allow
        workloads:
          targets:
            - pod/containers
          registries:
            - exp: "harbor/v2/customer-registry/.*"
              policy: ["Always"]

A Pod using imagePullPolicy: Never for that registry is rejected:

Error from server (Forbidden): error when creating "pod.yaml": admission webhook "pods.projectcapsule.dev" denied the request: containers[0] reference "harbor/v2/customer-registry/debian:latest" uses pullPolicy=Never which is not allowed (allowed: Always)

Policy is checked only after the final registry decision is allow. A final deny decision always denies the request, regardless of the configured pull policy.

Negation

A registry matcher can be negated with negate: true. Negation applies to the final result of the matcher, including both exact and exp.

For example, the following rule denies every regular container image that is not from the trusted registry path:

---
apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  ...
  rules:
    - enforce:
        action: deny
        workloads:
          targets:
            - pod/containers
          registries:
            - exp: "trusted/.*"
              negate: true

With this rule:

  • trusted/backend/api:1.0.0 is allowed in this deny-only example because it does not match the negated deny rule and no registry allow-list is configured.
  • docker.io/library/nginx:latest is denied because it does not match trusted/.*, so the negated matcher evaluates to true.

Negation also applies to exact values:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        workloads:
          targets:
            - pod/containers
          registries:
            - exact:
                - trusted/backend/api:1.0.0
                - trusted/frontend/web:1.0.0
              negate: true

This rule denies every explicit container image except the two exact references listed, as long as no separate registry allow-list requires an explicit allow. If an allow rule is configured for the same matcher scope, the excepted references must also match an allow rule.

You can combine exact values, regular expressions, negation, namespace selectors, and action precedence. For example, deny all untrusted container images by default, but allow a controlled exception in production namespaces:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        workloads:
          targets:
            - pod/containers
          registries:
            - exact:
                - trusted/base/debian:latest
              exp: "trusted/platform/.*"
              negate: true

    - enforce:
        action: allow
        workloads:
          targets:
            - pod/containers
          registries:
            - exact:
                - trusted/base/debian:latest
              exp: "trusted/platform/.*"

    - namespaceSelector:
        matchLabels:
          env: prod
      enforce:
        action: allow
        workloads:
          targets:
            - pod/containers
          registries:
            - exp: "partner-registry/prod-approved/.*"

The second rule explicitly allows the trusted references that were excluded from the negated deny rule, which is required when registry allow-list behavior is active. In a namespace labeled env=prod, partner-registry/prod-approved/app:1.0.0 is allowed because the later matching allow rule overrides the earlier negated deny rule.

OCI Examples

Registry exact match examples

Use exact when you want to allow or deny a fixed set of complete image references:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: allow
        workloads:
          targets:
            - pod/containers
          registries:
            - exact:
                - harbor/platform/debian:latest
                - harbor/platform/busybox:1.36

A Pod using harbor/platform/debian:latest or harbor/platform/busybox:1.36 is admitted. A Pod using harbor/platform/nginx:latest is denied because an allow rule exists for registry enforcement but does not match that reference.

You can combine exact and exp in the same registry matcher:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: allow
        workloads:
          registries:
            - exact:
                - harbor/platform/debian:latest
              exp: "harbor/shared/.*"

This rule allows the exact Debian image and any image under harbor/shared/*.

Reference

This complete Tenant combines workload kind restrictions, resource policies, image rules, QoS, all five placement policies, and PDB constraints. It applies only to its namespaces labeled example.com/profile: restricted; other namespaces in the Tenant keep their own profiles. Replace solar-owner with your owner identity.

The kind rule rejects DaemonSets. Resource, image, QoS, and placement policies use Pod targets and run when Pods are admitted, including controller-created Pods. The PDB rule also selects Deployment and StatefulSet templates, covering PDB writes and replica changes. See Workload targets to expand the scope of individual policies.

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    # Keep kind restrictions separate from property policies.
    - namespaceSelector:
        matchLabels:
          example.com/profile: restricted
      enforce:
        action: deny
        workloads:
          targets: [daemonset]
    # Manage resources on regular and init containers.
    - namespaceSelector:
        matchLabels:
          example.com/profile: restricted
      enforce:
        action: deny
        workloads:
          targets: [pod/containers, pod/initcontainers]
          resources:
            requests:
              cpu:
                policy: Default
                value: 100m
              memory:
                policy: Default
                value: 128Mi
            limits:
              cpu:
                policy: Remove
              memory:
                policy: Ratio
                value: "2"
    # Include the whole Pod for placement and its parts for image checks.
    - namespaceSelector:
        matchLabels:
          example.com/profile: restricted
      enforce:
        action: allow
        workloads:
          targets:
            - pod
            - pod/containers
            - pod/initcontainers
            - pod/ephemeralcontainers
            - pod/volumes
          registries:
            - exp: '^registry\.example\.com/solar/.+$'
              policy: [Always, IfNotPresent]
          qosClasses: [Burstable, Guaranteed]
          placement:
            schedulers:
              - exact: [default-scheduler, batch-scheduler]
            nodeSelector:
              - key: {exact: [kubernetes.io/os]}
                values: {exact: [linux]}
              - key: {exact: [infrastructure.example.com/pool]}
                values: {exact: [shared, batch]}
            tolerations:
              - key: {exact: [infrastructure.example.com/dedicated]}
                operators: [Equal]
                values: {exact: [shared]}
                effects: [NoSchedule]
              - key:
                  exact:
                    - node.kubernetes.io/not-ready
                    - node.kubernetes.io/unreachable
                operators: [Exists]
                effects: [NoExecute]
                tolerationSeconds:
                  max: 600
                  allowUnlimited: false
              - key: {exact: [node.kubernetes.io/memory-pressure]}
                operators: [Exists]
                effects: [NoSchedule]
            topologySpreadConstraints:
              - topologyKey:
                  exact: [topology.kubernetes.io/zone, kubernetes.io/hostname]
                whenUnsatisfiable: [DoNotSchedule, ScheduleAnyway]
                maxSkew: {min: 1, max: 3}
                labelSelector:
                  required: true
                  requirements:
                    - key: {exact: [app.kubernetes.io/part-of]}
                      operators: [In]
                      values: {exact: [solar]}
            affinity:
              - types: [nodeAffinity]
                modes: [required, preferred]
                requirements:
                  - key: {exact: [topology.kubernetes.io/zone]}
                    operators: [In]
                    values: {exact: [zone-a, zone-b]}
              - types: [podAffinity, podAntiAffinity]
                modes: [preferred]
                topologyKey:
                  exact: [topology.kubernetes.io/zone, kubernetes.io/hostname]
                namespaceScope: SameNamespace
                weight: {min: 1, max: 100}
                labelSelector:
                  required: true
                  requirements:
                    - key: {exact: [app.kubernetes.io/part-of]}
                      operators: [In]
                      values: {exact: [solar]}
    # Keep selected workloads compatible with voluntary maintenance.
    - namespaceSelector:
        matchLabels:
          example.com/profile: restricted
      enforce:
        action: allow
        workloads:
          targets: [pod, deployment, statefulset]
          disruptionBudgets:
            allowOverlap: false
            evictableReplicas:
              min: 1
              max: 2
            unhealthyPodEvictionPolicies:
              - AlwaysAllow
    # Override the broader registry allow rule for this path.
    - namespaceSelector:
        matchLabels:
          example.com/profile: restricted
      enforce:
        action: deny
        workloads:
          registries:
            - exp: '^registry\.example\.com/solar/blocked/.+$'
    - namespaceSelector:
        matchLabels:
          example.com/profile: restricted
      enforce:
        action: audit
        workloads:
          registries:
            - exp: '^registry\.example\.com/solar/legacy/.+$'

With this configuration:

  • DaemonSet creation and main-resource updates are denied in selected namespaces.
  • On Pod creation, missing regular and init container requests default to 100m CPU and 128Mi memory. CPU limits are removed. Missing memory limits become twice the corresponding request; explicit limits above that ratio are denied. Resource admission lifecycle describes update behavior.
  • Every selected image reference must use registry.example.com/solar/ with an allowed pull policy. The later deny rule blocks the solar/blocked/ path. Images under solar/legacy/ are audited while the other checks still apply.
  • The resulting Pod must have Burstable or Guaranteed QoS and use default-scheduler or batch-scheduler. A custom scheduler must already be installed to schedule Pods assigned to it.
  • Supplied node selectors, tolerations, spread constraints, and affinity terms must match the configured shapes. These allow-lists do not require those properties to be present or populate them. Use workload mutation to establish placement settings before enforcement.
  • The toleration list includes common Kubernetes-injected entries. Add any additional tolerations required by your selected workloads or cluster.
  • Matching PDBs must not overlap and must use AlwaysAllow for unhealthy Pod eviction. Each selected Deployment or StatefulSet must have one to two configured evictions available at its desired replica count when a PDB covers it; zero replicas are exempt. Remove max: 2 if only a minimum is needed. These checks neither require a PDB nor guarantee live drain progress.

2 - Metadata

Metadata Enforcement

Metadata enforcement is configured under spec.rules[].enforce.metadata. Use Metadata Targets to select resource kinds, then configure labels and annotations, including defaults, managed values, required keys, and value validation. Migration covers the legacy Namespace, Pod, and Service metadata options.

Rules use the shared namespace selection, audiences, conditions, and actions and order. See Reference for a complete Tenant example.

Rule Composition

Actions and order

Metadata rules are evaluated in declaration order after namespace selection, audience, and condition filtering. The last matching allow or deny wins for each metadata key and value.

ActionBehavior
allowCreates an allow-list for the evaluated metadata key. A present value with no matching allow or deny decision is rejected when an allow-list is active.
denyRejects matching metadata values. A later matching allow rule can override that decision.
auditEmits Kubernetes events and admission warnings without allowing or denying the request. Audit matches never satisfy an allow-list.

If action is omitted, it defaults to deny. Each label and annotation key is evaluated independently; allowing one key does not authorize another.

Admission scope and limits

Metadata rules are evaluated during create and update admission. On creation, all present controlled keys are checked. On update, unchanged values skip value matching, while required keys must still be present. Keep the following scope and limits in mind:

BehaviorExplanation
Namespaced resources and explicitly selected Namespaces are evaluatedMetadata rules normally target resources inside Tenant namespaces. Namespace is the only supported cluster-scoped kind, and it must be selected explicitly with kinds: ["Namespace"].
Managed objects remain subject to enforcementSetting managed-by=controller does not bypass metadata validation. Capsule-managed resources retain their separate protection against unauthorized edits.
Capsule-managed metadata is ignoredBuilt-in Capsule labels and annotations are treated as managed metadata and are ignored by metadata validation. Do not rely on metadata rules to validate Capsule-owned keys.
Managed annotation prefixes are ignoredCapsule-managed annotation prefixes such as resource quota and resource usage annotations are ignored.
Missing optional metadata is ignoredIf required: false, the key is only evaluated when it is present.
required applies to allow rulesrequired: true enforces presence for action: allow. deny and audit rules match values; they do not require missing keys to exist.
Empty metadata values are valid valuesA label or annotation with an empty string value is still present and can be matched with exact: [""].
Labels and annotations are independentA matching annotation does not satisfy a required label with the same key, and a matching label does not satisfy a required annotation.
Empty apiGroups means core v1Omitted apiGroups, an empty list, or an empty entry selects the core Kubernetes v1 API. Use apiGroups: ["*"] to match every API group and version.
kinds must be setUse kinds: ["*"] to match all namespaced kinds. A wildcard does not implicitly include Namespace.

Capsule-managed labels include labels used to track Tenant ownership, resource pools, freeze and cordon state, promotion state, Capsule ownership, and generated namespace resources. Capsule-managed annotations include release and reconciliation annotations, available class and registry annotations, forbidden namespace metadata annotations, protected Tenant annotations, and resource quota or resource usage annotation prefixes.

Because these keys are owned by Capsule, metadata rules that reference them are ignored by default. Use application-specific labels and annotations for Tenant policy enforcement.

Metadata Targets

Each metadata rule defines which resource kinds it applies to:

FieldDescription
apiGroupsList of API group or group/version selectors. Empty or omitted means core v1; apps matches every version in that group; apps/v1 matches that exact group/version; and "*" matches all groups and versions.
kindsList of Kubernetes kind selectors. "*" and partial wildcards match namespaced kinds, but Namespace must always appear as a separate literal entry to include it.

Examples:

metadata:
  - kinds:
      - ConfigMap
      - Service

This targets core v1 ConfigMap and Service resources because apiGroups is omitted.

metadata:
  - apiGroups:
      - apps/v1
    kinds:
      - Deployment
      - StatefulSet

This targets only apps/v1 Deployment and StatefulSet resources.

metadata:
  - apiGroups:
      - "*"
    kinds:
      - "*"

This targets all namespaced resources handled by the generic metadata webhook. It does not target Namespace, despite both selectors being wildcards.

Partial wildcards are also supported:

metadata:
  - apiGroups:
      - "apps/*"
    kinds:
      - "*Set"

This can match resources such as apps/v1 ReplicaSet and apps/v1 StatefulSet.

Namespace

Namespace is the only cluster-scoped resource supported by metadata rules. It is deliberately opt-in: the kinds list must contain the literal, case-sensitive value Namespace. This prevents a broad rule intended for resources inside Tenant namespaces from accidentally changing or rejecting the Namespace object itself.

The Namespace GVK is core v1, Kind=Namespace. The apiGroups selector must therefore match core v1. The clearest form is:

metadata:
  - apiGroups:
      - "v1"
    kinds:
      - "Namespace"

Because omitted apiGroups defaults to core v1, this shorter form is equivalent:

metadata:
  - kinds:
      - Namespace

An API-group wildcard may also match core v1, but it still does not remove the explicit-kind requirement. For example, this targets all namespaced kinds and Namespace:

metadata:
  - apiGroups:
      - "*"
    kinds:
      - "*"
      - Namespace

The following selectors do not target Namespace:

# A full kind wildcard is not an explicit Namespace opt-in.
metadata:
  - apiGroups:
      - "*"
    kinds:
      - "*"

# A partial kind wildcard is not an explicit Namespace opt-in either,
# even when its pattern would otherwise match the word "Namespace".
  - apiGroups:
      - "v1"
    kinds:
      - "Name*"

In short, both conditions must be true: apiGroups must match core v1, and kinds must contain a dedicated Namespace entry.

API group selection

Omitted or empty apiGroups does not mean all API groups and versions. It means the core Kubernetes API version v1.

For example:

metadata:
  - kinds:
      - Deployment

This does not match apps/v1 Deployment, because omitted apiGroups is interpreted as core v1.

To match apps/v1 deployments, set the API group/version selector explicitly:

metadata:
  - apiGroups:
      - apps/v1
    kinds:
      - Deployment

To match deployments across all API groups and versions, use "*":

metadata:
  - apiGroups:
      - "*"
    kinds:
      - Deployment

Labels and Annotations

The following rule requires an approved Tenant label and validates an optional cost-center annotation on ConfigMaps and Services:

rules:
  - enforce:
      action: allow
      metadata:
        - apiGroups:
            - "*"
          kinds:
            - ConfigMap
            - Service
          labels:
            corp.com/tenant:
              required: true
              values:
                - exact:
                    - prod
                    - test
          annotations:
            example.corp/cost-center:
              required: false
              values:
                - exp: "^INV-[0-9]{4}$"
                  exact:
                    - prod
                    - test

Label rules

Label rules are configured under metadata[].labels. Each map key is the label key to validate.

rules:
  - enforce:
      action: allow
      metadata:
        - kinds:
            - ConfigMap
          labels:
            env:
              required: true
              values:
                - exact:
                    - prod
                    - test

With this rule, a matching ConfigMap must contain metadata.labels["env"], and its value must be either prod or test.

This object is admitted:

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
  labels:
    env: prod
data:
  key: value

This object is denied because the required label is missing:

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  key: value

This object is denied because the label value does not match the allow-list:

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
  labels:
    env: stage
data:
  key: value

Example rejection:

Error from server (Forbidden): error when creating "configmap.yaml": admission webhook "rules.generic.projectcapsule.dev" denied the request: metadata label "env" is required at metadata.labels["env"]

Annotation rules

Annotation rules are configured under metadata[].annotations. Each map key is the annotation key to validate.

rules:
  - audience:
      - kind: "Group"
        name: "system:authenticated"
      - kind: "Custom"
        name: "CapsuleUser"
      - kind: "Custom"
        name: "Administrator"
      - kind: "Custom"
        name: "TenantOwner"
    enforce:
      action: allow
      metadata:
        - apiGroups:
            - "v1"
          kinds:
            - Namespace
          annotations:
            example.corp/cost-center:
              required: false
              values:
                - exp: "^INV-[0-9]{4}$"
              # Overwrites anything, even if the user has set a value, Should be applied using SSA by the rulestatus controller, if removed also removes (one fieldmanager per rulestatus which controlles all managed metadata). Also enforce at admission
              managed: "INV-10"
            example.corp/cost-center-2:
              values:
                - exp: "II-10"
              default: "{{$.tenant.spec.data.costCenter}}"

With this rule, the annotation is optional. If the object does not contain metadata.annotations["example.corp/cost-center"], Capsule ignores the rule. If the annotation is present, its value must match the configured expression.

This object is admitted because the annotation is absent and required is false:

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  key: value

This object is admitted because the annotation value matches:

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
  annotations:
    example.corp/cost-center: INV-1234
data:
  key: value

This object is denied because the annotation is present but does not match:

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
  annotations:
    example.corp/cost-center: BAD-1234
data:
  key: value

Default

The default field provides a value for coressponding field should no value be provided by the user. This is only applied at admission time and does not enforce the value to be present in the object.

default is meaningful for action: allow. deny and audit rules are value matchers; they do not require missing metadata to exist.

rules:
  - enforce:
      action: allow
      metadata:
        - kinds:
            - ConfigMap
          labels:
           cost-center:
              default: "internal"

Default values still validate against the configured values matchers. If the default value does not match any allow or deny rule, the request is denied.

Managed

Managed values set metadata to the configured value during admission, overwriting an existing value. For background reconciliation behavior, see managed metadata and conditions.

rules:
  - enforce:
      action: allow
      metadata:
        - apiGroups:
            - "v1"
          kinds:
            - Namespace
          annotations:
            example.corp/cost-center:
              required: false
              values:
                - exp: "^INV-[0-9]{4}$"
              # Overwrites anything, even if the user has set a value, Should be applied using SSA by the rulestatus controller, if removed also removes (one fieldmanager per rulestatus which controlles all managed metadata). Also enforce at admission
              managed: "INV-10"

Required

The required field controls whether the metadata key must be present.

requiredBehavior
trueFor action: allow, the key must be present on matching objects.
falseThe key is optional. If it is missing, Capsule ignores it. If it is present, configured values are evaluated.

required is meaningful for action: allow. deny and audit rules are value matchers; they do not require missing metadata to exist.

Presence-only enforcement is possible by setting required: true and omitting values:

rules:
  - enforce:
      action: allow
      metadata:
        - kinds:
            - ConfigMap
          labels:
            tenant-approved:
              required: true

With this rule, matching ConfigMap resources must contain the tenant-approved label, but any value is accepted.

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
  labels:
    tenant-approved: "true"
data:
  key: value

If the label is missing, the request is denied.

Validation

The values field uses the common match expression structure with exact, exp, and optional negate.

values:
  - exact:
      - prod
      - test
  - exp: "^sandbox-[0-9]+$"

A metadata value matches if any configured value matcher matches.

exact and exp can be combined in the same matcher:

values:
  - exact:
      - prod
      - test
    exp: "^dev-[0-9]+$"

This matcher allows prod, test, and values matching ^dev-[0-9]+$.

negate: true inverts the final matcher result:

rules:
  - enforce:
      action: deny
      metadata:
        - apiVersion: "*"
          kinds:
            - ConfigMap
          labels:
            team:
              values:
                - exp: "^trusted-.*"
                  negate: true

With this rule:

  • team=trusted-platform is not denied by this deny-only rule.
  • team=untrusted is denied.

If an allow-list also exists for the same metadata key, values excluded from a negated deny rule still need a matching allow rule.

Allow-list behavior for metadata

An allow rule creates an allow-list for the specific metadata key it controls.

rules:
  - enforce:
      action: allow
      metadata:
        - kinds:
            - ConfigMap
          labels:
            env:
              required: true
              values:
                - exact:
                    - prod
                    - test

With this rule:

Object labelResult
env=prodAllowed
env=testAllowed
env=stageDenied
missing envDenied because required: true

If required is false, missing metadata is ignored:

rules:
  - enforce:
      action: allow
      metadata:
        - kinds:
            - ConfigMap
          labels:
            env:
              required: false
              values:
                - exact:
                    - prod
                    - test

With this rule:

Object labelResult
env=prodAllowed
env=testAllowed
env=stageDenied
missing envAllowed

Allow-list behavior is evaluated per metadata key. A matching value for one key does not satisfy another required key.

For example:

rules:
  - enforce:
      action: allow
      metadata:
        - kinds:
            - ConfigMap
          labels:
            env:
              required: true
              values:
                - exact:
                    - prod
            team:
              required: true
              values:
                - exact:
                    - platform

The object must contain both env=prod and team=platform.

Deny metadata values

Use action: deny to reject specific metadata values.

rules:
  - enforce:
      action: deny
      metadata:
        - kinds:
            - ConfigMap
          labels:
            environment:
              values:
                - exact:
                    - deprecated

This ConfigMap is denied:

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
  labels:
    environment: deprecated
data:
  key: value

A later matching allow rule can override an earlier deny rule:

rules:
  - enforce:
      action: deny
      metadata:
        - kinds:
            - ConfigMap
          labels:
            environment:
              values:
                - exact:
                    - deprecated

  - namespaceSelector:
      matchLabels:
        allow-deprecated: "true"
    enforce:
      action: allow
      metadata:
        - kinds:
            - ConfigMap
          labels:
            environment:
              required: true
              values:
                - exact:
                    - deprecated

In namespaces labeled allow-deprecated=true, environment=deprecated is admitted because the later namespace-specific allow rule matches.

Audit metadata values

Use action: audit to observe metadata usage without blocking the request.

rules:
  - enforce:
      action: audit
      metadata:
        - apiGroups:
            - "*"
          kinds:
            - ConfigMap
            - Service
          labels:
            example.corp/audit:
              values:
                - exp: "^audit-.*"

A matching object is admitted in this audit-only example, but Capsule emits an audit event and returns an admission warning.

If an allow-list also exists for the same metadata key, audit does not satisfy that allow-list. The metadata value must still match an allow rule.

Multiple resource kinds

A single metadata rule can target multiple kinds:

rules:
  - enforce:
      action: allow
      metadata:
        - apiGroups:
            - "*"
          kinds:
            - ConfigMap
            - Service
          labels:
            corp.com/tenant:
              required: true
              values:
                - exact:
                    - prod
                    - test

With this rule, both matching ConfigMap and Service objects must contain corp.com/tenant=prod or corp.com/tenant=test.

Namespace-specific metadata rules

Metadata enforcement supports namespaceSelector like other namespace rules.

rules:
  - namespaceSelector:
      matchLabels:
        environment: prod
    enforce:
      action: allow
      metadata:
        - kinds:
            - ConfigMap
          annotations:
            example.corp/approval:
              required: true
              values:
                - exact:
                    - approved

This rule only applies to namespaces labeled environment=prod. In those namespaces, matching ConfigMap objects must contain example.corp/approval=approved.

Migration

The legacy metadata fields under Tenant.spec can be migrated to metadata rules. Rules use the same policy structure for Namespaces, Pods, Services, and other resources, and allow validation, admission defaults, and reconciled managed values to be combined.

Migrate one resource type at a time. When old and new configuration overlap, keep their values identical until the rule has been verified.

Migrate Namespace Metadata

The legacy Namespace metadata options map to rules as follows:

  • requiredMetadata becomes an allow rule with required: true and a values expression.
  • additionalMetadata and additionalMetadataList become managed values. Managed values are applied during admission and reconciled on existing Namespaces. Use default instead when a value should only be added during admission and remain user-editable afterwards.
  • An additionalMetadataList[].namespaceSelector moves to the rules[].namespaceSelector at the same rule level. For a Namespace target, the selector is evaluated against that Namespace’s labels.
  • Exact entries in forbiddenLabels.denied and forbiddenAnnotations.denied become deny rules that match any value for the concrete metadata key.

Namespace must be selected explicitly. Use the concrete core v1 API and the literal Namespace kind, especially when the rule contains managed values:

apiGroups:
  - "v1"
kinds:
  - Namespace

The following rules replace the requiredMetadata, additionalMetadataList, and exact forbidden-key examples from the legacy page:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - name: alice
      kind: User
  rules:
    # Require user-provided metadata and manage common metadata on every
    # Namespace in the Tenant.
    - enforce:
        action: allow
        metadata:
          - apiGroups:
              - "v1"
            kinds:
              - Namespace
            labels:
              env:
                required: true
                values:
                  - exp: "^(prod|test|dev)$"
              projectcapsule.dev/backup:
                managed: "true"
              templated-label:
                managed: "{{ .namespace.metadata.name }}"
            annotations:
              example.corp/cost-center:
                required: true
                values:
                  - exp: "^INV-[0-9]{4}$"
              storagelocationtype:
                managed: s3
              templated-annotation:
                managed: "{{ .tenant.metadata.name }}"

    # Apply the baseline security profile unless the Namespace explicitly
    # opts into the low-security profile.
    - namespaceSelector:
        matchExpressions:
          - key: projectcapsule.dev/low_security_profile
            operator: NotIn
            values:
              - "true"
      enforce:
        action: allow
        metadata:
          - apiGroups:
              - "v1"
            kinds:
              - Namespace
            labels:
              pod-security.kubernetes.io/enforce:
                managed: baseline

    # The selectors are mutually exclusive, so only one rule manages the Pod
    # Security Admission label for a Namespace.
    - namespaceSelector:
        matchExpressions:
          - key: projectcapsule.dev/low_security_profile
            operator: In
            values:
              - "true"
      enforce:
        action: allow
        metadata:
          - apiGroups:
              - "v1"
            kinds:
              - Namespace
            labels:
              pod-security.kubernetes.io/enforce:
                managed: privileged

    # Deny the presence of these concrete labels or annotations, regardless
    # of their values.
    - enforce:
        action: deny
        metadata:
          - apiGroups:
              - "v1"
            kinds:
              - Namespace
            labels:
              foo.acme.net:
                values:
                  - exp: ".*"
              bar.acme.net:
                values:
                  - exp: ".*"
            annotations:
              foo.acme.net:
                values:
                  - exp: ".*"
              bar.acme.net:
                values:
                  - exp: ".*"

With these rules:

  • Tenant owners must provide a valid env label and cost-center annotation.
  • Common and templated metadata is enforced on new and existing Namespaces.
  • The Pod Security Admission profile follows the Namespace selector.
  • foo.acme.net and bar.acme.net are denied as both labels and annotations, including when their value is an empty string.

Legacy options without a direct equivalent

Metadata rules are keyed by concrete label and annotation names. They do not currently provide a direct replacement for these key-wide legacy behaviors:

  • managedMetadataOnly: true, which rejects all user metadata not managed by Capsule; and
  • forbiddenLabels.deniedRegex or forbiddenAnnotations.deniedRegex, which match metadata keys by regular expression.

Enumerate known sensitive keys with deny rules as shown above. If the policy must reject every unlisted key or a changing family of keys, retain the legacy option during migration or enforce that part with another admission policy. Value regular expressions remain supported through values[].exp; the limitation only applies to regular expressions over metadata key names.

Rollout

  1. Add the equivalent rules while the legacy values are still configured. Ensure overlapping managed values are identical.
  2. Check the generated RuleStatus objects and verify both creation and update admission with a test Namespace.
  3. Confirm that managed metadata has been reconciled onto existing Namespaces.
  4. Remove the migrated requiredMetadata, additionalMetadata, additionalMetadataList, and exact forbidden-key entries.
  5. Keep managedMetadataOnly or key-regex restrictions until an alternative policy covers them.

Migrate Pod Metadata

Use the following rule to migrate the old spec.podOptions.additionalMetadata API to the new spec.rules[].enforce.metadata API:

rules:
  - enforce:
      action: allow
      metadata:
        - kinds:
            - Pod
          annotations:
            storagelocationtype:
              managed: s3
          labels:
            projectcapsule.dev/backup:
              managed: "true"

Migrate Service Metadata

Use the following rule to migrate the old spec.serviceOptions.additionalMetadata API to the new spec.rules[].enforce.metadata API:

rules:
  - enforce:
      action: allow
      metadata:
        - kinds:
            - Service
            - Endpoints
          annotations:
            customer.corp/routable:
              managed: "true"
          labels:
            customer.corp/network-tenant:
              managed: "{{ .tenant.metadata.name }}"
        - apiGroups:
            - "discovery.k8s.io/v1"
          kinds:
            - EndpointSlice
          annotations:
            customer.corp/routable:
              managed: "true"
          labels:
            customer.corp/network-tenant:
              managed: "{{ .tenant.metadata.name }}"

Reference

This complete Tenant combines required labels, optional annotations, multiple kinds, audit rules, deny rules, and namespace-specific exceptions. Replace solar-owner with your owner identity.

---
apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: allow
        metadata:
          - apiGroups:
              - "*"
            kinds:
              - ConfigMap
              - Service
            labels:
              corp.com/tenant:
                required: true
                values:
                  - exact:
                      - prod
                      - test
            annotations:
              example.corp/cost-center:
                required: false
                values:
                  - exp: "^INV-[0-9]{4}$"
                  - exact:
                      - prod
                      - test

    - enforce:
        action: deny
        metadata:
          - kinds:
              - ConfigMap
            labels:
              environment:
                values:
                  - exact:
                      - deprecated

    - enforce:
        action: audit
        metadata:
          - apiGroups:
              - "*"
            kinds:
              - ConfigMap
              - Service
            labels:
              example.corp/audit:
                values:
                  - exp: "^audit-.*"

    - namespaceSelector:
        matchLabels:
          environment: prod
      enforce:
        action: allow
        metadata:
          - kinds:
              - ConfigMap
            annotations:
              example.corp/approval:
                required: true
                values:
                  - exact:
                      - approved

With this configuration:

  • ConfigMap and Service objects must contain corp.com/tenant=prod or corp.com/tenant=test.
  • example.corp/cost-center is optional, but if present it must match ^INV-[0-9]{4}$, prod, or test.
  • ConfigMap objects with environment=deprecated are denied unless a later matching allow rule overrides the decision.
  • Objects with example.corp/audit values matching ^audit-.* emit audit events.
  • In namespaces labeled environment=prod, ConfigMap objects must also contain example.corp/approval=approved.

3 - Network

Enforcement for NetworkPolicies, Services, and ingress hostnames

Network enforcement covers three policy groups under spec.rules[].enforce:

PolicyConfiguration path
Network Policiesnetwork.policies.ingress.cidrs, network.policies.egress.cidrs
Servicesservices
Ingressingress

Use these policies to control ingress and egress CIDR grants, Service types and addresses, and hostnames on ingress resources. Reference provides a complete Tenant example for each policy group.

Rules use the shared namespace selection, audiences, conditions, and actions and order.

Rule Composition

Actions and order

Rules are evaluated in declaration order after namespace selection, audience, and condition filtering. The last matching allow or deny wins for each evaluated value. If action is omitted, it defaults to deny.

ActionBehavior
allowCreates an allow-list for the evaluated matcher. Values outside the allow-list are denied unless another applicable allow or deny rule matches.
denyRejects matching values. A later matching allow rule can override that decision.
auditReports matches without allowing or denying the request. It never satisfies an allow-list.

The three policy groups evaluate different values. Every applicable check must pass; an allow decision in one group does not bypass another.

PolicyUnit of evaluationAudit reporting
NetworkPolicy ingress and egress CIDRsEvery address granted after subtracting ipBlock.except. All granted addresses must pass.NamespaceRuleAudit Kubernetes events; no admission warnings.
ServicesEach Service type, IP address, ExternalName hostname, and node port covered by a matcher. Type and value allow-lists are independent.Kubernetes events and admission warnings.
IngressEach hostname on the selected resource types, including both routing and TLS hosts on an Ingress.Kubernetes events, including reports of missing hostname fields.

For NetworkPolicies, ordering applies per address and direction. A later allow for 10.20.0.0/17 overrides an earlier deny for 10.20.0.0/16 only within the smaller range. Several allowed CIDRs can together cover one grant. See Combine CIDR rules. Ingress and egress have independent allow-lists: an allow in one direction cannot override a deny or satisfy an allow-list in the other. When an enforcement block includes both directions, its action and conditions apply to both.

For Services, type-specific constraints do not grant the Service type. If a services.types allow-list is active, it must allow the type independently of any matching address or port rule. See Combining Service Rules.

Admission scope and limits

These policies validate creation and updates in the selected namespaces. They do not install network configuration or repair existing resources when a rule or namespace label changes. Those changes affect subsequent admissions.

NetworkPolicies

  • These policies check ingress source addresses and egress destination addresses. They do not constrain podSelector, namespaceSelector, or ports.
  • An omitted direction in the Capsule rule imposes no constraint in that direction. Existing egress-only rules keep their behavior.
  • An empty NetworkPolicy ingress or egress list contains no grants in that direction and passes its CIDR check. Only directions enabled by the NetworkPolicy’s policyTypes are evaluated, including Kubernetes defaults.
  • A rule with omitted peers (ingress: [{}] or egress: [{}]) grants every IPv4 and IPv6 address. An empty peer (from: [{}] or to: [{}]) does too. Restricting ports does not reduce this address grant.
  • Policy updates are checked against the new object, including metadata changes that affect enforcement conditions. This check does not block deletion.
  • CIDRs must include a prefix length and be valid IPv4 or IPv6 prefixes. IPv4-mapped IPv6 prefixes are unsupported. Host bits are normalized to the containing network. Each list accepts up to 64 unique CIDRs.

This is admission validation of NetworkPolicy grants. It does not itself install a policy or enforce packet filtering. Traffic behavior still depends on the NetworkPolicies present and the cluster’s network implementation.

Services

BehaviorExplanation
services.types is the type gateType-specific sections do not automatically grant the Service type. Include the Service type in services.types when an allow-list for Service types is active.
Value constraints create allow-lists for their valuesIf loadBalancers.cidrs, externalIPs.cidrs, externalNames.hostnames, or nodePorts.ports is configured with action: allow, non-matching values are denied.
External IP rules do not require a valueexternalIPs.cidrs is evaluated only when a Service supplies spec.externalIPs; every supplied address is evaluated independently.
An empty external IP deny rule denies all external IPsaction: deny with externalIPs: {} or externalIPs.cidrs: [] denies any Service that supplies an external IP.
loadBalancers.cidrs requires explicit valuesWhen CIDR constraints are configured, LoadBalancer Services must set spec.loadBalancerIP or spec.loadBalancerSourceRanges.
nodePorts.ports requires explicit node portsWhen port constraints are configured, NodePort Services and LoadBalancer Services with node port allocation enabled must set spec.ports[].nodePort.
LoadBalancer node port allocation mattersLoadBalancer Services are subject to NodePort range checks unless spec.allocateLoadBalancerNodePorts: false is set.
Negation applies to the whole matchernegate: true inverts the result of both exact and exp.

Ingress

Ingress rules are evaluated during create and update admission. A rule only participates when its types list contains the incoming resource kind. Other resource types are unaffected. For example, types: [Ingress, HTTPRoute] applies the hostname policy to those two kinds; it does not prohibit Gateways or other omitted kinds. The action applies to the selected resources' hostnames, not to the resource kinds themselves.

Ingress Types lists the API versions handled by Capsule. Gateway API and OpenShift resources require their respective APIs to be installed in the cluster.

When an allow or deny hostname rule targets a resource type, its expected hostname fields must be non-empty. Audit-only rules report missing values without making hostnames mandatory. See Missing hostnames.

Network Policies

NetworkPolicy enforcement applies to native networking.k8s.io/v1 NetworkPolicies in the namespaces selected by the rule.

Ingress CIDRs

Configure ingress source constraints under spec.rules[].enforce.network.policies.ingress.cidrs. Capsule checks the addresses granted by spec.ingress[].from[].ipBlock during NetworkPolicy creation and updates, after subtracting ipBlock.except.

This controls source addresses in NetworkPolicies. Hostname enforcement for Ingress, Gateway API, and OpenShift Route resources is covered under Ingress.

Disallow CIDR sources

To prevent users from granting ingress from any CIDR source, deny both address families. This rule applies to every namespace in the Tenant:

rules:
  - enforce:
      action: deny
      network:
        policies:
          ingress:
            cidrs:
              - 0.0.0.0/0
              - ::/0

With this rule, the following NetworkPolicy is rejected:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-cidr-ingress
spec:
  podSelector: {}
  policyTypes: [Ingress]
  ingress:
    - from:
        - ipBlock:
            cidr: 192.0.2.0/24

A rejection identifies the source grant, field path, and matching rule:

networkPolicy ingress CIDR "192.0.2.0/24" at spec.ingress[0].from[0].ipBlock.cidr matches deny rule for CIDR 0.0.0.0/0
NetworkPolicy ingress grantResult with the rule above
Any IPv4 or IPv6 ipBlock granting addressesDenied
ingress: [{}], including a rule with only portsDenied: omitted from grants every source address
from: [{}]Denied: the empty peer grants every source address
Peers using podSelector or namespaceSelectorPasses the CIDR check
ingress: []Passes: no ingress addresses are granted
An IP block whose exceptions exclude every addressPasses: the block grants no addresses

Setting ingress: {} or ingress.cidrs: [] in the Capsule rule applies no source constraint. The rule above leaves egress unchanged; configure network.policies.egress.cidrs separately to constrain destinations.

Allow approved source ranges

Allow ingress CIDR grants only from approved source networks in selected namespaces, then exclude a reserved subnet with a later deny:

rules:
  - namespaceSelector:
      matchLabels:
        network-profile: internal
    enforce:
      action: allow
      network:
        policies:
          ingress:
            cidrs:
              - 10.20.0.0/16
              - fd00:1234::/48
  - namespaceSelector:
      matchLabels:
        network-profile: internal
    enforce:
      action: deny
      network:
        policies:
          ingress:
            cidrs:
              - 10.20.66.0/24
NetworkPolicy sourceExceptionsResult in a selected namespace
10.20.1.0/24 or fd00:1234::/64NoneAllowed
10.20.0.0/16NoneDenied: includes the reserved subnet
10.20.0.0/1610.20.66.0/24Allowed: the reserved subnet is fully excluded
10.0.0.0/8NoneDenied: extends outside the approved sources
192.0.2.0/24NoneDenied: outside the allow-list
Omitted fromNoneDenied: grants all IPv4 and IPv6 sources

Namespaces without network-profile: internal are unaffected by these rules. Partial exceptions leave any remaining prohibited addresses subject to denial. Multiple allowed ranges can together cover a source grant, using the same CIDR composition as egress. Use action: audit to report matching ingress grants without allowing or denying them; audit-only matches emit NamespaceRuleAudit events and do not satisfy an allow-list.

Egress CIDRs

Configure egress CIDR constraints under spec.rules[].enforce.network.policies.egress.cidrs. Capsule checks these grants during NetworkPolicy creation and updates.

Disallow CIDR destinations

To prevent users from granting egress to any CIDR destination, deny both address families. 0.0.0.0/0 covers every IPv4 address; ::/0 covers every IPv6 address. This example applies to every namespace in the Tenant:

rules:
  - enforce:
      action: deny
      network:
        policies:
          egress:
            cidrs:
              - 0.0.0.0/0
              - ::/0

With this rule, the following NetworkPolicy is rejected:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-cidr-egress
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to:
        - ipBlock:
            cidr: 192.0.2.0/24

An egress rule with no to destinations, such as egress: [{}], grants access to all destinations. Capsule checks it against both address families and rejects it too. Restricting such a rule to particular ports does not bypass the CIDR restriction.

Selector-based peers remain permitted by this check. For example, this policy allows egress to matching Pods in the same namespace:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-selected-pods
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to:
        - podSelector:
            matchLabels:
              app: backend

Use both universal CIDRs when blocking all CIDR destinations. Denying only 0.0.0.0/0 leaves explicit IPv6 grants unaffected. Setting egress: {} or cidrs: [] in the Capsule rule applies no CIDR constraint.

This check evaluates the addresses a policy grants, rather than the presence of the ipBlock field. If except excludes every address in an IP block, that block grants nothing and passes this check.

Disallow selected CIDRs

List specific CIDRs to protect only those ranges. This rule applies only to namespaces labeled network-profile: restricted:

rules:
  - namespaceSelector:
      matchLabels:
        network-profile: restricted
    enforce:
      action: deny
      network:
        policies:
          egress:
            cidrs:
              - 10.20.0.0/16
              - fd00:1234::/48

Matching uses address overlap, including subnets and broader ranges. Capsule subtracts ipBlock.except before evaluating the remaining grant:

NetworkPolicy destinationExceptionsResult with the rule above
10.20.4.0/24NoneDenied: inside the protected range
10.0.0.0/8NoneDenied: includes the protected range
0.0.0.0/0NoneDenied: includes the protected range
10.0.0.0/810.20.0.0/16Allowed: the protected range is fully excluded
10.0.0.0/810.20.0.0/17Denied: part of the protected range remains
10.0.0.0/810.20.0.0/17, 10.20.128.0/17Allowed: the exceptions together exclude the protected range
192.0.2.0/24NoneAllowed: no overlap
::/0NoneDenied: includes the protected IPv6 range
::/0fd00:1234::/48Allowed: the protected IPv6 range is fully excluded

For example, this policy passes the CIDR check:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: exclude-protected-range
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to:
        - ipBlock:
            cidr: 10.0.0.0/8
            except:
              - 10.20.0.0/16

A rejection identifies the destination, its field path, and the matched rule:

networkPolicy egress CIDR "10.0.0.0/8" at spec.egress[0].to[0].ipBlock.cidr matches deny rule for CIDR 10.20.0.0/16

Combine CIDR rules

For example, these two allowed ranges together cover 192.0.2.0/24:

rules:
  - enforce:
      action: allow
      network:
        policies:
          egress:
            cidrs:
              - 192.0.2.0/25
              - 192.0.2.128/25

A grant to 192.0.2.0/24 passes. A grant to 0.0.0.0/0 fails because it includes addresses outside the allow-list. An unrestricted egress rule requires complete coverage of both IPv4 and IPv6 address space.

To observe matching grants without blocking them, use an audit-only rule:

rules:
  - enforce:
      action: audit
      network:
        policies:
          egress:
            cidrs:
              - 10.20.0.0/16

This rule admits a grant to 10.20.0.0/16 and emits an audit event. NetworkPolicy CIDR audit rules report through events; they do not add admission warnings.

Services

Service enforcement is configured under spec.rules[].enforce.services. Use Service Types to control which types can be created, then constrain LoadBalancer addresses, External IPs, ExternalName hostnames, and NodePort ranges.

rules:
  - enforce:
      action: allow
      services:
        types:
          - ClusterIP
          - NodePort
          - LoadBalancer
          - ExternalName
        loadBalancers:
          cidrs:
            - 10.0.0.2/32
        externalIPs:
          cidrs:
            - 10.20.0.0/16
        externalNames:
          hostnames:
            - exp: ".*\\.example\\.com"
              exact:
                - internal.git.com
        nodePorts:
          ports:
            - from: 30000
              to: 32767

Service Types

The services.types field controls which Kubernetes Service types are allowed, denied, or audited by a rule.

Supported values are:

TypeDescription
ClusterIPAllows, denies, or audits Services of type ClusterIP.
NodePortAllows, denies, or audits Services of type NodePort.
LoadBalancerAllows, denies, or audits Services of type LoadBalancer.
ExternalNameAllows, denies, or audits Services of type ExternalName.

Allow selected types

Allow only ClusterIP Services:

rules:
  - enforce:
      action: allow
      services:
        types:
          - ClusterIP

With this rule, a ClusterIP Service is admitted:

apiVersion: v1
kind: Service
metadata:
  name: internal-api
spec:
  type: ClusterIP
  ports:
    - name: http
      port: 8080
      targetPort: 8080

A Service of another type, for example ExternalName, is denied because an allow-list exists for Service types and ExternalName is not listed:

apiVersion: v1
kind: Service
metadata:
  name: external-api
spec:
  type: ExternalName
  externalName: internal.git.com
  ports:
    - name: http
      port: 443
      targetPort: 443

Example rejection:

Error from server (Forbidden): error when creating "svc.yaml": admission webhook "services.validating.projectcapsule.dev" denied the request: service type "ExternalName" at spec.type is not allowed by namespace rule: value did not match any allowed rule. Allowed service types: ClusterIP

Deny selected types

Deny LoadBalancer Services:

rules:
  - enforce:
      action: deny
      services:
        types:
          - LoadBalancer

Allow ClusterIP and ExternalName, but deny ExternalName again for selected namespaces:

rules:
  - enforce:
      action: allow
      services:
        types:
          - ClusterIP
          - ExternalName

  - namespaceSelector:
      matchLabels:
        external-services: blocked
    enforce:
      action: deny
      services:
        types:
          - ExternalName

Because later matching allow or deny decisions win, namespaces labeled external-services=blocked cannot create ExternalName Services, while other matching namespaces can.

Combine types and value constraints

The services.types field is the Service capability gate. Constraint sections such as loadBalancers, externalIPs, externalNames, and nodePorts do not automatically allow a Service type by themselves. The externalIPs constraint is type-independent and applies to any allowed Service that specifies spec.externalIPs.

For example, this rule restricts LoadBalancer CIDRs, but it does not by itself allow LoadBalancer Services if another type allow-list exists that excludes LoadBalancer:

rules:
  - enforce:
      action: allow
      services:
        types:
          - ClusterIP

  - enforce:
      action: allow
      services:
        loadBalancers:
          cidrs:
            - 10.0.0.2/32

In this example, a LoadBalancer Service is denied by the Service type allow-list because LoadBalancer is not included in services.types.

To allow and constrain LoadBalancer Services, configure both:

rules:
  - enforce:
      action: allow
      services:
        types:
          - LoadBalancer
        loadBalancers:
          cidrs:
            - 10.0.0.2/32

LoadBalancer

LoadBalancer rules allow administrators to restrict the IPs and source ranges used by Services of type LoadBalancer.

LoadBalancer constraints are configured under enforce.services.loadBalancers.cidrs.

Capsule evaluates the following Service fields:

FieldDescription
spec.loadBalancerIPExplicit LoadBalancer IP requested by the Service.
spec.loadBalancerSourceRanges[]Source CIDR ranges allowed to access the LoadBalancer.

Allow LoadBalancer Services only with a specific IP:

rules:
  - enforce:
      action: allow
      services:
        types:
          - LoadBalancer
        loadBalancers:
          cidrs:
            - 10.0.0.2/32

This Service is admitted:

apiVersion: v1
kind: Service
metadata:
  name: public-api
spec:
  type: LoadBalancer
  loadBalancerIP: 10.0.0.2
  ports:
    - name: http
      port: 80
      targetPort: 8080

This Service is denied because the requested IP is outside the allowed CIDR:

apiVersion: v1
kind: Service
metadata:
  name: public-api
spec:
  type: LoadBalancer
  loadBalancerIP: 10.0.171.239
  ports:
    - name: http
      port: 80
      targetPort: 8080

Example rejection:

Error from server (Forbidden): error when creating "svc.yaml": admission webhook "services.validating.projectcapsule.dev" denied the request: loadBalancer CIDR "10.0.171.239" at spec.loadBalancerIP is not allowed by namespace rule: value did not match any allowed rule. Allowed CIDRs: 10.0.0.2/32

Allow a LoadBalancer IP range:

rules:
  - enforce:
      action: allow
      services:
        types:
          - LoadBalancer
        loadBalancers:
          cidrs:
            - 10.0.1.0/24

The following Service is admitted because 10.0.1.44 is contained in 10.0.1.0/24:

apiVersion: v1
kind: Service
metadata:
  name: public-api
spec:
  type: LoadBalancer
  loadBalancerIP: 10.0.1.44
  ports:
    - name: http
      port: 80
      targetPort: 8080

Restrict loadBalancerSourceRanges:

rules:
  - enforce:
      action: allow
      services:
        types:
          - LoadBalancer
        loadBalancers:
          cidrs:
            - 10.0.1.0/24

This Service is admitted because the requested source range is fully contained in the allowed CIDR:

apiVersion: v1
kind: Service
metadata:
  name: public-api
spec:
  type: LoadBalancer
  loadBalancerSourceRanges:
    - 10.0.1.0/25
  ports:
    - name: http
      port: 80
      targetPort: 8080

This Service is denied because the requested source range is not fully contained in the allowed CIDR:

apiVersion: v1
kind: Service
metadata:
  name: public-api
spec:
  type: LoadBalancer
  loadBalancerSourceRanges:
    - 10.0.1.0/23
  ports:
    - name: http
      port: 80
      targetPort: 8080

Required LoadBalancer fields when CIDRs are configured

If any matching rule configures loadBalancers.cidrs, then a LoadBalancer Service must explicitly set at least one of:

  • spec.loadBalancerIP
  • spec.loadBalancerSourceRanges

This is intentional. If CIDR restrictions are configured, Capsule requires the Service request to provide a value that can be evaluated.

For example, this Service is denied when loadBalancers.cidrs is configured:

apiVersion: v1
kind: Service
metadata:
  name: public-api
spec:
  type: LoadBalancer
  ports:
    - name: http
      port: 80
      targetPort: 8080

Example rejection:

Error from server (Forbidden): error when creating "svc.yaml": admission webhook "services.validating.projectcapsule.dev" denied the request: loadBalancer service requires spec.loadBalancerIP or spec.loadBalancerSourceRanges because loadBalancer CIDR constraints are enforced by namespace rule

If no loadBalancers.cidrs constraint is configured, Capsule does not require these fields. In that case, a LoadBalancer Service can be admitted as long as the Service type itself is allowed.

Denying selected LoadBalancer CIDRs

You can also deny specific LoadBalancer CIDRs:

rules:
  - enforce:
      action: allow
      services:
        types:
          - LoadBalancer
        loadBalancers:
          cidrs:
            - 10.0.0.0/8

  - enforce:
      action: deny
      services:
        loadBalancers:
          cidrs:
            - 10.0.66.0/24

A Service using 10.0.66.10 is denied because the later deny rule matches:

Error from server (Forbidden): error when creating "svc.yaml": admission webhook "services.validating.projectcapsule.dev" denied the request: loadBalancer CIDR "10.0.66.10" at spec.loadBalancerIP is denied by namespace rule: 10.0.66.10 is contained in 10.0.66.0/24

A later namespace-specific allow rule can override an earlier allow miss or deny decision:

rules:
  - enforce:
      action: allow
      services:
        types:
          - LoadBalancer
        loadBalancers:
          cidrs:
            - 10.0.0.2/32

  - namespaceSelector:
      matchLabels:
        environment: prod
    enforce:
      action: allow
      services:
        loadBalancers:
          cidrs:
            - 10.0.171.0/24

In namespaces labeled environment=prod, a Service using 10.0.171.239 is admitted. In other namespaces, it is denied because it does not match the default allowed CIDR.

External IPs

External IP rules allow administrators to restrict the addresses supplied in spec.externalIPs.

External IP constraints are configured under enforce.services.externalIPs.cidrs. They are admission rules only: Capsule does not allocate addresses, add entries to spec.externalIPs, or configure routing for them.

The constraint applies to any Service type that specifies spec.externalIPs. Individual addresses in a rule are treated as host CIDRs:

  • an IPv4 address without a prefix is treated as /32;
  • an IPv6 address without a prefix is treated as /128.

Allow external IPs from selected networks:

rules:
  - enforce:
      action: allow
      services:
        types:
          - ClusterIP
        externalIPs:
          cidrs:
            - 10.20.0.0/16
            - 192.168.1.2
            - 2001:db8::/32

This Service is admitted because both requested addresses match an allowed CIDR:

apiVersion: v1
kind: Service
metadata:
  name: internal-api
spec:
  type: ClusterIP
  externalIPs:
    - 10.20.1.44
    - 192.168.1.2
  ports:
    - name: http
      port: 8080
      targetPort: 8080

Every entry in spec.externalIPs must satisfy the rule evaluation. The following Service is denied because 8.8.8.8 is outside the allowed CIDRs:

apiVersion: v1
kind: Service
metadata:
  name: public-dns
spec:
  type: ClusterIP
  externalIPs:
    - 10.20.1.44
    - 8.8.8.8
  ports:
    - name: dns
      protocol: UDP
      port: 53
      targetPort: 5353

Example rejection:

Error from server (Forbidden): error when creating "svc.yaml": admission webhook "services.validating.projectcapsule.dev" denied the request: external IP "8.8.8.8" at spec.externalIPs[1] is not allowed by namespace rule: value did not match any allowed rule. Allowed CIDRs: 10.20.0.0/16, 192.168.1.2, 2001:db8::/32

Unlike LoadBalancer CIDR and NodePort range constraints, an external IP rule does not require the Service to specify a value. A Service without spec.externalIPs is admitted if it satisfies the other applicable Service rules.

Denying selected external IPs

You can combine a broad allow-list with a later deny rule:

rules:
  - enforce:
      action: allow
      services:
        externalIPs:
          cidrs:
            - 10.20.0.0/16

  - enforce:
      action: deny
      services:
        externalIPs:
          cidrs:
            - 10.20.66.0/24

An address such as 10.20.1.44 is admitted, while 10.20.66.4 is denied because the later deny rule matches.

Denying all external IPs

For a deny rule, an empty cidrs array matches every value in spec.externalIPs:

rules:
  - enforce:
      action: deny
      services:
        externalIPs:
          cidrs: []

The equivalent short form is:

rules:
  - enforce:
      action: deny
      services:
        externalIPs: {}

Both forms deny any Service that supplies one or more external IPs. Services that omit spec.externalIPs remain unaffected. An empty cidrs array in an allow or audit rule does not create an external IP restriction.

ExternalName

ExternalName rules allow administrators to restrict spec.externalName for Services of type ExternalName.

ExternalName constraints are configured under enforce.services.externalNames.hostnames.

Each hostname matcher uses the common match expression structure with exact, exp, and optional negate.

Allow selected ExternalName hostnames:

rules:
  - enforce:
      action: allow
      services:
        types:
          - ExternalName
        externalNames:
          hostnames:
            - exact:
                - internal.git.com
            - exp: ".*\\.example\\.com"

The following Services are admitted:

apiVersion: v1
kind: Service
metadata:
  name: git
spec:
  type: ExternalName
  externalName: internal.git.com
  ports:
    - name: https
      port: 443
      targetPort: 443
apiVersion: v1
kind: Service
metadata:
  name: api
spec:
  type: ExternalName
  externalName: api.example.com
  ports:
    - name: https
      port: 443
      targetPort: 443

A non-matching hostname is denied:

apiVersion: v1
kind: Service
metadata:
  name: api
spec:
  type: ExternalName
  externalName: api.bad.com
  ports:
    - name: https
      port: 443
      targetPort: 443

Example rejection:

Error from server (Forbidden): error when creating "svc.yaml": admission webhook "services.validating.projectcapsule.dev" denied the request: externalName hostname "api.bad.com" at spec.externalName is not allowed by namespace rule: value did not match any allowed rule. Allowed hostnames: exact: internal.git.com, exp: .*\.example\.com

Use exact and exp together in the same matcher:

rules:
  - enforce:
      action: allow
      services:
        types:
          - ExternalName
        externalNames:
          hostnames:
            - exact:
                - combined.internal.git.com
              exp: "combined\\..*\\.example\\.com"

This matcher allows both:

  • combined.internal.git.com
  • hostnames matching combined\\..*\\.example\\.com

Negation for ExternalName hostnames

negate: true inverts the final matcher result. This applies to both exact and exp.

Deny every ExternalName except trusted hostnames:

rules:
  - enforce:
      action: deny
      services:
        externalNames:
          hostnames:
            - exp: "trusted\\..*"
              negate: true

  - enforce:
      action: allow
      services:
        types:
          - ExternalName
        externalNames:
          hostnames:
            - exp: "trusted\\..*"

With these rules:

  • trusted.api is admitted.
  • api.example.com is denied by the negated deny rule.

Example rejection:

Error from server (Forbidden): error when creating "svc.yaml": admission webhook "services.validating.projectcapsule.dev" denied the request: externalName hostname "api.example.com" at spec.externalName is denied by namespace rule: "api.example.com" matched hostname rule not exp: trusted\..*

Important: when an allow-list exists for ExternalName hostnames, values excluded from a negated deny rule still need a matching allow rule. The deny rule prevents untrusted values, while the allow rule satisfies allow-list behavior for trusted values.

Namespace-specific ExternalName rules

You can use namespaceSelector to apply ExternalName restrictions only to selected namespaces:

rules:
  - enforce:
      action: allow
      services:
        types:
          - ExternalName
        externalNames:
          hostnames:
            - exp: ".*\\.example\\.com"

  - namespaceSelector:
      matchLabels:
        external-policy: restricted
    enforce:
      action: deny
      services:
        externalNames:
          hostnames:
            - exact:
                - blocked.example.com

In namespaces labeled external-policy=restricted, blocked.example.com is denied. Other hostnames matching .*\\.example\\.com remain allowed.

NodePort

NodePort rules allow administrators to restrict explicitly requested spec.ports[].nodePort values.

NodePort constraints are configured under enforce.services.nodePorts.ports.

Each port range contains:

FieldDescription
fromFirst allowed or denied port in the range.
toLast allowed or denied port in the range.

The from value must be lower than or equal to to. Equal values are valid and represent a single port.

Allow selected NodePort ranges:

rules:
  - enforce:
      action: allow
      services:
        types:
          - NodePort
        nodePorts:
          ports:
            - from: 30000
              to: 30100
            - from: 30500
              to: 30500

This Service is admitted because 30080 is in the allowed range:

apiVersion: v1
kind: Service
metadata:
  name: tenant-api
spec:
  type: NodePort
  ports:
    - name: http
      port: 8080
      targetPort: 8080
      nodePort: 30080

This Service is also admitted because 30500 matches the single-port range:

apiVersion: v1
kind: Service
metadata:
  name: tenant-api-single
spec:
  type: NodePort
  ports:
    - name: http
      port: 8080
      targetPort: 8080
      nodePort: 30500

This Service is denied because 32080 is outside the allowed ranges:

apiVersion: v1
kind: Service
metadata:
  name: tenant-api
spec:
  type: NodePort
  ports:
    - name: http
      port: 8080
      targetPort: 8080
      nodePort: 32080

Example rejection:

Error from server (Forbidden): error when creating "svc.yaml": admission webhook "services.validating.projectcapsule.dev" denied the request: nodePort "32080" at spec.ports[0].nodePort is not allowed by namespace rule: value did not match any allowed rule. Allowed ranges: 30000-30100, 30500

Required explicit nodePort when ranges are configured

If any matching rule configures nodePorts.ports, then a NodePort Service must explicitly set spec.ports[].nodePort.

This is intentional. Kubernetes can allocate a node port automatically when the field is omitted, but the validating webhook cannot know the allocated value at admission time. To enforce configured port ranges reliably, Capsule requires the requested node port to be explicit.

The following Service is denied when nodePorts.ports is configured:

apiVersion: v1
kind: Service
metadata:
  name: tenant-api
spec:
  type: NodePort
  ports:
    - name: http
      port: 8080
      targetPort: 8080

Example rejection:

Error from server (Forbidden): error when creating "svc.yaml": admission webhook "services.validating.projectcapsule.dev" denied the request: service requires explicit spec.ports[*].nodePort because nodePort ranges are enforced by namespace rule

If no nodePorts.ports constraint is configured, Capsule does not require explicit nodePort values. In that case, a NodePort Service can be admitted as long as the Service type itself is allowed.

Denying selected NodePorts

You can allow a broad range and deny a specific port afterwards:

rules:
  - enforce:
      action: allow
      services:
        types:
          - NodePort
        nodePorts:
          ports:
            - from: 30000
              to: 30100

  - enforce:
      action: deny
      services:
        nodePorts:
          ports:
            - from: 30090
              to: 30090

A Service using 30080 is admitted. A Service using 30090 is denied because the later deny rule also matches.

Example rejection:

Error from server (Forbidden): error when creating "svc.yaml": admission webhook "services.validating.projectcapsule.dev" denied the request: nodePort "30090" at spec.ports[0].nodePort is denied by namespace rule: nodePort 30090 is within allowed range 30090

Although the detail says the port is within the matched range, the rule action is deny, so the request is rejected.

LoadBalancer Services and NodePorts

Kubernetes LoadBalancer Services may allocate node ports unless spec.allocateLoadBalancerNodePorts is explicitly set to false.

Therefore, NodePort range enforcement also applies to LoadBalancer Services when node port allocation is enabled.

This rule allows LoadBalancer Services, restricts the LoadBalancer IP, and restricts the allocated node port:

rules:
  - enforce:
      action: allow
      services:
        types:
          - LoadBalancer
        loadBalancers:
          cidrs:
            - 10.0.0.2/32
        nodePorts:
          ports:
            - from: 30000
              to: 30100

This Service is admitted because the LoadBalancer IP and node port are both allowed:

apiVersion: v1
kind: Service
metadata:
  name: public-api
spec:
  type: LoadBalancer
  loadBalancerIP: 10.0.0.2
  ports:
    - name: http
      port: 80
      targetPort: 8080
      nodePort: 30080

This Service is denied because the explicit node port is outside the allowed range:

apiVersion: v1
kind: Service
metadata:
  name: public-api
spec:
  type: LoadBalancer
  loadBalancerIP: 10.0.0.2
  ports:
    - name: http
      port: 80
      targetPort: 8080
      nodePort: 32080

When nodePorts.ports is configured and LoadBalancer node port allocation is enabled, Capsule requires explicit spec.ports[].nodePort values:

apiVersion: v1
kind: Service
metadata:
  name: public-api
spec:
  type: LoadBalancer
  loadBalancerIP: 10.0.0.2
  ports:
    - name: http
      port: 80
      targetPort: 8080

Example rejection:

Error from server (Forbidden): error when creating "svc.yaml": admission webhook "services.validating.projectcapsule.dev" denied the request: service requires explicit spec.ports[*].nodePort because nodePort ranges are enforced by namespace rule

To avoid node port enforcement for a LoadBalancer Service, disable node port allocation explicitly:

apiVersion: v1
kind: Service
metadata:
  name: public-api
spec:
  type: LoadBalancer
  allocateLoadBalancerNodePorts: false
  loadBalancerIP: 10.0.0.2
  ports:
    - name: http
      port: 80
      targetPort: 8080

With allocateLoadBalancerNodePorts: false, Capsule does not require or validate spec.ports[].nodePort for that LoadBalancer Service. The Service must still satisfy any configured LoadBalancer CIDR rules.

Auditing Services

Use action: audit to observe Service usage without directly blocking the request. Audit rules emit Kubernetes events and return admission warnings, but they do not allow or deny the request.

Audit ExternalName usage:

rules:
  - enforce:
      action: audit
      services:
        types:
          - ExternalName
        externalNames:
          hostnames:
            - exp: "audit\\..*"

A matching Service is admitted in this audit-only example because no Service type or hostname allow-list is configured:

apiVersion: v1
kind: Service
metadata:
  name: audited-external
spec:
  type: ExternalName
  externalName: audit.internal
  ports:
    - name: https
      port: 443
      targetPort: 443

If an allow-list is also configured, audit does not satisfy it:

rules:
  - enforce:
      action: audit
      services:
        externalNames:
          hostnames:
            - exp: "audit\\..*"

  - enforce:
      action: allow
      services:
        types:
          - ExternalName
        externalNames:
          hostnames:
            - exp: "allowed\\..*"

With these rules, audit.internal emits an audit event but is still denied because it does not match the allowed hostname rule.

Combining Service Rules

Service rules can be split across multiple rule blocks. This is useful when type permissions, external IP rules, LoadBalancer CIDR rules, hostname rules, and NodePort ranges should be managed independently.

For example:

rules:
  - enforce:
      action: allow
      services:
        types:
          - ClusterIP
          - ExternalName

  - enforce:
      action: allow
      services:
        externalNames:
          hostnames:
            - exp: ".*\\.example\\.com"

This configuration:

  • allows ClusterIP Services;
  • allows ExternalName Services as a type;
  • allows only ExternalName hostnames matching .*\\.example\\.com.

A Service of type ExternalName with externalName: api.example.com is admitted. A Service of type ExternalName with externalName: api.bad.com is denied by the hostname allow-list.

A later deny rule can override an earlier allow rule:

rules:
  - enforce:
      action: allow
      services:
        types:
          - ExternalName
        externalNames:
          hostnames:
            - exp: ".*\\.example\\.com"

  - enforce:
      action: deny
      services:
        externalNames:
          hostnames:
            - exact:
                - blocked.example.com

Here, api.example.com is allowed, but blocked.example.com is denied because the later deny rule matches.

A later allow rule can override an earlier deny rule:

rules:
  - enforce:
      action: deny
      services:
        nodePorts:
          ports:
            - from: 30080
              to: 30080

  - namespaceSelector:
      matchLabels:
        allow-special-nodeport: "true"
    enforce:
      action: allow
      services:
        types:
          - NodePort
        nodePorts:
          ports:
            - from: 30080
              to: 30080

In namespaces labeled allow-special-nodeport=true, a NodePort Service using 30080 is admitted because the namespace-specific allow rule matches later.

Ingress

Ingress enforcement is configured under spec.rules[].enforce.ingress. Use Ingress Types to select Kubernetes Ingresses, OpenShift Routes, and Gateway API resources, then configure Hostnames to allow, deny, or audit their hostname values.

Ingress Types

The types list selects the resource kinds whose hostnames the rule evaluates. Configure at least one type and one hostname expression together. A rule with only types or only hostnames is rejected at admission.

Capsule supports the following resource types and hostname fields:

TypeAPIEvaluated fields
Ingressnetworking.k8s.io/v1spec.rules[].host and spec.tls[].hosts[]
Routeroute.openshift.io/v1spec.host
Gatewaygateway.networking.k8s.io/v1spec.listeners[].hostname
ListenerSetgateway.networking.k8s.io/v1spec.listeners[].hostname
HTTPRoutegateway.networking.k8s.io/v1spec.hostnames[]
TLSRoutegateway.networking.k8s.io/v1spec.hostnames[]
GRPCRoutegateway.networking.k8s.io/v1spec.hostnames[]

Hostnames

The hostnames list contains match expressions. Each entry supports:

FieldMatching behavior
exactMatch one of the listed hostname strings exactly.
expMatch a regular expression. Use ^ and $ to match the complete hostname.
negateInvert the entry’s match result. Defaults to false.

An entry can use exact, exp, or both. When both are set, either match is sufficient before applying negate. Multiple entries provide alternative matches for the rule’s action.

Each hostname on a targeted resource is evaluated independently. The entire request is denied if any hostname is denied or does not satisfy an active allow-list. For an Ingress, this includes both routing hosts and TLS hosts, so all values in spec.rules[].host and spec.tls[].hosts[] must satisfy the policy.

Allow selected hostnames

The following rule allows one exact hostname and any single-label hostname under example.com for Kubernetes Ingress and Gateway API HTTPRoute resources:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: allow
        ingress:
          types:
            - Ingress
            - HTTPRoute
          hostnames:
            - exact:
                - internal.example.com
            - exp: "^[a-z0-9-]+\\.example\\.com$"

This Ingress is admitted because both its routing hostname and TLS hostname match the allow-list:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: tenant-api
spec:
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: tenant-api
                port:
                  number: 8080
  tls:
    - hosts:
        - api.example.com
      secretName: tenant-api-tls

Changing either occurrence to api.example.net denies the request. A rejection for the routing hostname includes the object path and configured allow-list:

ingress hostname "api.example.net" at spec.rules[0].host is not allowed by namespace rule: value did not match any allowed rule. Allowed hostnames: exact: internal.example.com, exp: ^[a-z0-9-]+\.example\.com$

An Ingress TLS entry is not exempt from enforcement. For example, the following object is denied even though its routing hostname is allowed, because legacy.example.net in spec.tls[0].hosts[0] is not allowed:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: mixed-hostnames
spec:
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: tenant-api
                port:
                  number: 8080
  tls:
    - hosts:
        - legacy.example.net
      secretName: tenant-api-tls

Gateway API and OpenShift Route examples

A single rule can target several supported resource shapes:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: allow
        ingress:
          types:
            - Route
            - Gateway
            - ListenerSet
            - HTTPRoute
            - TLSRoute
            - GRPCRoute
          hostnames:
            - exp: "^([a-z0-9-]+\\.)*apps\\.example\\.com$"

For an HTTPRoute, Capsule evaluates every entry in spec.hostnames:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: store
spec:
  hostnames:
    - store.apps.example.com
    - checkout.apps.example.com
  rules:
    - backendRefs:
        - name: store
          port: 8080

Both values match the expression, so the request is admitted. If one hostname does not match, Capsule denies the entire HTTPRoute.

For a Gateway or ListenerSet, Capsule evaluates the hostname of every listener:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: tenant-gateway
spec:
  gatewayClassName: shared
  listeners:
    - name: https
      protocol: HTTPS
      port: 443
      hostname: gateway.apps.example.com
      tls:
        mode: Terminate
        certificateRefs:
          - name: gateway-tls

For an OpenShift Route, the same rule evaluates spec.host:

apiVersion: route.openshift.io/v1
kind: Route
metadata:
  name: tenant-api
spec:
  host: api.apps.example.com
  to:
    kind: Service
    name: tenant-api

Deny hostnames and add exceptions

This Tenant allows a hostname family, denies a reserved hostname with a later rule, and allows that hostname again in namespaces selected by the final rule:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: allow
        ingress:
          types: [Ingress, HTTPRoute]
          hostnames:
            - exp: '^[a-z0-9-]+\.example\.com$'

    - enforce:
        action: deny
        ingress:
          types: [Ingress, HTTPRoute]
          hostnames:
            - exact: [admin.example.com]

    - namespaceSelector:
        matchLabels:
          ingress-admin: "true"
      enforce:
        action: allow
        ingress:
          types: [Ingress, HTTPRoute]
          hostnames:
            - exact: [admin.example.com]

api.example.com is admitted in every namespace in this Tenant. admin.example.com is admitted only in namespaces labeled ingress-admin=true; the later deny rule blocks it in other namespaces. Keep permission to change exception-selecting labels with the administrators who manage the policy.

Negated matches

You can also use negation to deny every hostname outside a trusted suffix:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        ingress:
          types:
            - Ingress
          hostnames:
            - exp: "^([a-z0-9-]+\\.)*example\\.com$"
              negate: true

This deny rule matches hostnames that do not match the expression. Because it does not create an allow-list, matching example.com hostnames pass unless another rule denies them.

Audit hostname usage

Use action: audit to observe selected hostnames without blocking them:

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: audit
        ingress:
          types:
            - Ingress
            - HTTPRoute
          hostnames:
            - exp: "^preview-.*\\.example\\.com$"

A matching hostname is admitted in this audit-only example, and Capsule emits a Kubernetes event for it. If an allow-list is also configured and the hostname does not match an allow rule, the request is still denied; the audit rule does not grant access.

Missing hostnames

As soon as at least one allow or deny hostname rule targets a resource type, every expected hostname field on that resource must contain a non-empty value. Omitted, empty, and whitespace-only values are treated as missing.

Examples of missing values include:

  • an Ingress with no routing or TLS hostname, an Ingress rule without host, or a TLS entry without hosts;
  • a Route without spec.host;
  • a Gateway or ListenerSet listener without hostname;
  • an HTTPRoute, TLSRoute, or GRPCRoute without spec.hostnames.

For example, this Gateway is denied when an allow or deny hostname rule targets Gateway:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: hostless
spec:
  gatewayClassName: shared
  listeners:
    - name: http
      protocol: HTTP
      port: 80

The rejection identifies the missing field:

hostname is required at spec.listeners[0].hostname because hostname rules target Gateway

If no ingress rule targets the resource type, Capsule does not apply this hostname requirement.

Audit-only rules do not make hostnames mandatory. When an expected hostname is missing, Capsule admits the request and emits an audit event that identifies the empty field, for example:

empty hostname detected at spec.listeners[0].hostname for Gateway by audit namespace rule

If audit and allow or deny rules target the same resource type, Capsule emits the empty-hostname audit event and enforces the non-audit rule, which denies the request.

Reference

The following Tenant examples combine the policies in each group. Use the configuration paths at the start of this chapter to combine them in one Tenant.

Network Policies

This complete Tenant blocks CIDR-based ingress and egress grants by default, permits approved ranges in selected namespaces, and denies a reserved subnet in those namespaces. Replace solar-owner with your owner identity.

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: deny
        network:
          policies:
            ingress:
              cidrs:
                - 0.0.0.0/0
                - ::/0
            egress:
              cidrs:
                - 0.0.0.0/0
                - ::/0

    - namespaceSelector:
        matchLabels:
          network-profile: approved-cidrs
      enforce:
        action: allow
        network:
          policies:
            ingress:
              cidrs:
                - 10.20.0.0/16
                - fd00:1234::/48
            egress:
              cidrs:
                - 10.20.0.0/16
                - fd00:1234::/48

    - namespaceSelector:
        matchLabels:
          network-profile: approved-cidrs
      enforce:
        action: deny
        network:
          policies:
            ingress:
              cidrs:
                - 10.20.66.0/24
            egress:
              cidrs:
                - 10.20.66.0/24
Namespace profileNetworkPolicy grant in either directionResult
Without network-profile: approved-cidrsAny CIDR granting addressesDenied
network-profile: approved-cidrs10.20.1.0/24 or fd00:1234::/64Allowed by the later allow rule
network-profile: approved-cidrs10.20.66.0/24Denied by the final deny rule
network-profile: approved-cidrs10.20.0.0/16 without exceptionsDenied: includes the reserved subnet
network-profile: approved-cidrs10.20.0.0/16 except 10.20.66.0/24Allowed
network-profile: approved-cidrs192.0.2.0/24Denied: outside the approved ranges
Any namespace in this TenantIngress or egress rule with omitted peersDenied
Any namespace in this TenantSelector-based peers or no grants in that directionPasses the CIDR check

Other applicable enforcement rules must also pass. Rules from this Tenant do not apply to namespaces belonging to another Tenant.

Services

This complete Tenant combines type enforcement, external IP restrictions, LoadBalancer CIDR restrictions, ExternalName hostname restrictions, NodePort range restrictions, audit rules, and namespace-specific exceptions. Replace solar-owner with your owner identity.

---
apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: allow
        services:
          types:
            - ClusterIP
            - NodePort
            - LoadBalancer
            - ExternalName

    - enforce:
        action: allow
        services:
          externalIPs:
            cidrs:
              - 10.20.0.0/16
              - 192.168.1.2

    - enforce:
        action: allow
        services:
          loadBalancers:
            cidrs:
              - 10.0.0.2/32
              - 10.0.1.0/24

    - enforce:
        action: allow
        services:
          externalNames:
            hostnames:
              - exact:
                  - internal.git.com
              - exp: ".*\\.example\\.com"

    - enforce:
        action: allow
        services:
          nodePorts:
            ports:
              - from: 30000
                to: 30100
              - from: 30500
                to: 30500

    - enforce:
        action: deny
        services:
          nodePorts:
            ports:
              - from: 30090
                to: 30090

    - enforce:
        action: deny
        services:
          externalIPs:
            cidrs:
              - 10.20.66.0/24

    - enforce:
        action: deny
        services:
          loadBalancers:
            cidrs:
              - 10.0.66.0/24

    - enforce:
        action: audit
        services:
          externalNames:
            hostnames:
              - exp: "audit\\..*"

    - namespaceSelector:
        matchLabels:
          environment: prod
      enforce:
        action: allow
        services:
          loadBalancers:
            cidrs:
              - 10.0.171.0/24

With this configuration:

  • ClusterIP, NodePort, LoadBalancer, and ExternalName Services are valid Service types.
  • External IPs must be contained in 10.20.0.0/16 or match 192.168.1.2.
  • External IPs in 10.20.66.0/24 are denied even though they are inside the broader allowed range.
  • LoadBalancer IPs must be contained in 10.0.0.2/32 or 10.0.1.0/24.
  • Namespaces labeled environment=prod can also use LoadBalancer IPs in 10.0.171.0/24.
  • ExternalName hostnames must be internal.git.com or match .*\\.example\\.com.
  • Explicit node ports must be in 30000-30100 or equal to 30500.
  • Node port 30090 is denied even though it is inside the broader allowed range.
  • ExternalName hostnames matching audit\\..* emit audit events and warnings.
  • Audit matches do not allow values that fail the allow-list.

Ingress

This complete Tenant allows a hostname family, denies a reserved hostname, audits preview hostnames, and grants a namespace-specific exception. Replace solar-owner with your owner identity. It covers every resource kind listed under Ingress Targets, including Gateway API and OpenShift resources when those APIs are installed.

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - enforce:
        action: allow
        ingress:
          types: [Ingress, Route, Gateway, ListenerSet, HTTPRoute, TLSRoute, GRPCRoute]
          hostnames:
            - exact: [internal.example.com]
            - exp: '^[a-z0-9-]+\.solar\.example\.com$'

    - enforce:
        action: deny
        ingress:
          types: [Ingress, Route, Gateway, ListenerSet, HTTPRoute, TLSRoute, GRPCRoute]
          hostnames:
            - exact: [admin.solar.example.com]

    - enforce:
        action: audit
        ingress:
          types: [Ingress, Route, Gateway, ListenerSet, HTTPRoute, TLSRoute, GRPCRoute]
          hostnames:
            - exp: '^preview-[a-z0-9-]+\.solar\.example\.com$'

    - namespaceSelector:
        matchLabels:
          ingress-admin: "true"
      enforce:
        action: allow
        ingress:
          types: [Ingress, Route, Gateway, ListenerSet, HTTPRoute, TLSRoute, GRPCRoute]
          hostnames:
            - exact: [admin.solar.example.com]

Each routing, TLS, or listener hostname is checked independently:

HostnameNamespaceResult
api.solar.example.com or internal.example.comAny namespace in this TenantAllowed
admin.solar.example.comWithout ingress-admin: "true"Denied by the later deny rule
admin.solar.example.comWith ingress-admin: "true"Allowed by the final exception
preview-api.solar.example.comAny namespace in this TenantAllowed and audited
api.example.netAny namespace in this TenantDenied because it does not match the allow-list
A required hostname is missingAny namespace in this TenantDenied because non-audit hostname rules target the resource type

Every hostname on a resource must pass. For example, an Ingress with an allowed routing host and a disallowed TLS host is denied. If you narrow the types lists, omitted resource kinds remain unaffected by these rules.

4 - Storage

Default PersistentVolume isolation and additional volume access through namespace rules.

Storage enforcement is configured under spec.rules[].enforce.storage. Persistent Volumes covers default Tenant isolation and additional volume access for PVs without a Tenant label.

Rules use the shared namespace selection, audiences, conditions, and actions and order. See Reference for a complete Tenant example.

Rule Composition

Actions and order

Storage rules use the existing ordered action evaluation, but affect only additional access to PVs without a Tenant label:

ActionEffect on additional access
allowGrants access when the selector, audience, namespace selection, and all conditions match.
denyRejects the exception when it is the last matching allow/deny rule. This is the default action.
auditEmits a Kubernetes event for a match; never grants access.

The last matching allow or deny wins. If none grants access, the existing missing-Tenant-label rejection remains. An audit-only rule therefore does not permit an unlabeled PV. Other applicable admission checks must still pass after a storage rule allows access.

Same-Tenant volume access remains available without these rules. A matching rule cannot authorize another Tenant’s labeled PV or a PV whose Tenant label has an empty value. See Ownership and label protection.

Admission scope and limits

  • Checks apply to PVC creation and updates of an unbound PVC with an explicit spec.volumeName, including the PATCH that sets it during a restore.
  • The referenced PV must exist. A PV being deleted cannot receive an additional grant.
  • PVCs without spec.volumeName do not use these additional rules. They do not change dynamic provisioning or how Kubernetes selects a PV.
  • Already-bound PVC updates keep their existing behavior. These rules do not reauthorize or revoke an established binding.
  • The grant authorizes the PVC admission request. It does not rewrite the PV’s claimRef, restore labels, move data, or complete binding. RBAC, StorageClass restrictions, other admission policies, and Kubernetes binding requirements continue to apply.

PVC selector mutation runs only on creation. All updates preserve the existing selector, including updates while the claim is still Pending. The PV ownership check still applies when an unbound PVC is updated to reference a volume. See PVC selectors and dynamic provisioning.

Persistent Volumes

Capsule isolates PersistentVolumes (PVs) between Tenants by default. Storage rules let administrators grant additional access to selected PVs that do not yet carry Capsule’s Tenant ownership label. Configure these additional grants under spec.rules[].enforce.storage.volumes.

This supports workflows that patch a destination PersistentVolumeClaim (PVC) before changing the restored PV’s claim reference from a temporary claim to the destination claim. The default isolation described below continues to apply.

Default PersistentVolume isolation

Tenant owners can create PVCs using the StorageClasses available to their Tenant. When a PV’s spec.claimRef points to a PVC in a Tenant namespace, Capsule’s PV controller labels the volume with capsule.clastix.io/tenant: <tenant-name>. The controller resolves the Tenant from the claim’s namespace; PVs themselves are cluster-scoped. This behavior does not require any storage.volumes rules.

For example, a PV claimed in solar-production, a namespace belonging to the solar Tenant, has the following relevant fields. This is an excerpt of the existing PV, not a complete volume definition:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: solar-data
  labels:
    capsule.clastix.io/tenant: solar
spec:
  storageClassName: standard
  persistentVolumeReclaimPolicy: Retain
  claimRef:
    apiVersion: v1
    kind: PersistentVolumeClaim
    namespace: solar-production
    name: data

Reusing retained volumes

What happens after a PVC is deleted depends on the PV’s reclaim policy. With Retain, the volume and its data remain for manual reclamation; it is not automatically ready for another claim. Follow the Kubernetes reclamation procedure before reusing the storage.

Once the PV is available for reuse, Capsule permits claims from namespaces belonging to solar to reference it. Kubernetes must still be able to bind the claim: the requested capacity, access modes, StorageClass, and other binding requirements must be satisfied. Deleting the old PVC does not remove the PV’s Tenant label or authorize a different Tenant to use it.

A claim from green-energy, a namespace belonging to another Tenant named green, cannot reference solar-data:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: data
  namespace: green-energy
spec:
  storageClassName: standard
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 3Gi
  volumeName: solar-data

Capsule rejects that request with a cross-Tenant mount error:

admission webhook "pvc.validating.projectcapsule.dev" denied the request: Preventing a cross-tenant mount for PersistentVolume solar-data

PVC selectors and dynamic provisioning

On PVC creation, Capsule adds the owning Tenant requirement to an existing spec.selector, or creates a selector when spec.volumeName is set. It preserves unrelated selector requirements and replaces conflicting requirements for capsule.clastix.io/tenant with the correct Tenant value.

A new PVC with neither spec.selector nor spec.volumeName receives no Tenant selector, preserving dynamic provisioning. On update, Capsule leaves the selector unchanged, even while the PVC is Pending: Kubernetes treats the selector as immutable after creation. This allows a restore component to set spec.volumeName without Capsule adding a selector change that Kubernetes would reject.

For an explicit spec.volumeName, Capsule also checks the referenced PV’s Tenant label during PVC admission. A PV labeled for the same Tenant passes this ownership check; another Tenant’s PV is rejected. Without an additional grant, a PV with no Tenant label is also rejected. The rules below extend this last case while retaining the default ownership checks.

Additional volume access

Each entry in storage.volumes has a required Kubernetes label selector and an optional name for admission and audit messages. The selector matches labels on the PV named by the PVC’s spec.volumeName.

rules:
  - namespaceSelector:
      matchLabels:
        storage-profile: shared-imports
    enforce:
      action: allow
      storage:
        volumes:
          - name: approved-imports
            selector:
              matchLabels:
                storage.example.com/pool: imports
              matchExpressions:
                - key: storage.example.com/quarantined
                  operator: DoesNotExist

Place this rules block under Tenant.spec. It grants additional access to PVs with the imports pool label and no quarantine label, only from selected namespaces. Without an audience, the rule applies to every caller in that scope.

  • All requirements in one selector must match. Entries in volumes are alternatives: any matching entry matches that enforcement rule.
  • selector: {} explicitly matches every eligible PV, including a PV with no labels. Use conditions and an audience to scope a restore exception.
  • Omitting selector, or setting it to null, is invalid. An omitted or empty volumes list grants no additional access.
  • A rule supports at most 64 volume entries. Optional names must be unique within the list and use DNS labels of up to 63 characters.

Ownership and label protection

Capsule checks the PV’s capsule.clastix.io/tenant label before evaluating any additional grant:

PV ownership labelVolume access decision
Matches the destination TenantExisting access is retained, even if no storage rule matches or a storage rule says deny.
Names another TenantDenied; no selector or CEL condition can override ownership.
Key exists with an empty valueDenied; this is not treated as an absent label.
Key is absentRequires an applicable matching allow rule.

Selectors only read PV labels. They never add, remove, or overwrite PV labels, and are never copied into PVC.spec.selector. Labels supplied on the PVC cannot satisfy a PV selector. Capsule’s existing PVC Tenant selector enforcement remains in place.

Labels used for authorization must be controlled by administrators or trusted storage components. Tenant actors must not receive RBAC permissions to edit PVs or the rules granting access. A name prefix or a label alone does not establish Tenant ownership.

Conditions

Use enforce.conditions to inspect both the PVC being admitted and the referenced PV. There is no separate conditions field on a volume entry.

CEL variableValue during additional volume access evaluation
objectThe incoming PVC, including its destination namespace and spec.volumeName.
requestAdmission metadata, including operation and authenticated caller.
volumeA read-only snapshot of the existing PV referenced by spec.volumeName.

Check optional fields with has(...), for example volume != null && has(volume.spec.claimRef). Outside additional volume access evaluation, volume is null, including in metadata conditions on a PVC. Keep conditions that depend on volume in a separate storage enforcement block when other policies need independent conditions.

All conditions must be true. A false condition skips the enforcement block; it does not grant access. Evaluation errors reject admission unless another condition in that block is false, following the normal condition evaluation rules.

Reference

Restore through a temporary namespace

For a restore workflow using temporary claims in openshift-adp, the destination PVC may need to reference a PV whose claimRef still names a temporary claim. PVs are cluster-scoped; the namespace in spec.claimRef belongs to that PVC. If that PV has no Tenant label, the default Capsule ownership check rejects the PVC patch. An additional storage rule can authorize this handoff without relaxing access to another Tenant’s labeled PVs.

The following Tenant grants the exception in namespaces labeled storage-profile: restore. It allows service accounts from openshift-adp to update a PVC only when the PV’s temporary claim is in that namespace and its name starts with the destination namespace followed by -.

apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
  name: solar
spec:
  owners:
    - kind: User
      name: solar-owner
  rules:
    - namespaceSelector:
        matchLabels:
          storage-profile: restore
      audience:
        - kind: Group
          name: system:serviceaccounts:openshift-adp
      enforce:
        action: allow
        conditions:
          - name: restore-handoff
            expression: >-
              request.operation == 'UPDATE' &&
              volume != null &&
              has(volume.spec.claimRef) &&
              volume.spec.claimRef.namespace == 'openshift-adp' &&
              volume.spec.claimRef.name.startsWith(object.metadata.namespace + '-')              
        storage:
          volumes:
            - name: temporary-restore-volume
              selector: {}

Replace the owner, staging namespace, and claim naming condition with values from your restore workflow. This example grants the audience to all service accounts in openshift-adp. To scope it to one restoring component, use an audience with kind: ServiceAccount and its full system:serviceaccount:<namespace>:<name> username. Match the identity making the PVC patch, which may differ from the user who submitted a NonAdminRestore.

The prefix check is a naming convention, not proof of ownership. The trusted restore component remains responsible for handing the correct volume to the destination claim. Use the actual temporary claim naming scheme; Capsule does not derive it from a NonAdminRestore object.

If the temporary PV carries an administrator-controlled pool label, replace selector: {} with a matchLabels selector to narrow the grant further. Capsule does not add that label on your behalf.

After the PVC patch is admitted, the restore component can finish the PV handoff. Capsule’s existing PV labeling behavior still applies when the claim reference points to a Tenant namespace. The rule itself does not change labels or claim references.

Request or PV stateResult with this example
Trusted restore caller, selected namespace, matching temporary claim, no Tenant labelAdditional access is allowed.
Tenant owner without the restore audienceNo additional access.
Namespace without storage-profile: restoreNo additional access.
PVC creation rather than updateNo additional access; the condition requires UPDATE.
Missing claim reference or a different temporary namespace/name prefixNo additional access.
PV labeled for another TenantDenied regardless of the rule.
PV already labeled for solarExisting access is retained; other checks still apply.