Developer experience
Scope tuple and multi-tenancy
The (organizationId, projectId, environmentId) tuple that gates every scoped row in Platos.
Scope tuple and multi-tenancy
Every scoped row in Platos carries (organizationId, projectId, environmentId). Every authenticated request resolves the same tuple. The auth guard refuses any read or write whose row tuple does not match the request tuple. This is how multi-tenant isolation works without per-customer service replicas.
What it is
The scope tuple is the architectural invariant that every Platos table either:
- Carries the three columns (and is filtered by the scope guard), or
- Is a parent in the scope graph (organization, project, environment themselves).
ScopeGuard is the NestJS guard that resolves the tuple from auth (header, JWT, or service secret) and stamps it on the request.scope object. Every controller method that touches scoped data accepts RequestScope as a parameter (or reads it from the guard) and threads it into queries.
cross-scope-isolation.test.ts runs probes that assert: a request with scope A cannot read or write any row with scope B. The probes cover threads, agents, memories, tools, providers, and audit.
Why it matters
The default failure mode for multi-tenant systems is "we forgot one query". One missing WHERE organizationId = ? and customer A reads customer B's chat. The scope guard plus the test bar make every code path that touches scoped data an explicit decision: either the developer threaded the scope through, or the IDOR test fails.
The three-level tuple (org, project, env) gives you flexibility without exploding deployment complexity. You can scope keys per environment, separate prod from dev within the same project, and run multiple projects under one org for a team.
How to use it
Read scope in a controller
@Get("agents")
async list(@RequestScopeParam() scope: RequestScope) {
return this.agentCrud.list(scope);
}
The guard runs first; the param decorator extracts. No manual unpacking.
Scope a query
Always include the tuple in your where:
this.prisma.platosAgent.findMany({
where: {
organizationId: scope.organizationId,
projectId: scope.projectId,
environmentId: scope.environmentId,
isActive: true,
},
});
The Prisma model has the three columns plus a composite index; the cost is one row read.
Resolve scope from headers (Mode 1)
Internal callers pass X-Platos-Organization-Id, X-Platos-Project-Id, X-Platos-Environment-Id. The guard rejects external callers (presence of X-Forwarded-For) on this path; external auth must use Mode 2.
Resolve scope from a session token (Mode 2)
External callers present a session JWT. Scope is in the JWT claims. The guard verifies the signature, extracts scope, and stamps request.scope.
Resolve scope from a service secret (Mode 3)
Entity backends connect via WebSocket with the service secret. Scope is fixed by the entity row.
Common pitfalls
- A query that omits even one of the three columns is a leak waiting to happen. The cross-scope IDOR test catches the obvious cases; review every new controller method against the test bar.
- Project-scope and org-scope are different. A user with org-admin can list every project; a project member can only see their own. The guard exposes both via
scope.organizationIdand a separate "is admin" check. - Environments share an org and project; their data is isolated. A
devagent does not seeprodmemory, even though both belong to the same project. - The scope tuple is on the
PlatosConnectedEntityrow but the entity's WebSocket is gateway-keyed. A misconfigured entity that talks to the wrong scope is rejected at the gateway, not at the row read.
Related
- Environments: the third axis of the tuple.
- Auth modes: the three modes that resolve scope.
- Encryption and secrets: per-scope key isolation.
