Files
sentryagent-idp/docs/openapi/agent-registry.yaml
SentryAgent.ai Developer 5943ff136f fix(security): enforce tenant isolation on all agent endpoints — resolves Test C.7
P0 security fix. Any authenticated agent could previously read, modify, or
decommission agents belonging to other organizations.

Changes:
- IAgentListFilters: add organizationId field (forced from JWT, never from query)
- AgentRepository.findAll(): filter by organizationId when set
- AgentService: getAgentById, updateAgent, decommissionAgent — accept organizationId
  and throw AuthorizationError(403) on cross-tenant access
- AgentController: extract req.user.organization_id on all 5 handlers; throw 403
  if claim is absent; registerAgent forces body.organizationId from JWT claim
- OpenAPI spec: document tenant isolation rules per endpoint
- Tests: update MOCK_USER with organization_id; add 5 new missing-org-id 403 tests;
  assert organizationId is passed through to service on all mutating calls

Fixes field trial failure: Test C.7 (Org Isolation).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 05:22:48 +00:00

902 lines
32 KiB
YAML

openapi: 3.0.3
info:
title: SentryAgent.ai — Agent Registry Service
version: 1.0.0
description: |
The Agent Registry Service provides full lifecycle management for AI agent
identities on the SentryAgent.ai AgentIdP platform. Every AI agent is treated
as a first-class non-human identity, aligned with the AGNTCY standard
(Linux Foundation).
Agents receive unique, immutable identifiers (UUIDs), typed capabilities,
and lifecycle status management. The registry is the authoritative source of
truth for all registered agent identities.
**Tenant Isolation**:
All agent endpoints enforce strict organization-level tenant isolation. The
caller's `organization_id` is derived exclusively from the verified JWT
`organization_id` claim — it can never be overridden by request body values
or query parameters. Cross-tenant access always returns `403 Forbidden`.
**Free Tier Limits**:
- Max 100 registered agents per account
- API rate limit: 100 requests/minute
servers:
- url: http://localhost:3000/api/v1
description: Local development server
- url: https://api.sentryagent.ai/v1
description: Production server
tags:
- name: Agent Registry
description: CRUD operations for AI agent identities
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT access token obtained via the OAuth 2.0 Client Credentials flow
(`POST /token`). Include in the `Authorization` header as:
`Authorization: Bearer <token>`
The JWT must contain an `organization_id` claim. This claim is used
to scope all agent operations to the caller's organization and cannot
be overridden by any value in the request body or query string.
schemas:
AgentType:
type: string
description: The functional classification of the AI agent.
enum:
- screener
- classifier
- orchestrator
- extractor
- summarizer
- router
- monitor
- custom
example: screener
DeploymentEnv:
type: string
description: The target deployment environment for the agent.
enum:
- development
- staging
- production
example: production
AgentStatus:
type: string
description: |
Lifecycle status of the agent.
- `active`: Agent is operational and can authenticate.
- `suspended`: Agent is temporarily disabled; credentials cannot be used.
- `decommissioned`: Agent is permanently retired; soft-deleted record remains.
enum:
- active
- suspended
- decommissioned
example: active
Agent:
type: object
description: Full representation of a registered AI agent identity.
required:
- agentId
- email
- agentType
- version
- capabilities
- owner
- deploymentEnv
- status
- createdAt
- updatedAt
properties:
agentId:
type: string
format: uuid
description: >
Immutable, system-assigned unique identifier for the agent.
Assigned at registration and never changes.
readOnly: true
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
email:
type: string
format: email
description: >
Unique email-format identifier for the agent. Acts as the human-readable
stable name for this agent identity.
example: "screener-001@sentryagent.ai"
agentType:
$ref: '#/components/schemas/AgentType'
version:
type: string
description: Semantic version string of the agent software.
pattern: '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$'
example: "1.4.2"
capabilities:
type: array
description: >
List of capability strings representing what this agent is permitted
to do. Uses a `resource:action` convention.
items:
type: string
pattern: '^[a-z0-9_-]+:[a-z0-9_*-]+$'
minItems: 1
example:
- "resume:read"
- "email:send"
- "candidate:score"
owner:
type: string
description: Team or organisation that owns and is responsible for this agent.
minLength: 1
maxLength: 128
example: "talent-acquisition-team"
deploymentEnv:
$ref: '#/components/schemas/DeploymentEnv'
status:
$ref: '#/components/schemas/AgentStatus'
createdAt:
type: string
format: date-time
description: ISO 8601 timestamp when the agent was first registered.
readOnly: true
example: "2026-03-28T09:00:00.000Z"
updatedAt:
type: string
format: date-time
description: ISO 8601 timestamp of the most recent update to this agent record.
readOnly: true
example: "2026-03-28T11:30:00.000Z"
CreateAgentRequest:
type: object
description: Request body for registering a new AI agent identity.
required:
- email
- agentType
- version
- capabilities
- owner
- deploymentEnv
properties:
email:
type: string
format: email
description: >
Unique email-format identifier for the agent.
Must be unique across all registered agents in the system.
example: "screener-001@sentryagent.ai"
agentType:
$ref: '#/components/schemas/AgentType'
version:
type: string
description: Semantic version string of the agent software.
pattern: '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$'
example: "1.0.0"
capabilities:
type: array
description: List of capability strings for this agent.
items:
type: string
pattern: '^[a-z0-9_-]+:[a-z0-9_*-]+$'
minItems: 1
example:
- "resume:read"
- "email:send"
owner:
type: string
description: Team or organisation that owns this agent.
minLength: 1
maxLength: 128
example: "talent-acquisition-team"
deploymentEnv:
$ref: '#/components/schemas/DeploymentEnv'
UpdateAgentRequest:
type: object
description: >
Request body for updating agent metadata. All fields are optional;
only provided fields are updated. `agentId`, `email`, and `createdAt`
are immutable and cannot be changed.
minProperties: 1
properties:
agentType:
$ref: '#/components/schemas/AgentType'
version:
type: string
description: Updated semantic version string.
pattern: '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$'
example: "1.5.0"
capabilities:
type: array
description: Updated list of capability strings. Replaces the full list.
items:
type: string
pattern: '^[a-z0-9_-]+:[a-z0-9_*-]+$'
minItems: 1
example:
- "resume:read"
- "email:send"
- "candidate:score"
- "report:write"
owner:
type: string
description: Updated owner team or organisation.
minLength: 1
maxLength: 128
example: "platform-team"
deploymentEnv:
$ref: '#/components/schemas/DeploymentEnv'
status:
$ref: '#/components/schemas/AgentStatus'
PaginatedAgentsResponse:
type: object
description: Paginated list of agent identities.
required:
- data
- total
- page
- limit
properties:
data:
type: array
items:
$ref: '#/components/schemas/Agent'
total:
type: integer
description: Total number of agents matching the query filters.
example: 47
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 used across all SentryAgent.ai APIs.
required:
- code
- message
properties:
code:
type: string
description: >
Machine-readable error code. Use this field for programmatic error handling.
example: "AGENT_NOT_FOUND"
message:
type: string
description: Human-readable description of the error.
example: "Agent with the specified ID was not found."
details:
type: object
description: >
Optional structured details providing additional context about the error,
such as field-level validation failures.
additionalProperties: true
example:
field: "email"
reason: "Email address is already registered to another agent."
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: The caller does not have permission to access this resource.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "AUTHORIZATION_ERROR"
message: "You do not have permission to access this resource."
NotFound:
description: The requested resource was not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "AGENT_NOT_FOUND"
message: "Agent with the specified ID was not found."
TooManyRequests:
description: Rate limit exceeded. Retry after the reset time.
headers:
X-RateLimit-Limit:
schema:
type: integer
description: Maximum number of requests allowed per minute.
example: 100
X-RateLimit-Remaining:
schema:
type: integer
description: Number of requests remaining in the current window.
example: 0
X-RateLimit-Reset:
schema:
type: integer
description: Unix timestamp (seconds) when the rate limit window resets.
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. Contact support if the issue persists.
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:
post:
operationId: registerAgent
tags:
- Agent Registry
summary: Register a new AI agent
description: |
Creates a new AI agent identity in the SentryAgent.ai registry.
A unique immutable `agentId` (UUID) is system-assigned on creation.
The `email` must be unique across all registered agents.
**Tenant Isolation — Rule 3 (Register Scoping)**:
The agent is always registered under the caller's organization, derived
from the JWT `organization_id` claim. Any `organizationId` value provided
in the request body is silently ignored. It is not possible to register
an agent under a different organization, regardless of request body content.
**Free Tier**: Maximum 100 registered agents per account. Attempting to
register beyond this limit returns `403 Forbidden` with code `FREE_TIER_LIMIT_EXCEEDED`.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateAgentRequest'
example:
email: "screener-001@sentryagent.ai"
agentType: "screener"
version: "1.0.0"
capabilities:
- "resume:read"
- "email:send"
owner: "talent-acquisition-team"
deploymentEnv: "production"
responses:
'201':
description: Agent registered successfully.
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/Agent'
example:
agentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
email: "screener-001@sentryagent.ai"
agentType: "screener"
version: "1.0.0"
capabilities:
- "resume:read"
- "email:send"
owner: "talent-acquisition-team"
deploymentEnv: "production"
status: "active"
createdAt: "2026-03-28T09:00:00.000Z"
updatedAt: "2026-03-28T09:00:00.000Z"
'400':
description: Invalid request body. Check `details` for field-level errors.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "VALIDATION_ERROR"
message: "Request validation failed."
details:
field: "email"
reason: "Must be a valid email address."
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: |
Forbidden. One of the following conditions applies:
- **`AUTHORIZATION_ERROR`**: The caller's JWT does not grant permission to
register agents in their organization.
- **`FREE_TIER_LIMIT_EXCEEDED`**: The free tier limit of 100 registered
agents per account has been reached.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
authorizationError:
summary: Caller does not have permission to register agents
value:
code: "AUTHORIZATION_ERROR"
message: "You do not have permission to access this resource."
freeTierLimit:
summary: Free tier agent limit reached
value:
code: "FREE_TIER_LIMIT_EXCEEDED"
message: "Free tier limit of 100 registered agents has been reached."
details:
limit: 100
current: 100
'409':
description: An agent with the provided email address is already registered.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "AGENT_ALREADY_EXISTS"
message: "An agent with this email address is already registered."
details:
email: "screener-001@sentryagent.ai"
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'
get:
operationId: listAgents
tags:
- Agent Registry
summary: List registered agents
description: |
Returns a paginated list of registered AI agent identities belonging to
the caller's organization.
**Tenant Isolation — Rule 1 (List Scoping)**:
Results are always scoped to the caller's organization, derived from the
JWT `organization_id` claim. It is not possible to retrieve agents from
another organization. The `owner` query parameter sub-filters within the
caller's organization only — it does not widen the scope beyond the
caller's organization.
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: owner
in: query
description: |
Filter agents by owner name (exact match). Applies within the caller's
organization only — does not allow cross-tenant access.
required: false
schema:
type: string
example: "talent-acquisition-team"
- name: agentType
in: query
description: Filter agents by type.
required: false
schema:
$ref: '#/components/schemas/AgentType'
- name: status
in: query
description: Filter agents by lifecycle status.
required: false
schema:
$ref: '#/components/schemas/AgentStatus'
responses:
'200':
description: Paginated list of agents returned successfully.
headers:
X-RateLimit-Limit:
schema:
type: integer
example: 100
X-RateLimit-Remaining:
schema:
type: integer
example: 95
X-RateLimit-Reset:
schema:
type: integer
example: 1743155400
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedAgentsResponse'
example:
data:
- agentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
email: "screener-001@sentryagent.ai"
agentType: "screener"
version: "1.4.2"
capabilities:
- "resume:read"
- "email:send"
- "candidate:score"
owner: "talent-acquisition-team"
deploymentEnv: "production"
status: "active"
createdAt: "2026-03-01T08:00:00.000Z"
updatedAt: "2026-03-28T09:00:00.000Z"
- agentId: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
email: "classifier-002@sentryagent.ai"
agentType: "classifier"
version: "2.1.0"
capabilities:
- "document:classify"
- "label:write"
owner: "talent-acquisition-team"
deploymentEnv: "staging"
status: "active"
createdAt: "2026-03-10T10:00:00.000Z"
updatedAt: "2026-03-10T10:00:00.000Z"
total: 47
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: "limit"
reason: "Must be an integer between 1 and 100."
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: |
Forbidden. The caller's JWT does not grant permission to list agents
in their organization.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "AUTHORIZATION_ERROR"
message: "You do not have permission to access this resource."
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'
/agents/{agentId}:
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"
get:
operationId: getAgentById
tags:
- Agent Registry
summary: Get agent by ID
description: |
Retrieves the full identity record for a single AI agent by its immutable `agentId`.
**Tenant Isolation — Rule 2 (Ownership Guard)**:
If the target agent's `organization_id` does not match the caller's
`organization_id` (derived from the JWT `organization_id` claim), the
request is rejected with `403 Forbidden` and error code `AUTHORIZATION_ERROR`.
This applies regardless of whether the `agentId` exists. A caller from
Org A cannot determine the existence of an agent belonging to Org B.
responses:
'200':
description: Agent record returned successfully.
headers:
X-RateLimit-Limit:
schema:
type: integer
example: 100
X-RateLimit-Remaining:
schema:
type: integer
example: 94
X-RateLimit-Reset:
schema:
type: integer
example: 1743155400
content:
application/json:
schema:
$ref: '#/components/schemas/Agent'
example:
agentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
email: "screener-001@sentryagent.ai"
agentType: "screener"
version: "1.4.2"
capabilities:
- "resume:read"
- "email:send"
- "candidate:score"
owner: "talent-acquisition-team"
deploymentEnv: "production"
status: "active"
createdAt: "2026-03-01T08:00:00.000Z"
updatedAt: "2026-03-28T09:00:00.000Z"
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: |
Forbidden. The target agent belongs to a different organization than
the caller's. The caller's `organization_id` (from JWT) does not match
the `organization_id` stored on the target agent record.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "AUTHORIZATION_ERROR"
message: "You do not have permission to access this resource."
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'
patch:
operationId: updateAgent
tags:
- Agent Registry
summary: Update agent metadata
description: |
Partially updates the metadata for an existing agent.
Only the fields provided in the request body are updated. Omitted fields
are left unchanged. The following fields are immutable and cannot be
updated: `agentId`, `email`, `createdAt`.
Setting `status` to `decommissioned` is a one-way operation — a
decommissioned agent cannot be reactivated.
**Tenant Isolation — Rule 2 (Ownership Guard)**:
If the target agent's `organization_id` does not match the caller's
`organization_id` (derived from the JWT `organization_id` claim), the
request is rejected with `403 Forbidden` and error code `AUTHORIZATION_ERROR`.
It is not possible to update an agent belonging to a different organization.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateAgentRequest'
example:
version: "1.5.0"
capabilities:
- "resume:read"
- "email:send"
- "candidate:score"
- "report:write"
deploymentEnv: "production"
responses:
'200':
description: Agent updated successfully. Returns the full updated agent record.
headers:
X-RateLimit-Limit:
schema:
type: integer
example: 100
X-RateLimit-Remaining:
schema:
type: integer
example: 93
X-RateLimit-Reset:
schema:
type: integer
example: 1743155400
content:
application/json:
schema:
$ref: '#/components/schemas/Agent'
example:
agentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
email: "screener-001@sentryagent.ai"
agentType: "screener"
version: "1.5.0"
capabilities:
- "resume:read"
- "email:send"
- "candidate:score"
- "report:write"
owner: "talent-acquisition-team"
deploymentEnv: "production"
status: "active"
createdAt: "2026-03-01T08:00:00.000Z"
updatedAt: "2026-03-28T11:30:00.000Z"
'400':
description: Invalid request body or attempt to modify an immutable field.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
validationError:
summary: Validation failure
value:
code: "VALIDATION_ERROR"
message: "Request validation failed."
details:
field: "version"
reason: "Must be a valid semantic version string."
immutableField:
summary: Attempt to modify immutable field
value:
code: "IMMUTABLE_FIELD"
message: "The field 'email' cannot be modified after registration."
details:
field: "email"
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: |
Forbidden. One of the following conditions applies:
- **`AUTHORIZATION_ERROR`**: The target agent belongs to a different
organization than the caller's. The caller's `organization_id` (from JWT)
does not match the `organization_id` stored on the target agent record.
- **`AGENT_DECOMMISSIONED`**: The target agent has been permanently
decommissioned and cannot be updated.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
authorizationError:
summary: Cross-tenant access denied
value:
code: "AUTHORIZATION_ERROR"
message: "You do not have permission to access this resource."
decommissioned:
summary: Agent is decommissioned
value:
code: "AGENT_DECOMMISSIONED"
message: "Decommissioned agents cannot be updated."
details:
agentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'
delete:
operationId: deactivateAgent
tags:
- Agent Registry
summary: Deactivate (soft-delete) an agent
description: |
Permanently decommissions an AI agent. This is a **soft delete** — the
agent record is retained in the database for audit purposes, but the
agent's status is set to `decommissioned`.
**Effects of decommissioning**:
- All active credentials for this agent are immediately revoked.
- The agent can no longer authenticate or obtain tokens.
- The agent record remains visible in the registry with status `decommissioned`.
- This operation is **irreversible**.
**Tenant Isolation — Rule 2 (Ownership Guard)**:
If the target agent's `organization_id` does not match the caller's
`organization_id` (derived from the JWT `organization_id` claim), the
request is rejected with `403 Forbidden` and error code `AUTHORIZATION_ERROR`.
It is not possible to decommission an agent belonging to a different organization.
responses:
'204':
description: Agent decommissioned successfully. No response body.
headers:
X-RateLimit-Limit:
schema:
type: integer
example: 100
X-RateLimit-Remaining:
schema:
type: integer
example: 92
X-RateLimit-Reset:
schema:
type: integer
example: 1743155400
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: |
Forbidden. The target agent belongs to a different organization than
the caller's. The caller's `organization_id` (from JWT) does not match
the `organization_id` stored on the target agent record.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "AUTHORIZATION_ERROR"
message: "You do not have permission to access this resource."
'404':
$ref: '#/components/responses/NotFound'
'409':
description: Agent is already decommissioned.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "AGENT_ALREADY_DECOMMISSIONED"
message: "This agent has already been decommissioned."
details:
agentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'