Chio/Docs
LOGIN · JOIN

BuildIdentity

Bind Workload Identity

Bind a capability to a SPIFFE workload: write the match policy, issue the token, and inspect the signed receipt.

You do not install SPIRE through Chio

Chio consumes SPIFFE identities; it does not issue them. This guide assumes your runtime already emits a SPIFFE ID, through an Envoy ext_authz principal, a SPIRE-managed SVID, an attestation JWT, or an explicit operator claim. Chio evaluates the policy, issues the capability, and verifies the receipt.

Prerequisites

  • A running chio kernel with a capability authority. See Installation.
  • A workload whose SPIFFE ID you know, for example spiffe://prod.chio/payments/worker.
  • One of the three delivery paths wired up (Envoy ext_authz, explicit attestation, or a verifier bridge). The policy and verification steps below are identical for all three.
  • A capability authority that can mint identity-bound capabilities. A policy carrying require_workload_identity compiles to an empty default scope (crates/guards/chio-policy/src/compiler/scope.rs:50-52), so the capability has to come from issuance rather than from the policy file.

Step 1: Write a WorkloadIdentityMatch Rule

require_workload_identity is a field on the tool_access rule block, alongside require_runtime_assurance_tier (crates/guards/chio-policy/src/models/rules.rs:194-215). The match is additive: scheme, trust domain, path prefix, and credential kind each have to pass, and each is skipped when you leave it unset (crates/guards/chio-policy/src/evaluate/matchers.rs:396-413). The evaluator applies it and denies on a miss at crates/guards/chio-policy/src/evaluate/matchers.rs:199-229.

payments-policy.yamlyaml
hushspec: "0.1.0"
name: payments-workload-identity
rules:
  tool_access:
    enabled: true
    default: block
    allow:
      - transfer_funds
      - reconcile_ledger
    require_workload_identity:
      scheme: spiffe
      trust_domain: prod.chio
      path_prefixes:
        - /payments/worker
        - /payments/reconciler
      credential_kinds:
        - x509_svid
        - jwt_svid
    require_runtime_assurance_tier: attested

chio policy analyze loads it and names both predicates. It marks them not_analyzed because the static analyzer cannot decide a runtime predicate without runtime evidence, not because the predicate is inert:

workload-identity · analyzetranscript
$ chio policy analyze ./payments-policy.yaml
policy_sha256  38c484dc787234cd2839a9e61682ad3028d0875a98f8fec8bbc396fbe2d89e53

ID           SEVERITY  KIND                  BLOCK                 RULE
-            notice    not_analyzed          tool_access           require_runtime_assurance_tier
                      runtime attestation predicate
-            notice    not_analyzed          tool_access           require_workload_identity
                      runtime identity predicate

summary: 0 error(s), 0 warning(s), 2 notice(s)
exit 0

Important field behavior:

  • Omitting credential_kinds accepts any of uri, x509_svid, and jwt_svid. Specify the accepted kinds to limit which credentials pass the rule.
  • path_prefixes matches on whole path segments, not on raw string prefixes. A workload at /payments/worker/shard-3 matches /payments/worker; a workload at /payments/worker-shadow does not (crates/guards/chio-policy/src/evaluate/matchers.rs:414-435).
  • Pairing require_workload_identity with require_runtime_assurance_tier gives you both which workload and how strongly attested. They take different routes: the tier compiles into a Constraint::MinimumRuntimeAssurance on the grant (crates/guards/chio-policy/src/compiler/scope.rs:207-209), while the identity match stays in the policy evaluator.

Soft match for gradual rollout

Use prefer_workload_identity instead of require_ during rollout. A missing or non-matching identity then yields a warning result rather than a deny (crates/guards/chio-policy/src/evaluate/matchers.rs:232-261), which lets you observe the identity population before flipping to a hard requirement. Both spellings empty the compiled default scope, so plan issuance before you add either one.

Step 2: Issue an Identity-Bound Capability

Issuance can carry a runtimeAttestation block. The caller supplies the already-normalized assurance tier as part of that evidence; the authority does not derive it from the other fields. The trust policy (trusted_verifiers and the runtime_assurance tiers) is what later decides whether the asserted tier is honored, rebound, or rejected, stamping the capability with a minimum runtime-assurance constraint. Governed execution then re-checks that the presented evidence still clears the stamped tier.

Identity-bound issuance uses this endpoint: POST /v1/capabilities/issue on a trust-control cluster. The JSON body is an IssueCapabilityRequest carrying the subject public key, the scope, a TTL in seconds, and the runtimeAttestation evidence.

bash
curl -X POST https://trust.example.com/v1/capabilities/issue \
  -H "Authorization: Bearer $CHIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d @issuance-request.json
issuance-request.jsonjson
{
  "subjectPublicKey": "7b0f6f63...",
  "scope": {
    "grants": [
      { "server_id": "payments", "tool_name": "transfer_funds", "operations": ["invoke"] }
    ]
  },
  "ttlSeconds": 300,
  "runtimeAttestation": {
    "schema": "chio.runtime-attestation.v1",
    "verifier": "spire-agent",
    "tier": "attested",
    "issued_at": 1744537800,
    "expires_at": 1744538100,
    "evidence_sha256": "…",
    "workload_identity": {
      "scheme": "spiffe",
      "credentialKind": "x509_svid",
      "uri": "spiffe://prod.chio/payments/worker",
      "trustDomain": "prod.chio",
      "path": "/payments/worker"
    }
  }
}

The mixed casing in that body is correct

The envelope is camelCase and the attestation block inside it is snake_case, because they are two structs with different serde settings. IssueCapabilityRequest carries rename_all = "camelCase" (crates/platform/chio-control-plane/src/trust_control/service_types/requests.rs:16-24), RuntimeAttestationEvidence carries no rename at all (crates/core/chio-core-types/src/capability/runtime_attestation.rs:26-48), and the nested WorkloadIdentity is camelCase again (crates/core/chio-core-types/src/capability/workload_identity.rs:24-37). Normalizing the body to one casing makes it fail to deserialize.

Fail-closed on conflict

If the request carries both an explicit workload_identity and a raw runtime_identity that do not agree, or if the SPIFFE URI is malformed, issuance fails. Do not wrap this in a retry. Fix the upstream and reissue.

Step 3: Verify the Binding on the Receipt

A governed transaction that carried runtime attestation records the accepted evidence, including the normalized workloadIdentity, in the receipt's governed-transaction metadata. The block is RuntimeAssuranceReceiptMetadata (crates/core/chio-core-types/src/receipt/governance.rs:32-49), it hangs under the governed_transaction metadata key (crates/core/chio-core-types/src/receipt/metadata.rs:209), and both it and the nested WorkloadIdentity serialize in camelCase. So the path a reviewer reads is:

jq path into a signed receiptbash
.metadata.governed_transaction.runtimeAssurance.workloadIdentity.uri

The block carries schema, verifierFamily, tier, verifier, evidenceSha256 and workloadIdentity. The schema value is the upstream attestation format, one of chio.runtime-attestation.v1 or a family-specific tag such as chio.runtime-attestation.azure-maa.jwt.v1.

Filter in the reader, not in the query

ReceiptQuery (crates/kernel/chio-kernel/src/receipt_query.rs:95-125) has no workload-identity filter. Its filters are capability id, tool server, tool name, outcome, time bounds, cost bounds and currency, cursor, limit, agent subject, and tenant. To get the set of admissions one workload made, narrow with those and then match the identity in jq over the returned receipts.

Runtime-Attestation Appraisal

Raw runtimeAttestation evidence rides on the governed and issuance requests. A separate, adapter-facing contract normalizes it into a runtime-attestation appraisal. It carries the verifier family, an evidence descriptor, normalized assertions, and reason codes for cross-organization verifiers and auditors.

Export an appraisal locally from an evidence payload, optionally passing a HushSpec policy to evaluate its policy-visible outcomes:

bash
chio trust appraisal export \
  --input runtime-attestation.json \
  --policy-file policy.yaml

Remote deployments produce the same report over HTTP at POST /v1/reports/runtime-attestation-appraisal. A signed appraisal result can be exported for a partner (chio trust appraisal export-result or POST /v1/reports/runtime-attestation-appraisal-result) and evaluated on import against local policy (chio trust appraisal import or POST /v1/reports/runtime-attestation-appraisal/import). Imported results are defended by signature and freshness. There is no replay registry on that path, so a signed, in-window result can be presented more than once.


Rebinding to verified via Trusted Verifiers

Raw attestation evidence lands at the attested tier by default. To let a rule require verified, add an explicit trusted_verifiers entry that binds a {schema, verifier} pair to an effective tier. This keeps verifier trust in a policy extension instead of individual rules.

payments-policy.yaml (extensions)yaml
extensions:
  runtime_assurance:
    tiers:
      verified:
        minimum_attestation_tier: verified
        max_scope:
          operations: ["invoke"]
          ttl_seconds: 300
    trusted_verifiers:
      spire_prod:
        schema: chio.runtime-attestation.v1
        verifier: https://spire.prod.internal
        verifier_family: enterprise_verifier
        effective_tier: verified
        max_evidence_age_seconds: 120
        allowed_attestation_types: [x509_svid]

With this in place, a rule that sets require_runtime_assurance_tier: verified admits only calls whose attestation matches a trusted-verifier rule and satisfies its freshness and claim constraints.

verifier_family names an appraisal family, not an identity scheme

verifier_family takes an AttestationVerifierFamily, and the vocabulary is exactly four values: azure_maa, aws_nitro, google_attestation, enterprise_verifier (crates/core/chio-core-types/src/runtime_attestation.rs:25-30). There is no spiffe member: a SPIRE deployment presents as enterprise_verifier. Writing the scheme name there makes the whole document fail to parse. The field is optional, so leaving it out is also valid (crates/guards/chio-policy/src/models/extensions.rs:397-406).

Verify the Result

The cheapest proof that the requirement is load-bearing is to dry-run the same call against the policy twice: once with the two identity fields removed, once with them present. Take the open form first.

workload-identity · allow-without-requirementstranscript
$ chio --session-db ./admission-1.db --receipt-db ./receipts.db \
  check --policy ./payments-open.yaml \
  --tool transfer_funds --params '{"amount": 100}'
verdict:    ALLOW
tool:       transfer_funds
server:     *
receipt_id: 92a63c5f60df32983311daf7ebd088313a8c5f31a2e3f067c5ab4efbd2817a30
policy:     8207ea76098d0750fa524b2244fddd1a7a9b4e329f3c0e0b6849d8e53ef9be41
source:     104d3a2e123436652356f069bd335fa15665efabe6eaaca07f704a39608b1c5b
mode:       preflight
fixture:    false
exit 0allow

Now the policy from Step 1, byte for byte the same except for require_workload_identity and require_runtime_assurance_tier. Nothing in the request carries a SPIFFE identity or an attestation document, so nothing can satisfy them:

workload-identity · deny-no-identitytranscript
$ chio --session-db ./admission-2.db --receipt-db ./receipts.db \
  check --policy ./payments-policy.yaml \
  --tool transfer_funds --params '{"amount": 100}'
verdict:    DENY
tool:       transfer_funds
server:     *
reason:     requested tool transfer_funds on server * is not in capability scope
receipt_id: 6b9544b7f38daec5d85c20c52fd18bf22de65ff5e49c036afce10e997c108678
policy:     9484ede27e47ea7cf5ed0bd0f22757156ea0e4a3bd09e392f6c8dadecfbd6130
source:     a06c6ed5b7ea03e60f224fb101118c560485a07c5982f7cf50c5766cb977aea1
mode:       preflight
fixture:    false
exit 2deny

Read the reason line carefully, because it is not the reason a first reading predicts. The refusal is a capability-selection refusal, not a named identity refusal: a policy carrying either workload-identity key compiles to an empty default scope (crates/guards/chio-policy/src/compiler/scope.rs:50-52), so chio check, which issues its capability from that scope, has nothing to present. In a real deployment the capability arrives from Step 2 instead, and the identity match then runs in the policy evaluator. Either way the call does not reach the tool.

The deny is on the record, and chio receipt explain reads it back with the policy hash that produced it:

workload-identity · explain-denytranscript
$ chio --receipt-db ./receipts.db receipt explain "$DENY" --admin-all
receipt: 6b9544b7f38daec5d85c20c52fd18bf22de65ff5e49c036afce10e997c108678
schema: chio.receipt.v1
identity: 6b9544b7f38daec5d85c20c52fd18bf22de65ff5e49c036afce10e997c108678
decision: deny
reason: requested tool transfer_funds on server * is not in capability scope
guard: kernel
policy_hash: 9484ede27e47ea7cf5ed0bd0f22757156ea0e4a3bd09e392f6c8dadecfbd6130
scope_diff: requested scope vs granted scope is not embedded in this receipt
parents: 0
repair_hint: inspect the guard and policy_hash, then mint or narrow a matching capability
exit 0

Failures and Recovery

The kernel denies admission, not warns, when any of the following hold. Matching operator recovery guidance lives in WORKLOAD_IDENTITY_RUNBOOK in the reference tree.

ConditionErrorWhat to do
Explicit workload_identity conflicts with raw runtime_identityWorkloadIdentityError::ConflictFix the upstream so both agree; do not mask the conflict at the kernel.
SPIFFE URI is malformed (missing trust domain, empty path)MissingTrustDomain, InvalidPath, MalformedUriRegenerate the SVID from SPIRE or your attestation source; inspect the exact string.
Presented identity fails require_workload_identityPolicy deny, reason runtime workload identity is missing or does not satisfy the required mappingEither add the workload to the allowed prefixes, or accept the deny. Do not relax the match to get past one call.
Evidence older than max_evidence_age_secondsAttestationTrustError::EvidenceTooOldRefresh the upstream attestation and resend. Do not extend the age ceiling to bypass freshness.
Attestation window has closedAttestationTrustError::StaleEvidenceThe evidence carries its own issued_at and expires_at; reissue rather than widening the window.
Verifier bridge projects a non-SPIFFE identifierOpaqueRuntimeIdentityConflict, or no identity resolves at allFix the projection rule on the bridge. Chio will not invent a typed identity from an opaque string.

The identity variants are WorkloadIdentityError (crates/core/chio-core-types/src/capability/workload_identity.rs:41-100), raised from normalized_workload_identity (crates/core/chio-core-types/src/capability/runtime_attestation.rs:57-110); the freshness variants are AttestationTrustError (crates/core/chio-core-types/src/capability/trust_policy.rs:62-91).


Summary

  1. Write the rule. Add require_workload_identity with an explicit trust domain, path prefix set, and credential kind set.
  2. Issue with attestation. Pass runtimeAttestation on issuance so the capability is stamped with the minimum runtime-assurance tier.
  3. Verify on the receipt. The accepted workloadIdentity appears under the kernel signature at .metadata.governed_transaction.runtimeAssurance. Audit pipelines narrow with the query filters and then match the identity in the reader.
  4. Rebind to verified when needed. Use trusted_verifiers to promote raw attestation into the verified tier under explicit operator policy.

Next Steps