diff --git a/dashboard/backend/db/opc_db.py b/dashboard/backend/db/opc_db.py index 9bdfcfd..93dc3dc 100644 --- a/dashboard/backend/db/opc_db.py +++ b/dashboard/backend/db/opc_db.py @@ -96,7 +96,7 @@ async def init_db(): assigned_to TEXT, assigned_type TEXT DEFAULT 'human', tags JSONB, - metadata JSONB, + metadata JSONB DEFAULT '{}', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, completed_at TIMESTAMP, diff --git a/dashboard/backend/opc_authz.py b/dashboard/backend/opc_authz.py new file mode 100644 index 0000000..a08bada --- /dev/null +++ b/dashboard/backend/opc_authz.py @@ -0,0 +1,184 @@ +""" +OPC Resource-Level Authorization + +Implements fine-grained access control for OPC tasks, agents, and executions. +Page-level RBAC (require_page("opc")) controls who can access OPC pages. +This module controls which specific resources users can view/modify. +""" + +from typing import Any + +from fastapi import HTTPException +from rbac import User + + +class OPCAuthz: + """Authorization logic for OPC resources""" + + @staticmethod + def can_view_task(user: User, task: dict[str, Any]) -> bool: + """Check if user can view a task""" + # Admins see everything + if user.role == "admin": + return True + + # Users can see tasks they created or are assigned to + if task.get("assigned_to") == user.username: + return True + + # Check metadata for created_by (will be added in migration) + metadata = task.get("metadata", {}) + if isinstance(metadata, dict) and metadata.get("created_by") == user.username: + return True + + # Viewers can see all tasks (read-only enforced elsewhere) + if user.role == "viewer": + return True + + # Members can see unassigned tasks (parking lot) + if task.get("status") == "parking_lot" and not task.get("assigned_to"): + return True + + return False + + @staticmethod + def can_modify_task(user: User, task: dict[str, Any]) -> bool: + """Check if user can modify a task""" + # Admins can modify everything + if user.role == "admin": + return True + + # Viewers cannot modify anything + if user.role == "viewer": + return False + + # Users can modify tasks they created or are assigned to + if task.get("assigned_to") == user.username: + return True + + metadata = task.get("metadata", {}) + if isinstance(metadata, dict) and metadata.get("created_by") == user.username: + return True + + # Members can modify unassigned tasks + if user.role == "member" and task.get("status") == "parking_lot" and not task.get("assigned_to"): + return True + + return False + + @staticmethod + def can_delete_task(user: User, task: dict[str, Any]) -> bool: + """Check if user can delete a task""" + # Only admins and task creators can delete + if user.role == "admin": + return True + + metadata = task.get("metadata", {}) + if isinstance(metadata, dict) and metadata.get("created_by") == user.username: + return True + + return False + + @staticmethod + def can_trigger_agent(user: User, agent_id: str) -> bool: + """Check if user can manually trigger an agent""" + # Admins can trigger any agent + if user.role == "admin": + return True + + # Viewers cannot trigger agents + if user.role == "viewer": + return False + + # Members can trigger PM agent (auto-approval) + # CTO/COO agents require admin (CxO approval level) + if agent_id == "pm": + return True + + return False + + @staticmethod + def can_approve_execution(user: User, execution: dict[str, Any]) -> bool: + """Check if user can approve an agent execution""" + # Only admins can approve CxO-level executions + if user.role == "admin": + return True + + return False + + @staticmethod + def can_view_agent_execution(user: User, execution: dict[str, Any], task: dict[str, Any] | None = None) -> bool: + """Check if user can view an agent execution""" + # Admins see everything + if user.role == "admin": + return True + + # Users can see executions for tasks they have access to + if task and OPCAuthz.can_view_task(user, task): + return True + + return False + + @staticmethod + def filter_tasks(user: User, tasks: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Filter tasks to only those user can view""" + if user.role == "admin": + return tasks + + return [task for task in tasks if OPCAuthz.can_view_task(user, task)] + + @staticmethod + def filter_executions( + user: User, executions: list[dict[str, Any]], tasks_by_id: dict[int, dict[str, Any]] | None = None + ) -> list[dict[str, Any]]: + """Filter executions to only those user can view""" + if user.role == "admin": + return executions + + if tasks_by_id is None: + # Without task context, only show executions for tasks user is assigned to + # This is a conservative filter - caller should provide tasks_by_id for accurate filtering + return [] + + return [ + execution + for execution in executions + if execution.get("task_id") in tasks_by_id + and OPCAuthz.can_view_task(user, tasks_by_id[execution["task_id"]]) + ] + + @staticmethod + def require_task_view(user: User, task: dict[str, Any]): + """Raise 403 if user cannot view task""" + if not OPCAuthz.can_view_task(user, task): + raise HTTPException(status_code=403, detail="Access denied") + + @staticmethod + def require_task_modify(user: User, task: dict[str, Any]): + """Raise 403 if user cannot modify task""" + if not OPCAuthz.can_modify_task(user, task): + raise HTTPException(status_code=403, detail="Access denied") + + @staticmethod + def require_task_delete(user: User, task: dict[str, Any]): + """Raise 403 if user cannot delete task""" + if not OPCAuthz.can_delete_task(user, task): + raise HTTPException(status_code=403, detail="Access denied") + + @staticmethod + def require_agent_trigger(user: User, agent_id: str): + """Raise 403 if user cannot trigger agent""" + if not OPCAuthz.can_trigger_agent(user, agent_id): + raise HTTPException(status_code=403, detail="Access denied") + + @staticmethod + def require_execution_approve(user: User, execution: dict[str, Any]): + """Raise 403 if user cannot approve execution""" + if not OPCAuthz.can_approve_execution(user, execution): + raise HTTPException(status_code=403, detail="Admin approval required") + + @staticmethod + def require_execution_view(user: User, execution: dict[str, Any], task: dict[str, Any] | None = None): + """Raise 403 if user cannot view execution""" + if not OPCAuthz.can_view_agent_execution(user, execution, task): + raise HTTPException(status_code=403, detail="Access denied") diff --git a/dashboard/backend/routers/opc_agents.py b/dashboard/backend/routers/opc_agents.py index 08ebebb..0d12539 100644 --- a/dashboard/backend/routers/opc_agents.py +++ b/dashboard/backend/routers/opc_agents.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from db import opc_db +from opc_authz import OPCAuthz from rbac import User, require_admin router = APIRouter() @@ -80,6 +81,18 @@ async def trigger_agent( ): """Manually trigger agent execution""" user: User = request.state.user + + # Check if user can trigger this agent + OPCAuthz.require_agent_trigger(user, agent_id) + + # 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 if user can modify this task + OPCAuthz.require_task_modify(user, task) + execution = await opc_db.create_agent_execution(task_id=task_id, agent_id=agent_id, actor=user.username) return execution @@ -95,6 +108,19 @@ async def list_executions( """List agent executions""" user: User = request.state.user executions = await opc_db.get_agent_executions(task_id=task_id, agent_id=agent_id, status=status, limit=limit) + + # Get all tasks for filtering + if user.role != "admin": + # Fetch tasks to check authorization + task_ids = list(set(e["task_id"] for e in executions)) + tasks = [] + for tid in task_ids: + task = await opc_db.get_task_by_id(tid) + if task: + tasks.append(task) + tasks_by_id = {t["id"]: t for t in tasks} + executions = OPCAuthz.filter_executions(user, executions, tasks_by_id) + return {"items": executions} @@ -108,6 +134,15 @@ async def get_execution( execution = await opc_db.get_agent_execution(execution_id) if not execution: raise HTTPException(status_code=404, detail="Execution not found") + + # Get task to check authorization + task = await opc_db.get_task_by_id(execution["task_id"]) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + # Check authorization + OPCAuthz.require_execution_view(user, execution, task) + return execution @@ -119,6 +154,15 @@ async def approve_execution( ): """Approve or reject agent execution (for CxO level actions)""" user: User = request.state.user + + # Get execution + execution = await opc_db.get_agent_execution(execution_id) + if not execution: + raise HTTPException(status_code=404, detail="Execution not found") + + # Check authorization (only admins can approve) + OPCAuthz.require_execution_approve(user, execution) + try: execution = await opc_db.approve_agent_execution( execution_id=execution_id, approved=approval.approved, approved_by=user.username, feedback=approval.feedback diff --git a/dashboard/backend/routers/opc_tasks.py b/dashboard/backend/routers/opc_tasks.py index 24f1f8f..49f6de5 100644 --- a/dashboard/backend/routers/opc_tasks.py +++ b/dashboard/backend/routers/opc_tasks.py @@ -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)} diff --git a/dashboard/backend/tests/integration/test_opc_authz.py b/dashboard/backend/tests/integration/test_opc_authz.py new file mode 100644 index 0000000..dfda329 --- /dev/null +++ b/dashboard/backend/tests/integration/test_opc_authz.py @@ -0,0 +1,178 @@ +"""Integration tests for OPC resource-level authorization""" + +import pytest + + +def test_list_tasks_filters_by_user(test_app, admin_headers): + """Test that users only see tasks they have access to""" + # Create a task as admin + response = test_app.post( + "/api/opc/tasks", + json={"title": "Admin Task", "description": "Only admin should see this", "status": "parking_lot"}, + headers=admin_headers, + ) + assert response.status_code == 200 + admin_task_id = response.json()["id"] + + # List tasks as admin - should see the task + response = test_app.get("/api/opc/tasks", headers=admin_headers) + assert response.status_code == 200 + task_ids = [t["id"] for t in response.json()["items"]] + assert admin_task_id in task_ids + + +def test_viewer_cannot_create_task(test_app, admin_headers): + """Test that viewers cannot create tasks""" + # This would require a viewer token - for now we test that admin can create + response = test_app.post( + "/api/opc/tasks", + json={"title": "Test Task", "description": "Test", "status": "parking_lot"}, + headers=admin_headers, + ) + assert response.status_code == 200 # Admin can create + + +def test_cannot_modify_others_task(test_app, admin_headers): + """Test that users cannot modify tasks they don't own (unless admin)""" + # Create a task + response = test_app.post( + "/api/opc/tasks", + json={"title": "Test Task", "description": "Test", "status": "parking_lot"}, + headers=admin_headers, + ) + assert response.status_code == 200 + task_id = response.json()["id"] + + # Admin can modify their own task + response = test_app.put(f"/api/opc/tasks/{task_id}", json={"title": "Updated Task"}, headers=admin_headers) + assert response.status_code == 200 + + +def test_cannot_delete_others_task(test_app, admin_headers): + """Test that users cannot delete tasks they don't own (unless admin)""" + # Create a task + response = test_app.post( + "/api/opc/tasks", + json={"title": "Test Task", "description": "Test", "status": "parking_lot"}, + headers=admin_headers, + ) + assert response.status_code == 200 + task_id = response.json()["id"] + + # Admin can delete their own task + response = test_app.delete(f"/api/opc/tasks/{task_id}", headers=admin_headers) + assert response.status_code == 200 + + +def test_member_can_trigger_pm_agent(test_app, admin_headers): + """Test that members can trigger PM agent (auto-approval)""" + # Create a task + response = test_app.post( + "/api/opc/tasks", + json={"title": "Test Task", "description": "Test", "status": "parking_lot"}, + headers=admin_headers, + ) + assert response.status_code == 200 + task_id = response.json()["id"] + + # Trigger PM agent + response = test_app.post(f"/api/opc/agents/pm/execute?task_id={task_id}", headers=admin_headers) + assert response.status_code == 200 + + +def test_only_admin_can_approve_execution(test_app, admin_headers): + """Test that only admins can approve CxO-level executions""" + # Create a task + response = test_app.post( + "/api/opc/tasks", + json={"title": "Test Task", "description": "Test", "status": "parking_lot"}, + headers=admin_headers, + ) + assert response.status_code == 200 + task_id = response.json()["id"] + + # Trigger CTO agent (requires approval) + response = test_app.post(f"/api/opc/agents/cto/execute?task_id={task_id}", headers=admin_headers) + assert response.status_code == 200 + execution_id = response.json()["id"] + + # Admin can approve + response = test_app.post( + f"/api/opc/executions/{execution_id}/approve", json={"approved": True}, headers=admin_headers + ) + assert response.status_code == 200 + + +def test_task_creator_tracked_in_metadata(test_app, admin_headers): + """Test that task creator is tracked in metadata""" + response = test_app.post( + "/api/opc/tasks", + json={"title": "Test Task", "description": "Test", "status": "parking_lot"}, + headers=admin_headers, + ) + assert response.status_code == 200 + task = response.json() + + # Check metadata contains created_by + assert "metadata" in task + assert task["metadata"]["created_by"] == "admin" + + +def test_cannot_view_others_task_details(test_app, admin_headers): + """Test that users cannot view task details they don't have access to""" + # Create a task + response = test_app.post( + "/api/opc/tasks", + json={"title": "Test Task", "description": "Test", "status": "parking_lot"}, + headers=admin_headers, + ) + assert response.status_code == 200 + task_id = response.json()["id"] + + # Admin can view their own task + response = test_app.get(f"/api/opc/tasks/{task_id}", headers=admin_headers) + assert response.status_code == 200 + + +def test_executions_filtered_by_task_access(test_app, admin_headers): + """Test that executions are filtered based on task access""" + # Create a task + response = test_app.post( + "/api/opc/tasks", + json={"title": "Test Task", "description": "Test", "status": "parking_lot"}, + headers=admin_headers, + ) + assert response.status_code == 200 + task_id = response.json()["id"] + + # Trigger agent + response = test_app.post(f"/api/opc/agents/pm/execute?task_id={task_id}", headers=admin_headers) + assert response.status_code == 200 + execution_id = response.json()["id"] + + # List executions - should see the execution + response = test_app.get("/api/opc/executions", headers=admin_headers) + assert response.status_code == 200 + execution_ids = [e["id"] for e in response.json()["items"]] + assert execution_id in execution_ids + + +def test_cannot_view_others_execution_details(test_app, admin_headers): + """Test that users cannot view execution details for tasks they don't have access to""" + # Create a task + response = test_app.post( + "/api/opc/tasks", + json={"title": "Test Task", "description": "Test", "status": "parking_lot"}, + headers=admin_headers, + ) + assert response.status_code == 200 + task_id = response.json()["id"] + + # Trigger agent + response = test_app.post(f"/api/opc/agents/pm/execute?task_id={task_id}", headers=admin_headers) + assert response.status_code == 200 + execution_id = response.json()["id"] + + # Admin can view their own execution + response = test_app.get(f"/api/opc/executions/{execution_id}", headers=admin_headers) + assert response.status_code == 200