Skip to content

Accept optional visible_project_ids filter on project list endpoint (defense-in-depth for cloud private projects) #720

Closed

Description

@jope-bm

Context

This is a defense-in-depth improvement requested by basic-memory-cloud. It's tracked in the cloud side as part of the discussion on basicmachines-co/basic-memory-cloud#518 and is independent of the is_everyone refactor happening there.

Basic Memory Cloud has a concept of per-project visibility in team workspaces — some projects are visible to all workspace members, others are invite-only. Today, the source of truth for visibility lives in the cloud database (project_permission table), and filtering is enforced at the cloud proxy layer by rewriting the project list response after it comes back from basic-memory.

This is fragile. The proxy must catch every code path that could return project data. We've already had one incident (basicmachines-co/basic-memory-cloud#544) where a trailing-slash variant of /v2/projects/ bypassed the filtering route and exposed other users' private projects. The fix was to add defense-in-depth in the catch-all route, but filtering-at-proxy is still a single layer with a wide attack surface: admin tools, backup flows, future endpoints, or any new direct-DB queries all need to re-implement the filter or risk leaking.

Proposal

Add an optional visible_project_ids filter parameter to basic-memory's project list endpoint (GET /v2/projects/). When provided, basic-memory applies the filter in SQL and only returns projects whose IDs are in the set.

GET /v2/projects/?visible_project_ids=uuid1,uuid2,uuid3

The cloud service would:

  1. Compute the set of projects the current user can see (from project_permission + project_share tables)
  2. Pass the resulting set as the visible_project_ids parameter on every call into basic-memory
  3. basic-memory enforces the filter at query time, not response-rewrite time

Why This Matters

Defense-in-depth: the filter lives in the SQL query, not a response rewriter. URL quirks, routing bugs, or new code paths in the cloud proxy can't bypass it — if the filter isn't passed, basic-memory returns an empty list by convention (or the full list if the parameter is absent, depending on the design choice below).

No migration needed: cloud remains the authoritative source of visibility rules. Nothing moves between databases. basic-memory just gains the ability to honor a filter passed by its caller.

Local-mode compatibility: the parameter is optional. When basic-memory runs in single-user local mode, the parameter is simply not passed and behavior is unchanged — all projects are visible. No new concepts leak into the local product.

Design Questions

  1. Parameter semantics when absent:

    • Option A: absent parameter → return all projects (current behavior, preserves local mode)
    • Option B: absent parameter → return empty list (fail-closed, stronger guarantee for cloud but breaks local mode)
    • Recommendation: Option A. The filter is a voluntary restriction a caller can apply. Cloud always passes it; local never does.
  2. Parameter format:

    • Comma-separated UUIDs in a query param: simple, works with GET
    • POST body with a JSON array: cleaner for large sets, but changes the endpoint shape
    • Recommendation: comma-separated for simplicity. Workspaces with hundreds of projects are rare, and URL length limits are generous enough.
  3. Scope of endpoints:

    • Just GET /v2/projects/ (project list)
    • Also GET /v2/projects/{id}/... endpoints (defense against resolving an ID the user can't see)
    • Recommendation: start with project list. The individual-project endpoints already require a specific ID that the caller provides, so the attack surface is smaller. If cloud decides to add per-project filtering later, the same mechanism extends naturally.

Related

  • basicmachines-co/basic-memory-cloud#518 — broader is_everyone refactor discussion
  • basicmachines-co/basic-memory-cloud#544 — trailing-slash bypass that motivated this

Not in Scope

  • Moving the project_permission table or visibility rules themselves into basic-memory's tenant DB. That's a larger migration with real costs around the local/self-hosted story and is tracked separately.
  • Adding per-user identity to basic-memory's API contract beyond what already flows through headers.

Activity

  1. claude commented on Apr 5, 2026

    @claude
    Contributor

    Claude finished @jope-bm's task in 1m 1s —— View job


    Triage Complete

    • Analyze issue
    • Explore relevant code
    • Check for duplicates
    • Add labels and triage summary

    Issue Classification

    Attribute Assessment
    Type Enhancement / Security improvement
    Priority Medium — not a new vulnerability (cloud proxy already filters), but closes a real bypass surface
    Component API (GET /v2/projects/), Cloud
    Complexity Medium

    Labels added: enhancement, cloud


    Triage Summary

    This is a well-scoped, clearly motivated enhancement. A real bypass incident (trailing-slash on /v2/projects/) already occurred (basicmachines-co/basic-memory-cloud#544), making this a credible defense-in-depth ask rather than speculative hardening.

    Implementation scope (based on code review):

    The change touches two main areas:

    1. GET /v2/projects/ endpoint (src/basic_memory/api/v2/routers/project_router.py:41): Accept an optional visible_project_ids: list[str] | None = Query(None) parameter and pass it through to the service layer.

    2. ProjectService.list_projects() (src/basic_memory/services/project_service.py:110): Accept the optional filter and apply it in the query via the repository. No SQL migration needed — this is a WHERE-clause filter on existing external_id column.

    There's also a legacy route alias at src/basic_memory/api/app.py:107 (/projects/projects) that imports and reuses the same list_projects handler — it would automatically pick up the new query parameter once the handler is updated.

    Design choices confirmed:

    • Option A (absent → return all) is correct — preserves local mode, cloud always passes the filter
    • Comma-separated UUIDs in query string is reasonable for the expected set sizes
    • Scope is just the list endpoint for now (individual-project routes have smaller attack surface)

    No duplicates found.

  2. self-assigned this
    on Apr 6, 2026
  3. phernandez commented on Apr 6, 2026

    @phernandez
    Member

    Closing — the cloud-side counterpart (#549) is also being closed. The cloud proxy already has three independent filtering layers that provide sufficient defense-in-depth. Adding visible_project_ids to basic-memory's API would leak multi-tenant visibility concerns into a local-first, single-user product without removing any cloud-side code. The original trailing-slash bypass (#544) has been patched at multiple layers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

cloudBasic Memory CloudenhancementNew feature or request

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions