bug(gateway): projects list endpoint returns ALL projects regardless of ownership — data privacy violation #197

Closed
opened 2026-03-17 02:20:13 +00:00 by jason.woltje · 0 comments
Owner

Severity: HIGH — Data Privacy

The GET /api/projects endpoint returns every project in the database to any authenticated user, regardless of ownership or team membership. Clicking on a project then fails with "Project does not belong to the current user" because the detail endpoint correctly enforces ownership.

This is a data privacy violation: users can see project names, descriptions, statuses, and metadata for projects they do not own and have no access to.

Root Cause

Two separate bugs working together:

Bug 1: Unscoped list query

ProjectsController.list() calls brain.projects.findAll() with no user filtering:

// apps/gateway/src/projects/projects.controller.ts
@Get()
async list() {  // ← no @CurrentUser(), no filtering
  return this.brain.projects.findAll();
}
// packages/brain/src/projects.ts
async findAll(): Promise<Project[]> {
  return db.select().from(projects);  // ← returns ALL projects
}

Compare with conversations and missions which do it correctly:

// Conversations — CORRECT: scoped to user
@Get()
async list(@CurrentUser() user: { id: string }) {
  return this.brain.conversations.findAll(user.id);
}

// Missions — CORRECT: scoped to user
@Get()
async list(@CurrentUser() user: { id: string }) {
  return this.brain.missions.findAllByUser(user.id);
}

Bug 2: Ownership lost after team/workspace migration

The "Project does not belong to the current user" error on detail view suggests that existing projects have owner_id values that no longer match the current user's session ID. This could be caused by:

  • User ID format change during a migration
  • Projects created before the ownership model was added (owner_id is nullable with ON DELETE SET NULL)
  • Team-owned projects (owner_type = 'team') where assertOwner only checks owner_id, not team membership

Fix Required

Immediate (list scoping):

// ProjectsController
@Get()
async list(@CurrentUser() user: { id: string }) {
  return this.brain.projects.findAccessible(user.id);
}
// ProjectsRepo — new method
async findAccessible(userId: string): Promise<Project[]> {
  // Return projects owned by user OR where user is a team member
  return db.select().from(projects)
    .where(
      or(
        eq(projects.ownerId, userId),
        // TODO: add team membership check when RBAC lands
      )
    );
}

Immediate (ownership check):

assertOwner needs to also check team membership for team-owned projects, not just ownerId:

// Current — only checks direct ownership
if (!ownerId || ownerId !== userId) {
  throw new ForbiddenException(...);
}

// Needs to also check: is userId a member of the team that owns this project?

Data audit:

Run SELECT id, name, owner_id, team_id, owner_type FROM projects to verify ownership state. Check if any projects have NULL owner_id (orphaned by cascade).

Location

  • apps/gateway/src/projects/projects.controller.ts:31 — unscoped list()
  • packages/brain/src/projects.ts:8findAll() returns all rows
  • apps/gateway/src/auth/resource-ownership.tsassertOwner lacks team membership check

Impact

  • Any authenticated user can see all project names, descriptions, statuses, and metadata
  • Users cannot access their own projects if ownership was lost during migration
  • Team-owned projects are inaccessible to team members (only direct owner_id matches)
## Severity: HIGH — Data Privacy The `GET /api/projects` endpoint returns **every project in the database** to any authenticated user, regardless of ownership or team membership. Clicking on a project then fails with "Project does not belong to the current user" because the detail endpoint correctly enforces ownership. This is a data privacy violation: users can see project names, descriptions, statuses, and metadata for projects they do not own and have no access to. ## Root Cause Two separate bugs working together: ### Bug 1: Unscoped list query `ProjectsController.list()` calls `brain.projects.findAll()` with **no user filtering**: ```typescript // apps/gateway/src/projects/projects.controller.ts @Get() async list() { // ← no @CurrentUser(), no filtering return this.brain.projects.findAll(); } ``` ```typescript // packages/brain/src/projects.ts async findAll(): Promise<Project[]> { return db.select().from(projects); // ← returns ALL projects } ``` Compare with conversations and missions which do it correctly: ```typescript // Conversations — CORRECT: scoped to user @Get() async list(@CurrentUser() user: { id: string }) { return this.brain.conversations.findAll(user.id); } // Missions — CORRECT: scoped to user @Get() async list(@CurrentUser() user: { id: string }) { return this.brain.missions.findAllByUser(user.id); } ``` ### Bug 2: Ownership lost after team/workspace migration The "Project does not belong to the current user" error on detail view suggests that existing projects have `owner_id` values that no longer match the current user's session ID. This could be caused by: - User ID format change during a migration - Projects created before the ownership model was added (owner_id is nullable with `ON DELETE SET NULL`) - Team-owned projects (`owner_type = 'team'`) where `assertOwner` only checks `owner_id`, not team membership ## Fix Required ### Immediate (list scoping): ```typescript // ProjectsController @Get() async list(@CurrentUser() user: { id: string }) { return this.brain.projects.findAccessible(user.id); } ``` ```typescript // ProjectsRepo — new method async findAccessible(userId: string): Promise<Project[]> { // Return projects owned by user OR where user is a team member return db.select().from(projects) .where( or( eq(projects.ownerId, userId), // TODO: add team membership check when RBAC lands ) ); } ``` ### Immediate (ownership check): `assertOwner` needs to also check team membership for team-owned projects, not just `ownerId`: ```typescript // Current — only checks direct ownership if (!ownerId || ownerId !== userId) { throw new ForbiddenException(...); } // Needs to also check: is userId a member of the team that owns this project? ``` ### Data audit: Run `SELECT id, name, owner_id, team_id, owner_type FROM projects` to verify ownership state. Check if any projects have `NULL` owner_id (orphaned by cascade). ## Location - `apps/gateway/src/projects/projects.controller.ts:31` — unscoped `list()` - `packages/brain/src/projects.ts:8` — `findAll()` returns all rows - `apps/gateway/src/auth/resource-ownership.ts` — `assertOwner` lacks team membership check ## Impact - Any authenticated user can see all project names, descriptions, statuses, and metadata - Users cannot access their own projects if ownership was lost during migration - Team-owned projects are inaccessible to team members (only direct owner_id matches)
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mosaicstack/stack#197