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>
339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
/**
|
|
* Agent Registry Service for SentryAgent.ai AgentIdP.
|
|
* Business logic for agent lifecycle management.
|
|
*/
|
|
|
|
import { AgentRepository } from '../repositories/AgentRepository.js';
|
|
import { CredentialRepository } from '../repositories/CredentialRepository.js';
|
|
import { AuditService } from './AuditService.js';
|
|
import { DIDService } from './DIDService.js';
|
|
import { EventPublisher } from './EventPublisher.js';
|
|
import { AnalyticsService } from './AnalyticsService.js';
|
|
import {
|
|
IAgent,
|
|
ICreateAgentRequest,
|
|
IUpdateAgentRequest,
|
|
IAgentListFilters,
|
|
IPaginatedAgentsResponse,
|
|
} from '../types/index.js';
|
|
import {
|
|
AgentNotFoundError,
|
|
AgentAlreadyExistsError,
|
|
AgentAlreadyDecommissionedError,
|
|
FreeTierLimitError,
|
|
AuthorizationError,
|
|
} from '../utils/errors.js';
|
|
import { agentsRegisteredTotal } from '../metrics/registry.js';
|
|
import { TierService } from './TierService.js';
|
|
|
|
const FREE_TIER_MAX_AGENTS = 100;
|
|
|
|
/**
|
|
* Service for agent registration and lifecycle management.
|
|
* Enforces free-tier limits and coordinates with AuditService.
|
|
*/
|
|
export class AgentService {
|
|
/**
|
|
* @param agentRepository - The agent data repository.
|
|
* @param credentialRepository - The credential repository (for decommission cleanup).
|
|
* @param auditService - The audit log service.
|
|
* @param didService - Optional DIDService. When provided, a W3C DID is generated for each
|
|
* newly registered agent. When null/undefined, DID generation is skipped
|
|
* (backward-compatible default).
|
|
* @param eventPublisher - Optional EventPublisher. When provided, lifecycle events are
|
|
* published as webhooks and Kafka messages (fire-and-forget).
|
|
* @param analyticsService - Optional AnalyticsService. When provided, agent_registered
|
|
* and agent_deactivated events are recorded fire-and-forget.
|
|
* @param tierService - Optional TierService. When provided, per-tier agent count limits
|
|
* are enforced at agent creation time (Phase 6 WS4).
|
|
*/
|
|
constructor(
|
|
private readonly agentRepository: AgentRepository,
|
|
private readonly credentialRepository: CredentialRepository,
|
|
private readonly auditService: AuditService,
|
|
private readonly didService: DIDService | null = null,
|
|
private readonly eventPublisher: EventPublisher | null = null,
|
|
private readonly analyticsService: AnalyticsService | null = null,
|
|
private readonly tierService: TierService | null = null,
|
|
) {}
|
|
|
|
/**
|
|
* Registers a new AI agent identity.
|
|
* Enforces the free-tier 100-agent limit and unique email constraint.
|
|
*
|
|
* @param data - The agent registration data.
|
|
* @param ipAddress - Client IP for audit logging.
|
|
* @param userAgent - Client User-Agent for audit logging.
|
|
* @returns The newly created agent record.
|
|
* @throws FreeTierLimitError if the 100-agent limit is reached.
|
|
* @throws AgentAlreadyExistsError if the email is already registered.
|
|
*/
|
|
async registerAgent(
|
|
data: ICreateAgentRequest,
|
|
ipAddress: string,
|
|
userAgent: string,
|
|
): Promise<IAgent> {
|
|
const orgId = data.organizationId ?? 'org_system';
|
|
|
|
// ── Tier-based agent count enforcement (Phase 6 WS4) ────────────────────
|
|
// When TierService is available and TIER_ENFORCEMENT is enabled, validate
|
|
// the per-tier agent limit for the requesting organization.
|
|
if (this.tierService !== null && process.env['TIER_ENFORCEMENT'] !== 'false') {
|
|
const tier = await this.tierService.fetchTier(orgId);
|
|
await this.tierService.enforceAgentLimit(orgId, tier);
|
|
}
|
|
|
|
// Enforce legacy free-tier agent count limit (global across all orgs)
|
|
const currentCount = await this.agentRepository.countActive();
|
|
if (currentCount >= FREE_TIER_MAX_AGENTS) {
|
|
throw new FreeTierLimitError(
|
|
'Free tier limit of 100 registered agents has been reached.',
|
|
{ limit: FREE_TIER_MAX_AGENTS, current: currentCount },
|
|
);
|
|
}
|
|
|
|
// Check email uniqueness
|
|
const existing = await this.agentRepository.findByEmail(data.email);
|
|
if (existing !== null) {
|
|
throw new AgentAlreadyExistsError(data.email);
|
|
}
|
|
|
|
const agent = await this.agentRepository.create(data);
|
|
|
|
// Generate a W3C DID for the new agent when DIDService is available
|
|
if (this.didService !== null) {
|
|
await this.didService.generateDIDForAgent(agent.agentId, orgId);
|
|
}
|
|
|
|
// Synchronous audit insert
|
|
await this.auditService.logEvent(
|
|
agent.agentId,
|
|
'agent.created',
|
|
'success',
|
|
ipAddress,
|
|
userAgent,
|
|
{ agentType: agent.agentType, owner: agent.owner },
|
|
);
|
|
|
|
// Instrument: count successful agent registrations
|
|
agentsRegisteredTotal.inc({ deployment_env: data.deploymentEnv });
|
|
|
|
// Analytics: record agent_registered event (fire-and-forget)
|
|
if (this.analyticsService !== null) {
|
|
void this.analyticsService.recordEvent(
|
|
agent.organizationId ?? 'org_system',
|
|
'agent_registered',
|
|
).catch((err: unknown) => {
|
|
// eslint-disable-next-line no-console
|
|
console.error('[AgentService] analytics record (agent_registered) failed', err);
|
|
});
|
|
}
|
|
|
|
// Publish event (fire-and-forget)
|
|
void this.eventPublisher?.publishEvent(
|
|
agent.organizationId,
|
|
'agent.created',
|
|
{ agentId: agent.agentId, email: agent.email, name: agent.owner },
|
|
);
|
|
|
|
return agent;
|
|
}
|
|
|
|
/**
|
|
* 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, 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;
|
|
}
|
|
|
|
/**
|
|
* Returns a paginated, optionally filtered list of agents.
|
|
*
|
|
* @param filters - Pagination and filter criteria.
|
|
* @returns Paginated agents response.
|
|
*/
|
|
async listAgents(filters: IAgentListFilters): Promise<IPaginatedAgentsResponse> {
|
|
const { agents, total } = await this.agentRepository.findAll(filters);
|
|
return {
|
|
data: agents,
|
|
total,
|
|
page: filters.page,
|
|
limit: filters.limit,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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(
|
|
agentId: string,
|
|
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);
|
|
}
|
|
|
|
// Detect if status changes
|
|
const oldStatus = agent.status;
|
|
const updated = await this.agentRepository.update(agentId, data);
|
|
if (!updated) {
|
|
throw new AgentNotFoundError(agentId);
|
|
}
|
|
|
|
// Determine which audit action to log
|
|
let auditAction: 'agent.updated' | 'agent.suspended' | 'agent.reactivated' | 'agent.decommissioned' =
|
|
'agent.updated';
|
|
if (data.status !== undefined && data.status !== oldStatus) {
|
|
if (data.status === 'suspended') auditAction = 'agent.suspended';
|
|
else if (data.status === 'active') auditAction = 'agent.reactivated';
|
|
else if (data.status === 'decommissioned') auditAction = 'agent.decommissioned';
|
|
}
|
|
|
|
await this.auditService.logEvent(
|
|
agentId,
|
|
auditAction,
|
|
'success',
|
|
ipAddress,
|
|
userAgent,
|
|
{ updatedFields: Object.keys(data) },
|
|
);
|
|
|
|
// Publish lifecycle event (fire-and-forget)
|
|
if (auditAction === 'agent.updated') {
|
|
void this.eventPublisher?.publishEvent(
|
|
updated.organizationId,
|
|
'agent.updated',
|
|
{ agentId, changes: data },
|
|
);
|
|
} else if (auditAction === 'agent.suspended') {
|
|
void this.eventPublisher?.publishEvent(
|
|
updated.organizationId,
|
|
'agent.suspended',
|
|
{ agentId },
|
|
);
|
|
} else if (auditAction === 'agent.reactivated') {
|
|
void this.eventPublisher?.publishEvent(
|
|
updated.organizationId,
|
|
'agent.reactivated',
|
|
{ agentId },
|
|
);
|
|
} else if (auditAction === 'agent.decommissioned') {
|
|
void this.eventPublisher?.publishEvent(
|
|
updated.organizationId,
|
|
'agent.decommissioned',
|
|
{ agentId },
|
|
);
|
|
}
|
|
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
// Revoke all active credentials
|
|
await this.credentialRepository.revokeAllForAgent(agentId);
|
|
|
|
await this.agentRepository.decommission(agentId);
|
|
|
|
await this.auditService.logEvent(
|
|
agentId,
|
|
'agent.decommissioned',
|
|
'success',
|
|
ipAddress,
|
|
userAgent,
|
|
{},
|
|
);
|
|
|
|
// Analytics: record agent_deactivated event (fire-and-forget)
|
|
if (this.analyticsService !== null) {
|
|
void this.analyticsService.recordEvent(
|
|
agent.organizationId ?? 'org_system',
|
|
'agent_deactivated',
|
|
).catch((err: unknown) => {
|
|
// eslint-disable-next-line no-console
|
|
console.error('[AgentService] analytics record (agent_deactivated) failed', err);
|
|
});
|
|
}
|
|
|
|
// Publish event (fire-and-forget)
|
|
void this.eventPublisher?.publishEvent(
|
|
agent.organizationId,
|
|
'agent.decommissioned',
|
|
{ agentId },
|
|
);
|
|
}
|
|
}
|