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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user