# REST API Authorization Fix — Design **Date:** 2026-05-13 **Branch (target):** `main` **Scope:** `server/` (self-hosted OSS REST server) + `docs/open-source/features/rest-api.mdx` **Trigger:** Security report from `vonbrubeck@gmail.com` (2026-05-01) titled "Mem0 OSS REST per-user API keys can access and delete other users' memories." ## Problem The self-hosted OSS REST server documents per-user API keys as "scoped to a single dashboard user" (`docs/open-source/features/rest-api.mdx:131-136, 185`), but the memory, configure, entities, and request-log routes resolve the caller via `verify_auth` (`server/auth.py:145-161`) and then **discard the resolved `User`**. Caller-supplied `user_id`/`agent_id`/`run_id` values are forwarded straight to the memory backend, raw `memory_id` operations skip ownership checks, and `/configure`/`/reset` accept any authenticated principal. The report's source-level claims are accurate against the current HEAD. Every cited line behaves as described. ## Threat model — what the report gets right, and what it under-weighs **Correct:** the contract in the docs and the behavior in the code do not match. The routes do not enforce a per-user scope, and `/configure`, `/reset`, `/entities` accept any authenticated caller as if they were admins. **Under-weighed by the report:** today only one `User` can exist with `role = "admin"`. Migration `004_unique_admin_role.py` enforces this via a partial unique index on `role = 'admin'`, and `/auth/register` (`server/routers/auth.py:100-114`) is closed after the first user is registered. There is **no public endpoint** to create non-admin users. The Alice-vs-Bob attack sketch therefore cannot be constructed through the public API as the code stands — the cross-tenant attack surface has exactly one tenant. The vulnerability is real but **latent**: it detonates the moment a second `User` row appears, whether via a future invite endpoint (the docs at `rest-api.mdx:267` already promise "additional accounts are provisioned by the existing admin") or via direct database manipulation. ## Decision: scope B+ (admin-gate now, defer owner_id) We fix the directly-fixable surface — global, destructive, and info-disclosing routes — and align the docs with reality. We do **not** add `owner_id` to memory payloads in this PR. The `owner_id` design needs the multi-user invite UX to be specified first (admin/member/viewer? per-org? hierarchical?), and JSONB payload schemas are very hard to back out once vector stores carry production data. That work is tracked as a follow-up issue. Rejected alternatives: - **A — Full multi-tenant now.** Premature schema commitment for an unspecified product feature. Violates KISS, locks us into payload shape we'd regret. - **C — Doc-only (retract the multi-user promise).** Worse signal than "not yet"; the report's findings would remain "known limitation" rather than fixed. ## Authorization model One new helper in `server/auth.py`: ```python def _ensure_admin(request: Request, user: User | None) -> None: """Single source of truth for admin gating. Allows: legacy ADMIN_API_KEY env (X-API-Key), AUTH_DISABLED=true, or a registered User with role == 'admin'. Raises 403 otherwise.""" auth_type = getattr(request.state, "auth_type", "none") if auth_type in {"admin_api_key", "disabled"}: return if user is not None and user.role == "admin": return raise HTTPException(status_code=403, detail="Admin role required.") async def require_admin( request: Request, user: User | None = Depends(verify_auth), ) -> None: """FastAPI dependency. Pure gate — returns nothing.""" _ensure_admin(request, user) ``` Properties: - Pure gate: returns `None`, never invents a synthetic `User`. - `_ensure_admin` is shared by the dep and by the inside-route guard on `GET /memories` (DRY). - FastAPI dedupes the sub-dep, so `verify_auth` runs once per request. ## Regression matrix (current deployment modes × affected routes) | Mode | What `verify_auth` returns today | After fix on admin-gated routes | |---|---|---| | Single admin JWT | admin `User` | passes (`role == "admin"`) | | Single admin per-user key (`m0sk_…`) | admin `User` | passes (same) | | Legacy `ADMIN_API_KEY` env (X-API-Key) | `None`, `auth_type="admin_api_key"` | passes (auth-type short-circuit) | | `AUTH_DISABLED=true` | `None`, `auth_type="disabled"` | passes (auth-type short-circuit) | | Hypothetical non-admin User | n/a (cannot exist today) | 403 | **Zero behavior change on every supported deployment today.** The new 403 is reachable only by a `User` type the current schema cannot produce. ## Per-route changes ### Full gate (`verify_auth` → `require_admin`) | Route | File:line | Reason | |---|---|---| | `GET /configure` | `server/main.py:303-305` | Pairs with `POST /configure`; redacted config still exposes provider/topology to future non-admins | | `POST /configure` | `server/main.py:313-318` | Global config mutation; **report-flagged** | | `POST /reset` | `server/main.py:475-482` | Destructive global op; **report-flagged** | | `GET /entities` | `server/routers/entities.py:44-68` | Info disclosure of every user/agent/run ID; **report-flagged** | | `DELETE /entities/{type}/{id}` | `server/routers/entities.py:71-77` | Destructive global op masquerading as tenant action; **report-flagged** | | `GET /requests` | `server/routers/requests.py:31-47` | Cross-user log leak the day a non-admin exists; **meta-reviewer-flagged** | Mechanical change at each call site: replace `_auth=Depends(verify_auth)` with `_admin=Depends(require_admin)`. In `requests.py`, drop the unused `user: User = Depends(require_auth)` parameter. ### Inside-route guard (filtered branch unchanged) `GET /memories` (`server/main.py:387-403`) — outer dep stays `verify_auth`. Inside the route, on the no-filter branch (which currently calls `_list_all_memories`), call `_ensure_admin(request, user)`. The filtered branch (`?user_id=alice`) is untouched. ```python @app.get("/memories", summary="Get memories") def get_all_memories( request: Request, user_id: Optional[str] = None, run_id: Optional[str] = None, agent_id: Optional[str] = None, user: User | None = Depends(verify_auth), ): try: if not any([user_id, run_id, agent_id]): _ensure_admin(request, user) return _list_all_memories() filters = { k: v for k, v in {"user_id": user_id, "run_id": run_id, "agent_id": agent_id}.items() if v is not None } return get_memory_instance().get_all(filters=filters) except Exception: raise upstream_error() ``` Rationale: full-gating the route would change behavior for the documented per-user-key pattern (filtered queries). The inside-route guard only changes the info-disclosure branch — the only branch the report's "list all" attack uses. ### Deliberately NOT changed in this PR | Route | Why | |---|---| | `POST /memories` | Real fix needs `owner_id` (follow-up) | | `POST /search` | Same | | `GET /memories/{memory_id}`, `PUT`, `DELETE`, `/history` | Same — ownership check requires `owner_id` | | `DELETE /memories?user_id=…` (filtered branch) | Same | | `POST /generate-instructions` | Rate-limit could regress batch callers; defer | | `GET /configure/providers` | Static bundled-provider tuple; not sensitive, not flagged | | `/api-keys/*`, `/auth/*` | Already scoped to caller via `require_auth` | ## Docs changes — `docs/open-source/features/rest-api.mdx` Surgical line edits, no restructure: 1. **Line 134** (per-user API key row): rewrite to `Programmatic access tied to the single admin user today. Per-user scoping arrives with the upcoming multi-user invite flow.` 2. **Line 185** (`Per-user keys inherit the creating user's scope`): delete the sentence; it describes a contract the server does not enforce. 3. **Endpoint reference tables (lines 247-296):** add an `Admin only` marker to `/configure` (GET, POST), `/reset`, `/entities` (GET, DELETE), and `/requests`. 4. **Line 267**: keep "additional accounts are provisioned by the existing admin" but mark `(coming soon)` and remove any implication that today's per-user keys are already scoped. ## Tests — `server/tests/` (new) `server/` currently has no pytest scaffolding. We add the minimum needed to guard the regression matrix. **New files:** - `server/tests/__init__.py` - `server/tests/conftest.py` — fixtures: - SQLite in-memory engine + session factory; bind `Base.metadata.create_all`. - `get_memory_instance` patched to return a `MagicMock` so memory routes don't need pgvector. - `client` fixture: FastAPI `TestClient` with the SQLite session injected via `app.dependency_overrides`. - `admin_user` fixture: inserts a `User(role="admin")` row and issues a JWT. - `non_admin_user` fixture: inserts a `User(role="member")` row (direct DB insert — no public endpoint produces this today) and issues a JWT. - `admin_api_key_env` and `auth_disabled_env` fixtures: monkey-patch the auth module to simulate those modes. - `server/tests/test_admin_gating.py` — parametrized over the 6 admin-gated routes and the `GET /memories` no-filter branch: - admin JWT → expected success status (200/204) - admin per-user key → expected success - `ADMIN_API_KEY` env → expected success - `AUTH_DISABLED=true` → expected success - non-admin JWT → 403 - no auth → 401 - `server/tests/test_unchanged_routes.py` — sanity coverage that filtered memory queries, `POST /memories`, `POST /search`, and `/api-keys/*` behave identically before/after. **Dev dependencies** added to `server/requirements.txt` (or a new `server/requirements-dev.txt`): - `pytest` - `pytest-asyncio` - `httpx` (for `TestClient`) **CI:** add a `server-ci.yml` workflow (model on `cli-python-ci.yml`) that runs `pytest server/tests/` on PRs touching `server/`. ## Out of scope — tracked, not shipped A single GitHub issue captures the deferred items. Each is one paragraph in the issue body, not a separate design doc. 1. **`owner_id` on memory payloads.** Schema decision (payload key vs first- class column), backfill story for existing vector stores, SDK compatibility, write-path injection (`POST /memories`), read-path filter on every memory route, ownership check on `memory_id` operations. 2. **Multi-user invite flow.** UX (roles, per-org, hierarchies), endpoint surface (`POST /auth/invite`, `POST /auth/accept`), role transitions. 3. **Rate-limit `POST /generate-instructions`.** Gather usage data first; adding a limit could regress existing batch callers. 4. **Per-owner `GET /requests` filtering.** Requires `owner_id` on the `RequestLog` table. ## Backwards compatibility - **No DB migration in this PR.** - **No SDK changes**; request and response shapes are identical. - **No environment variable changes.** - **No breaking change** to existing single-admin deployments — every supported auth mode keeps working. ## Acceptance criteria 1. All five existing deployment modes (single-admin JWT, single-admin per-user key, `ADMIN_API_KEY` env, `AUTH_DISABLED=true`, plus authenticated non-admin which cannot exist today) succeed on every route they succeed on today. 2. The six admin-gated routes return 403 to a synthetic non-admin User (test-only DB insert). 3. `GET /memories` with no filter returns 403 to a synthetic non-admin; filtered calls are unchanged. 4. `ADMIN_API_KEY` and `AUTH_DISABLED` escape hatches are covered by automated tests, not just by code review. 5. `docs/open-source/features/rest-api.mdx` accurately describes the current per-user-key reality and marks the multi-user promise as "coming soon." 6. CI runs the new test suite on every PR touching `server/`. 7. Follow-up GitHub issue filed and linked from the PR description. ## Open questions for the implementation plan - Test DB: SQLite in-memory vs SQLite file vs containerized Postgres? Default to in-memory unless `pgvector`-specific code paths are required (they aren't, given the `MagicMock` strategy). - Should the new `server-ci.yml` workflow also run `ruff check`? (Likely yes — match the rest of the repo.) - Whether to add a quick-start `make test` target in `server/Makefile`.