Reference updated 2026-04-23

Enrollment API

Request and response schemas for /v1/admin/agents/enroll/{byoca, spiffe} and the rest of the ADR-011 enrollment surface.

Enrollment API

Endpoints under /v1/admin/agents/enroll/ on the Mastio. All require X-Admin-Secret. Returns 201 Created on success; API key in the response body is shown exactly once — the server stores only its bcrypt hash.


POST /v1/admin/agents/enroll/byoca

Enroll an agent via a caller-supplied Org-CA-signed cert + key.

Request

{
  "agent_name": "inventory-bot",
  "display_name": "Inventory service",
  "capabilities": ["inventory.read", "inventory.write"],
  "cert_pem": "-----BEGIN CERTIFICATE-----\n...",
  "private_key_pem": "-----BEGIN EC PRIVATE KEY-----\n...",
  "dpop_jwk": {"kty": "EC", "crv": "P-256", "x": "...", "y": "..."},
  "federated": false
}
FieldTypeRequiredNotes
agent_namestringyes^[a-zA-Z0-9._-]{1,64}$ — the Mastio scopes it to its own org_id
display_namestringnoFree-form label; defaults to agent_name
capabilitiesstring[]noArbitrary capability strings; empty list allowed
cert_pemPEMyesMust chain to the Mastio’s loaded Org CA
private_key_pemPEMyesMust match cert_pem public key
dpop_jwkJWK objectnoPublic EC/RSA JWK. d (private) rejected. When supplied, the server computes RFC 7638 thumbprint and pins it

Response — 201

{
  "agent_id": "acme::inventory-bot",
  "display_name": "Inventory service",
  "capabilities": ["inventory.read", "inventory.write"],
  "api_key": "sk_local_inventory-bot_a1b2...",
  "cert_thumbprint": "f3d2...",
  "spiffe_id": null,
  "dpop_jkt": "uP6uY..."
}

If the cert carries a spiffe:// URI in its SubjectAlternativeName, spiffe_id is populated automatically.

Errors

CodeWhen
400cert_pem not signed by Org CA; key does not match cert; dpop_jwk contains d; unsupported kty
403Missing / wrong X-Admin-Secret
409Agent <org>::<name> already enrolled
503Mastio’s Org CA not loaded

POST /v1/admin/agents/enroll/spiffe

Enroll an agent via an X.509-SVID verified against a SPIRE trust bundle.

Request

{
  "agent_name": "k8s-inventory",
  "display_name": "K8s inventory workload",
  "capabilities": ["inventory.read"],
  "svid_pem": "-----BEGIN CERTIFICATE-----\n...",
  "svid_key_pem": "-----BEGIN EC PRIVATE KEY-----\n...",
  "trust_bundle_pem": "-----BEGIN CERTIFICATE-----\n...",
  "dpop_jwk": {"kty": "EC", "crv": "P-256", "x": "...", "y": "..."},
  "federated": false
}
FieldTypeRequiredNotes
svid_pemPEMyesSVID leaf with a spiffe:// URI SAN (mandatory for this method)
svid_key_pemPEMyesMust match svid_pem public key
trust_bundle_pemPEMnoPer-request bundle override. When omitted, the Mastio falls back to proxy_config.spire_trust_bundle
agent_name, display_name, capabilities, dpop_jwk, federatedsame as BYOCA

Response — 201

{
  "agent_id": "acme::k8s-inventory",
  "display_name": "K8s inventory workload",
  "capabilities": ["inventory.read"],
  "api_key": "sk_local_k8s-inventory_c4d5...",
  "cert_thumbprint": "e7a8...",
  "spiffe_id": "spiffe://acme.internal/k8s-inventory",
  "dpop_jkt": "vL3pW..."
}

Errors

CodeWhen
400SVID has no SPIFFE URI SAN; not signed by trust bundle; key mismatch; invalid dpop_jwk
403Missing / wrong admin secret
409Duplicate
503Neither trust_bundle_pem in body nor spire_trust_bundle in proxy config

POST /v1/admin/agents — admin manual create

The pre-ADR-011 path. Still the default for programmatic agents created from scripts or CI. See existing runbooks — behavior unchanged, except the row now carries enrollment_method='admin' automatically.

POST /v1/enrollment/start + polling — device-code flow

The device-code enrollment flow. An interactive client (typically running on a developer laptop or workstation) starts a session, the user approves the request from a browser-based dashboard, the client polls for the issued credential. Rows get enrollment_method='connector' for historical reasons. See SPIRE enrollment for the non-interactive equivalent.


Persistence contract (client side)

Every enroll_via_* helper in the Python SDK, given persist_to=<dir>, writes:

FileModeContents
<dir>/api-key0600plaintext API key
<dir>/dpop.jwk0600JSON {"private_jwk": {...}}
<dir>/agent.json0644{"agent_id": "...", "org_id": "...", "mastio_url": "..."}

Runtime pass api_key_path=<dir>/api-key and dpop_key_path=<dir>/dpop.jwk to CullisClient.from_api_key_file().


Row shape — internal_agents

Columns added by migration 0016 that every enroll endpoint populates:

ColumnValue
enrollment_methodadmin | connector | byoca | spiffe
spiffe_idSPIFFE URI when enrollment carried one, else NULL
enrolled_atISO timestamp of the successful enroll call
dpop_jktRFC 7638 thumbprint of the pinned public JWK (NULL if dpop_jwk was not supplied)

The operator dashboard surfaces all of these so audit + migration progress is observable without touching SQL.