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:
SentryAgent.ai Developer
2026-04-09 05:22:48 +00:00
parent 5e580b51dd
commit 5943ff136f
6 changed files with 256 additions and 30 deletions

View File

@@ -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':