RamshaAnwar commited on
Commit
3d08a7e
·
unverified ·
1 Parent(s): 02427e3

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 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