Implements all P0 features per OpenSpec change phase-1-mvp-implementation: - Agent Registry Service (CRUD) — full lifecycle management - OAuth 2.0 Token Service (Client Credentials flow) - Credential Management (generate, rotate, revoke) - Immutable Audit Log Service Tech: Node.js 18+, TypeScript 5.3+ strict, Express 4.18+, PostgreSQL 14+, Redis 7+ Standards: OpenAPI 3.0 specs, DRY/SOLID, zero `any` types Quality: 18 unit test suites, 244 tests passing, 97%+ coverage OpenAPI: 4 complete specs (14 endpoints total) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
688 lines
23 KiB
YAML
688 lines
23 KiB
YAML
openapi: 3.0.3
|
|
|
|
info:
|
|
title: SentryAgent.ai — Credential Management Service
|
|
version: 1.0.0
|
|
description: |
|
|
The Credential Management Service provides secure generation, listing,
|
|
rotation, and revocation of OAuth 2.0 client credentials for registered
|
|
AI agents.
|
|
|
|
Each agent can hold multiple credentials simultaneously to support
|
|
zero-downtime rotation. A credential consists of a `client_id` (= `agentId`)
|
|
and a `client_secret`.
|
|
|
|
**Security model**:
|
|
- `client_secret` is returned **once only** — at creation or rotation time.
|
|
- Secrets are stored as a bcrypt hash; plain-text is never persisted.
|
|
- An agent may only manage its own credentials unless the caller holds an
|
|
admin-scoped token.
|
|
- Rotating a credential immediately revokes the previous `client_secret`.
|
|
|
|
**Auth**: Bearer token (JWT) required on all endpoints.
|
|
|
|
servers:
|
|
- url: http://localhost:3000/api/v1
|
|
description: Local development server
|
|
- url: https://api.sentryagent.ai/v1
|
|
description: Production server
|
|
|
|
tags:
|
|
- name: Credential Management
|
|
description: Generate, list, rotate, and revoke agent credentials
|
|
|
|
components:
|
|
securitySchemes:
|
|
BearerAuth:
|
|
type: http
|
|
scheme: bearer
|
|
bearerFormat: JWT
|
|
description: |
|
|
JWT access token obtained via `POST /token`.
|
|
Include as: `Authorization: Bearer <token>`
|
|
|
|
schemas:
|
|
CredentialStatus:
|
|
type: string
|
|
description: |
|
|
Lifecycle status of a credential.
|
|
- `active`: Credential is valid and can be used to obtain tokens.
|
|
- `revoked`: Credential has been explicitly revoked and is permanently invalid.
|
|
enum:
|
|
- active
|
|
- revoked
|
|
example: active
|
|
|
|
Credential:
|
|
type: object
|
|
description: |
|
|
A credential record for an AI agent. The `clientSecret` is **never**
|
|
returned in this schema — it is only returned once in `CredentialWithSecret`
|
|
at the moment of creation or rotation.
|
|
required:
|
|
- credentialId
|
|
- clientId
|
|
- status
|
|
- createdAt
|
|
properties:
|
|
credentialId:
|
|
type: string
|
|
format: uuid
|
|
description: Immutable, system-assigned unique identifier for this credential.
|
|
readOnly: true
|
|
example: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
|
|
clientId:
|
|
type: string
|
|
format: uuid
|
|
description: >
|
|
The `agentId` this credential belongs to. Equal to the `agentId`
|
|
path parameter.
|
|
readOnly: true
|
|
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
|
status:
|
|
$ref: '#/components/schemas/CredentialStatus'
|
|
createdAt:
|
|
type: string
|
|
format: date-time
|
|
description: ISO 8601 timestamp when the credential was created.
|
|
readOnly: true
|
|
example: "2026-03-28T09:00:00.000Z"
|
|
expiresAt:
|
|
type: string
|
|
format: date-time
|
|
nullable: true
|
|
description: |
|
|
ISO 8601 timestamp when the credential expires.
|
|
`null` indicates the credential does not expire (valid until revoked).
|
|
example: "2027-03-28T09:00:00.000Z"
|
|
revokedAt:
|
|
type: string
|
|
format: date-time
|
|
nullable: true
|
|
description: >
|
|
ISO 8601 timestamp when the credential was revoked.
|
|
`null` if the credential has not been revoked.
|
|
readOnly: true
|
|
example: null
|
|
|
|
CredentialWithSecret:
|
|
allOf:
|
|
- $ref: '#/components/schemas/Credential'
|
|
- type: object
|
|
description: |
|
|
Extended credential record returned **only** at creation or rotation time.
|
|
The `clientSecret` is shown once and never retrievable again.
|
|
Store it securely immediately.
|
|
required:
|
|
- clientSecret
|
|
properties:
|
|
clientSecret:
|
|
type: string
|
|
description: |
|
|
The plain-text client secret. **Shown once only** — store securely immediately.
|
|
This value is not persisted in plain text on the server.
|
|
format: password
|
|
example: "sk_live_7f3a2b1c9d8e4f0a6b5c3d2e1f0a9b8c"
|
|
|
|
GenerateCredentialRequest:
|
|
type: object
|
|
description: |
|
|
Optional request body for generating new credentials.
|
|
If `expiresAt` is omitted, the credential does not expire.
|
|
properties:
|
|
expiresAt:
|
|
type: string
|
|
format: date-time
|
|
description: |
|
|
Optional ISO 8601 expiry timestamp for the credential.
|
|
Must be a future date. If omitted, the credential has no expiry.
|
|
example: "2027-03-28T09:00:00.000Z"
|
|
|
|
PaginatedCredentialsResponse:
|
|
type: object
|
|
description: Paginated list of credentials for an agent.
|
|
required:
|
|
- data
|
|
- total
|
|
- page
|
|
- limit
|
|
properties:
|
|
data:
|
|
type: array
|
|
items:
|
|
$ref: '#/components/schemas/Credential'
|
|
total:
|
|
type: integer
|
|
description: Total number of credentials for this agent.
|
|
example: 3
|
|
page:
|
|
type: integer
|
|
description: Current page number (1-based).
|
|
example: 1
|
|
limit:
|
|
type: integer
|
|
description: Number of items per page.
|
|
example: 20
|
|
|
|
ErrorResponse:
|
|
type: object
|
|
description: Standard error response envelope.
|
|
required:
|
|
- code
|
|
- message
|
|
properties:
|
|
code:
|
|
type: string
|
|
description: Machine-readable error code.
|
|
example: "CREDENTIAL_NOT_FOUND"
|
|
message:
|
|
type: string
|
|
description: Human-readable description of the error.
|
|
example: "Credential with the specified ID was not found."
|
|
details:
|
|
type: object
|
|
description: Optional structured details about the error.
|
|
additionalProperties: true
|
|
example: {}
|
|
|
|
responses:
|
|
Unauthorized:
|
|
description: Missing or invalid Bearer token.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/ErrorResponse'
|
|
example:
|
|
code: "UNAUTHORIZED"
|
|
message: "A valid Bearer token is required to access this resource."
|
|
|
|
Forbidden:
|
|
description: Valid token but insufficient permissions.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/ErrorResponse'
|
|
example:
|
|
code: "FORBIDDEN"
|
|
message: "You do not have permission to manage credentials for this agent."
|
|
|
|
AgentNotFound:
|
|
description: The specified agent does not exist.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/ErrorResponse'
|
|
example:
|
|
code: "AGENT_NOT_FOUND"
|
|
message: "Agent with the specified ID was not found."
|
|
|
|
CredentialNotFound:
|
|
description: The specified credential does not exist.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/ErrorResponse'
|
|
example:
|
|
code: "CREDENTIAL_NOT_FOUND"
|
|
message: "Credential with the specified ID was not found."
|
|
|
|
TooManyRequests:
|
|
description: Rate limit exceeded.
|
|
headers:
|
|
X-RateLimit-Limit:
|
|
schema:
|
|
type: integer
|
|
example: 100
|
|
X-RateLimit-Remaining:
|
|
schema:
|
|
type: integer
|
|
example: 0
|
|
X-RateLimit-Reset:
|
|
schema:
|
|
type: integer
|
|
example: 1743155400
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/ErrorResponse'
|
|
example:
|
|
code: "RATE_LIMIT_EXCEEDED"
|
|
message: "Too many requests. Please retry after the rate limit window resets."
|
|
|
|
InternalServerError:
|
|
description: Unexpected server error.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/ErrorResponse'
|
|
example:
|
|
code: "INTERNAL_SERVER_ERROR"
|
|
message: "An unexpected error occurred. Please try again later."
|
|
|
|
security:
|
|
- BearerAuth: []
|
|
|
|
paths:
|
|
/agents/{agentId}/credentials:
|
|
parameters:
|
|
- name: agentId
|
|
in: path
|
|
description: The unique UUID identifier of the agent.
|
|
required: true
|
|
schema:
|
|
type: string
|
|
format: uuid
|
|
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
|
|
|
post:
|
|
operationId: generateCredential
|
|
tags:
|
|
- Credential Management
|
|
summary: Generate new credentials for an agent
|
|
description: |
|
|
Generates a new `client_id` + `client_secret` credential pair for the
|
|
specified agent.
|
|
|
|
**Important**: The `clientSecret` is returned **once only** in this response.
|
|
It is not stored in plain text on the server and cannot be retrieved later.
|
|
Store it securely immediately (e.g. in a secrets manager).
|
|
|
|
An agent may hold multiple active credentials simultaneously. This supports
|
|
zero-downtime rotation: generate a new credential, update all consumers,
|
|
then revoke the old one.
|
|
|
|
**Restrictions**:
|
|
- The agent must be in `active` status.
|
|
- An agent may manage its own credentials via a self-issued token.
|
|
- Managing another agent's credentials requires an admin-scoped token.
|
|
requestBody:
|
|
required: false
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/GenerateCredentialRequest'
|
|
example:
|
|
expiresAt: "2027-03-28T09:00:00.000Z"
|
|
responses:
|
|
'201':
|
|
description: |
|
|
Credential generated successfully.
|
|
**Save the `clientSecret` immediately — it will not be shown again.**
|
|
headers:
|
|
X-RateLimit-Limit:
|
|
schema:
|
|
type: integer
|
|
example: 100
|
|
X-RateLimit-Remaining:
|
|
schema:
|
|
type: integer
|
|
example: 99
|
|
X-RateLimit-Reset:
|
|
schema:
|
|
type: integer
|
|
example: 1743155400
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/CredentialWithSecret'
|
|
example:
|
|
credentialId: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
|
|
clientId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
|
clientSecret: "sk_live_7f3a2b1c9d8e4f0a6b5c3d2e1f0a9b8c"
|
|
status: "active"
|
|
createdAt: "2026-03-28T09:00:00.000Z"
|
|
expiresAt: "2027-03-28T09:00:00.000Z"
|
|
revokedAt: null
|
|
'400':
|
|
description: Invalid request body.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/ErrorResponse'
|
|
example:
|
|
code: "VALIDATION_ERROR"
|
|
message: "Request validation failed."
|
|
details:
|
|
field: "expiresAt"
|
|
reason: "expiresAt must be a future date-time."
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
description: Insufficient permissions or agent is not active.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/ErrorResponse'
|
|
examples:
|
|
forbidden:
|
|
summary: Insufficient permissions
|
|
value:
|
|
code: "FORBIDDEN"
|
|
message: "You do not have permission to manage credentials for this agent."
|
|
agentNotActive:
|
|
summary: Agent not in active status
|
|
value:
|
|
code: "AGENT_NOT_ACTIVE"
|
|
message: "Credentials can only be generated for active agents."
|
|
details:
|
|
agentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
|
status: "suspended"
|
|
'404':
|
|
$ref: '#/components/responses/AgentNotFound'
|
|
'429':
|
|
$ref: '#/components/responses/TooManyRequests'
|
|
'500':
|
|
$ref: '#/components/responses/InternalServerError'
|
|
|
|
get:
|
|
operationId: listCredentials
|
|
tags:
|
|
- Credential Management
|
|
summary: List credentials for an agent
|
|
description: |
|
|
Returns a paginated list of all credentials (active and revoked) for the
|
|
specified agent. The `clientSecret` is **never** returned in list responses.
|
|
|
|
Results are ordered by `createdAt` descending (most recent first).
|
|
parameters:
|
|
- name: page
|
|
in: query
|
|
description: Page number (1-based). Defaults to `1`.
|
|
required: false
|
|
schema:
|
|
type: integer
|
|
minimum: 1
|
|
default: 1
|
|
example: 1
|
|
- name: limit
|
|
in: query
|
|
description: Number of results per page. Defaults to `20`, maximum `100`.
|
|
required: false
|
|
schema:
|
|
type: integer
|
|
minimum: 1
|
|
maximum: 100
|
|
default: 20
|
|
example: 20
|
|
- name: status
|
|
in: query
|
|
description: Filter credentials by status.
|
|
required: false
|
|
schema:
|
|
$ref: '#/components/schemas/CredentialStatus'
|
|
responses:
|
|
'200':
|
|
description: Credential list returned successfully.
|
|
headers:
|
|
X-RateLimit-Limit:
|
|
schema:
|
|
type: integer
|
|
example: 100
|
|
X-RateLimit-Remaining:
|
|
schema:
|
|
type: integer
|
|
example: 98
|
|
X-RateLimit-Reset:
|
|
schema:
|
|
type: integer
|
|
example: 1743155400
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/PaginatedCredentialsResponse'
|
|
example:
|
|
data:
|
|
- credentialId: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
|
|
clientId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
|
status: "active"
|
|
createdAt: "2026-03-28T09:00:00.000Z"
|
|
expiresAt: "2027-03-28T09:00:00.000Z"
|
|
revokedAt: null
|
|
- credentialId: "d8e7f6a5-b4c3-2109-edcb-a98765432109"
|
|
clientId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
|
status: "revoked"
|
|
createdAt: "2026-01-15T08:00:00.000Z"
|
|
expiresAt: null
|
|
revokedAt: "2026-03-28T08:59:00.000Z"
|
|
total: 2
|
|
page: 1
|
|
limit: 20
|
|
'400':
|
|
description: Invalid query parameters.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/ErrorResponse'
|
|
example:
|
|
code: "VALIDATION_ERROR"
|
|
message: "Invalid query parameter value."
|
|
details:
|
|
field: "status"
|
|
reason: "Must be 'active' or 'revoked'."
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/AgentNotFound'
|
|
'429':
|
|
$ref: '#/components/responses/TooManyRequests'
|
|
'500':
|
|
$ref: '#/components/responses/InternalServerError'
|
|
|
|
/agents/{agentId}/credentials/{credentialId}/rotate:
|
|
parameters:
|
|
- name: agentId
|
|
in: path
|
|
description: The unique UUID identifier of the agent.
|
|
required: true
|
|
schema:
|
|
type: string
|
|
format: uuid
|
|
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
|
- name: credentialId
|
|
in: path
|
|
description: The unique UUID identifier of the credential to rotate.
|
|
required: true
|
|
schema:
|
|
type: string
|
|
format: uuid
|
|
example: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
|
|
|
|
post:
|
|
operationId: rotateCredential
|
|
tags:
|
|
- Credential Management
|
|
summary: Rotate a credential
|
|
description: |
|
|
Rotates an existing credential by:
|
|
1. Immediately revoking the current `client_secret`.
|
|
2. Generating and returning a new `client_secret` for the same `credentialId`.
|
|
|
|
The `credentialId` remains the same after rotation; only the secret changes.
|
|
The new `clientSecret` is returned **once only** and must be stored securely.
|
|
|
|
**Use case**: Periodic secret rotation or emergency rotation after
|
|
credential compromise.
|
|
|
|
Only `active` credentials can be rotated. Attempting to rotate a `revoked`
|
|
credential returns `409 Conflict`.
|
|
requestBody:
|
|
required: false
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/GenerateCredentialRequest'
|
|
example:
|
|
expiresAt: "2028-03-28T09:00:00.000Z"
|
|
responses:
|
|
'200':
|
|
description: |
|
|
Credential rotated successfully.
|
|
**Save the new `clientSecret` immediately — it will not be shown again.**
|
|
The previous secret is permanently invalidated.
|
|
headers:
|
|
X-RateLimit-Limit:
|
|
schema:
|
|
type: integer
|
|
example: 100
|
|
X-RateLimit-Remaining:
|
|
schema:
|
|
type: integer
|
|
example: 97
|
|
X-RateLimit-Reset:
|
|
schema:
|
|
type: integer
|
|
example: 1743155400
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/CredentialWithSecret'
|
|
example:
|
|
credentialId: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
|
|
clientId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
|
clientSecret: "sk_live_9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d"
|
|
status: "active"
|
|
createdAt: "2026-03-28T09:00:00.000Z"
|
|
expiresAt: "2028-03-28T09:00:00.000Z"
|
|
revokedAt: null
|
|
'400':
|
|
description: Invalid request body.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/ErrorResponse'
|
|
example:
|
|
code: "VALIDATION_ERROR"
|
|
message: "Request validation failed."
|
|
details:
|
|
field: "expiresAt"
|
|
reason: "expiresAt must be a future date-time."
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
description: Agent or credential not found.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/ErrorResponse'
|
|
examples:
|
|
agentNotFound:
|
|
summary: Agent not found
|
|
value:
|
|
code: "AGENT_NOT_FOUND"
|
|
message: "Agent with the specified ID was not found."
|
|
credentialNotFound:
|
|
summary: Credential not found
|
|
value:
|
|
code: "CREDENTIAL_NOT_FOUND"
|
|
message: "Credential with the specified ID was not found."
|
|
'409':
|
|
description: Credential is already revoked and cannot be rotated.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/ErrorResponse'
|
|
example:
|
|
code: "CREDENTIAL_ALREADY_REVOKED"
|
|
message: "Revoked credentials cannot be rotated. Generate a new credential instead."
|
|
details:
|
|
credentialId: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
|
|
revokedAt: "2026-03-20T10:00:00.000Z"
|
|
'429':
|
|
$ref: '#/components/responses/TooManyRequests'
|
|
'500':
|
|
$ref: '#/components/responses/InternalServerError'
|
|
|
|
/agents/{agentId}/credentials/{credentialId}:
|
|
parameters:
|
|
- name: agentId
|
|
in: path
|
|
description: The unique UUID identifier of the agent.
|
|
required: true
|
|
schema:
|
|
type: string
|
|
format: uuid
|
|
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
|
- name: credentialId
|
|
in: path
|
|
description: The unique UUID identifier of the credential to revoke.
|
|
required: true
|
|
schema:
|
|
type: string
|
|
format: uuid
|
|
example: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
|
|
|
|
delete:
|
|
operationId: revokeCredential
|
|
tags:
|
|
- Credential Management
|
|
summary: Revoke a credential
|
|
description: |
|
|
Permanently revokes a credential, immediately preventing it from being
|
|
used to obtain new tokens.
|
|
|
|
**Effects of revocation**:
|
|
- The credential's status is set to `revoked`.
|
|
- Any tokens issued using this credential remain valid until they expire
|
|
naturally (token revocation is handled separately via `POST /token/revoke`).
|
|
- The credential record is retained for audit purposes.
|
|
- This operation is **irreversible** — a revoked credential cannot be re-activated.
|
|
|
|
Revoking an already-revoked credential returns `409 Conflict`.
|
|
responses:
|
|
'204':
|
|
description: Credential revoked successfully. No response body.
|
|
headers:
|
|
X-RateLimit-Limit:
|
|
schema:
|
|
type: integer
|
|
example: 100
|
|
X-RateLimit-Remaining:
|
|
schema:
|
|
type: integer
|
|
example: 96
|
|
X-RateLimit-Reset:
|
|
schema:
|
|
type: integer
|
|
example: 1743155400
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
description: Agent or credential not found.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/ErrorResponse'
|
|
examples:
|
|
agentNotFound:
|
|
summary: Agent not found
|
|
value:
|
|
code: "AGENT_NOT_FOUND"
|
|
message: "Agent with the specified ID was not found."
|
|
credentialNotFound:
|
|
summary: Credential not found
|
|
value:
|
|
code: "CREDENTIAL_NOT_FOUND"
|
|
message: "Credential with the specified ID was not found."
|
|
'409':
|
|
description: Credential is already revoked.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/ErrorResponse'
|
|
example:
|
|
code: "CREDENTIAL_ALREADY_REVOKED"
|
|
message: "This credential has already been revoked."
|
|
details:
|
|
credentialId: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
|
|
revokedAt: "2026-03-20T10:00:00.000Z"
|
|
'429':
|
|
$ref: '#/components/responses/TooManyRequests'
|
|
'500':
|
|
$ref: '#/components/responses/InternalServerError'
|