Skip to content

Commit 50fa285

Browse files
Merge pull request #124 from bg-playground/copilot/add-jwt-authentication-backend
Add JWT-based authentication and authorization to backend
2 parents 1add57c + 3dd3298 commit 50fa285

21 files changed

Lines changed: 659 additions & 24 deletions

backend/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,8 @@ BACKEND_CORS_ORIGINS=["http://localhost:3000", "http://localhost:8000"]
1111
AUTO_SUGGESTIONS_ENABLED=true
1212
AUTO_SUGGESTIONS_ALGORITHM=tfidf
1313
AUTO_SUGGESTIONS_THRESHOLD=0.3
14+
15+
# Authentication — CHANGE THESE IN PRODUCTION!
16+
SECRET_KEY=change-me-in-production-use-a-real-secret-key
17+
ACCESS_TOKEN_EXPIRE_MINUTES=60
18+
ALGORITHM=HS256

backend/alembic/env.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import app.models.requirement # noqa: E402, F401
2929
import app.models.suggestion # noqa: E402, F401
3030
import app.models.test_case # noqa: E402, F401
31+
import app.models.user # noqa: E402, F401
3132
from app.models.base import Base # noqa: E402
3233

3334
target_metadata = Base.metadata
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""add users table
2+
3+
Revision ID: b1c2d3e4f5a6
4+
Revises: a61ce9bc5970
5+
Create Date: 2026-02-22 15:00:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
13+
from alembic import op
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "b1c2d3e4f5a6"
17+
down_revision: Union[str, None] = "a61ce9bc5970"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
user_role = sa.Enum("admin", "reviewer", "viewer", name="userrole")
24+
25+
op.create_table(
26+
"users",
27+
sa.Column("id", sa.CHAR(36), primary_key=True),
28+
sa.Column("email", sa.String(255), nullable=False, unique=True),
29+
sa.Column("hashed_password", sa.String(255), nullable=False),
30+
sa.Column("full_name", sa.String(255), nullable=True),
31+
sa.Column("role", user_role, nullable=False, server_default="reviewer"),
32+
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
33+
sa.Column("created_at", sa.DateTime(), nullable=False),
34+
sa.Column("updated_at", sa.DateTime(), nullable=False),
35+
)
36+
op.create_index("idx_users_email", "users", ["email"])
37+
38+
39+
def downgrade() -> None:
40+
op.drop_index("idx_users_email", table_name="users")
41+
op.drop_table("users")
42+
sa.Enum(name="userrole").drop(op.get_bind())

backend/app/api/analytics.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@
55
from fastapi import APIRouter, Depends
66
from sqlalchemy.ext.asyncio import AsyncSession
77

8+
from app.auth.dependencies import get_current_user
89
from app.db.session import get_db
10+
from app.models.user import User
911
from app.services.analytics import SuggestionAnalytics
1012

1113
router = APIRouter()
1214

1315

1416
@router.get("/analytics/acceptance-rates", response_model=list[dict[str, Any]])
15-
async def get_acceptance_rates(db: AsyncSession = Depends(get_db)):
17+
async def get_acceptance_rates(
18+
db: AsyncSession = Depends(get_db),
19+
current_user: User = Depends(get_current_user),
20+
):
1621
"""
1722
Get acceptance/rejection rates for link suggestions over time.
1823
@@ -24,7 +29,10 @@ async def get_acceptance_rates(db: AsyncSession = Depends(get_db)):
2429

2530

2631
@router.get("/analytics/confidence-distribution", response_model=list[dict[str, Any]])
27-
async def get_confidence_distribution(db: AsyncSession = Depends(get_db)):
32+
async def get_confidence_distribution(
33+
db: AsyncSession = Depends(get_db),
34+
current_user: User = Depends(get_current_user),
35+
):
2836
"""
2937
Get distribution of confidence scores across suggestions.
3038
@@ -36,7 +44,10 @@ async def get_confidence_distribution(db: AsyncSession = Depends(get_db)):
3644

3745

3846
@router.get("/analytics/generation-trends", response_model=list[dict[str, Any]])
39-
async def get_generation_trends(db: AsyncSession = Depends(get_db)):
47+
async def get_generation_trends(
48+
db: AsyncSession = Depends(get_db),
49+
current_user: User = Depends(get_current_user),
50+
):
4051
"""
4152
Get trends in suggestion generation volume over time.
4253
@@ -47,7 +58,10 @@ async def get_generation_trends(db: AsyncSession = Depends(get_db)):
4758

4859

4960
@router.get("/analytics/review-velocity", response_model=dict[str, Any])
50-
async def get_review_velocity(db: AsyncSession = Depends(get_db)):
61+
async def get_review_velocity(
62+
db: AsyncSession = Depends(get_db),
63+
current_user: User = Depends(get_current_user),
64+
):
5165
"""
5266
Get review velocity metrics — how quickly suggestions are being reviewed.
5367
@@ -58,7 +72,10 @@ async def get_review_velocity(db: AsyncSession = Depends(get_db)):
5872

5973

6074
@router.get("/analytics/algorithm-comparison", response_model=list[dict[str, Any]])
61-
async def get_algorithm_comparison(db: AsyncSession = Depends(get_db)):
75+
async def get_algorithm_comparison(
76+
db: AsyncSession = Depends(get_db),
77+
current_user: User = Depends(get_current_user),
78+
):
6279
"""
6380
Compare performance metrics across algorithms (tfidf, keyword, hybrid, etc.).
6481

backend/app/api/auth.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""API endpoints for Authentication"""
2+
3+
from fastapi import APIRouter, Depends, HTTPException, status
4+
from sqlalchemy.ext.asyncio import AsyncSession
5+
6+
from app.auth.dependencies import get_current_user
7+
from app.auth.security import create_access_token, verify_password
8+
from app.crud.user import create_user, get_user_by_email
9+
from app.db.session import get_db
10+
from app.models.user import User
11+
from app.schemas.user import TokenResponse, UserCreate, UserLogin, UserResponse
12+
13+
router = APIRouter()
14+
15+
16+
@router.post("/auth/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
17+
async def register(user_create: UserCreate, db: AsyncSession = Depends(get_db)):
18+
"""Register a new user"""
19+
existing = await get_user_by_email(db, user_create.email)
20+
if existing:
21+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered")
22+
return await create_user(db, user_create)
23+
24+
25+
@router.post("/auth/login", response_model=TokenResponse)
26+
async def login(user_login: UserLogin, db: AsyncSession = Depends(get_db)):
27+
"""Authenticate and return a JWT token"""
28+
user = await get_user_by_email(db, user_login.email)
29+
if not user or not verify_password(user_login.password, user.hashed_password):
30+
raise HTTPException(
31+
status_code=status.HTTP_401_UNAUTHORIZED,
32+
detail="Incorrect email or password",
33+
headers={"WWW-Authenticate": "Bearer"},
34+
)
35+
if not user.is_active:
36+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user")
37+
access_token = create_access_token(data={"sub": str(user.id)})
38+
return TokenResponse(access_token=access_token)
39+
40+
41+
@router.get("/auth/me", response_model=UserResponse)
42+
async def get_me(current_user: User = Depends(get_current_user)):
43+
"""Return the currently authenticated user"""
44+
return current_user

backend/app/api/links.py

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
from fastapi import APIRouter, Depends, HTTPException, Query, status
77
from sqlalchemy.ext.asyncio import AsyncSession
88

9+
from app.auth.dependencies import get_current_user
910
from app.crud import link as crud
1011
from app.db.session import get_db
12+
from app.models.user import User
1113
from app.schemas.link import (
1214
BulkReviewRequest,
1315
LinkCreate,
@@ -21,7 +23,11 @@
2123

2224
# Link endpoints
2325
@router.post("/links", response_model=LinkResponse, status_code=status.HTTP_201_CREATED)
24-
async def create_link(link: LinkCreate, db: AsyncSession = Depends(get_db)):
26+
async def create_link(
27+
link: LinkCreate,
28+
db: AsyncSession = Depends(get_db),
29+
current_user: User = Depends(get_current_user),
30+
):
2531
"""Create a new requirement-test case link"""
2632
try:
2733
return await crud.create_link(db, link)
@@ -36,13 +42,22 @@ async def create_link(link: LinkCreate, db: AsyncSession = Depends(get_db)):
3642

3743

3844
@router.get("/links", response_model=list[LinkResponse])
39-
async def list_links(skip: int = 0, limit: int = 100, db: AsyncSession = Depends(get_db)):
45+
async def list_links(
46+
skip: int = 0,
47+
limit: int = 100,
48+
db: AsyncSession = Depends(get_db),
49+
current_user: User = Depends(get_current_user),
50+
):
4051
"""List all links"""
4152
return await crud.get_links(db, skip=skip, limit=limit)
4253

4354

4455
@router.get("/links/{link_id}", response_model=LinkResponse)
45-
async def get_link(link_id: UUID, db: AsyncSession = Depends(get_db)):
56+
async def get_link(
57+
link_id: UUID,
58+
db: AsyncSession = Depends(get_db),
59+
current_user: User = Depends(get_current_user),
60+
):
4661
"""Get a specific link by ID"""
4762
link = await crud.get_link(db, link_id)
4863
if not link:
@@ -51,19 +66,31 @@ async def get_link(link_id: UUID, db: AsyncSession = Depends(get_db)):
5166

5267

5368
@router.get("/requirements/{requirement_id}/links", response_model=list[LinkResponse])
54-
async def get_requirement_links(requirement_id: UUID, db: AsyncSession = Depends(get_db)):
69+
async def get_requirement_links(
70+
requirement_id: UUID,
71+
db: AsyncSession = Depends(get_db),
72+
current_user: User = Depends(get_current_user),
73+
):
5574
"""Get all links for a specific requirement"""
5675
return await crud.get_links_by_requirement(db, requirement_id)
5776

5877

5978
@router.get("/test-cases/{test_case_id}/links", response_model=list[LinkResponse])
60-
async def get_test_case_links(test_case_id: UUID, db: AsyncSession = Depends(get_db)):
79+
async def get_test_case_links(
80+
test_case_id: UUID,
81+
db: AsyncSession = Depends(get_db),
82+
current_user: User = Depends(get_current_user),
83+
):
6184
"""Get all links for a specific test case"""
6285
return await crud.get_links_by_test_case(db, test_case_id)
6386

6487

6588
@router.delete("/links/{link_id}", status_code=status.HTTP_204_NO_CONTENT)
66-
async def delete_link(link_id: UUID, db: AsyncSession = Depends(get_db)):
89+
async def delete_link(
90+
link_id: UUID,
91+
db: AsyncSession = Depends(get_db),
92+
current_user: User = Depends(get_current_user),
93+
):
6794
"""Delete a link"""
6895
deleted = await crud.delete_link(db, link_id)
6996
if not deleted:
@@ -72,7 +99,12 @@ async def delete_link(link_id: UUID, db: AsyncSession = Depends(get_db)):
7299

73100
# Suggestion endpoints
74101
@router.get("/suggestions", response_model=list[SuggestionResponse])
75-
async def list_suggestions(skip: int = 0, limit: int = 100, db: AsyncSession = Depends(get_db)):
102+
async def list_suggestions(
103+
skip: int = 0,
104+
limit: int = 100,
105+
db: AsyncSession = Depends(get_db),
106+
current_user: User = Depends(get_current_user),
107+
):
76108
"""List all link suggestions"""
77109
return await crud.get_suggestions(db, skip=skip, limit=limit)
78110

@@ -87,6 +119,7 @@ async def list_pending_suggestions(
87119
limit: int | None = Query(100, le=500, description="Maximum results to return"),
88120
search: str | None = Query(None, description="Search term to filter by requirement/test case title or description"),
89121
db: AsyncSession = Depends(get_db),
122+
current_user: User = Depends(get_current_user),
90123
):
91124
"""List pending suggestions with filtering and sorting"""
92125
return await crud.get_pending_suggestions(
@@ -102,7 +135,11 @@ async def list_pending_suggestions(
102135

103136

104137
@router.get("/suggestions/{suggestion_id}", response_model=SuggestionResponse)
105-
async def get_suggestion(suggestion_id: UUID, db: AsyncSession = Depends(get_db)):
138+
async def get_suggestion(
139+
suggestion_id: UUID,
140+
db: AsyncSession = Depends(get_db),
141+
current_user: User = Depends(get_current_user),
142+
):
106143
"""Get a specific suggestion by ID"""
107144
suggestion = await crud.get_suggestion(db, suggestion_id)
108145
if not suggestion:
@@ -111,7 +148,12 @@ async def get_suggestion(suggestion_id: UUID, db: AsyncSession = Depends(get_db)
111148

112149

113150
@router.post("/suggestions/{suggestion_id}/review", response_model=SuggestionResponse)
114-
async def review_suggestion(suggestion_id: UUID, review: SuggestionReview, db: AsyncSession = Depends(get_db)):
151+
async def review_suggestion(
152+
suggestion_id: UUID,
153+
review: SuggestionReview,
154+
db: AsyncSession = Depends(get_db),
155+
current_user: User = Depends(get_current_user),
156+
):
115157
"""Review a suggestion (accept/reject)"""
116158
reviewed = await crud.review_suggestion(db, suggestion_id, review)
117159
if not reviewed:
@@ -120,7 +162,11 @@ async def review_suggestion(suggestion_id: UUID, review: SuggestionReview, db: A
120162

121163

122164
@router.post("/suggestions/bulk-review", response_model=dict[str, Any])
123-
async def bulk_review_suggestions(request: BulkReviewRequest, db: AsyncSession = Depends(get_db)):
165+
async def bulk_review_suggestions(
166+
request: BulkReviewRequest,
167+
db: AsyncSession = Depends(get_db),
168+
current_user: User = Depends(get_current_user),
169+
):
124170
"""Review multiple suggestions at once"""
125171
reviewed = await crud.bulk_review_suggestions(
126172
db, request.suggestion_ids, request.status, request.feedback, request.reviewed_by

backend/app/api/requirements.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
from sqlalchemy.ext.asyncio import AsyncSession
77

88
from app.ai_suggestions.event_driven import generate_suggestions_for_requirement
9+
from app.auth.dependencies import get_current_user
910
from app.config import settings
1011
from app.crud import requirement as crud
1112
from app.db.session import get_db
13+
from app.models.user import User
1214
from app.schemas.requirement import (
1315
RequirementCreate,
1416
RequirementResponse,
@@ -23,6 +25,7 @@ async def create_requirement(
2325
requirement: RequirementCreate,
2426
background_tasks: BackgroundTasks,
2527
db: AsyncSession = Depends(get_db),
28+
current_user: User = Depends(get_current_user),
2629
):
2730
"""Create a new requirement"""
2831
# Check if external_id already exists
@@ -48,13 +51,22 @@ async def create_requirement(
4851

4952

5053
@router.get("/requirements", response_model=list[RequirementResponse])
51-
async def list_requirements(skip: int = 0, limit: int = 100, db: AsyncSession = Depends(get_db)):
54+
async def list_requirements(
55+
skip: int = 0,
56+
limit: int = 100,
57+
db: AsyncSession = Depends(get_db),
58+
current_user: User = Depends(get_current_user),
59+
):
5260
"""List all requirements"""
5361
return await crud.get_requirements(db, skip=skip, limit=limit)
5462

5563

5664
@router.get("/requirements/{requirement_id}", response_model=RequirementResponse)
57-
async def get_requirement(requirement_id: UUID, db: AsyncSession = Depends(get_db)):
65+
async def get_requirement(
66+
requirement_id: UUID,
67+
db: AsyncSession = Depends(get_db),
68+
current_user: User = Depends(get_current_user),
69+
):
5870
"""Get a specific requirement by ID"""
5971
requirement = await crud.get_requirement(db, requirement_id)
6072
if not requirement:
@@ -68,6 +80,7 @@ async def update_requirement(
6880
requirement: RequirementUpdate,
6981
background_tasks: BackgroundTasks,
7082
db: AsyncSession = Depends(get_db),
83+
current_user: User = Depends(get_current_user),
7184
):
7285
"""Update a requirement"""
7386
updated = await crud.update_requirement(db, requirement_id, requirement)
@@ -86,7 +99,11 @@ async def update_requirement(
8699

87100

88101
@router.delete("/requirements/{requirement_id}", status_code=status.HTTP_204_NO_CONTENT)
89-
async def delete_requirement(requirement_id: UUID, db: AsyncSession = Depends(get_db)):
102+
async def delete_requirement(
103+
requirement_id: UUID,
104+
db: AsyncSession = Depends(get_db),
105+
current_user: User = Depends(get_current_user),
106+
):
90107
"""Delete a requirement"""
91108
deleted = await crud.delete_requirement(db, requirement_id)
92109
if not deleted:

0 commit comments

Comments
 (0)