feat: Phase 1 MVP — complete AgentIdP implementation

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>
This commit is contained in:
SentryAgent.ai Developer
2026-03-28 09:14:41 +00:00
parent 245f8df427
commit d3530285b9
78 changed files with 20590 additions and 1 deletions

View File

@@ -0,0 +1,687 @@
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'