chore: drop internal planning docs from public branch
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,274 +0,0 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user