Files
mem0/docs/superpowers/plans/2026-05-13-rest-api-authz-fix-plan.md
T
Mgeeeek 7b109d90de docs: add implementation plan for REST API authorization fix
Eleven-task TDD plan tracking the spec at
docs/superpowers/specs/2026-05-13-rest-api-authz-fix-design.md
(commit 8f285c75). Adds _ensure_admin helper, six admin-gated routes,
inside-route guard on GET /memories, server/ pytest scaffold (SQLite +
MagicMock), server-ci.yml workflow, and surgical docs edits. Each task
follows red/green/commit TDD; every gate covers the four supported
deployment modes (admin JWT, admin per-user key, ADMIN_API_KEY env,
AUTH_DISABLED).
2026-05-13 20:47:17 +05:30

1530 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# REST API Authorization Fix Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Admin-gate six routes in mem0's self-hosted OSS REST server, add an inside-route guard on `GET /memories`, surgically fix the docs/code mismatch in `docs/open-source/features/rest-api.mdx`, and stand up a minimal `server/tests/` pytest scaffold — without regressing any of the four supported deployment modes (single-admin JWT, single-admin per-user key, legacy `ADMIN_API_KEY` env, `AUTH_DISABLED=true`).
**Architecture:** One new helper (`_ensure_admin`) and one FastAPI dependency (`require_admin`) in `server/auth.py`. Swap `Depends(verify_auth)` → `Depends(require_admin)` on six routes across three files. Add one inside-route guard on the no-filter branch of `GET /memories`. Surgical edits to four lines in the docs. Pytest scaffold uses SQLite in-memory + `MagicMock` for the memory backend — no Postgres, no pgvector, no Docker required for tests. No DB migration, no SDK changes, no payload schema changes.
**Tech Stack:** Python 3.11, FastAPI 0.115, SQLAlchemy 2.x (DeclarativeBase), SQLite (test-only), pytest, httpx (FastAPI TestClient), unittest.mock. Reuse existing project conventions in `server/`.
**Spec:** `docs/superpowers/specs/2026-05-13-rest-api-authz-fix-design.md` (commit `8f285c75`).
---
## File Map
**Create:**
| Path | Responsibility |
|---|---|
| `server/tests/__init__.py` | empty package marker |
| `server/tests/conftest.py` | pytest fixtures: SQLite engine, TestClient, admin/non-admin User, JWT helpers, env-var monkeypatches |
| `server/tests/test_auth_helpers.py` | unit tests for `_ensure_admin` (sync) and integration tests for `require_admin` via a throwaway probe route |
| `server/tests/test_admin_gating.py` | integration tests for the six admin-gated routes + inside-route guard |
| `server/tests/test_unchanged_routes.py` | regression tests for memory CRUD and `/api-keys/*` |
| `server/requirements-dev.txt` | pytest, pytest-asyncio, httpx |
| `.github/workflows/server-ci.yml` | pytest CI on PRs touching `server/` |
**Modify:**
| Path | Change |
|---|---|
| `server/auth.py` | append `_ensure_admin` + `require_admin` |
| `server/main.py` | swap dep on `GET /configure`, `POST /configure`, `POST /reset`; refactor `GET /memories` for inside-route guard |
| `server/routers/entities.py` | swap dep on `GET /entities` and `DELETE /entities/{type}/{id}`; import `require_admin` |
| `server/routers/requests.py` | swap dep on `GET /requests`; drop unused `user` parameter; trim imports |
| `docs/open-source/features/rest-api.mdx` | surgical line edits (lines 134, 185, endpoint tables, 267) |
---
## Task 1: Pytest scaffold for `server/`
**Files:**
- Create: `server/tests/__init__.py`
- Create: `server/requirements-dev.txt`
- Create: `server/tests/conftest.py`
- Create: `server/tests/test_smoke.py` (deleted at end of task — used only to prove scaffold works)
- [ ] **Step 1: Create the test package marker**
```bash
mkdir -p server/tests
```
Write `server/tests/__init__.py`:
```python
```
(Empty file. Marks `server/tests` as a package.)
- [ ] **Step 2: Create the dev-deps file**
Write `server/requirements-dev.txt`:
```text
-r requirements.txt
pytest>=8.0,<9.0
pytest-asyncio>=0.23,<1.0
httpx>=0.27,<1.0
```
- [ ] **Step 3: Install dev deps locally**
Run: `pip install -r server/requirements-dev.txt`
Expected: clean install, no errors.
- [ ] **Step 4: Create the conftest with shared fixtures**
Write `server/tests/conftest.py`:
```python
"""Shared pytest fixtures for server/ tests.
Strategy:
- SQLite in-memory engine for the auth DB (Users, APIKey, RefreshTokenJti, etc).
- get_memory_instance() is patched to return a MagicMock so routes that touch
the memory backend don't need pgvector.
- The FastAPI app is imported lazily after env vars are set so module-level
constants in server.auth (JWT_SECRET, AUTH_DISABLED, ADMIN_API_KEY) bind
to the test values, not whatever the host shell has.
"""
from __future__ import annotations
import os
import sys
import uuid
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# Make server/ importable as a top-level package (matches uvicorn's CWD).
SERVER_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(SERVER_DIR))
# Required for JWT issuance during tests.
os.environ.setdefault("JWT_SECRET", "test-secret-do-not-use-in-prod-" + "x" * 32)
os.environ.setdefault("OPENAI_API_KEY", "test-key")
os.environ.setdefault("POSTGRES_HOST", "localhost") # silence db url builder
@pytest.fixture
def test_engine():
"""Fresh in-memory SQLite engine per test (no cross-test pollution)."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
)
# Import Base AFTER env vars are set.
from db import Base # noqa: E402
# Importing models registers them on Base.metadata.
import models # noqa: F401, E402
Base.metadata.create_all(engine)
return engine
@pytest.fixture
def test_session_factory(test_engine):
return sessionmaker(bind=test_engine, autoflush=False, expire_on_commit=False)
@pytest.fixture
def mock_memory():
"""A MagicMock standing in for the global Memory instance."""
mock = MagicMock()
mock.get_all.return_value = {"results": []}
mock.search.return_value = {"results": []}
mock.add.return_value = {"results": [], "events": []}
mock.get.return_value = {"id": "memory-id", "memory": "stub"}
mock.history.return_value = []
mock.delete.return_value = None
mock.delete_all.return_value = None
mock.reset.return_value = None
mock.vector_store.list.return_value = [[]]
return mock
@pytest.fixture
def client(test_session_factory, mock_memory, monkeypatch):
"""FastAPI TestClient with overridden DB session and mocked memory instance."""
from db import get_db # noqa: E402
import server_state # noqa: E402
monkeypatch.setattr(server_state, "get_memory_instance", lambda: mock_memory)
from main import app # noqa: E402
# Also patch the binding inside main.py since it imported get_memory_instance by name.
monkeypatch.setattr("main.get_memory_instance", lambda: mock_memory)
def _override_get_db():
db = test_session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = _override_get_db
try:
with TestClient(app) as c:
yield c
finally:
app.dependency_overrides.pop(get_db, None)
@pytest.fixture
def db_session(test_session_factory):
"""A direct DB session for tests that need to insert User rows."""
session = test_session_factory()
try:
yield session
finally:
session.close()
def _make_user(db_session, *, role: str, email: str | None = None):
from auth import hash_password
from models import User
user = User(
id=uuid.uuid4(),
name=f"{role}-user",
email=email or f"{role}-{uuid.uuid4().hex[:8]}@example.com",
password_hash=hash_password("test-password-123"),
role=role,
)
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user
@pytest.fixture
def admin_user(db_session):
return _make_user(db_session, role="admin")
@pytest.fixture
def member_user(db_session):
"""Non-admin User. No public endpoint produces these today; we insert directly."""
return _make_user(db_session, role="member")
@pytest.fixture
def admin_jwt(admin_user):
from auth import create_access_token
return create_access_token(str(admin_user.id), admin_user.role)
@pytest.fixture
def member_jwt(member_user):
from auth import create_access_token
return create_access_token(str(member_user.id), member_user.role)
@pytest.fixture
def auth_admin_header(admin_jwt):
return {"Authorization": f"Bearer {admin_jwt}"}
@pytest.fixture
def auth_member_header(member_jwt):
return {"Authorization": f"Bearer {member_jwt}"}
@pytest.fixture
def admin_api_key_env(monkeypatch):
"""Activates the legacy ADMIN_API_KEY escape hatch."""
import auth
key = "admin-api-key-test-value-" + "y" * 16
monkeypatch.setattr(auth, "ADMIN_API_KEY", key)
return {"X-API-Key": key}
@pytest.fixture
def auth_disabled_env(monkeypatch):
"""Activates AUTH_DISABLED=true."""
import auth
monkeypatch.setattr(auth, "AUTH_DISABLED", True)
return {} # no headers needed
```
- [ ] **Step 5: Add a smoke test to prove the scaffold loads**
Write `server/tests/test_smoke.py`:
```python
def test_scaffold_imports(client):
"""Asserts the TestClient fixture wires up cleanly."""
response = client.get("/auth/setup-status")
assert response.status_code == 200
assert response.json() == {"needsSetup": True}
```
- [ ] **Step 6: Run the smoke test**
Run: `cd server && pytest tests/test_smoke.py -v`
Expected: `1 passed`. If imports fail, fix the `sys.path` insert in conftest.py and re-run.
- [ ] **Step 7: Delete the smoke test (scaffold proven)**
```bash
rm server/tests/test_smoke.py
```
- [ ] **Step 8: Commit**
```bash
git add server/tests/__init__.py server/tests/conftest.py server/requirements-dev.txt
git commit -m "test(server): add pytest scaffold with SQLite + mocked memory backend"
```
---
## Task 2: `_ensure_admin` and `require_admin` (TDD)
**Files:**
- Create: `server/tests/test_auth_helpers.py`
- Modify: `server/auth.py` (append at end of file)
- [ ] **Step 1: Write the failing tests**
Write `server/tests/test_auth_helpers.py`:
```python
"""Unit tests for _ensure_admin and require_admin in server/auth.py."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
def _fake_request(auth_type: str = "none"):
return SimpleNamespace(state=SimpleNamespace(auth_type=auth_type))
def test_ensure_admin_passes_for_admin_user(admin_user):
from auth import _ensure_admin
_ensure_admin(_fake_request("bearer"), admin_user) # must not raise
def test_ensure_admin_passes_for_admin_api_key_auth_type():
from auth import _ensure_admin
_ensure_admin(_fake_request("admin_api_key"), None) # must not raise
def test_ensure_admin_passes_for_auth_disabled():
from auth import _ensure_admin
_ensure_admin(_fake_request("disabled"), None) # must not raise
def test_ensure_admin_rejects_non_admin_user(member_user):
from auth import _ensure_admin
with pytest.raises(HTTPException) as exc:
_ensure_admin(_fake_request("bearer"), member_user)
assert exc.value.status_code == 403
assert "Admin role required" in exc.value.detail
def test_ensure_admin_rejects_no_user_with_no_legacy_path():
from auth import _ensure_admin
with pytest.raises(HTTPException) as exc:
_ensure_admin(_fake_request("none"), None)
assert exc.value.status_code == 403
def test_ensure_admin_rejects_bearer_with_no_user():
"""Defense-in-depth: a 'bearer' auth_type without a User should not pass."""
from auth import _ensure_admin
with pytest.raises(HTTPException) as exc:
_ensure_admin(_fake_request("bearer"), None)
assert exc.value.status_code == 403
```
- [ ] **Step 2: Run tests, confirm they fail**
Run: `cd server && pytest tests/test_auth_helpers.py -v`
Expected: 6 errors (`ImportError: cannot import name '_ensure_admin' from 'auth'`).
- [ ] **Step 3: Implement `_ensure_admin` and `require_admin`**
Edit `server/auth.py`. Append at the end of the file (after the existing `require_auth` function):
```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 header), AUTH_DISABLED=true,
or a registered User with role == 'admin'. Raises 403 otherwise.
Pure function — reusable from FastAPI deps and inline route guards.
"""
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. Admin-only gate; returns nothing."""
_ensure_admin(request, user)
```
- [ ] **Step 4: Run tests, confirm they pass**
Run: `cd server && pytest tests/test_auth_helpers.py -v`
Expected: `6 passed`.
- [ ] **Step 5: Commit**
```bash
git add server/auth.py server/tests/test_auth_helpers.py
git commit -m "feat(server): add _ensure_admin helper and require_admin FastAPI dep"
```
---
## Task 3: Admin-gate `GET /configure` and `POST /configure`
**Files:**
- Create: `server/tests/test_admin_gating.py`
- Modify: `server/main.py:303-318`
- [ ] **Step 1: Write the failing tests**
Write `server/tests/test_admin_gating.py`:
```python
"""Integration tests for admin-gated routes.
Covers, for each affected route, the regression matrix:
- admin JWT -> success
- ADMIN_API_KEY -> success (no DB user required)
- AUTH_DISABLED -> success
- member JWT -> 403
- no auth -> 401
"""
from __future__ import annotations
import pytest
# --- GET /configure ---
def test_get_configure_admin_jwt(client, auth_admin_header):
response = client.get("/configure", headers=auth_admin_header)
assert response.status_code == 200
def test_get_configure_admin_api_key(client, admin_api_key_env):
response = client.get("/configure", headers=admin_api_key_env)
assert response.status_code == 200
def test_get_configure_auth_disabled(client, auth_disabled_env):
response = client.get("/configure")
assert response.status_code == 200
def test_get_configure_member_forbidden(client, auth_member_header):
response = client.get("/configure", headers=auth_member_header)
assert response.status_code == 403
def test_get_configure_no_auth_unauthorized(client):
response = client.get("/configure")
assert response.status_code == 401
# --- POST /configure ---
def _valid_config():
return {
"vector_store": {"provider": "pgvector", "config": {"host": "h"}},
"llm": {"provider": "openai", "config": {"api_key": "x", "model": "m"}},
"embedder": {"provider": "openai", "config": {"api_key": "x", "model": "e"}},
}
def test_post_configure_admin_jwt(client, auth_admin_header):
response = client.post("/configure", headers=auth_admin_header, json=_valid_config())
assert response.status_code == 200
def test_post_configure_admin_api_key(client, admin_api_key_env):
response = client.post("/configure", headers=admin_api_key_env, json=_valid_config())
assert response.status_code == 200
def test_post_configure_auth_disabled(client, auth_disabled_env):
response = client.post("/configure", json=_valid_config())
assert response.status_code == 200
def test_post_configure_member_forbidden(client, auth_member_header):
response = client.post("/configure", headers=auth_member_header, json=_valid_config())
assert response.status_code == 403
def test_post_configure_no_auth_unauthorized(client):
response = client.post("/configure", json=_valid_config())
assert response.status_code == 401
```
- [ ] **Step 2: Run the tests, confirm member tests FAIL with 200 instead of 403**
Run: `cd server && pytest tests/test_admin_gating.py::test_get_configure_member_forbidden tests/test_admin_gating.py::test_post_configure_member_forbidden -v`
Expected: both FAIL with `assert 200 == 403` (the routes currently accept any authenticated caller).
- [ ] **Step 3: Swap the dep on both `/configure` routes**
Edit `server/main.py`. Change lines 303-305 (`GET /configure`):
From:
```python
@app.get("/configure", summary="Get current Mem0 configuration")
def get_config(_auth=Depends(verify_auth)):
return _redact_config(get_current_config())
```
To:
```python
@app.get("/configure", summary="Get current Mem0 configuration")
def get_config(_admin=Depends(require_admin)):
return _redact_config(get_current_config())
```
Change lines 313-318 (`POST /configure`):
From:
```python
@app.post("/configure", summary="Configure Mem0")
def set_config(config: Dict[str, Any], _auth=Depends(verify_auth)):
"""Set memory configuration."""
_validate_bundled_providers(config)
update_config(config)
return {"message": "Configuration set successfully"}
```
To:
```python
@app.post("/configure", summary="Configure Mem0")
def set_config(config: Dict[str, Any], _admin=Depends(require_admin)):
"""Set memory configuration."""
_validate_bundled_providers(config)
update_config(config)
return {"message": "Configuration set successfully"}
```
Update the import at the top of `server/main.py` (line 16) — add `require_admin`:
From:
```python
from auth import ADMIN_API_KEY, AUTH_DISABLED, JWT_SECRET, verify_auth
```
To:
```python
from auth import ADMIN_API_KEY, AUTH_DISABLED, JWT_SECRET, require_admin, verify_auth
```
- [ ] **Step 4: Run the configure tests, confirm all pass**
Run: `cd server && pytest tests/test_admin_gating.py -k "configure" -v`
Expected: `10 passed`.
- [ ] **Step 5: Commit**
```bash
git add server/main.py server/tests/test_admin_gating.py
git commit -m "feat(server): admin-gate GET/POST /configure"
```
---
## Task 4: Admin-gate `POST /reset`
**Files:**
- Modify: `server/main.py:475-482`
- Modify: `server/tests/test_admin_gating.py` (append)
- [ ] **Step 1: Append failing tests for `/reset`**
Append to `server/tests/test_admin_gating.py`:
```python
# --- POST /reset ---
def test_post_reset_admin_jwt(client, auth_admin_header):
response = client.post("/reset", headers=auth_admin_header)
assert response.status_code == 200
def test_post_reset_admin_api_key(client, admin_api_key_env):
response = client.post("/reset", headers=admin_api_key_env)
assert response.status_code == 200
def test_post_reset_auth_disabled(client, auth_disabled_env):
response = client.post("/reset")
assert response.status_code == 200
def test_post_reset_member_forbidden(client, auth_member_header):
response = client.post("/reset", headers=auth_member_header)
assert response.status_code == 403
def test_post_reset_no_auth_unauthorized(client):
response = client.post("/reset")
assert response.status_code == 401
```
- [ ] **Step 2: Run, confirm member test FAILS**
Run: `cd server && pytest tests/test_admin_gating.py::test_post_reset_member_forbidden -v`
Expected: FAIL with `assert 200 == 403`.
- [ ] **Step 3: Swap the dep on `POST /reset`**
Edit `server/main.py`. Change lines 475-482:
From:
```python
@app.post("/reset", summary="Reset all memories")
def reset_memory(_auth=Depends(verify_auth)):
"""Completely reset stored memories."""
try:
get_memory_instance().reset()
return {"message": "All memories reset"}
except Exception:
raise upstream_error()
```
To:
```python
@app.post("/reset", summary="Reset all memories")
def reset_memory(_admin=Depends(require_admin)):
"""Completely reset stored memories."""
try:
get_memory_instance().reset()
return {"message": "All memories reset"}
except Exception:
raise upstream_error()
```
- [ ] **Step 4: Run, confirm all pass**
Run: `cd server && pytest tests/test_admin_gating.py -k "reset" -v`
Expected: `5 passed`.
- [ ] **Step 5: Commit**
```bash
git add server/main.py server/tests/test_admin_gating.py
git commit -m "feat(server): admin-gate POST /reset"
```
---
## Task 5: Admin-gate `GET /entities` and `DELETE /entities/{type}/{id}`
**Files:**
- Modify: `server/routers/entities.py`
- Modify: `server/tests/test_admin_gating.py` (append)
- [ ] **Step 1: Append failing tests for `/entities`**
Append to `server/tests/test_admin_gating.py`:
```python
# --- GET /entities ---
def test_get_entities_admin_jwt(client, auth_admin_header):
response = client.get("/entities", headers=auth_admin_header)
assert response.status_code == 200
def test_get_entities_admin_api_key(client, admin_api_key_env):
response = client.get("/entities", headers=admin_api_key_env)
assert response.status_code == 200
def test_get_entities_auth_disabled(client, auth_disabled_env):
response = client.get("/entities")
assert response.status_code == 200
def test_get_entities_member_forbidden(client, auth_member_header):
response = client.get("/entities", headers=auth_member_header)
assert response.status_code == 403
def test_get_entities_no_auth_unauthorized(client):
response = client.get("/entities")
assert response.status_code == 401
# --- DELETE /entities/{type}/{id} ---
def test_delete_entity_admin_jwt(client, auth_admin_header):
response = client.delete("/entities/user/alice", headers=auth_admin_header)
assert response.status_code == 200
def test_delete_entity_admin_api_key(client, admin_api_key_env):
response = client.delete("/entities/user/alice", headers=admin_api_key_env)
assert response.status_code == 200
def test_delete_entity_auth_disabled(client, auth_disabled_env):
response = client.delete("/entities/user/alice")
assert response.status_code == 200
def test_delete_entity_member_forbidden(client, auth_member_header):
response = client.delete("/entities/user/alice", headers=auth_member_header)
assert response.status_code == 403
def test_delete_entity_no_auth_unauthorized(client):
response = client.delete("/entities/user/alice")
assert response.status_code == 401
```
- [ ] **Step 2: Run, confirm member tests FAIL**
Run: `cd server && pytest tests/test_admin_gating.py -k "entities" -v`
Expected: 2 failures on the `_member_forbidden` tests.
- [ ] **Step 3: Swap the deps on both entity routes**
Edit `server/routers/entities.py`.
Change the imports (line 8):
From:
```python
from auth import verify_auth
```
To:
```python
from auth import require_admin
```
Change lines 44-45:
From:
```python
@router.get("", response_model=list[Entity])
def list_entities(_auth=Depends(verify_auth)):
```
To:
```python
@router.get("", response_model=list[Entity])
def list_entities(_admin=Depends(require_admin)):
```
Change lines 71-72:
From:
```python
@router.delete("/{entity_type}/{entity_id}", response_model=MessageResponse)
def delete_entity(entity_type: EntityType, entity_id: str, _auth=Depends(verify_auth)):
```
To:
```python
@router.delete("/{entity_type}/{entity_id}", response_model=MessageResponse)
def delete_entity(entity_type: EntityType, entity_id: str, _admin=Depends(require_admin)):
```
- [ ] **Step 4: Run, confirm all entity tests pass**
Run: `cd server && pytest tests/test_admin_gating.py -k "entities or entity" -v`
Expected: `10 passed`.
- [ ] **Step 5: Commit**
```bash
git add server/routers/entities.py server/tests/test_admin_gating.py
git commit -m "feat(server): admin-gate GET /entities and DELETE /entities/{type}/{id}"
```
---
## Task 6: Admin-gate `GET /requests`
**Files:**
- Modify: `server/routers/requests.py`
- Modify: `server/tests/test_admin_gating.py` (append)
- [ ] **Step 1: Append failing tests for `/requests`**
Append to `server/tests/test_admin_gating.py`:
```python
# --- GET /requests ---
def test_get_requests_admin_jwt(client, auth_admin_header):
response = client.get("/requests", headers=auth_admin_header)
assert response.status_code == 200
def test_get_requests_admin_api_key(client, admin_api_key_env):
response = client.get("/requests", headers=admin_api_key_env)
assert response.status_code == 200
def test_get_requests_auth_disabled(client, auth_disabled_env):
response = client.get("/requests")
assert response.status_code == 200
def test_get_requests_member_forbidden(client, auth_member_header):
response = client.get("/requests", headers=auth_member_header)
assert response.status_code == 403
def test_get_requests_no_auth_unauthorized(client):
response = client.get("/requests")
assert response.status_code == 401
```
- [ ] **Step 2: Run, confirm member test FAILS**
Run: `cd server && pytest tests/test_admin_gating.py -k "requests" -v`
Expected: 1 failure on `test_get_requests_member_forbidden` (currently passes auth via `require_auth`).
- [ ] **Step 3: Swap dep + drop unused `user` parameter**
Edit `server/routers/requests.py`.
Change the imports block:
From:
```python
from datetime import datetime
import uuid
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from auth import require_auth
from db import get_db
from models import RequestLog, User
```
To:
```python
from datetime import datetime
import uuid
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from auth import require_admin
from db import get_db
from models import RequestLog
```
(Dropped `require_auth` and the now-unused `User` import.)
Change the route handler (lines 31-37):
From:
```python
@router.get("", response_model=list[RequestLogItem])
def list_requests(
user: User = Depends(require_auth),
db: Session = Depends(get_db),
limit: int = Query(default=50, ge=1, le=200),
):
```
To:
```python
@router.get("", response_model=list[RequestLogItem])
def list_requests(
_admin=Depends(require_admin),
db: Session = Depends(get_db),
limit: int = Query(default=50, ge=1, le=200),
):
```
- [ ] **Step 4: Run all admin-gating tests, confirm all pass**
Run: `cd server && pytest tests/test_admin_gating.py -v`
Expected: `30 passed` (5 routes × 5 cases each = 25, plus 5 from delete_entity = 30).
- [ ] **Step 5: Commit**
```bash
git add server/routers/requests.py server/tests/test_admin_gating.py
git commit -m "feat(server): admin-gate GET /requests"
```
---
## Task 7: Inside-route guard on `GET /memories` (no-filter branch)
**Files:**
- Modify: `server/main.py:387-403`
- Modify: `server/tests/test_admin_gating.py` (append)
- [ ] **Step 1: Append failing tests**
Append to `server/tests/test_admin_gating.py`:
```python
# --- GET /memories (no filter branch only) ---
def test_get_memories_no_filter_admin_jwt(client, auth_admin_header):
response = client.get("/memories", headers=auth_admin_header)
assert response.status_code == 200
def test_get_memories_no_filter_admin_api_key(client, admin_api_key_env):
response = client.get("/memories", headers=admin_api_key_env)
assert response.status_code == 200
def test_get_memories_no_filter_auth_disabled(client, auth_disabled_env):
response = client.get("/memories")
assert response.status_code == 200
def test_get_memories_no_filter_member_forbidden(client, auth_member_header):
"""The info-disclosure branch (_list_all_memories) is admin-only."""
response = client.get("/memories", headers=auth_member_header)
assert response.status_code == 403
def test_get_memories_filtered_member_succeeds(client, auth_member_header):
"""Filtered queries are unchanged — member can still call them."""
response = client.get("/memories?user_id=alice", headers=auth_member_header)
assert response.status_code == 200
def test_get_memories_no_auth_unauthorized(client):
response = client.get("/memories")
assert response.status_code == 401
```
- [ ] **Step 2: Run, confirm member-no-filter FAILS**
Run: `cd server && pytest tests/test_admin_gating.py -k "memories_no_filter_member" -v`
Expected: FAIL with `assert 200 == 403`.
- [ ] **Step 3: Refactor `GET /memories` to use the inside-route guard**
Edit `server/main.py`.
Update the import at line 16 to also pull `_ensure_admin`:
From:
```python
from auth import ADMIN_API_KEY, AUTH_DISABLED, JWT_SECRET, require_admin, verify_auth
```
To:
```python
from auth import ADMIN_API_KEY, AUTH_DISABLED, JWT_SECRET, _ensure_admin, require_admin, verify_auth
```
Replace the `GET /memories` handler (lines 387-403). From:
```python
@app.get("/memories", summary="Get memories")
def get_all_memories(
user_id: Optional[str] = None,
run_id: Optional[str] = None,
agent_id: Optional[str] = None,
_auth=Depends(verify_auth),
):
"""Retrieve stored memories. Lists all memories when no identifier is provided."""
try:
if not any([user_id, run_id, agent_id]):
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()
```
To:
```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),
):
"""Retrieve stored memories. Lists all memories when no identifier is provided.
Note: the unfiltered listing branch is admin-only — it would otherwise leak
every payload in the vector store across tenants once multi-user lands.
Filtered queries remain available to any authenticated caller."""
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 HTTPException:
raise
except Exception:
raise upstream_error()
```
Two changes worth flagging:
- The signature now takes `request: Request` and renames `_auth` → `user` so the guard can read it.
- The `try` block now re-raises `HTTPException` before falling through to `upstream_error()`. Without this, the 403 we just added would get swallowed and turned into a generic 502/500.
Also add `User` to the imports if it's not already there. Check `server/main.py:31` — it imports `RequestLog, User` from `models`, so we're covered.
- [ ] **Step 4: Run, confirm all `/memories` tests pass**
Run: `cd server && pytest tests/test_admin_gating.py -k "memories" -v`
Expected: `6 passed`.
- [ ] **Step 5: Run the full admin-gating suite to confirm no regression**
Run: `cd server && pytest tests/test_admin_gating.py -v`
Expected: `36 passed`.
- [ ] **Step 6: Commit**
```bash
git add server/main.py server/tests/test_admin_gating.py
git commit -m "feat(server): admin-gate unfiltered GET /memories branch"
```
---
## Task 8: Regression tests for unchanged routes
**Files:**
- Create: `server/tests/test_unchanged_routes.py`
These tests assert that the routes we *deliberately* left alone still behave correctly for admins (the only user type that exists today). They lock the behavior in place so future refactors can't silently change it.
- [ ] **Step 1: Write the regression tests**
Write `server/tests/test_unchanged_routes.py`:
```python
"""Regression tests for routes deliberately left untouched by the authz fix.
These prove the per-route changes did not accidentally regress memory CRUD,
search, or the API-key management endpoints."""
from __future__ import annotations
def test_post_memories_admin_succeeds(client, auth_admin_header):
response = client.post(
"/memories",
headers=auth_admin_header,
json={
"messages": [{"role": "user", "content": "I love pizza."}],
"user_id": "alice",
},
)
assert response.status_code == 200
def test_post_memories_requires_identifier(client, auth_admin_header):
response = client.post(
"/memories",
headers=auth_admin_header,
json={"messages": [{"role": "user", "content": "hi"}]},
)
assert response.status_code == 400
def test_get_memories_filtered_admin_succeeds(client, auth_admin_header):
response = client.get("/memories?user_id=alice", headers=auth_admin_header)
assert response.status_code == 200
def test_post_search_admin_succeeds(client, auth_admin_header):
response = client.post(
"/search",
headers=auth_admin_header,
json={"query": "pizza", "user_id": "alice"},
)
assert response.status_code == 200
def test_get_memory_by_id_admin_succeeds(client, auth_admin_header):
response = client.get("/memories/some-id", headers=auth_admin_header)
assert response.status_code == 200
def test_delete_memories_filtered_admin_succeeds(client, auth_admin_header):
response = client.delete("/memories?user_id=alice", headers=auth_admin_header)
assert response.status_code == 200
def test_delete_memories_no_filter_returns_400(client, auth_admin_header):
"""Even admin must supply an identifier for filtered delete."""
response = client.delete("/memories", headers=auth_admin_header)
assert response.status_code == 400
def test_get_configure_providers_admin_succeeds(client, auth_admin_header):
"""GET /configure/providers stays on verify_auth (not admin-gated)."""
response = client.get("/configure/providers", headers=auth_admin_header)
assert response.status_code == 200
def test_get_configure_providers_member_succeeds(client, auth_member_header):
"""Members can still query bundled providers — not admin-gated."""
response = client.get("/configure/providers", headers=auth_member_header)
assert response.status_code == 200
def test_api_keys_list_admin_succeeds(client, auth_admin_header):
response = client.get("/api-keys", headers=auth_admin_header)
assert response.status_code == 200
def test_api_keys_list_member_succeeds(client, auth_member_header):
"""API-key management remains scoped to the caller (require_auth, not require_admin)."""
response = client.get("/api-keys", headers=auth_member_header)
assert response.status_code == 200
def test_auth_me_admin_succeeds(client, auth_admin_header):
response = client.get("/auth/me", headers=auth_admin_header)
assert response.status_code == 200
def test_setup_status_no_auth(client):
"""Open route — no auth required even after the fix."""
response = client.get("/auth/setup-status")
assert response.status_code == 200
```
- [ ] **Step 2: Run the regression suite**
Run: `cd server && pytest tests/test_unchanged_routes.py -v`
Expected: `13 passed`.
- [ ] **Step 3: Run the full server test suite to confirm everything is green**
Run: `cd server && pytest tests/ -v`
Expected: `55 passed` (6 helpers + 36 admin-gating + 13 unchanged).
- [ ] **Step 4: Commit**
```bash
git add server/tests/test_unchanged_routes.py
git commit -m "test(server): regression coverage for routes untouched by authz fix"
```
---
## Task 9: Docs — surgical edits to `rest-api.mdx`
**Files:**
- Modify: `docs/open-source/features/rest-api.mdx`
- [ ] **Step 1: Update the auth-table row for per-user API keys (line 134)**
Find this line (in the auth-modes table around lines 131-136):
```markdown
| Per-user API key | `X-API-Key: m0sk_...` | Programmatic access scoped to a single dashboard user |
```
Replace with:
```markdown
| Per-user API key | `X-API-Key: m0sk_...` | Programmatic access tied to the single admin user today. Per-user scoping arrives with the upcoming multi-user invite flow |
```
- [ ] **Step 2: Remove the inaccurate "inherit scope" sentence (line 185)**
Find this line:
```markdown
Per-user keys inherit the creating user's scope. List or revoke them via `GET /api-keys` and `DELETE /api-keys/{id}`.
```
Replace with:
```markdown
List or revoke per-user keys via `GET /api-keys` and `DELETE /api-keys/{id}`. The dashboard user issued the key remains its owner; admin-only routes such as `/configure`, `/reset`, `/entities`, and `/requests` require the issuing user to have the admin role.
```
- [ ] **Step 3: Annotate admin-only endpoints in the reference tables**
In the "Memory operations" table (around lines 247-260), change:
```markdown
| `POST` | `/configure` | Set memory configuration. Rejects unbundled providers with a 400 |
| `GET` | `/configure` | Get the current memory configuration |
```
To:
```markdown
| `POST` | `/configure` | **Admin only.** Set memory configuration. Rejects unbundled providers with a 400 |
| `GET` | `/configure` | **Admin only.** Get the current memory configuration |
```
And:
```markdown
| `POST` | `/reset` | Reset all memories |
```
To:
```markdown
| `POST` | `/reset` | **Admin only.** Reset all memories |
```
In the "Entities" table (around lines 290-296), change:
```markdown
| `GET` | `/entities` | Distinct `user_id` / `agent_id` / `run_id` values with memory counts |
| `DELETE` | `/entities/{entity_type}/{entity_id}` | Cascade-delete all memories for an entity; `entity_type` is `user`, `agent`, or `run` |
```
To:
```markdown
| `GET` | `/entities` | **Admin only.** Distinct `user_id` / `agent_id` / `run_id` values with memory counts |
| `DELETE` | `/entities/{entity_type}/{entity_id}` | **Admin only.** Cascade-delete all memories for an entity; `entity_type` is `user`, `agent`, or `run` |
```
In the "Request logs" table:
```markdown
| `GET` | `/requests?limit=N` | Recent API call log (JWT or admin key) |
```
To:
```markdown
| `GET` | `/requests?limit=N` | **Admin only.** Recent API call log |
```
- [ ] **Step 4: Add a "coming soon" marker to the multi-user promise (line ~267)**
Find this line in the auth-endpoints section:
```markdown
| `POST` | `/auth/register` | Register the first admin. Registration closes after the first admin is created; additional accounts are provisioned by the existing admin. |
```
Replace with:
```markdown
| `POST` | `/auth/register` | Register the first admin. Registration closes after the first admin is created; additional accounts will be provisioned by the existing admin (multi-user invite flow coming soon). |
```
- [ ] **Step 5: Verify `docs/llms.txt` is still in sync**
Run: `python scripts/check-llms-txt-coverage.py`
Expected: exit 0 (no changes — we did not add or remove any `.mdx` pages, just edited an existing one).
- [ ] **Step 6: Commit**
```bash
git add docs/open-source/features/rest-api.mdx
git commit -m "docs(rest-api): document admin-only routes and clarify per-user-key scope"
```
---
## Task 10: CI workflow for `server/`
**Files:**
- Create: `.github/workflows/server-ci.yml`
- [ ] **Step 1: Create the workflow file**
Write `.github/workflows/server-ci.yml`:
```yaml
name: Server CI
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'server/**'
- '.github/workflows/server-ci.yml'
pull_request:
paths:
- 'server/**'
- '.github/workflows/server-ci.yml'
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dev dependencies
working-directory: server
run: pip install -r requirements-dev.txt
- name: Run tests
working-directory: server
env:
JWT_SECRET: ci-test-secret-do-not-use-in-prod-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
OPENAI_API_KEY: ci-test-key
run: pytest tests/ -v
```
- [ ] **Step 2: Verify locally that the workflow would pass**
Run: `cd server && JWT_SECRET=ci-test-secret-do-not-use-in-prod-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx OPENAI_API_KEY=ci-test-key pytest tests/ -v`
Expected: `55 passed`.
- [ ] **Step 3: Commit**
```bash
git add .github/workflows/server-ci.yml
git commit -m "ci(server): add pytest workflow on PRs touching server/"
```
---
## Task 11: Final verification + PR draft
- [ ] **Step 1: Run the full test suite one more time**
Run: `cd server && pytest tests/ -v`
Expected: `55 passed, 0 failed`.
- [ ] **Step 2: Review the diff**
Run: `git log --oneline main..HEAD` and `git diff main --stat`
Expected: ~10 commits, modifications confined to `server/`, `docs/open-source/features/rest-api.mdx`, `docs/superpowers/`, and `.github/workflows/server-ci.yml`. Roughly 30 LOC of production code change, the rest is tests, docs, and CI.
- [ ] **Step 3: Draft the PR description**
Use this template:
```markdown
## Linked Issue
Closes <fill in> — security report from vonbrubeck@gmail.com (2026-05-01) on REST API per-user API key authorization.
## Description
Hardens the self-hosted OSS REST server's authorization model by admin-gating
six global / destructive / info-disclosing routes and adding an inside-route
guard on `GET /memories`. Also adds the first `server/tests/` pytest scaffold
so this change (and future ones) have automated regression coverage.
Scope: B+ from `docs/superpowers/specs/2026-05-13-rest-api-authz-fix-design.md`.
Deferred to follow-up issue:
- `owner_id` on memory payloads
- Multi-user invite/provision flow
- Rate-limiting `/generate-instructions`
- Per-owner filtering on `/requests`
Zero behavior change on every supported deployment mode today (single-admin
JWT, single-admin per-user API key, legacy `ADMIN_API_KEY` env,
`AUTH_DISABLED=true`).
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature
- [ ] Breaking change
- [ ] Refactor
- [x] Documentation update
## Breaking Changes
None. The new 403 path is reachable only by a non-admin `User`, which the
current schema (partial unique index in migration 004, closed `/auth/register`)
cannot produce via public APIs.
## Test Coverage
- [x] Added unit tests for `_ensure_admin`
- [x] Added integration tests for the 6 admin-gated routes + `GET /memories` inside-route guard
- [x] Added regression tests for routes deliberately left unchanged
- [x] Tested manually: `ADMIN_API_KEY` env, `AUTH_DISABLED=true`, admin JWT, admin per-user key
## Checklist
- [x] Code follows the project's style guidelines
- [x] Self-review performed
- [x] New tests prove the fix works
- [x] All tests pass locally
- [x] Documentation updated
```
- [ ] **Step 4: Open the PR**
```bash
git push -u origin <current-branch>
gh pr create --title "fix(server): admin-gate global REST routes; fix docs/code authz mismatch" \
--body-file <(cat <<'EOF'
[paste PR body from Step 3]
EOF
)
```
- [ ] **Step 5: After PR opens, file the follow-up issue**
Body of the follow-up GitHub issue (single body, no separate design doc):
```markdown
## Follow-up: REST API multi-user authorization (owner_id + invite flow)
Tracked from #<this-PR> per `docs/superpowers/specs/2026-05-13-rest-api-authz-fix-design.md`.
The B+ fix in #<this-PR> admin-gates the global/destructive routes but does not
add per-user scoping to the memory CRUD routes. That requires:
### 1. `owner_id` on memory payloads
Decide whether to store `owner_id` as a JSONB payload key or as a first-class
column on the vector-store row. Spec the backfill story for existing
deployments (assign all existing rows to the single admin). Wire the write
path (`POST /memories`) to inject `owner_id` from the authenticated `User`,
and every read path (`GET /memories` filtered, `POST /search`, all
`memory_id` ops) to filter or check ownership.
### 2. Multi-user invite flow
The docs at `rest-api.mdx` already promise this. Spec the UX: roles
(admin/member/viewer?), per-org structure, invite endpoint, accept flow,
role transitions.
### 3. Rate-limit `POST /generate-instructions`
Burns LLM tokens charged to the server's `OPENAI_API_KEY`. Adding a limit
could regress existing batch callers — gather usage data first, then decide.
### 4. Per-owner filtering on `GET /requests`
Requires `owner_id` on the `RequestLog` table (DB migration).
```
---
## Self-Review
Checking the plan against the spec:
**Spec coverage:**
| Spec section | Task(s) |
|---|---|
| `_ensure_admin` helper + `require_admin` dep | Task 2 |
| Full gate on `/configure` (GET + POST) | Task 3 |
| Full gate on `/reset` | Task 4 |
| Full gate on `/entities` (GET + DELETE) | Task 5 |
| Full gate on `/requests` | Task 6 |
| Inside-route guard on `GET /memories` | Task 7 |
| Routes deliberately NOT changed | Task 8 (regression coverage) |
| Docs surgical edits | Task 9 |
| Test scaffolding (`server/tests/`) | Tasks 1, 2, 3-8 |
| CI workflow | Task 10 |
| Regression matrix coverage (5 modes) | Built into every gating test |
| Acceptance criterion: ADMIN_API_KEY / AUTH_DISABLED automated coverage | Tasks 3-7 each include both |
| Follow-up issue body | Task 11, Step 5 |
**Type and method consistency:**
- `_ensure_admin(request, user)` signature is consistent in `server/auth.py`, the unit tests in Task 2, and the inside-route call site in Task 7.
- `require_admin` is called via `_admin=Depends(require_admin)` in every modified route (Tasks 3-6).
- Test fixtures (`admin_user`, `member_user`, `auth_admin_header`, `auth_member_header`, `admin_api_key_env`, `auth_disabled_env`) are defined in `conftest.py` (Task 1) and reused unchanged in Tasks 2-8.
- `JWT_SECRET` and `OPENAI_API_KEY` env vars are set in conftest (Task 1) and again in the CI workflow (Task 10) — same names.
**Placeholder scan:** none. Every step contains either a complete code block, an exact command, or a precise instruction with a from→to diff.
**KISS / DRY / minimal-changes audit:**
- Single helper (`_ensure_admin`) reused by both the FastAPI dep and the inline route guard — DRY.
- Six dep swaps are mechanically identical — one pattern, six call sites — KISS.
- No new infrastructure beyond what the spec required (no Postgres for tests, no separate design doc for the follow-up).
- `GET /configure/providers` deliberately not admin-gated, per the trim agreed during brainstorming.
---
## Execution Handoff
Plan complete and saved to `docs/superpowers/plans/2026-05-13-rest-api-authz-fix-plan.md`. Two execution options:
**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration.
**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.
Which approach?