S225.5 Sessions Multi Tenant Per User Analysis
Documentation for Sessions
S225.5: Sessions Multi-Tenant (Per Space) and Per-User Isolation Analysis
Session: 225.5
Focus: Research whether sessions features/endpoints are adapted for multi-tenant (per space) and per-user isolation
Date: 2026-03-16
Executive Summary
Per-user: Partially adapted. Most primary endpoints (/my/active, /my/history, /my/groups) correctly filter by userId from JWT. However, several endpoints that accept a session ID do not verify ownership.
Per-space (multi-tenant): Partially adapted. The schema stores spaceId and tenantId, and spaceId is set when messages are appended. But the main list endpoints (/my/active, /my/history) do not filter by space. They return all sessions for the user across all spaces. Space filtering is done client-side via setContextFilter (Studio, etc.).
tenantId: Schema and service support it, but it is never populated by the chat flow. Only spaceId is used.
1. Critical Issues (High Confidence, Real Impact)
1.1 GET /sessions/:id — No Ownership Verification — Certain
Location: sessions.controller.ts line 463
@Get(':id')
async getSession(@Param('id') id: string): Promise<{ session: PersistedSession | null }> {
const session = await this.sessionsService.getSession(id);
return { session };
}
The endpoint returns any session by ID. There is no check that authUser.userId matches session.userId or that the user is a participant. An authenticated user could access another user's session if they know or guess the session ID.
Impact: Information disclosure. Session IDs are chat-{timestamp}-{random} — not cryptographically secure. In a multi-user deployment, this is a real risk.
Fix: After fetching, verify session.userId === authUser.userId or session.participants?.some(p => p.userId === authUser.userId). Return 404 if not authorized.
1.2 GET /sessions/history — Returns All Sessions (No userId Filter) — Certain
Location: sessions.controller.ts lines 480–496
@Get('history')
async getHistory(
@Query('limit') limit?: string,
@Query('offset') offset?: string,
@Query('context') context?: string
): Promise<{ sessions: SessionSummary[]; total: number }> {
const result = await this.sessionsService.getSessionHistory({
limit: limitNum,
offset: offsetNum,
context: context as any,
// userId and tenantId are NOT passed
});
return result;
}
getSessionHistory builds a query from options.userId and options.tenantId. Neither is passed. The query has no user filter and returns all sessions in the database.
Impact: Critical if this endpoint is ever used. The frontend uses /sessions/my/history instead, so /sessions/history may be legacy or admin-only. Verify who calls it. If it's exposed to clients, it leaks all users' sessions.
Fix: Either deprecate/remove this route, or require userId from JWT and pass it to getSessionHistory.
1.3 /my/active and /my/history — Not Space-Aware — Certain
Location: sessions.service.ts — getActiveSession, getUserSessions
getActiveSession(userId): Returns the single most recent session for the user, regardless of space.getUserSessions(userId, limit): Returns the N most recent sessions for the user, regardless of space.
Impact: When a user has sessions in multiple spaces (e.g. Space A and Space B), /my/active returns whichever was updated most recently, which may be from a different space than the one the user is viewing. /my/history mixes sessions from all spaces. The frontend compensates with setContextFilter (Studio, etc.), but the backend does not support space-scoped queries.
Fix: Add optional spaceId (and/or tenantId) to these endpoints. When provided, filter by userId AND spaceId. The frontend would pass the current space when loading.
2. Non-Critical Improvements (Pragmatic, Optional)
2.1 tenantId Never Populated
Location: Schema has tenantId; appendMessages and upsertSession do not set it.
The chat flow passes spaceId via SyncSessionMeta and appendMessages body. tenantId is never sent. The schema supports it for getSessionHistory and searchSessions, but it will always be undefined for chat-created sessions.
Recommendation: If your multi-tenant model uses "tenant" as the top-level isolation (e.g. tenant = organization), consider populating it from JWT authUser.tenantId when creating/updating sessions. If "space" is the only isolation you need, tenantId can remain unused.
2.2 DELETE /sessions/:id — No Ownership Check
Location: sessions.controller.ts line 398
The delete endpoint does not verify that the requesting user owns the session. Same pattern as GET — any authenticated user could delete another user's session if they know the ID.
Fix: Add ownership check before delete.
2.3 POST /sessions/:id/whistle, GET /sessions/:id/participants, etc.
Several endpoints that take sessionId do not verify that the caller has access. For group sessions, the caller might be a participant rather than the owner. The fix would be to allow access if session.userId === authUser.userId OR session.participants includes authUser.userId.
3. What Is Working (Per-User and Per-Space)
3.1 Per-User Isolation — Solid for Primary Flows
| Endpoint | User Filter | Notes |
|---|---|---|
| GET /sessions/my/active | ✓ authUser.userId |
Correct |
| GET /sessions/my/history | ✓ authUser.userId |
Correct |
| GET /sessions/my/groups | ✓ authUser.userId |
Correct |
| POST /sessions/:id/messages | ✓ authUser.userId || body.userId |
Correct — used for append |
| POST /sessions/:id/reconcile | — | No explicit check; session ID is the gate |
3.2 Per-Space — Schema and Write Path
- Schema:
spaceIdandtenantIdexist and are indexed. - Write path:
appendMessagesacceptsspaceIdand backfills it on the session.ChatBackendSyncServicepassesspaceIdfrommeta.spaceId(fromVittoChatPersistenceService._spaceId()). - findByUserAndSpace: Used by the channel bridge (
GET /sessions/by-space?userId=&spaceId=) — correctly finds sessions by user + space.
3.3 Frontend Space Filtering
VittoChatPersistenceService uses setContextFilter({ type, spaceSlug, universeSlug }) for context-bound views (e.g. Studio). The sessionList computed filters by userId and, when a context filter is active, by context.type, context.spaceSlug, context.universeSlug. So the frontend achieves space-scoped views, but the backend returns all user sessions and the filtering is client-side.
4. Nitpicks (Style, Naming, Minor)
tenantIdvsspaceId— The codebase uses both. Clarify in docs: is "tenant" = org/workspace, "space" = project/instance? They may be the same or different depending on your model.GET /sessions/user/:userId— Allows fetching any user's sessions by path param. No auth check that the requester is that user or an admin. Likely admin-only; should be guarded.
5. Confidence Calibration
| Finding | Confidence | Basis |
|---|---|---|
| GET /:id no ownership check | Certain | Code inspection |
| GET /history no userId | Certain | Controller does not pass userId to getSessionHistory |
| /my/active, /my/history not space-aware | Certain | Service methods only filter by userId |
| tenantId never set | Certain | appendMessages, sync flow never send tenantId |
| spaceId set on append | Certain | appendMessages accepts spaceId, backfills on session |
6. Gaps / Assumptions
- Assumption:
GET /sessions/historyis not used by the main chat UI (which uses/my/history). Verify with a codebase search for callers. - Assumption: Session IDs are not easily guessable. Format
chat-{timestamp}-{random}— if timestamp and random are predictable, enumeration might be possible. Verify randomness and consider UUIDs for new sessions. - Gap: The relationship between
tenantId(JWT, backend-api) andspaceId(chat sessions) is not documented. Are they 1:1? Is space a child of tenant?
7. Recommendations Summary
| Priority | Action |
|---|---|
| Critical | Add ownership check to GET /sessions/:id and DELETE /sessions/:id |
| Critical | Fix or deprecate GET /sessions/history — ensure it never returns cross-user data |
| High | Add optional spaceId to /my/active and /my/history for space-scoped loading |
| Medium | Add ownership/participant check to other session-ID endpoints (whistle, participants, etc.) |
| Low | Populate tenantId from JWT when creating sessions, if tenant isolation is required |