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

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

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)


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


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