Agent Attestation Extension
The Agent Attestation Extension extends the agent attestation process using two cooperating components:
- Agent extension (client-side) — A long-running executable on the agent host that produces proof data. The agent sends this proof to the Trust Domain Server at login.
- Server webhook (server-side) — An HTTPS endpoint the Trust Domain Server calls after validating built-in attestors. It receives the agent's proof data and attributes from other attestors, validates or enriches them, and returns custom attributes added to the agent session.
No agent-side configuration is needed for the server webhook component beyond the proof the agent extension already provides.
This method is used during agent attestation (when the agent connects to the Trust Domain Server). For extending workload attestation (when workloads request SVIDs), see the Workload Attestation Extension.
Server-side Attestation Flow
Agent-side Attestation Flow
Attributes available for SVID issuance
Custom attributes returned by the webhook are added to the agent session under the custom origin. The attribute names are defined by your webhook implementation.
| Attribute | Description |
|---|---|
custom.<key> | Any key returned in the webhook's JSON response |
Example SPIFFE ID template using webhook-returned attributes:
/agents/{{custom.environment}}/{{custom.region}}
How to Deploy
Step 1 — Configure the Trust Domain Server
Add type: extension as a required attestor in the AgentAttestation policy:
section: AgentAttestation
schema: v1
spec:
policies:
- name: custom_attestation
requiredAttestors:
- type: extension
config:
webhookURL: "https://attestation.example.com/webhook"
timeout: "10s"
caCerts: |
-----BEGIN CERTIFICATE-----
MIIBxTCCAW...
-----END CERTIFICATE-----
authType: "BEARER"
maxRetries: 3
Apply using spirlctl:
spirlctl config set cluster --id <cluster-id> attestation-policy.yaml
Or using Terraform:
resource "spirl_cluster_config" "agent_attestation" {
cluster_id = spirl_cluster.my_cluster.id
sections = {
AgentAttestation = <<-YAML
section: AgentAttestation
schema: v1
spec:
policies:
- name: custom_attestation
requiredAttestors:
- type: extension
config:
webhookURL: "https://attestation.example.com/webhook"
timeout: "10s"
authType: "BEARER"
YAML
}
}
Once a configuration document passes validation and is stored, the Defakto control plane syncs it to your Trust Domain Servers automatically. No server or agent restart is required.
Server Configuration Reference
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
webhookURL | string | Yes | — | HTTPS endpoint to call during agent attestation |
timeout | duration | No | 5s | Maximum wait time per webhook call (Go duration format: 10s, 1m) |
caCerts | string | No | System roots | PEM-encoded CA certificate bundle for validating the webhook's TLS certificate |
insecureSkipVerify | bool | No | false | Skip TLS verification. Never use in production. |
authType | string | No | NONE | Authentication type: BEARER or NONE |
tokenPath | string | No | In-cluster service account token | Path to bearer token file. Used when authType is BEARER |
maxRetries | int | No | 2 | Maximum retry attempts for transient errors (5xx, timeouts). Set 0 to disable |
Step 2 — Configure the Agent Extension
The agent extension is a long-running executable launched by the agent at startup. Configure it in the agent's attestors list.
- Helm Installation
- Linux Installation
agent:
auth:
clusterId: c-xxxxxx
attestors:
- type: extension
config:
cmd: "/usr/local/bin/custom-attestor"
args:
- "--mode=production"
checksum: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
requestTimeout: "200ms"
cluster-id: c-xxxxxx
agent-attestors:
- type: extension
config:
cmd: "/usr/local/bin/custom-attestor"
args:
- "--mode=production"
checksum: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
requestTimeout: "200ms"
Start the agent with:
spirl-agent --config-file-path=/etc/spirl-agent/agent-config.yaml
Agent Configuration Reference
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
cmd | string | Yes | — | Absolute path to the extension executable |
args | []string | No | [] | Command-line arguments to pass to the executable |
checksum | string | No | "" | SHA256 checksum for integrity verification (format: sha256:<hex>) |
requestTimeout | duration | No | 100ms | Maximum time to wait for the extension response |
Checksum Verification
Generate the checksum for your extension executable with:
sha256sum /usr/local/bin/custom-attestor
# e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 /usr/local/bin/custom-attestor
# Use in config: sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
When configured, the agent verifies the executable's SHA256 hash at startup and refuses to run if the checksums don't match.
Step 3 — Implement the Server Webhook
Your webhook must accept HTTP POST requests at the configured webhookURL. The Trust Domain Server sends a JSON body and expects a JSON response.
A formal OpenAPI 3.1 specification for the webhook is available in the OpenAPI Specification section. Use it to generate server stubs, validate your implementation, or run a mock server for testing.
Request format
{
"_meta": {
"version": "1.0"
},
"cluster": {
"cluster_id": "c-qgd1hs6hez"
},
"payload": "<string from agent extension>",
"<attestor_key>": {
"<nested>": "<attributes>"
}
}
| Field | Description |
|---|---|
_meta.version | Protocol version — currently "1.0" |
cluster.cluster_id | ID of the cluster the agent belongs to |
payload | Proof data submitted by the agent extension, forwarded verbatim |
<attestor_key> | Attributes from other successful attestors (e.g., k8s, aws, x509pop) |
HTTP request format
POST /webhook HTTP/1.1
Host: attestation-service.example.com
Content-Type: application/json
Authorization: Bearer <token> (when authType is BEARER)
Content-Length: <length>
<JSON payload>
Example request body for an agent using AWS IID alongside the extension:
{
"_meta": { "version": "1.0" },
"cluster": {
"cluster_id": "c-qgd1hs6hez"
},
"aws": {
"account": { "id": "123456789012" },
"ec2": {
"instance": {
"id": "i-0abc123def456",
"region": "us-west-2"
}
}
},
"payload": "eyJhY2NvdW50SWQiOiIxMjM0NTY3ODkwMTIiLCJpbnN0YW5jZUlkIjoiaS0wYWJjMTIzZGVmNDU2In0="
}
Success response
Return HTTP 200 with a flat JSON object. All keys become custom.* attributes on the agent session:
{
"environment": "production",
"region": "us-west-2",
"team": "platform",
"validated_by": "extension-v1"
}
An empty object {} is valid — the login succeeds with no additional attributes.
Error response
Return a JSON object with an error key to reject the login:
{
"error": "instance i-0abc123def456 is not registered in CMDB"
}
The error message is logged and returned to the agent. Any keys other than error are ignored when error is present.
HTTP status codes
| Status | Behavior |
|---|---|
| 2xx | Login proceeds; response body parsed for custom attributes |
| 400, 401, 403 | Login rejected immediately — not retried |
| 5xx | Retried up to maxRetries times with exponential back-off |
Retry behavior
Retryable (retried up to maxRetries times with exponential back-off):
- HTTP 5xx responses — server-side errors
- Connection timeouts — webhook took too long to respond
- Temporary network failures — connection refused, connection reset
- Temporary DNS failures — DNS resolution temporarily unavailable
Non-retryable (fail immediately):
- HTTP 4xx responses — including 400, 401, and 403
- TLS certificate validation failures — invalid or expired certificate
- Authentication failures — invalid bearer token
- Invalid webhook responses — malformed JSON or unexpected format
- Configuration errors — invalid webhook URL
Step 4 — Implement the Agent Extension Executable
The agent extension is a long-running process that communicates with the agent via newline-delimited JSON on stdin/stdout. The agent sends one request per login attempt; the executable responds with proof data or an error.
Request (sent by agent via stdin)
{"_meta":{"version":"1.0"}}
Success response (returned via stdout)
{"payload":"<proof data>"}
The payload value is forwarded verbatim to the server webhook in the HTTP request body.
Error response (returned via stdout)
{"error":"unable to load proof data"}
When the executable returns an error or exceeds requestTimeout, the agent's login fails.
Step 5 — Verify
Server logs — look for webhook activity:
DEBUG Calling external webhook {"url": "https://attestation.example.com/webhook", "attempt": 0}
INFO Custom attributes added {"count": 3}
INFO Authorization received and verified {"agentAttestationAttributes": ["custom:custom.environment=\"production\"", "custom:custom.region=\"us-west-2\""]}
INFO Connected to agent
On retry:
DEBUG Agent attestor webhook call failed, will retry {"attempt": 0, "error": "connection timeout"}
DEBUG Calling external webhook {"url": "https://...", "attempt": 1}
ERROR Failed to attest agent via extension webhook {"attempts": 2, "error": "..."}
Metrics — confirm calls are succeeding:
spirl_attestation_signer.proof{attestor_type="extension",outcome="success"}
spirl_attestation_agent.proof{attestor_type="extension",outcome="success"}
Alert on outcome="failed" to detect webhook rejections or connectivity failures.
Example Configurations
Minimal server extension — no authentication
section: AgentAttestation
schema: v1
spec:
policies:
- name: custom_attestation
requiredAttestors:
- type: extension
config:
webhookURL: "https://attestation.example.com/webhook"
Production server extension — custom CA, bearer token, retries
section: AgentAttestation
schema: v1
spec:
policies:
- name: custom_attestation
requiredAttestors:
- type: extension
config:
webhookURL: "https://attestation.prod.example.com/webhook"
timeout: "15s"
caCerts: |
-----BEGIN CERTIFICATE-----
MIIBxTCCAW...
-----END CERTIFICATE-----
authType: "BEARER"
maxRetries: 3
Development server extension — self-signed certificate
section: AgentAttestation
schema: v1
spec:
policies:
- name: dev_attestation
requiredAttestors:
- type: extension
config:
webhookURL: "https://localhost:8443/webhook"
insecureSkipVerify: true
insecureSkipVerify: true disables TLS certificate validation. Use only for local development with self-signed certificates — never in production.
Combined with another attestor (AND logic)
The extension can be required alongside other attestors. Both must pass:
section: AgentAttestation
schema: v1
spec:
policies:
- name: eks_and_custom
requiredAttestors:
- type: k8s_token
config:
issuerURL: https://oidc.eks.us-east-1.amazonaws.com/id/ABCDEF123456
- type: extension
config:
webhookURL: "https://attestation.example.com/webhook"
Agent extension — production with checksum verification
agent:
auth:
clusterId: "prod-cluster-123"
attestors:
- type: extension
config:
cmd: "/usr/local/bin/custom-attestor"
args:
- "--mode=production"
- "--log-level=info"
checksum: "sha256:a1b2c3d4e5f6789012345678901234567890123456789012345678901234567890"
requestTimeout: "150ms"
Agent extension — combined with other attestors
The agent extension can be used alongside built-in attestors. All proofs are submitted together:
agent:
auth:
clusterId: "prod-cluster-123"
attestors:
- type: k8s_psat
config:
audience: "spirl-server"
- type: aws_iid
- type: extension
config:
cmd: "/usr/local/bin/custom-attestor"
checksum: "sha256:a1b2c3d4e5f6789012345678901234567890123456789012345678901234567890"
Disabling the server extension
Remove the type: extension attestor from the AgentAttestation policy. Agents authenticate using the remaining attestors:
section: AgentAttestation
schema: v1
spec:
policies:
- name: linux-vm-policy
requiredAttestors:
- type: x509pop
config:
caCerts: |
-----BEGIN CERTIFICATE-----
MIIBxTCCAW...
-----END CERTIFICATE-----
Disabling the agent extension
Remove the type: extension entry from the agent's attestors list:
agent:
auth:
clusterId: "prod-cluster-123"
attestors:
- type: k8s_psat
config:
audience: "spirl-server"
# Extension not included — disabled
Example Webhook Implementation
The following example shows a webhook server (Python/Flask) that validates AWS IID data cross-checked against the aws attestor attributes:
import base64
import json
from flask import Flask, request, jsonify
app = Flask(__name__)
ALLOWED_ACCOUNT_ID = 'your-account-id'
def dig(data, *keys, default=''):
"""Safely traverse nested dicts by key path."""
for key in keys:
if not isinstance(data, dict):
return default
data = data.get(key, default)
return data
@app.route('/webhook', methods=['POST'])
def attest():
data = request.get_json()
payload_str = data.get('payload', '')
if not payload_str:
return jsonify({'error': 'missing payload'}), 400
try:
iid = json.loads(base64.b64decode(payload_str))
except Exception as e:
return jsonify({'error': f'invalid document: {e}'}), 400
account_id = iid.get('accountId', '')
instance_id = iid.get('instanceId', '')
region = iid.get('region', '')
# Cross-check IID fields against the aws attestor attributes
req_account_id = dig(data, 'aws', 'account', 'id')
req_instance_id = dig(data, 'aws', 'ec2', 'instance', 'id')
if account_id != req_account_id or instance_id != req_instance_id:
return jsonify({'error': 'document mismatch with request fields'}), 403
if account_id != ALLOWED_ACCOUNT_ID:
return jsonify({'error': 'unauthorized account'}), 403
environment = 'production' if instance_id == 'i-prod' else 'staging'
return jsonify({
'environment': environment,
'region': region,
'validated_by': 'extension-v1',
})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080)
Production webhook — Go with Kubernetes TokenReview
The following example shows a production-ready webhook server with TLS support and bearer token authentication using the Kubernetes TokenReview API:
package main
import (
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"slices"
"strings"
"time"
authenticationv1 "k8s.io/api/authentication/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
type Config struct {
Port string
TLSCertPath string
TLSKeyPath string
ReadTimeout time.Duration
WriteTimeout time.Duration
AllowedAudiences []string
AllowedNamespaces []string
AllowedAccountIDs []string
}
type Server struct {
config Config
logger *slog.Logger
k8sClient *kubernetes.Clientset
}
type InstanceIdentityDocument struct {
AccountID string `json:"accountId"`
InstanceID string `json:"instanceId"`
Region string `json:"region"`
InstanceType string `json:"instanceType"`
AvailabilityZone string `json:"availabilityZone"`
ImageID string `json:"imageId"`
}
type WebhookRequest struct {
Meta map[string]string `json:"_meta"`
Payload string `json:"payload"`
Cluster map[string]string `json:"cluster"`
Aws map[string]any `json:"aws,omitempty"`
}
type WebhookResponse struct {
Error string `json:"error,omitempty"`
Environment string `json:"environment,omitempty"`
CostCenter string `json:"cost_center,omitempty"`
Region string `json:"region,omitempty"`
AvailabilityZone string `json:"availability_zone,omitempty"`
InstanceType string `json:"instance_type,omitempty"`
ImageID string `json:"image_id,omitempty"`
Validated string `json:"validated,omitempty"`
}
func NewServer(config Config, logger *slog.Logger) (*Server, error) {
k8sConfig, err := rest.InClusterConfig()
if err != nil {
return nil, fmt.Errorf("failed to create in-cluster config: %w", err)
}
clientset, err := kubernetes.NewForConfig(k8sConfig)
if err != nil {
return nil, fmt.Errorf("failed to create kubernetes client: %w", err)
}
return &Server{config: config, logger: logger, k8sClient: clientset}, nil
}
func dig(data map[string]any, keys ...string) (string, bool) {
var current any = data
for _, key := range keys {
m, ok := current.(map[string]any)
if !ok {
return "", false
}
current, ok = m[key]
if !ok {
return "", false
}
}
s, ok := current.(string)
return s, ok
}
func (s *Server) validateToken(ctx context.Context, token string) error {
tokenReview := &authenticationv1.TokenReview{
Spec: authenticationv1.TokenReviewSpec{Token: token},
}
if len(s.config.AllowedAudiences) > 0 {
tokenReview.Spec.Audiences = s.config.AllowedAudiences
}
result, err := s.k8sClient.AuthenticationV1().TokenReviews().Create(ctx, tokenReview, metav1.CreateOptions{})
if err != nil {
return fmt.Errorf("token review failed: %w", err)
}
if !result.Status.Authenticated {
return fmt.Errorf("token not authenticated: %s", result.Status.Error)
}
if len(s.config.AllowedNamespaces) > 0 {
ns := extractNamespace(result.Status.User.Username)
if !slices.Contains(s.config.AllowedNamespaces, ns) {
return fmt.Errorf("namespace %s not allowed", ns)
}
}
return nil
}
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
parts := strings.SplitN(r.Header.Get("Authorization"), " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
s.respondError(w, http.StatusUnauthorized, "missing or invalid authorization header")
return
}
if err := s.validateToken(r.Context(), parts[1]); err != nil {
s.logger.Warn("token validation failed", "error", err)
s.respondError(w, http.StatusUnauthorized, "invalid token")
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) AttestationHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
s.respondError(w, http.StatusMethodNotAllowed, "only POST is allowed")
return
}
var req WebhookRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.respondError(w, http.StatusBadRequest, "invalid JSON")
return
}
s.logger.Info("received attestation request", "cluster_id", req.Cluster["cluster_id"])
resp := s.processAttestation(req)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func parseIID(payloadStr string) (*InstanceIdentityDocument, error) {
data, err := base64.StdEncoding.DecodeString(payloadStr)
if err != nil {
data = []byte(payloadStr)
}
var iid InstanceIdentityDocument
if err := json.Unmarshal(data, &iid); err != nil {
return nil, fmt.Errorf("failed to parse IID: %w", err)
}
if iid.AccountID == "" || iid.InstanceID == "" || iid.Region == "" {
return nil, fmt.Errorf("IID missing required fields")
}
return &iid, nil
}
func (s *Server) processAttestation(req WebhookRequest) WebhookResponse {
if req.Payload == "" {
return WebhookResponse{Error: "missing payload"}
}
iid, err := parseIID(req.Payload)
if err != nil {
s.logger.Warn("failed to parse IID", "error", err)
return WebhookResponse{Error: "invalid identity document"}
}
reqAccountID, _ := dig(req.Aws, "account", "id")
reqInstanceID, _ := dig(req.Aws, "ec2", "instance", "id")
if iid.AccountID != reqAccountID || iid.InstanceID != reqInstanceID {
s.logger.Warn("IID mismatch",
"iid_account", iid.AccountID, "req_account", reqAccountID,
"iid_instance", iid.InstanceID, "req_instance", reqInstanceID,
)
return WebhookResponse{Error: "document mismatch with request fields"}
}
if len(s.config.AllowedAccountIDs) > 0 && !slices.Contains(s.config.AllowedAccountIDs, iid.AccountID) {
return WebhookResponse{Error: "unauthorized account id"}
}
var environment, costCenter string
switch iid.InstanceID {
case "i-prod":
environment, costCenter = "production", "prod-ops"
case "i-staging":
environment, costCenter = "staging", "staging-ops"
default:
environment, costCenter = "development", "dev-ops"
}
s.logger.Info("attestation processed",
"account_id", iid.AccountID,
"instance_id", iid.InstanceID,
"environment", environment,
)
return WebhookResponse{
Environment: environment,
CostCenter: costCenter,
Region: iid.Region,
AvailabilityZone: iid.AvailabilityZone,
InstanceType: iid.InstanceType,
ImageID: iid.ImageID,
Validated: "true",
}
}
func (s *Server) respondError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(WebhookResponse{Error: msg})
}
func loggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
logger.Info("request", "method", r.Method, "path", r.URL.Path, "duration", time.Since(start))
})
}
}
func extractNamespace(username string) string {
parts := strings.Split(username, ":")
if len(parts) >= 3 {
return parts[2]
}
return ""
}
func getEnv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
config := Config{
Port: getEnv("PORT", "8443"),
TLSCertPath: getEnv("TLS_CERT_PATH", "/etc/tls/tls.crt"),
TLSKeyPath: getEnv("TLS_KEY_PATH", "/etc/tls/tls.key"),
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
AllowedAudiences: strings.Split(getEnv("ALLOWED_AUDIENCES", ""), ","),
AllowedNamespaces: strings.Split(getEnv("ALLOWED_NAMESPACES", "spirl-system"), ","),
AllowedAccountIDs: strings.Split(getEnv("ALLOWED_ACCOUNT_IDS", ""), ","),
}
server, err := NewServer(config, logger)
if err != nil {
logger.Error("failed to create server", "error", err)
os.Exit(1)
}
mux := http.NewServeMux()
mux.HandleFunc("/attest", server.AttestationHandler)
httpServer := &http.Server{
Addr: ":" + config.Port,
Handler: loggingMiddleware(logger)(server.authMiddleware(mux)),
ReadTimeout: config.ReadTimeout,
WriteTimeout: config.WriteTimeout,
TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}
logger.Info("starting HTTPS server", "port", config.Port)
if err := httpServer.ListenAndServeTLS(config.TLSCertPath, config.TLSKeyPath); err != nil {
logger.Error("server failed", "error", err)
os.Exit(1)
}
}
This example requires the service account to have TokenReview permissions:
- apiGroups: ["authentication.k8s.io"]
resources: ["tokenreviews"]
verbs: ["create"]
Example Agent Extension
The following example shows an agent extension executable (Go) that reads an Instance Identity Document from disk and returns it as the proof payload:
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"fmt"
"os"
)
type Request struct {
Meta map[string]string `json:"_meta"`
}
type Response struct {
Payload string `json:"payload,omitempty"`
Error string `json:"error,omitempty"`
}
func main() {
scanner := bufio.NewScanner(os.Stdin)
encoder := json.NewEncoder(os.Stdout)
for scanner.Scan() {
var req Request
if err := json.Unmarshal(scanner.Bytes(), &req); err != nil {
encoder.Encode(Response{Error: fmt.Sprintf("parse error: %v", err)})
continue
}
data, err := os.ReadFile("/etc/iid/document")
if err != nil {
encoder.Encode(Response{Error: fmt.Sprintf("failed to read IID: %v", err)})
continue
}
encoder.Encode(Response{
Payload: base64.StdEncoding.EncodeToString(data),
})
}
}
Build and record the checksum before deploying:
go build -o /usr/local/bin/custom-attestor main.go
sha256sum /usr/local/bin/custom-attestor
OpenAPI Specification
A formal OpenAPI 3.1 specification is available for the server webhook endpoint. It documents the complete request/response schemas, HTTP status codes, authentication, error handling, retry behavior, and includes sample requests and responses.
Use the spec to:
- Generate server or client code using tools like oapi-codegen (Go), OpenAPI Generator, or Swagger Codegen
- Validate your webhook implementation with Swagger UI, Postman, or OpenAPI validators
- Generate interactive documentation with Redoc or Swagger UI
- Mock the webhook for testing with Prism or Mockoon
Usage examples
Generate a Go server stub:
go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@latest
oapi-codegen \
-package webhook \
-generate types,server,spec \
webhook-openapi.yaml \
> webhook/server.go
Run interactive documentation with Swagger UI:
docker run -p 8080:8080 \
-e SWAGGER_JSON=/openapi.yaml \
-v $(pwd)/webhook-openapi.yaml:/openapi.yaml \
swaggerapi/swagger-ui
Mock the webhook with Prism:
npm install -g @stoplight/prism-cli
prism mock webhook-openapi.yaml
Complete specification
openapi: "3.1.0"
info:
title: Agent Attestation Extension Webhook
version: "1.0"
description: |
OpenAPI spec for the external webhook consumed by the server-side agent
attestation extension.
The Trust Domain Server POSTs a JSON body to the configured `webhookURL`
after all other attestors have succeeded. The webhook must respond with a
flat map of string key-value pairs; each pair becomes a `custom` agent
attribute. A non-empty `"error"` field in the response signals rejection.
The full URL — including scheme, host, and path — is provided by the
operator via the `webhookURL` config field. There is no fixed path; the
examples below use common names for illustration only.
servers:
- url: "{webhookURL}"
description: |
Full webhook URL as configured in the attestor's webhookURL field,
including the scheme, host, and any path prefix chosen by the operator.
variables:
webhookURL:
default: "https://your-service.example.com/attest"
description: Complete base URL for the webhook endpoint.
paths:
/:
post:
summary: Attest an agent session
description: |
Called once per agent session after all other attestors have succeeded.
The Trust Domain Server POSTs the request body and expects either a
200 OK with a flat attribute map, or a non-2xx status / non-empty
"error" field to reject the session.
Retry behavior: 5xx responses and transient network errors are retried
with exponential back-off (default: 2 retries). 4xx responses are never
retried.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AttestationRequest"
examples:
minimal:
summary: Minimal request (no collected attributes)
value:
_meta:
version: "1.0"
cluster:
cluster_id: "c-qgd1hs6hez"
payload: "eyJhbGciOi..."
with_k8s_psat:
summary: Request enriched with k8s_psat attributes
value:
_meta:
version: "1.0"
cluster:
cluster_id: "c-qgd1hs6hez"
payload: "eyJhbGciOi..."
k8s_psat:
cluster: "my-cluster"
agentNs: "my-agent-ns"
agentSa: "spirl-agent"
responses:
"200":
description: |
Attestation approved. The response body is a flat JSON object
whose string values become custom SPIFFE attributes.
If the "error" field is present and non-empty the session is
rejected even when the HTTP status is 200.
content:
application/json:
schema:
$ref: "#/components/schemas/AttestationResponse"
examples:
approved:
summary: Approved — returns custom attributes
value:
environment: production
team: platform
tier: gold
rejected_via_error_field:
summary: Rejected via error field (HTTP 200 but session denied)
value:
error: "agent identity not recognized"
"400":
description: Bad request — session rejected immediately, not retried.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"401":
description: Unauthorized — session rejected immediately, not retried.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"403":
description: Forbidden — session rejected immediately, not retried.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"500":
description: Internal server error — retried with exponential back-off.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
components:
schemas:
AttestationRequest:
type: object
required:
- _meta
- cluster
- payload
properties:
_meta:
type: object
description: Metadata added by the server.
required:
- version
properties:
version:
type: string
description: Extension protocol version.
example: "1.0"
additionalProperties: false
cluster:
type: object
description: Cluster information from the signer configuration.
required:
- cluster_id
properties:
cluster_id:
type: string
description: Logical cluster identifier configured in control plane.
example: "c-qgd1hs6hez"
additionalProperties: false
payload:
type: string
description: |
Agent proof data provided by the agent-side extension executable,
forwarded verbatim as a string. Empty string when the agent
supplied no proof data.
example: "eyJhbGciOiJSUzI1NiJ9..."
additionalProperties:
description: |
Attributes collected from other successful attestors (e.g. k8s_psat)
are merged into the request root as nested objects.
type: object
additionalProperties: true
AttestationResponse:
type: object
description: |
Flat string-to-string map. Each key-value pair (except "error")
becomes a custom attribute on the issued SVID.
A non-empty "error" value causes the session to be rejected.
properties:
error:
type: string
description: |
When non-empty the server rejects the session and surfaces this
message as the error reason. An empty string or absent key is
treated as success.
example: "agent identity not recognized"
additionalProperties:
type: string
description: Arbitrary string attribute included under the custom namespace.
example:
environment: production
team: platform
ErrorResponse:
type: object
description: Error details returned for non-2xx responses.
properties:
error:
type: string
description: Human-readable error message.
example: "unauthorized: missing bearer token"
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
Optional bearer token authentication. Configured via authType: BEARER
and tokenPath in the attestor config. The token is read from a file
and refreshed automatically.
security:
- {}
- BearerAuth: []
Security Considerations
- Use HTTPS for the webhook URL in all non-development environments.
- Validate the payload in your webhook — cross-check it against attributes from other attestors to detect spoofing.
- Use checksum verification for the agent extension executable to prevent tampering at the install path.
- Rotate bearer tokens regularly if using
authType: BEARER. - Limit error detail in webhook responses — error messages are returned to the agent and logged; avoid exposing sensitive internal details.
- Set a short
timeout— a slow webhook delays every agent login. Keep response time well under the configured limit. - Set a short
requestTimeoutfor the agent extension — the default100msis appropriate for local reads; increase only if necessary.
Troubleshooting
Webhook not being called — Verify type: extension is in the cluster's AgentAttestation policy and the config has been applied. Check server logs for the attestation attempt.
TLS validation failure — Verify the webhook certificate is valid and matches the hostname. Use caCerts if the webhook uses a private CA.
Authentication failures (401) — Verify authType and tokenPath are correct. Check that the token file exists and is readable by the Trust Domain Server process. 401 responses are not retried.
Webhook returning errors — Check the error field value in server logs. Test the webhook manually using curl with a sample request body.
Timeout errors — Increase timeout, optimize webhook response time, or add caching for external lookups.
Custom attributes not appearing in SVID — Verify the webhook returns a valid flat JSON object (not nested). Check that Attribute Redaction is not filtering out custom.* attributes.
Agent extension not responding — Check that the path in cmd is correct and the binary is executable. Enable debug logging on the agent to see extension startup and communication.
Checksum mismatch — Regenerate the checksum using sha256sum and update the configuration. This error occurs when the executable is updated without updating the configured checksum.