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 and lifecycle status management. The registry is the authoritative source of
truth for all registered agent identities. 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**: **Free Tier Limits**:
- Max 100 registered agents per account - Max 100 registered agents per account
- API rate limit: 100 requests/minute - API rate limit: 100 requests/minute
@@ -38,6 +44,10 @@ components:
(`POST /token`). Include in the `Authorization` header as: (`POST /token`). Include in the `Authorization` header as:
`Authorization: Bearer <token>` `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: schemas:
AgentType: AgentType:
type: string type: string
@@ -294,14 +304,14 @@ components:
message: "A valid Bearer token is required to access this resource." message: "A valid Bearer token is required to access this resource."
Forbidden: Forbidden:
description: Valid token but insufficient permissions. description: The caller does not have permission to access this resource.
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
example: example:
code: "FORBIDDEN" code: "AUTHORIZATION_ERROR"
message: "You do not have permission to perform this action." message: "You do not have permission to access this resource."
NotFound: NotFound:
description: The requested resource was not found. description: The requested resource was not found.
@@ -365,6 +375,12 @@ paths:
A unique immutable `agentId` (UUID) is system-assigned on creation. A unique immutable `agentId` (UUID) is system-assigned on creation.
The `email` must be unique across all registered agents. 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 **Free Tier**: Maximum 100 registered agents per account. Attempting to
register beyond this limit returns `403 Forbidden` with code `FREE_TIER_LIMIT_EXCEEDED`. register beyond this limit returns `403 Forbidden` with code `FREE_TIER_LIMIT_EXCEEDED`.
requestBody: requestBody:
@@ -430,17 +446,23 @@ paths:
'401': '401':
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
'403': '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: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
examples: examples:
insufficientPermissions: authorizationError:
summary: Insufficient permissions summary: Caller does not have permission to register agents
value: value:
code: "FORBIDDEN" code: "AUTHORIZATION_ERROR"
message: "You do not have permission to register agents." message: "You do not have permission to access this resource."
freeTierLimit: freeTierLimit:
summary: Free tier agent limit reached summary: Free tier agent limit reached
value: value:
@@ -471,10 +493,16 @@ paths:
- Agent Registry - Agent Registry
summary: List registered agents summary: List registered agents
description: | description: |
Returns a paginated list of all registered AI agent identities accessible Returns a paginated list of registered AI agent identities belonging to
to the authenticated caller. 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). Results are ordered by `createdAt` descending (most recent first).
parameters: parameters:
- name: page - name: page
@@ -498,7 +526,9 @@ paths:
example: 20 example: 20
- name: owner - name: owner
in: query 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 required: false
schema: schema:
type: string type: string
@@ -580,7 +610,16 @@ paths:
'401': '401':
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
'403': '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': '429':
$ref: '#/components/responses/TooManyRequests' $ref: '#/components/responses/TooManyRequests'
'500': '500':
@@ -604,6 +643,13 @@ paths:
summary: Get agent by ID summary: Get agent by ID
description: | description: |
Retrieves the full identity record for a single AI agent by its immutable `agentId`. 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: responses:
'200': '200':
description: Agent record returned successfully. description: Agent record returned successfully.
@@ -641,7 +687,17 @@ paths:
'401': '401':
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
'403': '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': '404':
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
'429': '429':
@@ -663,6 +719,12 @@ paths:
Setting `status` to `decommissioned` is a one-way operation — a Setting `status` to `decommissioned` is a one-way operation — a
decommissioned agent cannot be reactivated. 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: requestBody:
required: true required: true
content: content:
@@ -737,17 +799,24 @@ paths:
'401': '401':
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
'403': '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: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
examples: examples:
forbidden: authorizationError:
summary: Insufficient permissions summary: Cross-tenant access denied
value: value:
code: "FORBIDDEN" code: "AUTHORIZATION_ERROR"
message: "You do not have permission to update this agent." message: "You do not have permission to access this resource."
decommissioned: decommissioned:
summary: Agent is decommissioned summary: Agent is decommissioned
value: value:
@@ -777,6 +846,12 @@ paths:
- The agent can no longer authenticate or obtain tokens. - The agent can no longer authenticate or obtain tokens.
- The agent record remains visible in the registry with status `decommissioned`. - The agent record remains visible in the registry with status `decommissioned`.
- This operation is **irreversible**. - 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: responses:
'204': '204':
description: Agent decommissioned successfully. No response body. description: Agent decommissioned successfully. No response body.
@@ -796,7 +871,17 @@ paths:
'401': '401':
$ref: '#/components/responses/Unauthorized' $ref: '#/components/responses/Unauthorized'
'403': '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': '404':
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
'409': '409':

View File

@@ -48,7 +48,14 @@ export class AgentController {
}); });
} }
const organizationId = req.user.organization_id;
if (!organizationId) {
throw new AuthorizationError();
}
const data = value as ICreateAgentRequest; const data = value as ICreateAgentRequest;
// Rule 3: always register under the caller's org — body value is ignored.
data.organizationId = organizationId;
const ipAddress = req.ip ?? '0.0.0.0'; const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown'; const userAgent = req.headers['user-agent'] ?? 'unknown';
@@ -80,8 +87,15 @@ export class AgentController {
}); });
} }
const organizationId = req.user.organization_id;
if (!organizationId) {
throw new AuthorizationError();
}
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
const filters: IAgentListFilters = { const filters: IAgentListFilters = {
// organizationId is forced from JWT — never from query params.
organizationId,
page: value.page as number, page: value.page as number,
limit: value.limit as number, limit: value.limit as number,
owner: value.owner as string | undefined, owner: value.owner as string | undefined,
@@ -110,8 +124,13 @@ export class AgentController {
throw new AuthorizationError(); throw new AuthorizationError();
} }
const organizationId = req.user.organization_id;
if (!organizationId) {
throw new AuthorizationError();
}
const { agentId } = req.params; const { agentId } = req.params;
const agent = await this.agentService.getAgentById(agentId); const agent = await this.agentService.getAgentById(agentId, organizationId);
res.status(200).json(agent); res.status(200).json(agent);
} catch (err) { } catch (err) {
next(err); next(err);
@@ -148,6 +167,11 @@ export class AgentController {
}); });
} }
const organizationId = req.user.organization_id;
if (!organizationId) {
throw new AuthorizationError();
}
const { agentId } = req.params; const { agentId } = req.params;
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
const data: IUpdateAgentRequest = { const data: IUpdateAgentRequest = {
@@ -163,7 +187,7 @@ export class AgentController {
const ipAddress = req.ip ?? '0.0.0.0'; const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown'; const userAgent = req.headers['user-agent'] ?? 'unknown';
const updated = await this.agentService.updateAgent(agentId, data, ipAddress, userAgent); const updated = await this.agentService.updateAgent(agentId, data, ipAddress, userAgent, organizationId);
res.status(200).json(updated); res.status(200).json(updated);
} catch (err) { } catch (err) {
next(err); next(err);
@@ -183,11 +207,16 @@ export class AgentController {
throw new AuthorizationError(); throw new AuthorizationError();
} }
const organizationId = req.user.organization_id;
if (!organizationId) {
throw new AuthorizationError();
}
const { agentId } = req.params; const { agentId } = req.params;
const ipAddress = req.ip ?? '0.0.0.0'; const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown'; const userAgent = req.headers['user-agent'] ?? 'unknown';
await this.agentService.decommissionAgent(agentId, ipAddress, userAgent); await this.agentService.decommissionAgent(agentId, ipAddress, userAgent, organizationId);
res.status(204).send(); res.status(204).send();
} catch (err) { } catch (err) {
next(err); next(err);

View File

@@ -129,8 +129,10 @@ export class AgentRepository {
/** /**
* Returns a paginated list of agents with optional filters. * Returns a paginated list of agents with optional filters.
* When `organizationId` is provided the result set is strictly scoped to that
* organization — agents belonging to other organizations are never returned.
* *
* @param filters - Pagination and filter criteria. * @param filters - Pagination and filter criteria (organizationId is applied first).
* @returns Object containing the agent list and total count. * @returns Object containing the agent list and total count.
*/ */
async findAll(filters: IAgentListFilters): Promise<{ agents: IAgent[]; total: number }> { async findAll(filters: IAgentListFilters): Promise<{ agents: IAgent[]; total: number }> {
@@ -138,6 +140,11 @@ export class AgentRepository {
const params: unknown[] = []; const params: unknown[] = [];
let paramIndex = 1; let paramIndex = 1;
if (filters.organizationId !== undefined) {
conditions.push(`organization_id = $${paramIndex++}`);
params.push(filters.organizationId);
}
if (filters.owner !== undefined) { if (filters.owner !== undefined) {
conditions.push(`owner = $${paramIndex++}`); conditions.push(`owner = $${paramIndex++}`);
params.push(filters.owner); params.push(filters.owner);

View File

@@ -21,6 +21,7 @@ import {
AgentAlreadyExistsError, AgentAlreadyExistsError,
AgentAlreadyDecommissionedError, AgentAlreadyDecommissionedError,
FreeTierLimitError, FreeTierLimitError,
AuthorizationError,
} from '../utils/errors.js'; } from '../utils/errors.js';
import { agentsRegisteredTotal } from '../metrics/registry.js'; import { agentsRegisteredTotal } from '../metrics/registry.js';
import { TierService } from './TierService.js'; import { TierService } from './TierService.js';
@@ -140,16 +141,23 @@ export class AgentService {
/** /**
* Retrieves a single agent by its UUID. * Retrieves a single agent by its UUID.
* When `organizationId` is provided the agent's organization is verified — callers
* from a different organization receive an AuthorizationError (403).
* *
* @param agentId - The agent UUID. * @param agentId - The agent UUID.
* @param organizationId - Optional. When present, the agent must belong to this org.
* @returns The agent record. * @returns The agent record.
* @throws AgentNotFoundError if the agent does not exist. * @throws AgentNotFoundError if the agent does not exist.
* @throws AuthorizationError if the agent belongs to a different organization.
*/ */
async getAgentById(agentId: string): Promise<IAgent> { async getAgentById(agentId: string, organizationId?: string): Promise<IAgent> {
const agent = await this.agentRepository.findById(agentId); const agent = await this.agentRepository.findById(agentId);
if (!agent) { if (!agent) {
throw new AgentNotFoundError(agentId); throw new AgentNotFoundError(agentId);
} }
if (organizationId !== undefined && agent.organizationId !== organizationId) {
throw new AuthorizationError();
}
return agent; return agent;
} }
@@ -173,14 +181,18 @@ export class AgentService {
* Partially updates an agent's metadata. * Partially updates an agent's metadata.
* Immutable fields (agentId, email, createdAt) cannot be changed. * Immutable fields (agentId, email, createdAt) cannot be changed.
* Decommissioned agents cannot be updated. * Decommissioned agents cannot be updated.
* When `organizationId` is provided the agent's organization is verified — callers
* from a different organization receive an AuthorizationError (403).
* *
* @param agentId - The agent UUID to update. * @param agentId - The agent UUID to update.
* @param data - The fields to update. * @param data - The fields to update.
* @param ipAddress - Client IP for audit logging. * @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging. * @param userAgent - Client User-Agent for audit logging.
* @param organizationId - Optional. When present, the agent must belong to this org.
* @returns The updated agent record. * @returns The updated agent record.
* @throws AgentNotFoundError if the agent does not exist. * @throws AgentNotFoundError if the agent does not exist.
* @throws AgentAlreadyDecommissionedError if the agent is decommissioned. * @throws AgentAlreadyDecommissionedError if the agent is decommissioned.
* @throws AuthorizationError if the agent belongs to a different organization.
* @throws ValidationError if immutable fields are included. * @throws ValidationError if immutable fields are included.
*/ */
async updateAgent( async updateAgent(
@@ -188,12 +200,17 @@ export class AgentService {
data: IUpdateAgentRequest, data: IUpdateAgentRequest,
ipAddress: string, ipAddress: string,
userAgent: string, userAgent: string,
organizationId?: string,
): Promise<IAgent> { ): Promise<IAgent> {
const agent = await this.agentRepository.findById(agentId); const agent = await this.agentRepository.findById(agentId);
if (!agent) { if (!agent) {
throw new AgentNotFoundError(agentId); throw new AgentNotFoundError(agentId);
} }
if (organizationId !== undefined && agent.organizationId !== organizationId) {
throw new AuthorizationError();
}
if (agent.status === 'decommissioned') { if (agent.status === 'decommissioned') {
throw new AgentAlreadyDecommissionedError(agentId); throw new AgentAlreadyDecommissionedError(agentId);
} }
@@ -256,23 +273,32 @@ export class AgentService {
/** /**
* Permanently decommissions an agent (soft delete). * Permanently decommissions an agent (soft delete).
* Revokes all active credentials for the agent. * Revokes all active credentials for the agent.
* When `organizationId` is provided the agent's organization is verified — callers
* from a different organization receive an AuthorizationError (403).
* *
* @param agentId - The agent UUID to decommission. * @param agentId - The agent UUID to decommission.
* @param ipAddress - Client IP for audit logging. * @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging. * @param userAgent - Client User-Agent for audit logging.
* @param organizationId - Optional. When present, the agent must belong to this org.
* @throws AgentNotFoundError if the agent does not exist. * @throws AgentNotFoundError if the agent does not exist.
* @throws AgentAlreadyDecommissionedError if already decommissioned. * @throws AgentAlreadyDecommissionedError if already decommissioned.
* @throws AuthorizationError if the agent belongs to a different organization.
*/ */
async decommissionAgent( async decommissionAgent(
agentId: string, agentId: string,
ipAddress: string, ipAddress: string,
userAgent: string, userAgent: string,
organizationId?: string,
): Promise<void> { ): Promise<void> {
const agent = await this.agentRepository.findById(agentId); const agent = await this.agentRepository.findById(agentId);
if (!agent) { if (!agent) {
throw new AgentNotFoundError(agentId); throw new AgentNotFoundError(agentId);
} }
if (organizationId !== undefined && agent.organizationId !== organizationId) {
throw new AuthorizationError();
}
if (agent.status === 'decommissioned') { if (agent.status === 'decommissioned') {
throw new AgentAlreadyDecommissionedError(agentId); throw new AgentAlreadyDecommissionedError(agentId);
} }

View File

@@ -170,6 +170,8 @@ export interface IPaginatedAgentsResponse {
/** Query filters for listing agents. */ /** Query filters for listing agents. */
export interface IAgentListFilters { export interface IAgentListFilters {
/** Restricts results to agents belonging to this organization. Enforced by the controller from the JWT claim. */
organizationId?: string;
owner?: string; owner?: string;
agentType?: AgentType; agentType?: AgentType;
status?: AgentStatus; status?: AgentStatus;

View File

@@ -15,6 +15,8 @@ const MockAgentService = AgentService as jest.MockedClass<typeof AgentService>;
// ─── helpers ───────────────────────────────────────────────────────────────── // ─── helpers ─────────────────────────────────────────────────────────────────
const MOCK_ORG_ID = 'org-test-001';
const MOCK_USER: ITokenPayload = { const MOCK_USER: ITokenPayload = {
sub: 'agent-id-001', sub: 'agent-id-001',
client_id: 'agent-id-001', client_id: 'agent-id-001',
@@ -22,11 +24,12 @@ const MOCK_USER: ITokenPayload = {
jti: 'jti-001', jti: 'jti-001',
iat: 1000, iat: 1000,
exp: 9999999999, exp: 9999999999,
organization_id: MOCK_ORG_ID,
}; };
const MOCK_AGENT: IAgent = { const MOCK_AGENT: IAgent = {
agentId: 'agent-id-001', agentId: 'agent-id-001',
organizationId: 'org_system', organizationId: MOCK_ORG_ID,
email: 'agent@sentryagent.ai', email: 'agent@sentryagent.ai',
agentType: 'screener', agentType: 'screener',
version: '1.0.0', version: '1.0.0',
@@ -117,6 +120,23 @@ describe('AgentController', () => {
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
}); });
it('should call next(AuthorizationError) when JWT has no organization_id', async () => {
const { req, res, next } = buildMocks();
req.user = { ...MOCK_USER, organization_id: undefined };
req.body = {
email: 'agent@sentryagent.ai',
agentType: 'screener',
version: '1.0.0',
capabilities: ['resume:read'],
owner: 'team-a',
deploymentEnv: 'production',
};
await controller.registerAgent(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should forward service errors to next', async () => { it('should forward service errors to next', async () => {
const { req, res, next } = buildMocks(); const { req, res, next } = buildMocks();
req.body = { req.body = {
@@ -139,7 +159,7 @@ describe('AgentController', () => {
// ── listAgents ─────────────────────────────────────────────────────────────── // ── listAgents ───────────────────────────────────────────────────────────────
describe('listAgents()', () => { describe('listAgents()', () => {
it('should return 200 with paginated agents', async () => { it('should return 200 with paginated agents scoped to caller org', async () => {
const { req, res, next } = buildMocks(); const { req, res, next } = buildMocks();
req.query = { page: '1', limit: '20' }; req.query = { page: '1', limit: '20' };
const paginatedResponse = { data: [MOCK_AGENT], total: 1, page: 1, limit: 20 }; const paginatedResponse = { data: [MOCK_AGENT], total: 1, page: 1, limit: 20 };
@@ -147,6 +167,9 @@ describe('AgentController', () => {
await controller.listAgents(req as Request, res as Response, next); await controller.listAgents(req as Request, res as Response, next);
expect(agentService.listAgents).toHaveBeenCalledWith(
expect.objectContaining({ organizationId: MOCK_ORG_ID }),
);
expect(res.status).toHaveBeenCalledWith(200); expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(paginatedResponse); expect(res.json).toHaveBeenCalledWith(paginatedResponse);
}); });
@@ -160,6 +183,15 @@ describe('AgentController', () => {
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
}); });
it('should call next(AuthorizationError) when JWT has no organization_id', async () => {
const { req, res, next } = buildMocks();
req.user = { ...MOCK_USER, organization_id: undefined };
await controller.listAgents(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should call next(ValidationError) when query params are invalid', async () => { it('should call next(ValidationError) when query params are invalid', async () => {
const { req, res, next } = buildMocks(); const { req, res, next } = buildMocks();
req.query = { page: 'not-a-number' }; req.query = { page: 'not-a-number' };
@@ -184,13 +216,14 @@ describe('AgentController', () => {
// ── getAgentById ───────────────────────────────────────────────────────────── // ── getAgentById ─────────────────────────────────────────────────────────────
describe('getAgentById()', () => { describe('getAgentById()', () => {
it('should return 200 with the agent', async () => { it('should return 200 with the agent, passing organizationId to service', async () => {
const { req, res, next } = buildMocks(); const { req, res, next } = buildMocks();
req.params = { agentId: MOCK_AGENT.agentId }; req.params = { agentId: MOCK_AGENT.agentId };
agentService.getAgentById.mockResolvedValue(MOCK_AGENT); agentService.getAgentById.mockResolvedValue(MOCK_AGENT);
await controller.getAgentById(req as Request, res as Response, next); await controller.getAgentById(req as Request, res as Response, next);
expect(agentService.getAgentById).toHaveBeenCalledWith(MOCK_AGENT.agentId, MOCK_ORG_ID);
expect(res.status).toHaveBeenCalledWith(200); expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(MOCK_AGENT); expect(res.json).toHaveBeenCalledWith(MOCK_AGENT);
}); });
@@ -205,6 +238,16 @@ describe('AgentController', () => {
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
}); });
it('should call next(AuthorizationError) when JWT has no organization_id', async () => {
const { req, res, next } = buildMocks();
req.user = { ...MOCK_USER, organization_id: undefined };
req.params = { agentId: MOCK_AGENT.agentId };
await controller.getAgentById(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should forward AgentNotFoundError to next', async () => { it('should forward AgentNotFoundError to next', async () => {
const { req, res, next } = buildMocks(); const { req, res, next } = buildMocks();
req.params = { agentId: 'nonexistent' }; req.params = { agentId: 'nonexistent' };
@@ -220,7 +263,7 @@ describe('AgentController', () => {
// ── updateAgent ────────────────────────────────────────────────────────────── // ── updateAgent ──────────────────────────────────────────────────────────────
describe('updateAgent()', () => { describe('updateAgent()', () => {
it('should return 200 with the updated agent', async () => { it('should return 200 with the updated agent, passing organizationId to service', async () => {
const { req, res, next } = buildMocks(); const { req, res, next } = buildMocks();
req.params = { agentId: MOCK_AGENT.agentId }; req.params = { agentId: MOCK_AGENT.agentId };
req.body = { version: '2.0.0' }; req.body = { version: '2.0.0' };
@@ -229,6 +272,13 @@ describe('AgentController', () => {
await controller.updateAgent(req as Request, res as Response, next); await controller.updateAgent(req as Request, res as Response, next);
expect(agentService.updateAgent).toHaveBeenCalledWith(
MOCK_AGENT.agentId,
expect.any(Object),
expect.any(String),
expect.any(String),
MOCK_ORG_ID,
);
expect(res.status).toHaveBeenCalledWith(200); expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(updated); expect(res.json).toHaveBeenCalledWith(updated);
}); });
@@ -244,6 +294,17 @@ describe('AgentController', () => {
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
}); });
it('should call next(AuthorizationError) when JWT has no organization_id', async () => {
const { req, res, next } = buildMocks();
req.user = { ...MOCK_USER, organization_id: undefined };
req.params = { agentId: MOCK_AGENT.agentId };
req.body = { version: '2.0.0' };
await controller.updateAgent(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should call next(ValidationError) when body is invalid', async () => { it('should call next(ValidationError) when body is invalid', async () => {
const { req, res, next } = buildMocks(); const { req, res, next } = buildMocks();
req.params = { agentId: MOCK_AGENT.agentId }; req.params = { agentId: MOCK_AGENT.agentId };
@@ -270,13 +331,19 @@ describe('AgentController', () => {
// ── decommissionAgent ──────────────────────────────────────────────────────── // ── decommissionAgent ────────────────────────────────────────────────────────
describe('decommissionAgent()', () => { describe('decommissionAgent()', () => {
it('should return 204 on success', async () => { it('should return 204 on success, passing organizationId to service', async () => {
const { req, res, next } = buildMocks(); const { req, res, next } = buildMocks();
req.params = { agentId: MOCK_AGENT.agentId }; req.params = { agentId: MOCK_AGENT.agentId };
agentService.decommissionAgent.mockResolvedValue(); agentService.decommissionAgent.mockResolvedValue();
await controller.decommissionAgent(req as Request, res as Response, next); await controller.decommissionAgent(req as Request, res as Response, next);
expect(agentService.decommissionAgent).toHaveBeenCalledWith(
MOCK_AGENT.agentId,
expect.any(String),
expect.any(String),
MOCK_ORG_ID,
);
expect(res.status).toHaveBeenCalledWith(204); expect(res.status).toHaveBeenCalledWith(204);
expect(res.send).toHaveBeenCalled(); expect(res.send).toHaveBeenCalled();
expect(next).not.toHaveBeenCalled(); expect(next).not.toHaveBeenCalled();
@@ -292,6 +359,16 @@ describe('AgentController', () => {
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
}); });
it('should call next(AuthorizationError) when JWT has no organization_id', async () => {
const { req, res, next } = buildMocks();
req.user = { ...MOCK_USER, organization_id: undefined };
req.params = { agentId: MOCK_AGENT.agentId };
await controller.decommissionAgent(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should forward service errors to next', async () => { it('should forward service errors to next', async () => {
const { req, res, next } = buildMocks(); const { req, res, next } = buildMocks();
req.params = { agentId: MOCK_AGENT.agentId }; req.params = { agentId: MOCK_AGENT.agentId };