Add multi-role permission system with a Role Management admin page

Users can now hold multiple roles, each with its own editable set of
create/edit/delete-style permissions across assets, shots, tasks, task
assignment, review approve/retake, submissions, uploads, and notes
(including internal vs. client note visibility). The 4 existing roles
(coordinator/director/artist/developer) are migrated into the new system
as system roles, seeded to reproduce today's actual behavior exactly;
admins can create custom roles (e.g. "Reviewer", "Outsourcing") via the
new Role Management page and assign multiple roles to a user via a new
"Manage Roles" action on the Team page.

Backend:
- New Role/Permission models and role_permissions/user_roles tables,
  plus a one-off, idempotent seed/backfill migration script.
- New require_permission()/user_has_permission() dependency, wired into
  the actual mutation endpoints across shots/assets/tasks/reviews,
  always preserving existing ownership- and self-service-based access
  (e.g. artists editing their own task status, own notes, own uploads,
  own submissions) as an unconditional fallback alongside the new
  permission checks - nothing that worked before now requires a role.
- New endpoints: PUT/DELETE on task submissions (wires up soft-deletion
  columns that existed on the model but were never exposed), plus full
  role CRUD and per-user role assignment.
- Along the way: fixed newly-created users not being linked to their
  matching system role (silently leaving them with zero permissions),
  and unified an inconsistency between the single vs. bulk task status
  endpoints that allowed different roles to bulk-update status.

Frontend:
- Role Management page with a grouped, human-readable permission editor
  (icons, plain-language action labels, per-resource select-all, live
  selected count) replacing an earlier dense matrix prototype.
- hasPermission() added to the existing usePermission() composable
  without touching its current isAdmin/isCoordinatorOrAdmin consumers.
- Note composer gets an Internal/Client toggle; submissions gain inline
  edit/delete actions gated the same ownership-or-permission way as notes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 20:41:17 +08:00
parent 976ec40b52
commit db2c414c1a
33 changed files with 1697 additions and 137 deletions
+151 -49
View File
@@ -8,7 +8,7 @@ import json
from datetime import datetime
from database import get_db
from models.task import Task, ProductionNote, TaskAttachment, Submission, Review
from models.task import Task, ProductionNote, TaskAttachment, Submission, Review, NoteType
from models.user import User, UserRole, DepartmentRole
from models.project import Project, ProjectMember
from models.asset import Asset
@@ -19,10 +19,10 @@ from schemas.task import (
TaskCreate, TaskUpdate, TaskResponse, TaskListResponse, TaskStatusUpdate, TaskAssignment,
ProductionNoteCreate, ProductionNoteUpdate, ProductionNoteResponse,
TaskAttachmentCreate, TaskAttachmentResponse,
SubmissionCreate, SubmissionResponse,
SubmissionCreate, SubmissionUpdate, SubmissionResponse,
BulkStatusUpdate, BulkAssignment, BulkActionResult
)
from utils.auth import get_current_user_from_token, _get_user_from_db, require_role
from utils.auth import get_current_user_from_token, _get_user_from_db, require_role, require_permission, user_has_permission
from utils.notifications import notification_service
from utils.file_handler import file_handler
@@ -93,20 +93,6 @@ def validate_task_status(db: Session, project_id: int, status_value: str) -> boo
return False
def require_admin_or_coordinator(
token_data: dict = Depends(get_current_user_from_token),
db: Session = Depends(get_db)
):
"""Dependency to require admin permission or coordinator role."""
current_user = _get_user_from_db(db, token_data["user_id"])
if not current_user.is_admin and current_user.role != UserRole.COORDINATOR:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin permission or Coordinator role required"
)
return current_user
def get_current_user(
token_data: dict = Depends(get_current_user_from_token),
db: Session = Depends(get_db)
@@ -372,7 +358,7 @@ async def get_my_tasks(
async def create_task(
task: TaskCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_or_coordinator)
current_user: User = Depends(require_permission('task', 'create'))
):
"""Create a new task. Only coordinators and users with admin permission can create tasks."""
@@ -520,7 +506,7 @@ async def bulk_update_task_status(
})
failed_count += 1
continue
elif current_user.role not in [UserRole.COORDINATOR, UserRole.DIRECTOR] and not current_user.is_admin:
elif not user_has_permission(current_user, 'task', 'change_status', db):
errors.append({
"task_id": task_id,
"error": "Insufficient permissions"
@@ -597,7 +583,7 @@ async def bulk_update_task_status(
async def bulk_assign_tasks(
bulk_assignment: BulkAssignment,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_or_coordinator)
current_user: User = Depends(require_permission('assignment', 'edit'))
):
"""
Assign multiple tasks to a user atomically.
@@ -780,7 +766,7 @@ async def update_task(
# Artists can only update status
if task_update.model_dump(exclude_unset=True).keys() - {"status"}:
raise HTTPException(status_code=403, detail="Artists can only update task status")
elif current_user.role != UserRole.COORDINATOR and not current_user.is_admin:
elif not user_has_permission(current_user, 'task', 'edit', db):
raise HTTPException(status_code=403, detail="Not authorized to update tasks")
# Verify assigned user if being updated
@@ -789,7 +775,11 @@ async def update_task(
assigned_user = db.query(User).filter(User.id == task_update.assigned_user_id).first()
if not assigned_user:
raise HTTPException(status_code=404, detail="Assigned user not found")
assignment_action = 'edit' if task.assigned_user_id else 'create'
if not user_has_permission(current_user, 'assignment', assignment_action, db):
raise HTTPException(status_code=403, detail="Not authorized to assign this task")
# Check if user is a project member
project_member = db.query(ProjectMember).filter(
and_(
@@ -800,6 +790,8 @@ async def update_task(
if not project_member:
raise HTTPException(status_code=400, detail="Assigned user is not a member of this project")
else:
if not user_has_permission(current_user, 'assignment', 'delete', db):
raise HTTPException(status_code=403, detail="Not authorized to unassign this task")
task_update.assigned_user_id = None
# Validate status if being updated
@@ -893,9 +885,10 @@ async def update_task_status(
raise HTTPException(status_code=404, detail="Task not found")
# Permission check
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to update this task")
elif current_user.role not in [UserRole.ARTIST, UserRole.COORDINATOR] and not current_user.is_admin:
if current_user.role == UserRole.ARTIST:
if task.assigned_user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to update this task")
elif not user_has_permission(current_user, 'task', 'change_status', db):
raise HTTPException(status_code=403, detail="Not authorized to update task status")
# Validate the status for the task's project
@@ -966,10 +959,10 @@ async def assign_task(
task_id: int,
assignment: TaskAssignment,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_or_coordinator)
current_user: User = Depends(get_current_user)
):
"""Assign a task to a user with department role filtering."""
task = db.query(Task).outerjoin(Shot, Task.shot_id == Shot.id).outerjoin(Asset, Task.asset_id == Asset.id).filter(
Task.id == task_id,
Task.deleted_at.is_(None),
@@ -981,7 +974,13 @@ async def assign_task(
).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# Assigning a previously-unassigned task requires assignment:create;
# reassigning an already-assigned task requires assignment:edit.
assignment_action = 'edit' if task.assigned_user_id else 'create'
if not user_has_permission(current_user, 'assignment', assignment_action, db):
raise HTTPException(status_code=403, detail="Not authorized to assign this task")
# Verify assigned user exists and is a project member
assigned_user = db.query(User).filter(User.id == assignment.assigned_user_id).first()
if not assigned_user:
@@ -1064,7 +1063,7 @@ async def assign_task(
async def delete_task(
task_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_or_coordinator)
current_user: User = Depends(require_permission('task', 'delete'))
):
"""Delete a task. Only coordinators and users with admin permission can delete tasks."""
@@ -1118,7 +1117,18 @@ async def get_task_notes(
ProductionNote.task_id == task_id,
ProductionNote.deleted_at.is_(None)
).order_by(ProductionNote.created_at).all()
# Filter by note-type view permission (a note whose type the viewer can't
# see is dropped entirely - any reply to it is dropped too, since it
# would otherwise reference content the viewer isn't allowed to see)
can_view_internal = user_has_permission(current_user, 'note', 'view_internal', db)
can_view_client = user_has_permission(current_user, 'note', 'view_client', db)
notes = [
note for note in notes
if (note.note_type == NoteType.INTERNAL and can_view_internal)
or (note.note_type == NoteType.CLIENT and can_view_client)
]
# Build threaded structure
notes_dict = {}
root_notes = []
@@ -1127,6 +1137,7 @@ async def get_task_notes(
note_data = {
"id": note.id,
"content": note.content,
"note_type": note.note_type,
"parent_note_id": note.parent_note_id,
"task_id": note.task_id,
"user_id": note.user_id,
@@ -1171,10 +1182,13 @@ async def create_task_note(
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# Artists can only add notes to their own tasks
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to add notes to this task")
# Artists can only add notes to their own tasks; everyone else needs note:create
if current_user.role == UserRole.ARTIST:
if task.assigned_user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to add notes to this task")
elif not user_has_permission(current_user, 'note', 'create', db):
raise HTTPException(status_code=403, detail="Not authorized to add notes")
# Verify parent note exists if specified
if note.parent_note_id:
parent_note = db.query(ProductionNote).filter(
@@ -1192,6 +1206,7 @@ async def create_task_note(
task_id=task_id,
user_id=current_user.id,
content=note.content,
note_type=note.note_type,
parent_note_id=note.parent_note_id
)
db.add(db_note)
@@ -1206,6 +1221,7 @@ async def create_task_note(
note_data = {
"id": db_note.id,
"content": db_note.content,
"note_type": db_note.note_type,
"parent_note_id": db_note.parent_note_id,
"task_id": db_note.task_id,
"user_id": db_note.user_id,
@@ -1217,7 +1233,7 @@ async def create_task_note(
"user_avatar_url": db_note.user.avatar_url,
"child_notes": []
}
return ProductionNoteResponse(**note_data)
@@ -1244,8 +1260,9 @@ async def update_task_note(
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# Users can only update their own notes, unless they have admin permission
if note.user_id != current_user.id and not current_user.is_admin:
# Users can only update their own notes, unless they have admin permission or note:edit
if (note.user_id != current_user.id and not current_user.is_admin
and not user_has_permission(current_user, 'note', 'edit', db)):
raise HTTPException(status_code=403, detail="Not authorized to update this note")
note.content = note_update.content
@@ -1255,6 +1272,7 @@ async def update_task_note(
note_data = {
"id": note.id,
"content": note.content,
"note_type": note.note_type,
"parent_note_id": note.parent_note_id,
"task_id": note.task_id,
"user_id": note.user_id,
@@ -1290,8 +1308,9 @@ async def delete_task_note(
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# Users can only delete their own notes, unless they have admin permission
if note.user_id != current_user.id and not current_user.is_admin:
# Users can only delete their own notes, unless they have admin permission or note:delete
if (note.user_id != current_user.id and not current_user.is_admin
and not user_has_permission(current_user, 'note', 'delete', db)):
raise HTTPException(status_code=403, detail="Not authorized to delete this note")
db.delete(note)
@@ -1368,9 +1387,12 @@ async def upload_task_attachment(
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# Artists can only upload attachments to their own tasks
if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to upload attachments to this task")
# Artists can only upload attachments to their own tasks; everyone else needs upload:create
if current_user.role == UserRole.ARTIST:
if task.assigned_user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to upload attachments to this task")
elif not user_has_permission(current_user, 'upload', 'create', db):
raise HTTPException(status_code=403, detail="Not authorized to upload attachments")
# Validate file using file handler
file_handler.validate_file(file, file_handler.MAX_ATTACHMENT_SIZE, db)
@@ -1443,9 +1465,9 @@ async def delete_task_attachment(
if not attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
# Users can only delete their own attachments, unless they're admin/coordinator
if (attachment.user_id != current_user.id and
not current_user.is_admin and current_user.role != UserRole.COORDINATOR):
# Users can only delete their own attachments, unless they have admin permission or upload:delete
if (attachment.user_id != current_user.id and not current_user.is_admin
and not user_has_permission(current_user, 'upload', 'delete', db)):
raise HTTPException(status_code=403, detail="Not authorized to delete this attachment")
# Delete file from filesystem using file handler
@@ -1534,8 +1556,9 @@ async def submit_work(
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# Only assigned artist can submit work
if task.assigned_user_id != current_user.id:
# Only the assigned artist can submit work, unless the caller holds submission:create
if (task.assigned_user_id != current_user.id
and not user_has_permission(current_user, 'submission', 'create', db)):
raise HTTPException(status_code=403, detail="Only the assigned artist can submit work for this task")
# Validate file using file handler
@@ -1598,5 +1621,84 @@ async def submit_work(
"thumbnail_url": f"/files/submissions/{db_submission.id}?thumbnail=true" if file_handler.is_image_file(db_submission.file_path) else None,
"stream_url": f"/files/submissions/{db_submission.id}/stream" if file_handler.is_video_file(db_submission.file_path) else None
}
return SubmissionResponse(**submission_data)
return SubmissionResponse(**submission_data)
@router.put("/{task_id}/submissions/{submission_id}", response_model=SubmissionResponse)
async def update_task_submission(
task_id: int,
submission_id: int,
submission_update: SubmissionUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update a submission's notes. Users can only update their own submissions."""
submission = db.query(Submission).options(
joinedload(Submission.user)
).filter(
Submission.id == submission_id,
Submission.task_id == task_id,
Submission.deleted_at.is_(None)
).first()
if not submission:
raise HTTPException(status_code=404, detail="Submission not found")
# Users can only update their own submissions, unless they have admin permission or submission:edit
if (submission.user_id != current_user.id and not current_user.is_admin
and not user_has_permission(current_user, 'submission', 'edit', db)):
raise HTTPException(status_code=403, detail="Not authorized to update this submission")
if submission_update.notes is not None:
submission.notes = submission_update.notes
db.commit()
db.refresh(submission)
submission_data = {
"id": submission.id,
"task_id": submission.task_id,
"user_id": submission.user_id,
"file_path": submission.file_path,
"file_name": submission.file_name,
"version_number": submission.version_number,
"notes": submission.notes,
"submitted_at": submission.submitted_at,
"user_first_name": submission.user.first_name,
"user_last_name": submission.user.last_name,
"latest_review": None,
"download_url": f"/files/submissions/{submission.id}",
"thumbnail_url": f"/files/submissions/{submission.id}?thumbnail=true" if file_handler.is_image_file(submission.file_path) else None,
"stream_url": f"/files/submissions/{submission.id}/stream" if file_handler.is_video_file(submission.file_path) else None
}
return SubmissionResponse(**submission_data)
@router.delete("/{task_id}/submissions/{submission_id}")
async def delete_task_submission(
task_id: int,
submission_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Soft delete a submission. Users can only delete their own submissions."""
submission = db.query(Submission).filter(
Submission.id == submission_id,
Submission.task_id == task_id,
Submission.deleted_at.is_(None)
).first()
if not submission:
raise HTTPException(status_code=404, detail="Submission not found")
# Users can only delete their own submissions, unless they have admin permission or submission:delete
if (submission.user_id != current_user.id and not current_user.is_admin
and not user_has_permission(current_user, 'submission', 'delete', db)):
raise HTTPException(status_code=403, detail="Not authorized to delete this submission")
submission.deleted_at = datetime.utcnow()
submission.deleted_by = current_user.id
db.commit()
return {"message": "Submission deleted successfully"}