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>
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:
AgentController.listAgents()setsfilters.organizationId = req.user.organization_idunconditionally, overwriting any value that might have arrived in the query string.AgentRepository.findAll()always includesWHERE organization_id = $nwhenorganizationIdis present inIAgentListFilters. Because the controller always sets it, this clause is always active.- The
ownerquery parameter is applied as an additionalAND owner = $nclause — 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:
- The repository fetches the agent record by
agentIdwithout org filtering (the ID lookup is always exact-match by primary key). - Immediately after retrieval, the service compares
agent.organizationIdagainst thecallerOrganizationIdparameter passed in from the controller. - If they do not match, the service throws
AuthorizationErrorwith codeAUTHORIZATION_ERRORand message "You do not have permission to access this resource." - 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:
- The controller ignores any
organizationIdfield inreq.body. - Before calling the service, it sets
organizationId = req.user.organization_id. - 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:
organizationIdis 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.