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

65 lines
3.9 KiB
Markdown

# 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.