feat: add resource-level authorization for OPC
- Add OPCAuthz module with fine-grained access control
- Users can only view/modify tasks they created or are assigned to
- Admins have full access to all resources
- Members can trigger PM agent, only admins can trigger CTO/COO
- Only admins can approve CxO-level agent executions
- Track task creator in metadata for authorization
- Add metadata column with default '{}' to tasks table
- Filter task and execution lists based on user permissions
- Add 10 integration tests for authorization logic
This commit is contained in:
@@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from db import opc_db
|
||||
from opc_authz import OPCAuthz
|
||||
from rbac import User
|
||||
from routers import opc_ws
|
||||
|
||||
@@ -71,7 +72,9 @@ async def list_tasks(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return {"items": tasks, "total": len(tasks)}
|
||||
# Filter tasks based on user permissions
|
||||
filtered_tasks = OPCAuthz.filter_tasks(user, tasks)
|
||||
return {"items": filtered_tasks, "total": len(filtered_tasks)}
|
||||
|
||||
|
||||
@router.post("/tasks")
|
||||
@@ -81,6 +84,11 @@ async def create_task(
|
||||
):
|
||||
"""Create a new task"""
|
||||
user: User = request.state.user
|
||||
|
||||
# Viewers cannot create tasks
|
||||
if user.role == "viewer":
|
||||
raise HTTPException(status_code=403, detail="Read-only access")
|
||||
|
||||
# Strip timezone info from due_date if present (database uses TIMESTAMP not TIMESTAMPTZ)
|
||||
due_date = task.due_date.replace(tzinfo=None) if task.due_date else None
|
||||
created_task = await opc_db.create_task(
|
||||
@@ -96,6 +104,14 @@ async def create_task(
|
||||
actor=user.username,
|
||||
)
|
||||
|
||||
# Track creator in metadata for authorization
|
||||
metadata = created_task.get("metadata", {})
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
metadata["created_by"] = user.username
|
||||
await opc_db.update_task(task_id=created_task["id"], updates={"metadata": metadata}, actor=user.username)
|
||||
created_task["metadata"] = metadata
|
||||
|
||||
# Start automatic timer if status is in_progress
|
||||
if task.status == "in_progress":
|
||||
await opc_db.start_time_entry(task_id=created_task["id"], project_id=task.project_id, actor=user.username)
|
||||
@@ -113,10 +129,13 @@ async def get_task(
|
||||
):
|
||||
"""Get task details"""
|
||||
user: User = request.state.user
|
||||
tasks = await opc_db.get_tasks(limit=1, offset=0)
|
||||
task = next((t for t in tasks if t["id"] == task_id), None)
|
||||
task = await opc_db.get_task_by_id(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check authorization
|
||||
OPCAuthz.require_task_view(user, task)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
@@ -130,12 +149,14 @@ async def update_task(
|
||||
user: User = request.state.user
|
||||
updates = task.dict(exclude_unset=True)
|
||||
|
||||
# Get current task status
|
||||
tasks = await opc_db.get_tasks(limit=1000, offset=0)
|
||||
current_task = next((t for t in tasks if t["id"] == task_id), None)
|
||||
# Get current task
|
||||
current_task = await opc_db.get_task_by_id(task_id)
|
||||
if not current_task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check authorization
|
||||
OPCAuthz.require_task_modify(user, current_task)
|
||||
|
||||
# Handle automatic time tracking
|
||||
old_status = current_task["status"]
|
||||
new_status = updates.get("status", old_status)
|
||||
@@ -173,6 +194,15 @@ async def delete_task(
|
||||
):
|
||||
"""Delete a task"""
|
||||
user: User = request.state.user
|
||||
|
||||
# Get task to check authorization
|
||||
task = await opc_db.get_task_by_id(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check authorization
|
||||
OPCAuthz.require_task_delete(user, task)
|
||||
|
||||
await opc_db.delete_task(task_id=task_id, actor=user.username)
|
||||
|
||||
# Broadcast WebSocket update
|
||||
@@ -193,11 +223,13 @@ async def move_task(
|
||||
updates = {"status": move.status}
|
||||
|
||||
# Get current task
|
||||
tasks = await opc_db.get_tasks(limit=1000, offset=0)
|
||||
current_task = next((t for t in tasks if t["id"] == task_id), None)
|
||||
current_task = await opc_db.get_task_by_id(task_id)
|
||||
if not current_task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check authorization
|
||||
OPCAuthz.require_task_modify(user, current_task)
|
||||
|
||||
old_status = current_task["status"]
|
||||
print(
|
||||
f"[Move] Task {task_id}: old_status={old_status}, new_status={move.status}, assigned_to={current_task.get('assigned_to')}"
|
||||
@@ -249,6 +281,15 @@ async def assign_task(
|
||||
):
|
||||
"""Assign task to human or agent"""
|
||||
user: User = request.state.user
|
||||
|
||||
# Get task to check authorization
|
||||
task = await opc_db.get_task_by_id(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check authorization
|
||||
OPCAuthz.require_task_modify(user, task)
|
||||
|
||||
updates = {"assigned_to": assign.assigned_to, "assigned_type": assign.assigned_type}
|
||||
|
||||
updated_task = await opc_db.update_task(task_id=task_id, updates=updates, actor=user.username)
|
||||
@@ -267,6 +308,15 @@ async def get_task_history(
|
||||
):
|
||||
"""Get task history"""
|
||||
user: User = request.state.user
|
||||
|
||||
# Get task to check authorization
|
||||
task = await opc_db.get_task_by_id(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check authorization
|
||||
OPCAuthz.require_task_view(user, task)
|
||||
|
||||
history = await opc_db.get_task_history(task_id=task_id)
|
||||
return {"items": history}
|
||||
|
||||
@@ -278,6 +328,15 @@ async def get_task_time(
|
||||
):
|
||||
"""Get time entries for a task"""
|
||||
user: User = request.state.user
|
||||
|
||||
# Get task to check authorization
|
||||
task = await opc_db.get_task_by_id(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check authorization
|
||||
OPCAuthz.require_task_view(user, task)
|
||||
|
||||
entries = await opc_db.get_time_entries(task_id=task_id)
|
||||
total_minutes = sum(e["duration_minutes"] for e in entries)
|
||||
return {"entries": entries, "total_minutes": total_minutes, "total_hours": round(total_minutes / 60, 2)}
|
||||
|
||||
Reference in New Issue
Block a user