Add multi-tenant support with owner_id isolation

- Add owner_id (FK to users) column to llm_messages, jobs, and
  tracked_companies tables via schema migration in initialize_schema()
- Filter all read/write operations by authenticated user's owner_id
  so users cannot see or modify each other's data
- Add user-scoped /tracked endpoints alongside existing admin ones
- Add admin-scoped /admin/analyses and /admin/jobs endpoints that
  return cross-tenant data without owner filtering
- Create migration script (scripts/migrate_add_owner_id.py) that
  backfills owner_id=1 for all existing rows
- Replace global UNIQUE on tracked_companies.company_name with
  per-owner unique index (company_name, owner_id)
- Fix route ordering: /analyze/batch and /analyze/patent routes now
  registered before /analyze/{company_name} to prevent path conflicts
- Update all existing API tests with proper auth headers and owner_id
  assertions
- Add comprehensive cross-tenant isolation test suite
  (tests/test_multi_tenant.py)

Closes leeworks-agents/SPARC#1677

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
agent-company
2026-05-19 16:04:58 +00:00
parent 3dfa651f2d
commit e37859dabc
8 changed files with 964 additions and 164 deletions
+71 -10
View File
@@ -1,17 +1,18 @@
"""Tests for tracked company admin endpoints and scheduler integration.
"""Tests for tracked company endpoints and scheduler integration.
Covers issue #1656:
- GET /admin/tracked (list tracked companies)
- POST /admin/tracked (add a tracked company)
- DELETE /admin/tracked/{company_name} (remove a tracked company)
Covers:
- GET /tracked (user-scoped list)
- POST /tracked (user-scoped add)
- DELETE /tracked/{company_name} (user-scoped remove)
- GET /admin/tracked (admin: all companies)
- POST /admin/tracked (admin: add)
- DELETE /admin/tracked/{company_name} (admin: remove any)
- GET /admin/alerts (list alerts)
- scheduler.run_scheduled_analysis() integration
All tests mock the database layer and use JWT auth fixtures.
"""
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch, call
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
@@ -125,7 +126,7 @@ class TestAddTrackedCompany:
assert response.status_code == 200
data = response.json()
assert data["company_name"] == "Intel"
mock_db.add_tracked_company.assert_called_once_with("Intel")
mock_db.add_tracked_company.assert_called_once_with("Intel", owner_id=1)
def test_add_duplicate_returns_409(self, client, mock_db):
"""Adding an already-tracked company returns 409."""
@@ -141,7 +142,7 @@ class TestAddTrackedCompany:
assert "already tracked" in response.json()["detail"].lower()
def test_add_tracked_requires_admin(self, client, mock_db):
"""Regular user cannot add tracked companies."""
"""Regular user cannot add tracked companies via admin endpoint."""
mock_db.get_user_by_id.return_value = {
"id": 2,
"email": "user@test.com",
@@ -215,6 +216,66 @@ class TestRemoveTrackedCompany:
assert response.status_code == 403
# ---------- User-scoped tracked companies ----------
class TestUserScopedTrackedCompanies:
"""Tests for /tracked user-scoped endpoints."""
def test_user_list_tracked(self, client, mock_db):
"""Regular user can list their own tracked companies."""
mock_db.get_user_by_id.return_value = {
"id": 2,
"email": "user@test.com",
"role": "user",
"created_at": datetime(2025, 1, 1, tzinfo=timezone.utc),
}
mock_db.list_tracked_companies.return_value = [
{"company_name": "AMD", "owner_id": 2},
]
response = client.get("/tracked", headers=_user_header())
assert response.status_code == 200
mock_db.list_tracked_companies.assert_called_with(owner_id=2)
def test_user_add_tracked(self, client, mock_db):
"""Regular user can add a company to their own tracked list."""
mock_db.get_user_by_id.return_value = {
"id": 2,
"email": "user@test.com",
"role": "user",
"created_at": datetime(2025, 1, 1, tzinfo=timezone.utc),
}
mock_db.add_tracked_company.return_value = {
"company_name": "Intel",
"owner_id": 2,
}
response = client.post(
"/tracked",
json={"company_name": "Intel"},
headers=_user_header(),
)
assert response.status_code == 200
mock_db.add_tracked_company.assert_called_once_with("Intel", owner_id=2)
def test_user_remove_tracked(self, client, mock_db):
"""Regular user can remove a company from their own tracked list."""
mock_db.get_user_by_id.return_value = {
"id": 2,
"email": "user@test.com",
"role": "user",
"created_at": datetime(2025, 1, 1, tzinfo=timezone.utc),
}
mock_db.remove_tracked_company.return_value = True
response = client.delete("/tracked/Intel", headers=_user_header())
assert response.status_code == 200
mock_db.remove_tracked_company.assert_called_once_with("Intel", owner_id=2)
# ---------- GET /admin/alerts ----------
class TestListAlerts: