From 6d58ee779c35539f137523b0e33a4b20fcf84307 Mon Sep 17 00:00:00 2001 From: Zhi Date: Tue, 24 Feb 2026 04:16:32 +0000 Subject: [PATCH 1/6] feat: RBAC module + project endpoints protected (admin/mgr roles) --- app/api/rbac.py | 46 +++++++++++++++++++++++++++++++++++++ app/api/routers/projects.py | 39 +++++++++++++++++++++++++++---- 2 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 app/api/rbac.py diff --git a/app/api/rbac.py b/app/api/rbac.py new file mode 100644 index 0000000..501acef --- /dev/null +++ b/app/api/rbac.py @@ -0,0 +1,46 @@ +"""Role-based access control helpers.""" +from functools import wraps +from fastapi import HTTPException, status +from sqlalchemy.orm import Session +from app.models.models import ProjectMember, User + + +# Role hierarchy: admin > mgr > dev > ops > viewer +ROLE_LEVELS = { + "admin": 50, + "mgr": 40, + "dev": 30, + "ops": 20, + "viewer": 10, +} + + +def get_member_role(db: Session, user_id: int, project_id: int) -> str | None: + """Get user's role in a project. Returns None if not a member.""" + member = db.query(ProjectMember).filter( + ProjectMember.user_id == user_id, + ProjectMember.project_id == project_id, + ).first() + if member: + return member.role + # Check if user is global admin + user = db.query(User).filter(User.id == user_id).first() + if user and user.is_admin: + return "admin" + return None + + +def check_project_role(db: Session, user_id: int, project_id: int, min_role: str = "viewer"): + """Raise 403 if user doesn't have the minimum required role in the project.""" + role = get_member_role(db, user_id, project_id) + if role is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not a member of this project" + ) + if ROLE_LEVELS.get(role, 0) < ROLE_LEVELS.get(min_role, 0): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Requires role '{min_role}' or higher, you have '{role}'" + ) + return role diff --git a/app/api/routers/projects.py b/app/api/routers/projects.py index 75eab8f..4ca9ab1 100644 --- a/app/api/routers/projects.py +++ b/app/api/routers/projects.py @@ -1,4 +1,4 @@ -"""Projects router.""" +"""Projects router with RBAC.""" from typing import List from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session @@ -6,6 +6,8 @@ from sqlalchemy.orm import Session from app.core.config import get_db from app.models import models from app.schemas import schemas +from app.api.deps import get_current_user_or_apikey +from app.api.rbac import check_project_role router = APIRouter(prefix="/projects", tags=["Projects"]) @@ -16,6 +18,10 @@ def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db) db.add(db_project) db.commit() db.refresh(db_project) + # Auto-add creator as admin member + db_member = models.ProjectMember(project_id=db_project.id, user_id=project.owner_id, role="admin") + db.add(db_member) + db.commit() return db_project @@ -33,7 +39,13 @@ def get_project(project_id: int, db: Session = Depends(get_db)): @router.patch("/{project_id}", response_model=schemas.ProjectResponse) -def update_project(project_id: int, project_update: schemas.ProjectUpdate, db: Session = Depends(get_db)): +def update_project( + project_id: int, + project_update: schemas.ProjectUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user_or_apikey), +): + check_project_role(db, current_user.id, project_id, min_role="mgr") project = db.query(models.Project).filter(models.Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") @@ -45,7 +57,12 @@ def update_project(project_id: int, project_update: schemas.ProjectUpdate, db: S @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_project(project_id: int, db: Session = Depends(get_db)): +def delete_project( + project_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user_or_apikey), +): + check_project_role(db, current_user.id, project_id, min_role="admin") project = db.query(models.Project).filter(models.Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") @@ -57,7 +74,13 @@ def delete_project(project_id: int, db: Session = Depends(get_db)): # ---- Members ---- @router.post("/{project_id}/members", response_model=schemas.ProjectMemberResponse, status_code=status.HTTP_201_CREATED) -def add_project_member(project_id: int, member: schemas.ProjectMemberCreate, db: Session = Depends(get_db)): +def add_project_member( + project_id: int, + member: schemas.ProjectMemberCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user_or_apikey), +): + check_project_role(db, current_user.id, project_id, min_role="mgr") project = db.query(models.Project).filter(models.Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") @@ -82,7 +105,13 @@ def list_project_members(project_id: int, db: Session = Depends(get_db)): @router.delete("/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -def remove_project_member(project_id: int, user_id: int, db: Session = Depends(get_db)): +def remove_project_member( + project_id: int, + user_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user_or_apikey), +): + check_project_role(db, current_user.id, project_id, min_role="admin") member = db.query(models.ProjectMember).filter( models.ProjectMember.project_id == project_id, models.ProjectMember.user_id == user_id ).first() -- 2.49.1 From 26ee18a4a4568cc338f498476887bf9482ea0563 Mon Sep 17 00:00:00 2001 From: Zhi Date: Tue, 24 Feb 2026 04:20:43 +0000 Subject: [PATCH 2/6] feat: RBAC on issues (create/update/delete require dev+/mgr+) --- app/api/routers/issues.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/api/routers/issues.py b/app/api/routers/issues.py index 1db850e..0162ffe 100644 --- a/app/api/routers/issues.py +++ b/app/api/routers/issues.py @@ -11,6 +11,8 @@ from app.models import models from app.schemas import schemas from app.services.webhook import fire_webhooks_sync from app.models.notification import Notification as NotificationModel +from app.api.deps import get_current_user_or_apikey +from app.api.rbac import check_project_role router = APIRouter(tags=["Issues"]) @@ -26,7 +28,8 @@ def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, enti # ---- CRUD ---- @router.post("/issues", response_model=schemas.IssueResponse, status_code=status.HTTP_201_CREATED) -def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session = Depends(get_db)): +def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): + db.add(issue); check_project_role(db, current_user.id, issue.project_id, min_role="dev") db_issue = models.Issue(**issue.model_dump()) db.add(db_issue) db.commit() @@ -97,7 +100,7 @@ def get_issue(issue_id: int, db: Session = Depends(get_db)): @router.patch("/issues/{issue_id}", response_model=schemas.IssueResponse) -def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session = Depends(get_db)): +def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() if not issue: raise HTTPException(status_code=404, detail="Issue not found") @@ -109,7 +112,7 @@ def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session = @router.delete("/issues/{issue_id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_issue(issue_id: int, db: Session = Depends(get_db)): +def delete_issue(issue_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() if not issue: raise HTTPException(status_code=404, detail="Issue not found") -- 2.49.1 From 622112c02fbde971071e4acc5a323f0a84220559 Mon Sep 17 00:00:00 2001 From: Zhi Date: Tue, 24 Feb 2026 04:22:42 +0000 Subject: [PATCH 3/6] feat: comments RBAC + notification on new comment --- app/api/routers/comments.py | 42 ++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/app/api/routers/comments.py b/app/api/routers/comments.py index ee36398..7ea3bd6 100644 --- a/app/api/routers/comments.py +++ b/app/api/routers/comments.py @@ -1,4 +1,4 @@ -"""Comments router.""" +"""Comments router with RBAC and notifications.""" from typing import List from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session @@ -6,16 +6,47 @@ from sqlalchemy.orm import Session from app.core.config import get_db from app.models import models from app.schemas import schemas +from app.api.deps import get_current_user_or_apikey +from app.api.rbac import check_project_role +from app.models.notification import Notification as NotificationModel router = APIRouter(tags=["Comments"]) +def _notify_if_needed(db, issue_id, user_ids, ntype, title): + """Helper to notify multiple users.""" + issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if not issue: + return + for uid in set(user_ids): + if uid: + n = NotificationModel(user_id=uid, type=ntype, title=title, entity_type="issue", entity_id=issue_id) + db.add(n) + db.commit() + + @router.post("/comments", response_model=schemas.CommentResponse, status_code=status.HTTP_201_CREATED) -def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db)): +def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): + # Get project_id from issue first + issue = db.query(models.Issue).filter(models.Issue.id == comment.issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + check_project_role(db, current_user.id, issue.project_id, min_role="viewer") + db_comment = models.Comment(**comment.model_dump()) db.add(db_comment) db.commit() db.refresh(db_comment) + + # Notify reporter and assignee (but not the commenter themselves) + notify_users = [] + if issue.reporter_id != current_user.id: + notify_users.append(issue.reporter_id) + if issue.assignee_id and issue.assignee_id != current_user.id: + notify_users.append(issue.assignee_id) + if notify_users: + _notify_if_needed(db, issue.id, notify_users, "comment_added", f"New comment on: {issue.title[:50]}") + return db_comment @@ -37,10 +68,15 @@ def update_comment(comment_id: int, comment_update: schemas.CommentUpdate, db: S @router.delete("/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_comment(comment_id: int, db: Session = Depends(get_db)): +def delete_comment(comment_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first() if not comment: raise HTTPException(status_code=404, detail="Comment not found") + # Get issue to check project role + issue = db.query(models.Issue).filter(models.Issue.id == comment.issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + check_project_role(db, current_user.id, issue.project_id, min_role="dev") db.delete(comment) db.commit() return None -- 2.49.1 From a56faacc4cdcfd399328aa55372cb24caaf057d3 Mon Sep 17 00:00:00 2001 From: Zhi Date: Fri, 27 Feb 2026 09:37:42 +0000 Subject: [PATCH 4/6] feat: add curl to Dockerfile for health check --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index d782d96..c4ae15a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ WORKDIR /app # Install system dependencies RUN apt-get update && apt-get install -y \ build-essential \ + curl \ default-libmysqlclient-dev \ pkg-config \ && rm -rf /var/lib/apt/lists/* -- 2.49.1 From 3cf2b1bc49e1e495d900f61661909522cfe72258 Mon Sep 17 00:00:00 2001 From: Zhi Date: Fri, 27 Feb 2026 09:39:39 +0000 Subject: [PATCH 5/6] feat: auto activity logging on issue create/delete, fix schema db.add bug --- app/api/routers/issues.py | 5 ++++- app/api/routers/projects.py | 1 + app/services/activity.py | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 app/services/activity.py diff --git a/app/api/routers/issues.py b/app/api/routers/issues.py index 0162ffe..7369dd0 100644 --- a/app/api/routers/issues.py +++ b/app/api/routers/issues.py @@ -13,6 +13,7 @@ from app.services.webhook import fire_webhooks_sync from app.models.notification import Notification as NotificationModel from app.api.deps import get_current_user_or_apikey from app.api.rbac import check_project_role +from app.services.activity import log_activity router = APIRouter(tags=["Issues"]) @@ -29,7 +30,7 @@ def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, enti @router.post("/issues", response_model=schemas.IssueResponse, status_code=status.HTTP_201_CREATED) def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): - db.add(issue); check_project_role(db, current_user.id, issue.project_id, min_role="dev") + check_project_role(db, current_user.id, issue.project_id, min_role="dev") db_issue = models.Issue(**issue.model_dump()) db.add(db_issue) db.commit() @@ -38,6 +39,7 @@ def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session = bg.add_task(fire_webhooks_sync, event, {"issue_id": db_issue.id, "title": db_issue.title, "type": db_issue.issue_type, "status": db_issue.status}, db_issue.project_id, db) + log_activity(db, "issue.created", "issue", db_issue.id, current_user.id, {"title": db_issue.title}) return db_issue @@ -116,6 +118,7 @@ def delete_issue(issue_id: int, db: Session = Depends(get_db), current_user: mod issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() if not issue: raise HTTPException(status_code=404, detail="Issue not found") + log_activity(db, "issue.deleted", "issue", issue.id, current_user.id, {"title": issue.title}) db.delete(issue) db.commit() return None diff --git a/app/api/routers/projects.py b/app/api/routers/projects.py index 4ca9ab1..f07af99 100644 --- a/app/api/routers/projects.py +++ b/app/api/routers/projects.py @@ -8,6 +8,7 @@ from app.models import models from app.schemas import schemas from app.api.deps import get_current_user_or_apikey from app.api.rbac import check_project_role +from app.services.activity import log_activity router = APIRouter(prefix="/projects", tags=["Projects"]) diff --git a/app/services/activity.py b/app/services/activity.py new file mode 100644 index 0000000..d924f4c --- /dev/null +++ b/app/services/activity.py @@ -0,0 +1,18 @@ +"""Activity logging helper — auto-record CRUD operations.""" +import json +from sqlalchemy.orm import Session +from app.models.activity import ActivityLog + + +def log_activity(db: Session, action: str, entity_type: str, entity_id: int, user_id: int = None, details: dict = None): + """Record an activity log entry.""" + entry = ActivityLog( + action=action, + entity_type=entity_type, + entity_id=entity_id, + user_id=user_id, + details=json.dumps(details) if details else None, + ) + db.add(entry) + db.commit() + return entry -- 2.49.1 From a21026ac09e85dc53b525a27140f3a219995ba10 Mon Sep 17 00:00:00 2001 From: zhi Date: Wed, 11 Mar 2026 10:43:31 +0000 Subject: [PATCH 6/6] fix: enforce missing RBAC checks on issue/comment updates and deletes --- app/api/routers/comments.py | 6 +++++- app/api/routers/issues.py | 4 ++++ app/api/routers/projects.py | 1 - 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/api/routers/comments.py b/app/api/routers/comments.py index 7ea3bd6..59d71a5 100644 --- a/app/api/routers/comments.py +++ b/app/api/routers/comments.py @@ -56,10 +56,14 @@ def list_comments(issue_id: int, db: Session = Depends(get_db)): @router.patch("/comments/{comment_id}", response_model=schemas.CommentResponse) -def update_comment(comment_id: int, comment_update: schemas.CommentUpdate, db: Session = Depends(get_db)): +def update_comment(comment_id: int, comment_update: schemas.CommentUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first() if not comment: raise HTTPException(status_code=404, detail="Comment not found") + issue = db.query(models.Issue).filter(models.Issue.id == comment.issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + check_project_role(db, current_user.id, issue.project_id, min_role="viewer") for field, value in comment_update.model_dump(exclude_unset=True).items(): setattr(comment, field, value) db.commit() diff --git a/app/api/routers/issues.py b/app/api/routers/issues.py index 7369dd0..deaca01 100644 --- a/app/api/routers/issues.py +++ b/app/api/routers/issues.py @@ -104,6 +104,8 @@ def get_issue(issue_id: int, db: Session = Depends(get_db)): @router.patch("/issues/{issue_id}", response_model=schemas.IssueResponse) def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if issue: + check_project_role(db, current_user.id, issue.project_id, min_role="dev") if not issue: raise HTTPException(status_code=404, detail="Issue not found") for field, value in issue_update.model_dump(exclude_unset=True).items(): @@ -116,6 +118,8 @@ def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session = @router.delete("/issues/{issue_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_issue(issue_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first() + if issue: + check_project_role(db, current_user.id, issue.project_id, min_role="mgr") if not issue: raise HTTPException(status_code=404, detail="Issue not found") log_activity(db, "issue.deleted", "issue", issue.id, current_user.id, {"title": issue.title}) diff --git a/app/api/routers/projects.py b/app/api/routers/projects.py index f07af99..4ca9ab1 100644 --- a/app/api/routers/projects.py +++ b/app/api/routers/projects.py @@ -8,7 +8,6 @@ from app.models import models from app.schemas import schemas from app.api.deps import get_current_user_or_apikey from app.api.rbac import check_project_role -from app.services.activity import log_activity router = APIRouter(prefix="/projects", tags=["Projects"]) -- 2.49.1