Parameter Schemas and Dynamic Forms

Validate parameterized APIs with JSON Schema and Kubernetes CEL, and enrich clients with live Kubernetes resource selectors.

Capsule parameter schemas define the contract between a template and the values submitted by a consuming API object. Resource Permits currently use spec.paramSchema to validate ResourcePermit.spec.params before context loading and rendering.

spec.paramSchema on the referenced template is the complete contract for ResourcePermit.spec.params. It accepts JSON Schema 2020-12 keywords, Kubernetes-compatible x-kubernetes-validations CEL rules, and Capsule’s x-capsule-form presentation extension.

Capsule validates parameters during ResourcePermit admission, before loading context or rendering resources. Invalid parameters are rejected and no ResourcePermit is created. Failures that require runtime context or rendering remain visible on an accepted request as Created with Ready=False; Capsule does not apply any resources.

JSON Schema 2020-12

Use ordinary JSON Schema for types, required fields, enums, ranges, lengths, patterns, and conditional structure:

paramSchema:
  $schema: https://json-schema.org/draft/2020-12/schema
  type: object
  additionalProperties: false
  required:
    - subjectKind
    - subjectName
  properties:
    subjectKind:
      type: string
      enum: [User, Group, ServiceAccount]
    subjectName:
      type: string
      minLength: 1
  allOf:
    - if:
        properties:
          subjectKind:
            const: ServiceAccount
        required: [subjectKind]
      then:
        properties:
          subjectName:
            pattern: '^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?/[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$'

With this schema:

  • ServiceAccount with operations/resource-permit-runner is valid;
  • ServiceAccount with resource-permit-runner is invalid; and
  • User with alice@example.com remains valid.

The example represents a ServiceAccount as namespace/name. When rendering an RBAC Subject, split that value into the subject’s namespace and name fields.

Other useful JSON Schema keywords include dependentRequired, dependentSchemas, contains, prefixItems, allOf, anyOf, oneOf, and unevaluatedProperties.

Kubernetes CEL rules

Use x-kubernetes-validations for readable cross-field and collection invariants. It has the same shape as validation rules generated from Kubebuilder’s +kubebuilder:validation:XValidation marker. Capsule converts the parameter schema to a Kubernetes structural schema and delegates evaluation to Kubernetes’ CEL validator.

paramSchema:
  type: object
  additionalProperties: false
  required:
    - subjectKind
    - subjectName
  properties:
    subjectKind:
      type: string
      enum: [User, Group, ServiceAccount]
    subjectName:
      type: string
      minLength: 1
  x-kubernetes-validations:
    - rule: >-
        self.subjectKind != 'ServiceAccount' ||
        self.subjectName.matches('^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?/[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$')        
      message: ServiceAccount subjects must use namespace/name
      reason: FieldValueInvalid
      fieldPath: .subjectName

self is the value at the schema node containing the extension. At the root it is the whole parameters object. Use has(self.field) for optional properties.

The accepted validation-rule fields are:

FieldMeaning
ruleRequired CEL expression returning a boolean
messageStatic failure message; required when a rule contains line breaks
messageExpressionCEL expression returning a dynamic failure string
reasonFieldValueInvalid, FieldValueForbidden, FieldValueRequired, or FieldValueDuplicate
fieldPathRelative field path associated with the error
optionalOldSelfKubernetes transition-rule behavior for an optional previous value

Rule and message-expression syntax is compiled when the template is admitted. Evaluation uses Kubernetes CEL cost limits.

Parameter validation has no previous parameter object. A transition rule using oldSelf therefore behaves like Kubernetes validation on create: an ordinary transition rule is skipped, while optionalOldSelf: true can explicitly handle an absent previous value. Use self for request-time constraints.

Dynamic failure messages are supported:

x-kubernetes-validations:
  - rule: "self.subjectKind != 'ServiceAccount' || self.subjectName.contains('/')"
    message: invalid ServiceAccount path
    messageExpression: >-
      'subjectName ' + self.subjectName + ' must use namespace/name for a ServiceAccount'      
    reason: FieldValueInvalid
    fieldPath: .subjectName

If messageExpression fails or produces an invalid message, evaluation falls back to message, then to the rule text.

Rules may also be attached to structural properties, array items, and additional-property schemas. Their self value is scoped accordingly:

paramSchema:
  type: object
  properties:
    subjects:
      type: array
      items:
        type: object
        required: [name]
        properties:
          name:
            type: string
        x-kubernetes-validations:
          - rule: "!self.name.startsWith('system:')"
            messageExpression: "'subject ' + self.name + ' uses a reserved prefix'"
            fieldPath: .name

CEL rules must be attached to Kubernetes structural schema nodes. Do not place them inside $defs, if/then, or another JSON-Schema-only branch. Put the rule on a root, property, item, or additional-property node that can see the required values. Standard JSON Schema may still use those constructs alongside CEL.

Choose the smallest validation mechanism that expresses the invariant:

RequirementMechanism
Type, enum, range, length, or regexJSON Schema property keyword
Field required for a discriminator valueJSON Schema if/then
Relationship between fieldsx-kubernetes-validations CEL
Collection-item relationshipNested CEL rule or CEL collection macro
Populate choices from live objectsx-capsule-form
Authoritatively load a selected objectTemplate context.resources

Dynamic forms and live cluster data

x-capsule-form is an optional JSON Schema vendor extension for compatible clients such as a Headlamp plugin. It populates a string field from live Kubernetes objects without changing server-side JSON Schema semantics.

This complete example combines JSON Schema, conditional validation, CEL, and a live Secret selector:

paramSchema:
  $schema: https://json-schema.org/draft/2020-12/schema
  type: object
  additionalProperties: false
  required:
    - subjectKind
    - subjectName
    - credentialsSecret
  properties:
    subjectKind:
      type: string
      enum: [User, Group, ServiceAccount]
    subjectName:
      type: string
      minLength: 1
    credentialsSecret:
      type: string
      pattern: '^[a-z0-9.-]+/[a-z0-9.-]+$'
      x-capsule-form:
        widget: kubernetes-resource
        source:
          apiVersion: v1
          kind: Secret
          namespace: request
          labelSelector: access.example.com/selectable=true
        option:
          labelTemplate: '{{ .metadata.name }}'
          valueTemplate: '{{ .metadata.namespace }}/{{ .metadata.name }}'
  allOf:
    - if:
        properties:
          subjectKind:
            const: ServiceAccount
        required: [subjectKind]
      then:
        properties:
          subjectName:
            pattern: '^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?/[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$'
  x-kubernetes-validations:
    - rule: >-
        self.subjectKind != 'Group' ||
        !self.subjectName.startsWith('system:')        
      message: Groups using the system prefix cannot be requested
      reason: FieldValueForbidden
      fieldPath: .subjectName

The accepted form-extension shape is:

x-capsule-form:
  widget: kubernetes-resource       # required
  source:                           # required
    apiVersion: group.example.io/v1 # required; v1 is also valid
    kind: Example                   # required; one exact kind
    namespace: request              # optional: request, "*", or a namespace
    labelSelector: key=value        # optional
    fieldSelector: metadata.name=x  # optional
  option:                           # optional
    labelTemplate: '{{ .metadata.name }}'
    valueTemplate: '{{ .metadata.namespace }}/{{ .metadata.name }}'

Unknown widgets and extension fields are rejected. option may be omitted; both templates then default to {{ .metadata.name }}.

Resource source

FieldRequiredMeaning
apiVersionYesExact version such as v1, apps/v1, or an arbitrary CRD version
kindYesExact Kubernetes kind; wildcards are unsupported
namespaceNoList scope for a namespaced GVK
labelSelectorNoKubernetes label selector passed to the list request
fieldSelectorNoKubernetes field selector passed to the list request

namespace supports four modes:

ValueBehavior
omittedRequest namespace for namespaced GVKs; cluster endpoint for cluster-scoped GVKs
requestExplicitly use the ResourcePermit namespace
*List a namespaced GVK across every namespace
namespace nameList only that literal namespace

Any exact discoverable GVK is supported, including CRDs. Capsule validates API-version syntax, selectors, namespaces, extension fields, and option-template syntax when the template is admitted. It does not require the GVK to exist at that time, allowing the template to be installed before its CRD.

The form client uses Kubernetes discovery to determine whether a GVK is namespaced or cluster-scoped. For example:

properties:
  gatewayClass:
    type: string
    x-capsule-form:
      widget: kubernetes-resource
      source:
        apiVersion: gateway.networking.k8s.io/v1
        kind: GatewayClass
      option:
        labelTemplate: '{{ .metadata.name }} — {{ .spec.controllerName }}'
        valueTemplate: '{{ .metadata.name }}'

To list a namespaced type across all namespaces:

properties:
  sourceConfigMap:
    type: string
    x-capsule-form:
      widget: kubernetes-resource
      source:
        apiVersion: v1
        kind: ConfigMap
        namespace: '*'
        labelSelector: access.example.com/exportable=true
      option:
        valueTemplate: '{{ .metadata.namespace }}/{{ .metadata.name }}'

The user’s Kubernetes RBAC must permit the corresponding list operation.

Option templates

The listed object is an unstructured map at the Go-template root:

option:
  labelTemplate: '{{ .metadata.name }} ({{ .metadata.namespace }})'
  valueTemplate: '{{ .metadata.namespace }}/{{ .metadata.name }}'

Both fields default to {{ .metadata.name }}. They use Capsule’s safe Go-template function map; environment-reading functions are unavailable. Keep values deterministic because the rendered value is stored in spec.params.

Nested form schemas

Capsule finds and validates x-capsule-form recursively in properties, array items, tuple and prefix items, $defs, definitions, allOf, anyOf, oneOf, and conditional schemas:

paramSchema:
  type: object
  properties:
    targets:
      type: array
      items:
        type: object
        properties:
          secret:
            type: string
            x-capsule-form:
              widget: kubernetes-resource
              source:
                apiVersion: v1
                kind: Secret
                namespace: request

Arrays and multiple selections

For one or more selections, define an array and place x-capsule-form on its string items schema. A compatible client may display this as a multi-select control or as repeatable resource selectors:

properties:
  clusterRoles:
    type: array
    minItems: 1
    uniqueItems: true
    items:
      type: string
      x-capsule-form:
        widget: kubernetes-resource
        source:
          apiVersion: rbac.authorization.k8s.io/v1
          kind: ClusterRole
  namespaces:
    type: array
    minItems: 1
    uniqueItems: true
    items:
      type: string
      x-capsule-form:
        widget: kubernetes-resource
        source:
          apiVersion: v1
          kind: Namespace

minItems enforces at least one selection and uniqueItems prevents duplicate generator output. The submitted values remain ordinary arrays in spec.params and can be iterated with nested Go-template range actions.

Client and security contract

A compatible client:

  1. traverses JSON Schema and locates x-capsule-form;
  2. resolves the exact apiVersion and kind through Kubernetes discovery;
  3. selects the namespaced, all-namespaces, or cluster list endpoint;
  4. passes the configured label and field selectors;
  5. renders a label and value for every unstructured object; and
  6. submits the chosen value through ordinary spec.params.

Discovery and listing must use the logged-in user’s credentials, not the template’s execution ServiceAccount. Otherwise a dropdown could disclose objects the user cannot list.

x-capsule-form is presentation metadata, not an authorization boundary:

  • it grants no list permission;
  • it does not prove that a value came from the dropdown;
  • it does not load the selected object into rendering context; and
  • it does not validate that the GVK is installed.

Validate submitted values with JSON Schema or CEL. For authoritative lookup, load the selected object through spec.context.resources, which uses the recorded template ServiceAccount:

paramSchema:
  type: object
  required: [crd]
  properties:
    crd:
      type: string
      x-capsule-form:
        widget: kubernetes-resource
        source:
          apiVersion: apiextensions.k8s.io/v1
          kind: CustomResourceDefinition
context:
  resources:
    - apiVersion: apiextensions.k8s.io/v1
      kind: CustomResourceDefinition
      name: '{{ .crd }}'
      index: crdInfo
      optional: false

If the context object is missing or the execution identity cannot read it, the request becomes Ready=False.