Trust anchors & allowed_sources
Concept
A real API has many callers from different identity sources: an external SaaS partner
with a software/JWKS key, an in-cluster workload with a SPIFFE SVID, an EC2 box, an n8n runner.
allowed_sources lets a single route authorize multiple distinct sources — each with its
own issuer, trust domain, subject, and required key binding — instead of one global trust domain.
And because trust is data, not code, you can add a new client by publishing trust material
— no application change, no adapter image rebuild. That is the "add a caller without redeploy"
property, delivered by trustplane bundle merge-source.
Implementation
Multi-anchor route authorization
Each route's allowed_sources is a list of source rules. A source rule names:
issuertrust_domainsubject_exactorsubject_prefixrequired_key_binding(signer class minimum)- optional
provenance_policyandcontext_policy
Source authorization runs before replay consume, with stable denials:
| Deny | Meaning |
|---|---|
source_issuer_mismatch | Issuer not in any allowed source |
source_trust_domain_mismatch | Trust domain not allowed |
source_subject_mismatch | Subject (exact/prefix) not allowed |
insufficient_key_binding | Key class below the route's minimum |
missing_provenance / provenance_mismatch | Source provenance policy unmet |
missing_context / context_mismatch | Source context policy unmet |
Bundle-level provenance_policy applies to every accepted source; source-local policies
compose as additional constraints (they don't replace it). context.max_txn_value is treated
as a numeric ceiling: at/below allows, above or malformed denies with context_mismatch.
Non-destructive merge (trustplane bundle merge-source)
Appends a client public key to trust material and a matching source rule to a route, while
preserving existing issuers, keys, route groups, routes, and source rules by default.
Duplicate key IDs or matching selectors are rejected unless you pass --replace-existing.
Example
A real four-anchor route from the hosted-demo fixture — one route, four different source types:
"allowed_sources": [
{ "issuer": "https://issuer.acme.demo/external-jwks", "trust_domain": "acme.demo.external",
"subject_exact": "external:jwks:hosted-demo-caller", "required_key_binding": "software" },
{ "issuer": "https://issuer.example.com/spire", "trust_domain": "example.local",
"subject_prefix": "spiffe://example.local/ns/default/sa/",
"required_key_binding": "attested_workload",
"provenance_policy": { "profile": "trustplane-spiffe-spire-k8s-v1",
"required_spiffe_trust_domain": "example.local", "required_posture": "spiffe_svid_verified" } },
{ "issuer": "https://issuer.acme.demo/ec2-jwks", "trust_domain": "acme.demo.ec2",
"subject_prefix": "aws:ec2:us-east-1:", "required_key_binding": "software" },
{ "issuer": "https://issuer.acme.demo/eks-n8n-jwks", "trust_domain": "acme.demo.eks-n8n",
"subject_exact": "system:serviceaccount:n8n:n8n-runner", "required_key_binding": "software" }
]
Only the SPIFFE/SPIRE source requires attested_workload; the software/JWKS sources stay at
software and do not claim workload attestation.
Add a new client without touching the app or adapter image:
trustplane bundle merge-source \
--trust-material trust-material.json \
--policy-bundle trustplane.bundle.json \
--out-trust-material trust-material.merged.json \
--out-policy-bundle trustplane.bundle.merged.json \
--issuer https://issuer.acme.demo/external-jwks \
--trust-domain acme.demo.external \
--kid hosted-demo-client-2 \
--public-key "$CLIENT_PUBLIC_KEY_B64URL" \
--route-id acme.demo.orders.read \
--subject-exact external:jwks:hosted-demo-client-2 \
--required-key-binding software
The command writes full merged bundle files; you then publish/mount the reviewed artifacts with your own process (locally, or via the bundle refresh workflow in your deployment process). A full walkthrough is in Add a client, no redeploy.
What to notice
- One adapter, one route, many trust sources — each scoped precisely.
- "Auto-enroll" here means policy-based acceptance: a caller matching a configured source rule is accepted. It does not create a persistent principal record — acceptance is decided per request.
- Merge is append-only by default; destructive replacement is an explicit opt-in
(
--replace-existing). - Local source removal exists through
trustplane bundle remove-sourcewith--confirm-remove; add--revokewhen the reviewed output should append local revocation metadata.
→ Next: Brownfield adapter.