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

@@ -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<IAgent> {
async getAgentById(agentId: string, organizationId?: string): Promise<IAgent> {
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<IAgent> {
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<void> {
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);
}