feat: RBAC + activity logging + Docker health check #3

Merged
zhi merged 6 commits from feat/rbac-and-polish into main 2026-03-11 10:43:42 +00:00
6 changed files with 156 additions and 12 deletions

View File

@@ -5,6 +5,7 @@ WORKDIR /app
# Install system dependencies # Install system dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
build-essential \ build-essential \
curl \
default-libmysqlclient-dev \ default-libmysqlclient-dev \
pkg-config \ pkg-config \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

46
app/api/rbac.py Normal file
View File

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

View File

@@ -1,4 +1,4 @@
"""Comments router.""" """Comments router with RBAC and notifications."""
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -6,16 +6,47 @@ from sqlalchemy.orm import Session
from app.core.config import get_db from app.core.config import get_db
from app.models import models from app.models import models
from app.schemas import schemas 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"]) 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) @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_comment = models.Comment(**comment.model_dump())
db.add(db_comment) db.add(db_comment)
db.commit() db.commit()
db.refresh(db_comment) 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 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) @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() comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first()
if not comment: if not comment:
raise HTTPException(status_code=404, detail="Comment not found") 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(): for field, value in comment_update.model_dump(exclude_unset=True).items():
setattr(comment, field, value) setattr(comment, field, value)
db.commit() 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) @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() comment = db.query(models.Comment).filter(models.Comment.id == comment_id).first()
if not comment: if not comment:
raise HTTPException(status_code=404, detail="Comment not found") 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.delete(comment)
db.commit() db.commit()
return None return None

View File

@@ -11,6 +11,9 @@ from app.models import models
from app.schemas import schemas from app.schemas import schemas
from app.services.webhook import fire_webhooks_sync from app.services.webhook import fire_webhooks_sync
from app.models.notification import Notification as NotificationModel 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"]) router = APIRouter(tags=["Issues"])
@@ -26,7 +29,8 @@ def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, enti
# ---- CRUD ---- # ---- CRUD ----
@router.post("/issues", response_model=schemas.IssueResponse, status_code=status.HTTP_201_CREATED) @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_issue = models.Issue(**issue.model_dump())
db.add(db_issue) db.add(db_issue)
db.commit() db.commit()
@@ -35,6 +39,7 @@ def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session =
bg.add_task(fire_webhooks_sync, event, 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}, {"issue_id": db_issue.id, "title": db_issue.title, "type": db_issue.issue_type, "status": db_issue.status},
db_issue.project_id, db) db_issue.project_id, db)
log_activity(db, "issue.created", "issue", db_issue.id, current_user.id, {"title": db_issue.title})
return db_issue 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) @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() 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: if not issue:
raise HTTPException(status_code=404, detail="Issue not found") raise HTTPException(status_code=404, detail="Issue not found")
for field, value in issue_update.model_dump(exclude_unset=True).items(): 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) @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() 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: if not issue:
raise HTTPException(status_code=404, detail="Issue not found") 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.delete(issue)
db.commit() db.commit()
return None return None

View File

@@ -1,4 +1,4 @@
"""Projects router.""" """Projects router with RBAC."""
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -6,6 +6,8 @@ from sqlalchemy.orm import Session
from app.core.config import get_db from app.core.config import get_db
from app.models import models from app.models import models
from app.schemas import schemas 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"]) 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.add(db_project)
db.commit() db.commit()
db.refresh(db_project) 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 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) @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() project = db.query(models.Project).filter(models.Project.id == project_id).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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) @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() project = db.query(models.Project).filter(models.Project.id == project_id).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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 ---- # ---- Members ----
@router.post("/{project_id}/members", response_model=schemas.ProjectMemberResponse, status_code=status.HTTP_201_CREATED) @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() project = db.query(models.Project).filter(models.Project.id == project_id).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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) @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( member = db.query(models.ProjectMember).filter(
models.ProjectMember.project_id == project_id, models.ProjectMember.user_id == user_id models.ProjectMember.project_id == project_id, models.ProjectMember.user_id == user_id
).first() ).first()

18
app/services/activity.py Normal file
View File

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