From 5943ff136f8c8a5706ffdf74c0e491f01cee9c48 Mon Sep 17 00:00:00 2001 From: "SentryAgent.ai Developer" Date: Thu, 9 Apr 2026 05:22:48 +0000 Subject: [PATCH] =?UTF-8?q?fix(security):=20enforce=20tenant=20isolation?= =?UTF-8?q?=20on=20all=20agent=20endpoints=20=E2=80=94=20resolves=20Test?= =?UTF-8?q?=20C.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/openapi/agent-registry.yaml | 125 +++++++++++++++--- src/controllers/AgentController.ts | 35 ++++- src/repositories/AgentRepository.ts | 9 +- src/services/AgentService.ts | 28 +++- src/types/index.ts | 2 + .../unit/controllers/AgentController.test.ts | 87 +++++++++++- 6 files changed, 256 insertions(+), 30 deletions(-) diff --git a/docs/openapi/agent-registry.yaml b/docs/openapi/agent-registry.yaml index cd3ae6e..e8baa36 100644 --- a/docs/openapi/agent-registry.yaml +++ b/docs/openapi/agent-registry.yaml @@ -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 ` + 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': diff --git a/src/controllers/AgentController.ts b/src/controllers/AgentController.ts index b2b089d..a848d5a 100644 --- a/src/controllers/AgentController.ts +++ b/src/controllers/AgentController.ts @@ -48,7 +48,14 @@ export class AgentController { }); } + const organizationId = req.user.organization_id; + if (!organizationId) { + throw new AuthorizationError(); + } + 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 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 */ const filters: IAgentListFilters = { + // organizationId is forced from JWT — never from query params. + organizationId, page: value.page as number, limit: value.limit as number, owner: value.owner as string | undefined, @@ -110,8 +124,13 @@ export class AgentController { throw new AuthorizationError(); } + const organizationId = req.user.organization_id; + if (!organizationId) { + throw new AuthorizationError(); + } + const { agentId } = req.params; - const agent = await this.agentService.getAgentById(agentId); + const agent = await this.agentService.getAgentById(agentId, organizationId); res.status(200).json(agent); } catch (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; /* eslint-disable @typescript-eslint/no-unsafe-member-access */ const data: IUpdateAgentRequest = { @@ -163,7 +187,7 @@ export class AgentController { const ipAddress = req.ip ?? '0.0.0.0'; 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); } catch (err) { next(err); @@ -183,11 +207,16 @@ export class AgentController { throw new AuthorizationError(); } + const organizationId = req.user.organization_id; + if (!organizationId) { + throw new AuthorizationError(); + } + const { agentId } = req.params; const ipAddress = req.ip ?? '0.0.0.0'; 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(); } catch (err) { next(err); diff --git a/src/repositories/AgentRepository.ts b/src/repositories/AgentRepository.ts index aac4a87..80476ed 100644 --- a/src/repositories/AgentRepository.ts +++ b/src/repositories/AgentRepository.ts @@ -129,8 +129,10 @@ export class AgentRepository { /** * 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. */ async findAll(filters: IAgentListFilters): Promise<{ agents: IAgent[]; total: number }> { @@ -138,6 +140,11 @@ export class AgentRepository { const params: unknown[] = []; let paramIndex = 1; + if (filters.organizationId !== undefined) { + conditions.push(`organization_id = $${paramIndex++}`); + params.push(filters.organizationId); + } + if (filters.owner !== undefined) { conditions.push(`owner = $${paramIndex++}`); params.push(filters.owner); diff --git a/src/services/AgentService.ts b/src/services/AgentService.ts index 4b7646a..29ac039 100644 --- a/src/services/AgentService.ts +++ b/src/services/AgentService.ts @@ -21,6 +21,7 @@ import { AgentAlreadyExistsError, AgentAlreadyDecommissionedError, FreeTierLimitError, + AuthorizationError, } from '../utils/errors.js'; import { agentsRegisteredTotal } from '../metrics/registry.js'; import { TierService } from './TierService.js'; @@ -140,16 +141,23 @@ export class AgentService { /** * 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 organizationId - Optional. When present, the agent must belong to this org. * @returns The agent record. * @throws AgentNotFoundError if the agent does not exist. + * @throws AuthorizationError if the agent belongs to a different organization. */ - async getAgentById(agentId: string): Promise { + async getAgentById(agentId: string, organizationId?: string): Promise { const agent = await this.agentRepository.findById(agentId); if (!agent) { throw new AgentNotFoundError(agentId); } + if (organizationId !== undefined && agent.organizationId !== organizationId) { + throw new AuthorizationError(); + } return agent; } @@ -173,14 +181,18 @@ export class AgentService { * Partially updates an agent's metadata. * Immutable fields (agentId, email, createdAt) cannot be changed. * 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 data - The fields to update. * @param ipAddress - Client IP 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. * @throws AgentNotFoundError if the agent does not exist. * @throws AgentAlreadyDecommissionedError if the agent is decommissioned. + * @throws AuthorizationError if the agent belongs to a different organization. * @throws ValidationError if immutable fields are included. */ async updateAgent( @@ -188,12 +200,17 @@ export class AgentService { data: IUpdateAgentRequest, ipAddress: string, userAgent: string, + organizationId?: string, ): Promise { const agent = await this.agentRepository.findById(agentId); if (!agent) { throw new AgentNotFoundError(agentId); } + if (organizationId !== undefined && agent.organizationId !== organizationId) { + throw new AuthorizationError(); + } + if (agent.status === 'decommissioned') { throw new AgentAlreadyDecommissionedError(agentId); } @@ -256,23 +273,32 @@ export class AgentService { /** * Permanently decommissions an agent (soft delete). * 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 ipAddress - Client IP 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 AgentAlreadyDecommissionedError if already decommissioned. + * @throws AuthorizationError if the agent belongs to a different organization. */ async decommissionAgent( agentId: string, ipAddress: string, userAgent: string, + organizationId?: string, ): Promise { const agent = await this.agentRepository.findById(agentId); if (!agent) { throw new AgentNotFoundError(agentId); } + if (organizationId !== undefined && agent.organizationId !== organizationId) { + throw new AuthorizationError(); + } + if (agent.status === 'decommissioned') { throw new AgentAlreadyDecommissionedError(agentId); } diff --git a/src/types/index.ts b/src/types/index.ts index 69b857b..4743904 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -170,6 +170,8 @@ export interface IPaginatedAgentsResponse { /** Query filters for listing agents. */ export interface IAgentListFilters { + /** Restricts results to agents belonging to this organization. Enforced by the controller from the JWT claim. */ + organizationId?: string; owner?: string; agentType?: AgentType; status?: AgentStatus; diff --git a/tests/unit/controllers/AgentController.test.ts b/tests/unit/controllers/AgentController.test.ts index 15950ca..081c271 100644 --- a/tests/unit/controllers/AgentController.test.ts +++ b/tests/unit/controllers/AgentController.test.ts @@ -15,6 +15,8 @@ const MockAgentService = AgentService as jest.MockedClass; // ─── helpers ───────────────────────────────────────────────────────────────── +const MOCK_ORG_ID = 'org-test-001'; + const MOCK_USER: ITokenPayload = { sub: 'agent-id-001', client_id: 'agent-id-001', @@ -22,11 +24,12 @@ const MOCK_USER: ITokenPayload = { jti: 'jti-001', iat: 1000, exp: 9999999999, + organization_id: MOCK_ORG_ID, }; const MOCK_AGENT: IAgent = { agentId: 'agent-id-001', - organizationId: 'org_system', + organizationId: MOCK_ORG_ID, email: 'agent@sentryagent.ai', agentType: 'screener', version: '1.0.0', @@ -117,6 +120,23 @@ describe('AgentController', () => { 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 () => { const { req, res, next } = buildMocks(); req.body = { @@ -139,7 +159,7 @@ describe('AgentController', () => { // ── 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(); req.query = { 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); + expect(agentService.listAgents).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: MOCK_ORG_ID }), + ); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith(paginatedResponse); }); @@ -160,6 +183,15 @@ describe('AgentController', () => { 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 () => { const { req, res, next } = buildMocks(); req.query = { page: 'not-a-number' }; @@ -184,13 +216,14 @@ describe('AgentController', () => { // ── 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(); req.params = { agentId: MOCK_AGENT.agentId }; agentService.getAgentById.mockResolvedValue(MOCK_AGENT); 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.json).toHaveBeenCalledWith(MOCK_AGENT); }); @@ -205,6 +238,16 @@ describe('AgentController', () => { 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 () => { const { req, res, next } = buildMocks(); req.params = { agentId: 'nonexistent' }; @@ -220,7 +263,7 @@ describe('AgentController', () => { // ── 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(); req.params = { agentId: MOCK_AGENT.agentId }; req.body = { version: '2.0.0' }; @@ -229,6 +272,13 @@ describe('AgentController', () => { 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.json).toHaveBeenCalledWith(updated); }); @@ -244,6 +294,17 @@ describe('AgentController', () => { 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 () => { const { req, res, next } = buildMocks(); req.params = { agentId: MOCK_AGENT.agentId }; @@ -270,13 +331,19 @@ describe('AgentController', () => { // ── 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(); req.params = { agentId: MOCK_AGENT.agentId }; agentService.decommissionAgent.mockResolvedValue(); 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.send).toHaveBeenCalled(); expect(next).not.toHaveBeenCalled(); @@ -292,6 +359,16 @@ describe('AgentController', () => { 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 () => { const { req, res, next } = buildMocks(); req.params = { agentId: MOCK_AGENT.agentId };