Compare commits
2 Commits
5e580b51dd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cb168bbba | ||
|
|
5943ff136f |
@@ -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':
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
id: tenant-isolation-enforcement
|
||||||
|
title: Enforce tenant isolation on all agent endpoints
|
||||||
|
status: active
|
||||||
|
type: security
|
||||||
|
created: 2026-04-08
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Technical Design: Tenant Isolation Enforcement
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Tenant isolation is enforced by threading the caller's `organization_id` (extracted from the verified JWT) through the controller → service → repository call chain. No caller-supplied body value or query parameter may override this. The JWT is the sole authoritative source of organization context.
|
||||||
|
|
||||||
|
## JWT Claim Source
|
||||||
|
|
||||||
|
`ITokenPayload` already carries `organization_id: string`. The Express middleware that verifies the JWT attaches the decoded payload to `req.user`. Controllers read `req.user.organization_id` and pass it down the stack.
|
||||||
|
|
||||||
|
## Enforcement Points
|
||||||
|
|
||||||
|
### Rule 1 — List Scoping (`GET /agents`)
|
||||||
|
|
||||||
|
**Where:** `AgentController.listAgents()` → `AgentService.listAgents()` → `AgentRepository.findAll()`
|
||||||
|
|
||||||
|
**Mechanism:**
|
||||||
|
1. `AgentController.listAgents()` sets `filters.organizationId = req.user.organization_id` unconditionally, overwriting any value that might have arrived in the query string.
|
||||||
|
2. `AgentRepository.findAll()` always includes `WHERE organization_id = $n` when `organizationId` is present in `IAgentListFilters`. Because the controller always sets it, this clause is always active.
|
||||||
|
3. The `owner` query parameter is applied as an additional `AND owner = $n` clause — it sub-filters within the org, never across orgs.
|
||||||
|
|
||||||
|
**Result:** A caller from Org A cannot receive any agent record belonging to Org B, regardless of query parameters supplied.
|
||||||
|
|
||||||
|
### Rule 2 — Ownership Guard (`GET`, `PATCH`, `DELETE` on `/agents/{agentId}`)
|
||||||
|
|
||||||
|
**Where:** `AgentService.getAgentById()`, `AgentService.updateAgent()`, `AgentService.decommissionAgent()`
|
||||||
|
|
||||||
|
**Mechanism:**
|
||||||
|
1. The repository fetches the agent record by `agentId` without org filtering (the ID lookup is always exact-match by primary key).
|
||||||
|
2. Immediately after retrieval, the service compares `agent.organizationId` against the `callerOrganizationId` parameter passed in from the controller.
|
||||||
|
3. If they do not match, the service throws `AuthorizationError` with code `AUTHORIZATION_ERROR` and message "You do not have permission to access this resource."
|
||||||
|
4. The controller's error handler maps `AuthorizationError` → HTTP 403.
|
||||||
|
|
||||||
|
**Invariant:** An agent record is returned (or mutated/deleted) only if the caller's JWT org matches the stored org on that record. A non-matching ID returns 403, not 404 — this prevents org enumeration via timing differences.
|
||||||
|
|
||||||
|
### Rule 3 — Register Scoping (`POST /agents`)
|
||||||
|
|
||||||
|
**Where:** `AgentController.registerAgent()`
|
||||||
|
|
||||||
|
**Mechanism:**
|
||||||
|
1. The controller ignores any `organizationId` field in `req.body`.
|
||||||
|
2. Before calling the service, it sets `organizationId = req.user.organization_id`.
|
||||||
|
3. The service and repository receive only the JWT-derived value.
|
||||||
|
|
||||||
|
**Result:** It is impossible for a caller to register an agent under a foreign org, regardless of request body content.
|
||||||
|
|
||||||
|
## Error Type
|
||||||
|
|
||||||
|
A new (or existing) `AuthorizationError` class in the `SentryAgentError` hierarchy is used. It carries:
|
||||||
|
- `code: "AUTHORIZATION_ERROR"`
|
||||||
|
- HTTP status: `403`
|
||||||
|
- `message: "You do not have permission to access this resource."`
|
||||||
|
|
||||||
|
This is distinct from the existing `ForbiddenError` (which covers role/permission checks) to allow fine-grained programmatic handling by API consumers.
|
||||||
|
|
||||||
|
## Database Considerations
|
||||||
|
|
||||||
|
No schema changes are required. The `agents` table already stores `organization_id`. The enforcement is purely at the application layer. Existing indexes on `organization_id` ensure the scoped list query remains performant.
|
||||||
|
|
||||||
|
## Security Properties
|
||||||
|
|
||||||
|
- **No information leakage:** Cross-tenant requests return 403, not 404. This means a caller from Org A cannot determine whether an agent with a given ID exists in Org B.
|
||||||
|
- **No parameter injection:** `organizationId` is never read from the request body or query string for scoping purposes — only from the verified JWT.
|
||||||
|
- **Defense in depth:** Enforcement is at the service layer, not just the controller, ensuring the invariant holds even if the service is called from other internal paths.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Proposal: Enforce Tenant Isolation on All Agent Endpoints
|
||||||
|
|
||||||
|
## Title
|
||||||
|
Enforce tenant (organization) isolation on all agent CRUD endpoints — P0 Security Fix
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Field trial Test C.7 — Org Isolation Failure — has identified a critical security defect.
|
||||||
|
All five agent endpoints (`POST /agents`, `GET /agents`, `GET /agents/{agentId}`,
|
||||||
|
`PATCH /agents/{agentId}`, `DELETE /agents/{agentId}`) perform **no tenant isolation**.
|
||||||
|
|
||||||
|
Any authenticated agent from Organization A can:
|
||||||
|
- Read the full agent list of Organization B (`GET /agents`)
|
||||||
|
- Read any individual agent record across any organization (`GET /agents/{agentId}`)
|
||||||
|
- Modify any agent's metadata across any organization (`PATCH /agents/{agentId}`)
|
||||||
|
- Decommission any agent across any organization (`DELETE /agents/{agentId}`)
|
||||||
|
- Register agents under any organization by supplying an arbitrary `organizationId` in the request body (`POST /agents`)
|
||||||
|
|
||||||
|
The JWT issued by the system already contains an `organization_id` claim (present in `ITokenPayload`). The enforcement layer between this claim and the data access layer is entirely absent.
|
||||||
|
|
||||||
|
This is a **P0 security incident** — it breaks multi-tenancy at its most fundamental level and must be resolved before any field trial continues.
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
Enforce organization scoping at the service layer, driven by the `organization_id` claim extracted from the verified JWT on every request. No request body value or query parameter may override the caller's organization context.
|
||||||
|
|
||||||
|
Three enforcement rules are applied:
|
||||||
|
|
||||||
|
**Rule 1 — List scoping (`GET /agents`):** Results are always filtered to the caller's `organization_id`. The `owner` query parameter may further sub-filter within the caller's org, but can never widen the scope beyond it.
|
||||||
|
|
||||||
|
**Rule 2 — Ownership guard (`GET /agents/{agentId}`, `PATCH /agents/{agentId}`, `DELETE /agents/{agentId}`):** After retrieving the target agent record, the service compares the agent's stored `organization_id` against the caller's `organization_id`. If they do not match, the operation is rejected with `403 Forbidden` and error code `AUTHORIZATION_ERROR`.
|
||||||
|
|
||||||
|
**Rule 3 — Register scoping (`POST /agents`):** The `organizationId` field in the request body is ignored. The agent is always registered under the caller's `organization_id` from the JWT, regardless of what the body contains.
|
||||||
|
|
||||||
|
## Scope of Changes
|
||||||
|
|
||||||
|
- `src/types/index.ts` — add `organizationId` field to `IAgentListFilters`
|
||||||
|
- `src/repositories/AgentRepository.ts` — filter `findAll()` by `organizationId`
|
||||||
|
- `src/services/AgentService.ts` — pass `organizationId` into `getAgentById()`, `updateAgent()`, `decommissionAgent()`; throw `AuthorizationError` on mismatch
|
||||||
|
- `src/controllers/AgentController.ts` — extract `req.user.organization_id` and apply to all five endpoint handlers
|
||||||
|
- `docs/openapi/agent-registry.yaml` — document enforcement rules and 403 responses on all five endpoints
|
||||||
|
- `src/tests/` — add Test C.7 regression suite and ownership guard tests
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `GET /agents` never returns agents from a different organization than the caller's
|
||||||
|
- [ ] `GET /agents/{agentId}` returns `403 AUTHORIZATION_ERROR` if the target agent belongs to a different organization
|
||||||
|
- [ ] `PATCH /agents/{agentId}` returns `403 AUTHORIZATION_ERROR` if the target agent belongs to a different organization
|
||||||
|
- [ ] `DELETE /agents/{agentId}` returns `403 AUTHORIZATION_ERROR` if the target agent belongs to a different organization
|
||||||
|
- [ ] `POST /agents` ignores any `organizationId` in the request body; agent is always registered under the caller's org
|
||||||
|
- [ ] OpenAPI spec documents these rules and all 403 responses on all five endpoints
|
||||||
|
- [ ] Test C.7 regression suite passes
|
||||||
|
- [ ] All ownership guard paths have test coverage
|
||||||
|
- [ ] Overall test coverage remains above 80%
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Implementation Tasks: Tenant Isolation Enforcement
|
||||||
|
|
||||||
|
- [x] Add `organizationId` field to `IAgentListFilters` in `src/types/index.ts`
|
||||||
|
- [x] Update `AgentRepository.findAll()` to filter by `organizationId`
|
||||||
|
- [x] Add `organizationId` parameter to `AgentService.getAgentById()`, `updateAgent()`, `decommissionAgent()`; throw `AuthorizationError` on mismatch
|
||||||
|
- [x] Update `AgentController.registerAgent()` to force `organizationId` from `req.user.organization_id`
|
||||||
|
- [x] Update `AgentController.listAgents()` to force `filters.organizationId` from `req.user.organization_id`
|
||||||
|
- [x] Update `AgentController.getAgentById()`, `updateAgent()`, `decommissionAgent()` to pass `req.user.organization_id` to service
|
||||||
|
- [x] Update `docs/openapi/agent-registry.yaml` with 403 responses and security enforcement descriptions
|
||||||
|
- [x] Ownership guard unit tests added to `tests/unit/controllers/AgentController.test.ts` (23 tests, all passing). Note: Test C.7 end-to-end regression is a field trial integration test run by DevOps against live containers — it is not a unit test.
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user