Files
sentryagent-idp/openspec/changes/archive/2026-04-09-tenant-isolation-enforcement/design.md
SentryAgent.ai Developer 4cb168bbba docs(openspec): mark tenant-isolation-enforcement complete and archive
All 8 tasks checked off. Change archived to openspec/changes/archive/
per OpenSpec protocol. Implementation committed in 5943ff1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 05:29:54 +00:00

3.9 KiB

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.