SPIRE enrollment
Who this is for: a platform engineer whose organization already runs SPIRE as the workload identity fabric, and who wants Cullis to trust the same SPIFFE identities the rest of the stack does. If you don’t run SPIRE, use BYOCA instead — it covers both long-lived agents signed by an org CA and ad-hoc developer enrollment.
When SPIRE is the right enrollment method
- SPIRE already issues SVIDs to your K8s workloads, and you want those same workloads to talk to Cullis as the identities SPIRE attested.
- Your agents live in short-lived containers (K8s pods, autoscaling workers) where rotating long-lived BYOCA certs is painful.
- You want automatic cert rotation (typical SVID TTL ~1h) without manual API calls to the Mastio.
Stay on BYOCA when agents are long-lived, externally issued, and the operators prefer manual rotation with certificate thumbprint pinning as a stronger anti-rogue-CA control.
Threat model — read this before you deploy
Moving to SPIRE shifts what the Mastio enforces. Be deliberate:
- The Org CA stops being the signing oracle. It becomes an offline trust anchor. Your SPIRE server holds a short-lived intermediate signed by the Org CA; SVIDs mint under that intermediate. A compromised SPIRE server can mint SVIDs until the intermediate rotates or is revoked.
- Thumbprint pinning is disabled for SPIRE-enrolled agents. Pinning assumes cert stability across calls — SVIDs change every hour, so pinning would break auth. Identity is instead bound by chain walk + SPIFFE URI match, and ultimately by SPIRE’s workload attestation (which Cullis delegates to).
- Org CA
pathLenConstraint ≤ 1. The Mastio refuses to onboard an Org CA withpathLen > 1under a declaredtrust_domain. One intermediate only. - One
trust_domainperorg_id. Two SPIRE clusters under the same logical org need two separate Mastios.
If this trade-off isn’t acceptable, don’t enable SPIRE enrollment for that org — stay on BYOCA.
Prerequisites
- An Org CA (your SPIFFE trust domain’s root of trust). Keep the private key offline or in an HSM. Issue it with
BasicConstraints: CA=true, pathLen=1. - A SPIRE server configured with your Org CA as
UpstreamAuthority. Any topology works — single server, HA pair, multi-region — as long as every SPIRE instance chains to the same Org CA. - A Cullis Mastio deployed in your org
- A
trust_domainchosen — conventionally reverse-DNS under your control (acme.com,payments.acme.internal). Must be unique to your Mastio. - The Mastio admin secret (
$MASTIO_ADMIN_SECRET)
1. Configure the Mastio’s trust domain
The standalone Mastio derives its org and trust domain at first boot via the admin wizard. To enable SPIRE-issued SVIDs, two pieces of configuration land on the Mastio:
- Set
MCP_PROXY_REQUIRE_SPIFFE_SAN=trueinproxy.envto require a SPIFFE URI in every agent cert’s SAN at enrollment time. - Upload the Org CA that signs your SPIRE intermediate via the dashboard (Setup → Org CA → Upload) or with
POST /v1/admin/pki/attach-ca. The Mastio validatesCA=true, key size ≥ 2048 RSA or a recognised EC curve, andpathLenConstraint ≤ 1. A 400 on pathLen means your CA was issued too permissively — re-issue it and retry.
Restart the Mastio so the env change takes effect.
2. Configure SPIRE
Set your Org CA as SPIRE’s UpstreamAuthority (minimal example, adapt to your topology):
UpstreamAuthority "disk" {
plugin_data {
cert_file_path = "/etc/spire/org-ca.pem"
key_file_path = "/etc/spire/org-ca-key.pem"
}
}
Create a registration entry for each workload:
spire-server entry create \
-spiffeID spiffe://acme.com/workload/sales-agent \
-parentID spiffe://acme.com/spire/agent/x509pop/... \
-selector unix:uid:1000
The SPIFFE ID’s last path segment becomes the Cullis agent name. For the entry above, agent_id = "acme::sales-agent".
3. Enroll the workload against the Mastio
Pull the SVID + trust bundle from the Workload API and POST to /v1/admin/agents/enroll/spiffe. The SDK wrapper:
from cullis_sdk import CullisClient
CullisClient.enroll_via_spiffe(
"https://mastio.acme.corp",
admin_secret="$MASTIO_ADMIN_SECRET",
agent_name="sales-agent",
svid_pem=open("svid.pem").read(),
svid_key_pem=open("svid-key.pem").read(),
trust_bundle_pem=open("spire-bundle.pem").read(),
capabilities=["quote.read", "quote.write"],
persist_to="/etc/cullis/agent/",
)
The SPIFFE URI SAN on the SVID is mandatory — without it the endpoint returns 400 svid_missing_spiffe_uri. The URI pins as spiffe_id on the internal_agents row; SPIRE rotates the SVID, but the Cullis runtime credentials (API key + DPoP JWK) stay valid because runtime auth isn’t the SVID — it’s the pinned jkt.
Trust bundle resolution
body.trust_bundle_pem(per-request override)proxy_config.spire_trust_bundleon the Mastio (operator-configured baseline)- Neither →
503 spire_trust_bundle_not_configured
Via raw HTTP
Same semantics when you’d rather not pull the SDK:
curl -X POST https://mastio.acme.corp/v1/admin/agents/enroll/spiffe \
-H "X-Admin-Secret: $MASTIO_ADMIN_SECRET" \
-H "Content-Type: application/json" \
-d '{
"agent_name": "sales-agent",
"capabilities": ["quote.read", "quote.write"],
"svid_pem": "-----BEGIN CERTIFICATE-----\n...",
"svid_key_pem": "-----BEGIN PRIVATE KEY-----\n...",
"trust_bundle_pem": "-----BEGIN CERTIFICATE-----\n...",
"dpop_jwk": {"kty": "EC", "crv": "P-256", "x": "...", "y": "..."}
}'
See Enrollment API reference for the full schema.
4. Runtime
Runtime auth is identical to every other enrollment method — API key + DPoP proof to the Mastio:
from cullis_sdk import CullisClient
client = CullisClient.from_api_key_file(
mastio_url="https://mastio.acme.corp",
api_key_path="/etc/cullis/agent/api-key",
dpop_key_path="/etc/cullis/agent/dpop.jwk",
)
client.send_oneshot("globex::fulfillment-bot", {"order_id": "A123"})
SPIRE continues to rotate the SVID. When the SVID expires, the Cullis credentials don’t — the Mastio doesn’t re-verify the SVID on every call, only at enrollment. On the next Org CA rotation (or if you explicitly revoke), you’ll re-run enroll_via_spiffe to pick up the new cert material.
5. Validate end-to-end
From the workload host:
spire-agent api fetch x509 -socketPath /run/spire/sockets/agent.sock
python -c "
from cullis_sdk import CullisClient
c = CullisClient.from_api_key_file(
mastio_url='https://mastio.acme.corp',
api_key_path='/etc/cullis/agent/api-key',
dpop_key_path='/etc/cullis/agent/dpop.jwk',
)
print('agent_id:', c.agent_id)
print('token_prefix:', c.get_token()[:24])
"
Check the audit dashboard at https://mastio.example.com/proxy/audit for the auth.token_issued event. You should see agent.id=acme::sales-agent with chain length 2 in the span attributes (auth.x509_chain_verify.chain.length).
Operational notes
Mixed mode inside the same org
An agent either authenticates with a classic BYOCA cert (pinning on) or with an SVID (pinning off). Both can coexist under the same org_id — the Mastio discriminates per-cert, not per-org. The trust_domain on the org enables the SVID path; it doesn’t disable BYOCA for agents that don’t present SVIDs.
Multiple proxies in the same trust domain
N Mastios can share a trust_domain as long as every SPIRE instance chains to the same Org CA. The Mastio accepts any SVID whose chain terminates at the registered Org CA, regardless of which intermediate signed it. HA, multi-region, site isolation — all work naturally.
Name Constraints (recommended, not enforced)
If your CA supports it, issue the Org CA with a nameConstraints extension limiting acceptable SPIFFE URIs to your trust domain:
permittedSubtrees: URI:.acme.com
Cullis doesn’t verify nameConstraints programmatically today, but OpenSSL / browsers do, and any third-party auditor will expect it. Defence-in-depth against SPIRE-side misconfiguration.
Rotating the Org CA
Coordinated rotation:
- Issue a new Org CA with
pathLen=1 - Configure SPIRE to use both old and new as
UpstreamAuthorityduring the overlap - Upload the new CA on the Mastio via the dashboard (Setup → Org CA → Replace) or
POST /v1/admin/pki/attach-ca - Once all workloads rotated SVIDs under the new intermediate, decommission the old CA
Workloads don’t need to reconnect — SPIRE rotation + SDK re-auth covers the window within an SVID TTL.
Revoking a single workload
- SPIRE-native:
spire-server entry delete <id>. The workload loses its SVID within one rotation cycle. - Cullis-native:
POST /v1/admin/certs/revokewith the SVID’sserial_hexfor immediate effect at the Mastio. Useful if SPIRE rotation is slow or its signing material is compromised.
Troubleshoot
| Symptom | Likely cause |
|---|---|
No organization registered for trust domain 'X' | trust_domain not configured on the Mastio, or set to a different value. Check the Mastio’s MCP_PROXY_TRUST_DOMAIN env / dashboard Org page. |
CA pathLenConstraint is 2 — pathLen must be ≤ 1 | Org CA too permissive. Re-issue with pathLen=1 and re-register via attach-ca. |
certificate chain broken at position 0 | x5c ordering wrong (must be leaf first, then intermediates; never the trust anchor) or SDK sending only the leaf. Confirm len(x5c) >= 2. |
Agent not found or org mismatch | The agent_id derived from the SVID’s last path segment isn’t registered on the Mastio. Re-run the SPIFFE enrollment step. |
certificate chain contains a duplicate entry | Your SDK is appending the Org CA to x5c. Strip it — the trust anchor is implicit. |
svid_missing_spiffe_uri | The SVID has no SPIFFE URI SAN. SPIRE workload attestation didn’t bind a SPIFFE ID. Check the registration entry. |
Next
- BYOCA enrollment — the alternative when SPIRE isn’t part of your stack
- Enrollment API reference —
POST /v1/admin/agents/enroll/spiffefull schema - BYOCA enrollment — the alternative for long-lived agents signed by an existing org CA
- Rotate keys § 3 — the Org CA rotation flow in detail
References
- ADR-003 — SPIRE 3-level PKI for SPIRE-mode agents
- RFC 7515 §4.1.6 —
x5cheader semantics - SPIFFE standards
- SPIRE UpstreamAuthority