Spaces:
Runtime error
Runtime error
feat(hubs): tenant-safe list, get, and delete (#5)
Browse files* feat(models): add relationships and hub response schemas
* feat(hubs): add tenant-safe list, get, and delete endpoints
* test(isolation): prove cross-tenant read, list, and delete are cloaked
- app/main.py +2 -1
- app/models.py +9 -0
- app/repo.py +16 -0
- app/routers/hubs.py +59 -0
- app/schemas.py +27 -1
- tests/conftest.py +10 -2
- tests/test_isolation.py +45 -0
app/main.py
CHANGED
|
@@ -4,7 +4,7 @@ from fastapi import FastAPI, Request, Depends
|
|
| 4 |
from fastapi.exceptions import RequestValidationError
|
| 5 |
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 6 |
from app.errors import AppError, error_response
|
| 7 |
-
from app.routers import auth
|
| 8 |
from app.deps import get_current_user
|
| 9 |
from app.models import User
|
| 10 |
|
|
@@ -19,6 +19,7 @@ def create_app() -> FastAPI:
|
|
| 19 |
)
|
| 20 |
|
| 21 |
app.include_router(auth.router, prefix="/api/v1")
|
|
|
|
| 22 |
|
| 23 |
@app.middleware("http")
|
| 24 |
async def catch_unhandled(request: Request, call_next):
|
|
|
|
| 4 |
from fastapi.exceptions import RequestValidationError
|
| 5 |
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 6 |
from app.errors import AppError, error_response
|
| 7 |
+
from app.routers import auth,hubs
|
| 8 |
from app.deps import get_current_user
|
| 9 |
from app.models import User
|
| 10 |
|
|
|
|
| 19 |
)
|
| 20 |
|
| 21 |
app.include_router(auth.router, prefix="/api/v1")
|
| 22 |
+
app.include_router(hubs.router, prefix="/api/v1")
|
| 23 |
|
| 24 |
@app.middleware("http")
|
| 25 |
async def catch_unhandled(request: Request, call_next):
|
app/models.py
CHANGED
|
@@ -2,6 +2,7 @@ import uuid
|
|
| 2 |
from datetime import datetime
|
| 3 |
from sqlalchemy import Column, String, Boolean, Integer, DateTime, ForeignKey, UniqueConstraint, LargeBinary
|
| 4 |
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
|
|
| 5 |
from pgvector.sqlalchemy import Vector
|
| 6 |
from app.db import Base
|
| 7 |
|
|
@@ -24,6 +25,10 @@ class Hub(Base):
|
|
| 24 |
suggested_questions = Column(JSONB, nullable=False, default=list)
|
| 25 |
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
| 26 |
updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
__table_args__ = (
|
| 29 |
UniqueConstraint("owner_id", "name", name="uq_hub_name_per_owner"),
|
|
@@ -46,6 +51,8 @@ class Document(Base):
|
|
| 46 |
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
| 47 |
updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
| 48 |
|
|
|
|
|
|
|
| 49 |
__table_args__ = (
|
| 50 |
UniqueConstraint("hub_id", "checksum_sha256", name="uq_doc_checksum_per_hub"),
|
| 51 |
)
|
|
@@ -62,6 +69,8 @@ class Chunk(Base):
|
|
| 62 |
embedding = Column(Vector(768), nullable=False)
|
| 63 |
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
| 64 |
|
|
|
|
|
|
|
| 65 |
class IdempotencyKey(Base):
|
| 66 |
__tablename__ = "idempotency_keys"
|
| 67 |
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
|
|
|
|
| 2 |
from datetime import datetime
|
| 3 |
from sqlalchemy import Column, String, Boolean, Integer, DateTime, ForeignKey, UniqueConstraint, LargeBinary
|
| 4 |
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
| 5 |
+
from sqlalchemy.orm import relationship
|
| 6 |
from pgvector.sqlalchemy import Vector
|
| 7 |
from app.db import Base
|
| 8 |
|
|
|
|
| 25 |
suggested_questions = Column(JSONB, nullable=False, default=list)
|
| 26 |
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
| 27 |
updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
| 28 |
+
|
| 29 |
+
# These relationships belong in Hub, not User!
|
| 30 |
+
documents = relationship("Document", back_populates="hub", cascade="all, delete-orphan")
|
| 31 |
+
chunks = relationship("Chunk", back_populates="hub", cascade="all, delete-orphan")
|
| 32 |
|
| 33 |
__table_args__ = (
|
| 34 |
UniqueConstraint("owner_id", "name", name="uq_hub_name_per_owner"),
|
|
|
|
| 51 |
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
| 52 |
updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
| 53 |
|
| 54 |
+
hub = relationship("Hub", back_populates="documents")
|
| 55 |
+
|
| 56 |
__table_args__ = (
|
| 57 |
UniqueConstraint("hub_id", "checksum_sha256", name="uq_doc_checksum_per_hub"),
|
| 58 |
)
|
|
|
|
| 69 |
embedding = Column(Vector(768), nullable=False)
|
| 70 |
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
| 71 |
|
| 72 |
+
hub = relationship("Hub", back_populates="chunks")
|
| 73 |
+
|
| 74 |
class IdempotencyKey(Base):
|
| 75 |
__tablename__ = "idempotency_keys"
|
| 76 |
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
|
app/repo.py
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from uuid import UUID
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from app.models import Hub
|
| 4 |
+
|
| 5 |
+
def list_hubs_for_owner(db: Session, owner_id: UUID, limit: int, offset: int):
|
| 6 |
+
query = db.query(Hub).filter(Hub.owner_id == owner_id).order_by(Hub.created_at.desc())
|
| 7 |
+
total = query.count()
|
| 8 |
+
hubs = query.offset(offset).limit(limit).all()
|
| 9 |
+
return hubs, total
|
| 10 |
+
|
| 11 |
+
def get_hub_for_owner(db: Session, hub_id: UUID, owner_id: UUID) -> Hub | None:
|
| 12 |
+
return db.query(Hub).filter(Hub.id == hub_id, Hub.owner_id == owner_id).first()
|
| 13 |
+
|
| 14 |
+
def delete_hub_for_owner(db: Session, hub_id: UUID, owner_id: UUID) -> int:
|
| 15 |
+
# Returns the number of rows deleted (0 or 1)
|
| 16 |
+
return db.query(Hub).filter(Hub.id == hub_id, Hub.owner_id == owner_id).delete()
|
app/routers/hubs.py
CHANGED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from uuid import UUID
|
| 2 |
+
from fastapi import APIRouter, Depends, status, Query, Response
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from app.db import get_db
|
| 5 |
+
from app.deps import get_current_user
|
| 6 |
+
from app.models import User
|
| 7 |
+
from app.schemas import HubResponse, HubDetail
|
| 8 |
+
from app.errors import AppError
|
| 9 |
+
from app import repo
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/hubs", tags=["hubs"])
|
| 12 |
+
|
| 13 |
+
@router.get("", response_model=dict)
|
| 14 |
+
def list_hubs(
|
| 15 |
+
limit: int = Query(20, ge=1, le=100),
|
| 16 |
+
offset: int = Query(0, ge=0),
|
| 17 |
+
current_user: User = Depends(get_current_user),
|
| 18 |
+
db: Session = Depends(get_db)
|
| 19 |
+
):
|
| 20 |
+
hubs, total = repo.list_hubs_for_owner(db, current_user.id, limit, offset)
|
| 21 |
+
|
| 22 |
+
# Convert ORM models to Pydantic models to include counts
|
| 23 |
+
items = []
|
| 24 |
+
for hub in hubs:
|
| 25 |
+
hub_data = HubResponse.model_validate(hub)
|
| 26 |
+
hub_data.document_count = len(hub.documents)
|
| 27 |
+
hub_data.chunk_count = len(hub.chunks)
|
| 28 |
+
items.append(hub_data)
|
| 29 |
+
|
| 30 |
+
return {"items": items, "total": total, "limit": limit, "offset": offset}
|
| 31 |
+
|
| 32 |
+
@router.get("/{hub_id}", response_model=HubDetail)
|
| 33 |
+
def get_hub(
|
| 34 |
+
hub_id: UUID,
|
| 35 |
+
current_user: User = Depends(get_current_user),
|
| 36 |
+
db: Session = Depends(get_db)
|
| 37 |
+
):
|
| 38 |
+
hub = repo.get_hub_for_owner(db, hub_id, current_user.id)
|
| 39 |
+
if not hub:
|
| 40 |
+
# Cloaked 404
|
| 41 |
+
raise AppError(404, "HUB_NOT_FOUND", "Hub not found.")
|
| 42 |
+
|
| 43 |
+
hub_data = HubDetail.model_validate(hub)
|
| 44 |
+
hub_data.document_count = len(hub.documents)
|
| 45 |
+
hub_data.chunk_count = len(hub.chunks)
|
| 46 |
+
return hub_data
|
| 47 |
+
|
| 48 |
+
@router.delete("/{hub_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 49 |
+
def delete_hub(
|
| 50 |
+
hub_id: UUID,
|
| 51 |
+
current_user: User = Depends(get_current_user),
|
| 52 |
+
db: Session = Depends(get_db)
|
| 53 |
+
):
|
| 54 |
+
deleted_count = repo.delete_hub_for_owner(db, hub_id, current_user.id)
|
| 55 |
+
if deleted_count == 0:
|
| 56 |
+
raise AppError(404, "HUB_NOT_FOUND", "Hub not found.")
|
| 57 |
+
|
| 58 |
+
db.commit()
|
| 59 |
+
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
app/schemas.py
CHANGED
|
@@ -44,4 +44,30 @@ class ChangePasswordRequest(BaseModel):
|
|
| 44 |
raise ValueError("must contain at least one letter and one digit")
|
| 45 |
if len(v.encode("utf-8")) > 72:
|
| 46 |
raise ValueError("must be at most 72 bytes when encoded as UTF-8")
|
| 47 |
-
return v
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
raise ValueError("must contain at least one letter and one digit")
|
| 45 |
if len(v.encode("utf-8")) > 72:
|
| 46 |
raise ValueError("must be at most 72 bytes when encoded as UTF-8")
|
| 47 |
+
return v
|
| 48 |
+
# ... (keep existing schemas) ...
|
| 49 |
+
|
| 50 |
+
class DocumentSummary(BaseModel):
|
| 51 |
+
model_config = ConfigDict(from_attributes=True)
|
| 52 |
+
id: UUID
|
| 53 |
+
origin: str
|
| 54 |
+
filename: str
|
| 55 |
+
status: str
|
| 56 |
+
chunk_count: int = 0
|
| 57 |
+
|
| 58 |
+
class HubResponse(BaseModel):
|
| 59 |
+
model_config = ConfigDict(from_attributes=True)
|
| 60 |
+
id: UUID
|
| 61 |
+
name: str
|
| 62 |
+
description: str | None
|
| 63 |
+
ai_summary: str | None
|
| 64 |
+
suggested_questions: list[str]
|
| 65 |
+
document_count: int = 0
|
| 66 |
+
chunk_count: int = 0
|
| 67 |
+
created_at: datetime
|
| 68 |
+
updated_at: datetime
|
| 69 |
+
|
| 70 |
+
class HubDetail(HubResponse):
|
| 71 |
+
documents: list[DocumentSummary] = []
|
| 72 |
+
|
| 73 |
+
|
tests/conftest.py
CHANGED
|
@@ -25,7 +25,7 @@ os.environ["JWT_SECRET_KEY"] = "test-secret-test-secret-test-secret-123"
|
|
| 25 |
os.environ["GEMINI_API_KEY"] = "test-key"
|
| 26 |
|
| 27 |
from app.main import app
|
| 28 |
-
from app.db import engine
|
| 29 |
|
| 30 |
@pytest.fixture(scope="session", autouse=True)
|
| 31 |
def setup_database():
|
|
@@ -43,4 +43,12 @@ def clean_tables():
|
|
| 43 |
@pytest.fixture
|
| 44 |
def client():
|
| 45 |
with TestClient(app) as c:
|
| 46 |
-
yield c
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
os.environ["GEMINI_API_KEY"] = "test-key"
|
| 26 |
|
| 27 |
from app.main import app
|
| 28 |
+
from app.db import engine, SessionLocal
|
| 29 |
|
| 30 |
@pytest.fixture(scope="session", autouse=True)
|
| 31 |
def setup_database():
|
|
|
|
| 43 |
@pytest.fixture
|
| 44 |
def client():
|
| 45 |
with TestClient(app) as c:
|
| 46 |
+
yield c
|
| 47 |
+
|
| 48 |
+
@pytest.fixture
|
| 49 |
+
def db_session():
|
| 50 |
+
db = SessionLocal()
|
| 51 |
+
try:
|
| 52 |
+
yield db
|
| 53 |
+
finally:
|
| 54 |
+
db.close()
|
tests/test_isolation.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from uuid import uuid4
|
| 3 |
+
from app.models import Hub
|
| 4 |
+
|
| 5 |
+
def create_test_hub(db_session, owner_id, name="Test Hub"):
|
| 6 |
+
hub = Hub(owner_id=owner_id, name=name)
|
| 7 |
+
db_session.add(hub)
|
| 8 |
+
db_session.commit()
|
| 9 |
+
return hub
|
| 10 |
+
|
| 11 |
+
def test_user_b_cannot_read_user_a_hub(client, db_session):
|
| 12 |
+
# 1. Create two users
|
| 13 |
+
client.post("/api/v1/auth/signup", json={"email": "usera@example.com", "password": "Pass1234"})
|
| 14 |
+
client.post("/api/v1/auth/signup", json={"email": "userb@example.com", "password": "Pass1234"})
|
| 15 |
+
|
| 16 |
+
token_a = client.post("/api/v1/auth/login", json={"email": "usera@example.com", "password": "Pass1234"}).json()["access_token"]
|
| 17 |
+
token_b = client.post("/api/v1/auth/login", json={"email": "userb@example.com", "password": "Pass1234"}).json()["access_token"]
|
| 18 |
+
|
| 19 |
+
from app.models import User
|
| 20 |
+
user_a = db_session.query(User).filter(User.email == "usera@example.com").first()
|
| 21 |
+
user_b = db_session.query(User).filter(User.email == "userb@example.com").first()
|
| 22 |
+
|
| 23 |
+
# 2. Create a hub for User A
|
| 24 |
+
hub_a = create_test_hub(db_session, user_a.id, "A's Secret Hub")
|
| 25 |
+
|
| 26 |
+
# 3. User A can read it
|
| 27 |
+
r_a = client.get(f"/api/v1/hubs/{hub_a.id}", headers={"Authorization": f"Bearer {token_a}"})
|
| 28 |
+
assert r_a.status_code == 200
|
| 29 |
+
|
| 30 |
+
# 4. User B CANNOT read it (404 Cloaking)
|
| 31 |
+
r_b = client.get(f"/api/v1/hubs/{hub_a.id}", headers={"Authorization": f"Bearer {token_b}"})
|
| 32 |
+
assert r_b.status_code == 404
|
| 33 |
+
assert r_b.json()["error"]["code"] == "HUB_NOT_FOUND"
|
| 34 |
+
|
| 35 |
+
# 5. User B cannot list it
|
| 36 |
+
r_list_b = client.get("/api/v1/hubs", headers={"Authorization": f"Bearer {token_b}"})
|
| 37 |
+
assert r_list_b.json()["total"] == 0
|
| 38 |
+
|
| 39 |
+
# 6. User B cannot delete it
|
| 40 |
+
r_del_b = client.delete(f"/api/v1/hubs/{hub_a.id}", headers={"Authorization": f"Bearer {token_b}"})
|
| 41 |
+
assert r_del_b.status_code == 404
|
| 42 |
+
|
| 43 |
+
# 7. User A can delete it
|
| 44 |
+
r_del_a = client.delete(f"/api/v1/hubs/{hub_a.id}", headers={"Authorization": f"Bearer {token_a}"})
|
| 45 |
+
assert r_del_a.status_code == 204
|