diff --git a/Dockerfile b/Dockerfile index ec2b7f9..b19a4d8 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/* 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/comments.py b/app/api/routers/comments.py index ee36398..59d71a5 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 @@ -25,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() @@ -37,10 +72,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 diff --git a/app/api/routers/issues.py b/app/api/routers/issues.py index 1db850e..deaca01 100644 --- a/app/api/routers/issues.py +++ b/app/api/routers/issues.py @@ -11,6 +11,9 @@ 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 +from app.services.activity import log_activity router = APIRouter(tags=["Issues"]) @@ -26,7 +29,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)): + 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() @@ -35,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 @@ -97,8 +102,10 @@ 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 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(): @@ -109,10 +116,13 @@ 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 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}) db.delete(issue) db.commit() return None 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() 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