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>
This commit is contained in:
@@ -13,6 +13,12 @@ info:
|
||||
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
|
||||
@@ -38,6 +44,10 @@ components:
|
||||
(`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
|
||||
@@ -294,14 +304,14 @@ components:
|
||||
message: "A valid Bearer token is required to access this resource."
|
||||
|
||||
Forbidden:
|
||||
description: Valid token but insufficient permissions.
|
||||
description: The caller does not have permission to access this resource.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
example:
|
||||
code: "FORBIDDEN"
|
||||
message: "You do not have permission to perform this action."
|
||||
code: "AUTHORIZATION_ERROR"
|
||||
message: "You do not have permission to access this resource."
|
||||
|
||||
NotFound:
|
||||
description: The requested resource was not found.
|
||||
@@ -365,6 +375,12 @@ paths:
|
||||
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:
|
||||
@@ -430,17 +446,23 @@ paths:
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
description: Forbidden. Either insufficient permissions or free tier limit reached.
|
||||
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:
|
||||
insufficientPermissions:
|
||||
summary: Insufficient permissions
|
||||
authorizationError:
|
||||
summary: Caller does not have permission to register agents
|
||||
value:
|
||||
code: "FORBIDDEN"
|
||||
message: "You do not have permission to register agents."
|
||||
code: "AUTHORIZATION_ERROR"
|
||||
message: "You do not have permission to access this resource."
|
||||
freeTierLimit:
|
||||
summary: Free tier agent limit reached
|
||||
value:
|
||||
@@ -471,10 +493,16 @@ paths:
|
||||
- Agent Registry
|
||||
summary: List registered agents
|
||||
description: |
|
||||
Returns a paginated list of all registered AI agent identities accessible
|
||||
to the authenticated caller.
|
||||
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 can be filtered by `owner`, `agentType`, and/or `status`.
|
||||
Results are ordered by `createdAt` descending (most recent first).
|
||||
parameters:
|
||||
- name: page
|
||||
@@ -498,7 +526,9 @@ paths:
|
||||
example: 20
|
||||
- name: owner
|
||||
in: query
|
||||
description: Filter agents by owner name (exact match).
|
||||
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
|
||||
@@ -580,7 +610,16 @@ paths:
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
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':
|
||||
@@ -604,6 +643,13 @@ paths:
|
||||
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.
|
||||
@@ -641,7 +687,17 @@ paths:
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
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':
|
||||
@@ -663,6 +719,12 @@ paths:
|
||||
|
||||
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:
|
||||
@@ -737,17 +799,24 @@ paths:
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
description: Forbidden. Insufficient permissions or agent is decommissioned.
|
||||
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:
|
||||
forbidden:
|
||||
summary: Insufficient permissions
|
||||
authorizationError:
|
||||
summary: Cross-tenant access denied
|
||||
value:
|
||||
code: "FORBIDDEN"
|
||||
message: "You do not have permission to update this agent."
|
||||
code: "AUTHORIZATION_ERROR"
|
||||
message: "You do not have permission to access this resource."
|
||||
decommissioned:
|
||||
summary: Agent is decommissioned
|
||||
value:
|
||||
@@ -777,6 +846,12 @@ paths:
|
||||
- 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.
|
||||
@@ -796,7 +871,17 @@ paths:
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
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':
|
||||
|
||||
Reference in New Issue
Block a user