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