feat: enhance project and scenario management with role-based access control

- Implemented role-based access control for project and scenario routes.
- Added authorization checks to ensure users have appropriate roles for viewing and managing projects and scenarios.
- Introduced utility functions for ensuring project and scenario access based on user roles.
- Refactored project and scenario routes to utilize new authorization helpers.
- Created initial data seeding script to set up default roles and an admin user.
- Added tests for authorization helpers and initial data seeding functionality.
- Updated exception handling to include authorization errors.
This commit is contained in:
2025-11-09 23:14:54 +01:00
parent 27262bdfa3
commit 0f79864188
16 changed files with 997 additions and 132 deletions

View File

@@ -7,8 +7,13 @@ from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from dependencies import get_unit_of_work
from models import ResourceType, Scenario, ScenarioStatus
from dependencies import (
get_unit_of_work,
require_any_role,
require_roles,
require_scenario_resource,
)
from models import ResourceType, Scenario, ScenarioStatus, User
from schemas.scenario import (
ScenarioComparisonRequest,
ScenarioComparisonResponse,
@@ -26,6 +31,9 @@ from services.unit_of_work import UnitOfWork
router = APIRouter(tags=["Scenarios"])
templates = Jinja2Templates(directory="templates")
READ_ROLES = ("viewer", "analyst", "project_manager", "admin")
MANAGE_ROLES = ("project_manager", "admin")
def _to_read_model(scenario: Scenario) -> ScenarioRead:
return ScenarioRead.model_validate(scenario)
@@ -44,20 +52,36 @@ def _scenario_status_choices() -> list[tuple[str, str]]:
]
def _require_project_repo(uow: UnitOfWork):
if not uow.projects:
raise RuntimeError("Project repository not initialised")
return uow.projects
def _require_scenario_repo(uow: UnitOfWork):
if not uow.scenarios:
raise RuntimeError("Scenario repository not initialised")
return uow.scenarios
@router.get(
"/projects/{project_id}/scenarios",
response_model=List[ScenarioRead],
)
def list_scenarios_for_project(
project_id: int, uow: UnitOfWork = Depends(get_unit_of_work)
project_id: int,
_: User = Depends(require_any_role(*READ_ROLES)),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> List[ScenarioRead]:
project_repo = _require_project_repo(uow)
scenario_repo = _require_scenario_repo(uow)
try:
uow.projects.get(project_id)
project_repo.get(project_id)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
scenarios = uow.scenarios.list_for_project(project_id)
scenarios = scenario_repo.list_for_project(project_id)
return [_to_read_model(scenario) for scenario in scenarios]
@@ -69,10 +93,11 @@ def list_scenarios_for_project(
def compare_scenarios(
project_id: int,
payload: ScenarioComparisonRequest,
_: User = Depends(require_any_role(*READ_ROLES)),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> ScenarioComparisonResponse:
try:
uow.projects.get(project_id)
_require_project_repo(uow).get(project_id)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
@@ -116,10 +141,13 @@ def compare_scenarios(
def create_scenario_for_project(
project_id: int,
payload: ScenarioCreate,
_: User = Depends(require_roles(*MANAGE_ROLES)),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> ScenarioRead:
project_repo = _require_project_repo(uow)
scenario_repo = _require_scenario_repo(uow)
try:
uow.projects.get(project_id)
project_repo.get(project_id)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -127,7 +155,7 @@ def create_scenario_for_project(
scenario = Scenario(project_id=project_id, **payload.model_dump())
try:
created = uow.scenarios.create(scenario)
created = scenario_repo.create(scenario)
except EntityConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
@@ -136,28 +164,19 @@ def create_scenario_for_project(
@router.get("/scenarios/{scenario_id}", response_model=ScenarioRead)
def get_scenario(
scenario_id: int, uow: UnitOfWork = Depends(get_unit_of_work)
scenario: Scenario = Depends(require_scenario_resource()),
) -> ScenarioRead:
try:
scenario = uow.scenarios.get(scenario_id)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return _to_read_model(scenario)
@router.put("/scenarios/{scenario_id}", response_model=ScenarioRead)
def update_scenario(
scenario_id: int,
payload: ScenarioUpdate,
scenario: Scenario = Depends(
require_scenario_resource(require_manage=True)
),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> ScenarioRead:
try:
scenario = uow.scenarios.get(scenario_id)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(scenario, field, value)
@@ -168,13 +187,12 @@ def update_scenario(
@router.delete("/scenarios/{scenario_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_scenario(
scenario_id: int, uow: UnitOfWork = Depends(get_unit_of_work)
scenario: Scenario = Depends(
require_scenario_resource(require_manage=True)
),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> None:
try:
uow.scenarios.delete(scenario_id)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
_require_scenario_repo(uow).delete(scenario.id)
def _normalise(value: str | None) -> str | None:
@@ -208,10 +226,13 @@ def _parse_discount_rate(value: str | None) -> float | None:
name="scenarios.create_scenario_form",
)
def create_scenario_form(
project_id: int, request: Request, uow: UnitOfWork = Depends(get_unit_of_work)
project_id: int,
request: Request,
_: User = Depends(require_roles(*MANAGE_ROLES)),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> HTMLResponse:
try:
project = uow.projects.get(project_id)
project = _require_project_repo(uow).get(project_id)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
@@ -243,6 +264,7 @@ def create_scenario_form(
def create_scenario_submit(
project_id: int,
request: Request,
_: User = Depends(require_roles(*MANAGE_ROLES)),
name: str = Form(...),
description: str | None = Form(None),
status_value: str = Form(ScenarioStatus.DRAFT.value),
@@ -253,8 +275,10 @@ def create_scenario_submit(
primary_resource: str | None = Form(None),
uow: UnitOfWork = Depends(get_unit_of_work),
):
project_repo = _require_project_repo(uow)
scenario_repo = _require_scenario_repo(uow)
try:
project = uow.projects.get(project_id)
project = project_repo.get(project_id)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
@@ -288,7 +312,7 @@ def create_scenario_submit(
)
try:
uow.scenarios.create(scenario)
scenario_repo.create(scenario)
except EntityConflictError as exc:
return templates.TemplateResponse(
request,
@@ -322,16 +346,13 @@ def create_scenario_submit(
name="scenarios.view_scenario",
)
def view_scenario(
scenario_id: int, request: Request, uow: UnitOfWork = Depends(get_unit_of_work)
request: Request,
scenario: Scenario = Depends(
require_scenario_resource(with_children=True)
),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> HTMLResponse:
try:
scenario = uow.scenarios.get(scenario_id, with_children=True)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
project = uow.projects.get(scenario.project_id)
project = _require_project_repo(uow).get(scenario.project_id)
financial_inputs = sorted(
scenario.financial_inputs, key=lambda item: item.created_at
)
@@ -366,16 +387,13 @@ def view_scenario(
name="scenarios.edit_scenario_form",
)
def edit_scenario_form(
scenario_id: int, request: Request, uow: UnitOfWork = Depends(get_unit_of_work)
request: Request,
scenario: Scenario = Depends(
require_scenario_resource(require_manage=True)
),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> HTMLResponse:
try:
scenario = uow.scenarios.get(scenario_id)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
project = uow.projects.get(scenario.project_id)
project = _require_project_repo(uow).get(scenario.project_id)
return templates.TemplateResponse(
request,
@@ -386,10 +404,10 @@ def edit_scenario_form(
"scenario_statuses": _scenario_status_choices(),
"resource_types": _resource_type_choices(),
"form_action": request.url_for(
"scenarios.edit_scenario_submit", scenario_id=scenario_id
"scenarios.edit_scenario_submit", scenario_id=scenario.id
),
"cancel_url": request.url_for(
"scenarios.view_scenario", scenario_id=scenario_id
"scenarios.view_scenario", scenario_id=scenario.id
),
},
)
@@ -401,8 +419,10 @@ def edit_scenario_form(
name="scenarios.edit_scenario_submit",
)
def edit_scenario_submit(
scenario_id: int,
request: Request,
scenario: Scenario = Depends(
require_scenario_resource(require_manage=True)
),
name: str = Form(...),
description: str | None = Form(None),
status_value: str = Form(ScenarioStatus.DRAFT.value),
@@ -413,14 +433,7 @@ def edit_scenario_submit(
primary_resource: str | None = Form(None),
uow: UnitOfWork = Depends(get_unit_of_work),
):
try:
scenario = uow.scenarios.get(scenario_id)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
project = uow.projects.get(scenario.project_id)
project = _require_project_repo(uow).get(scenario.project_id)
scenario.name = name.strip()
scenario.description = _normalise(description)
@@ -447,6 +460,6 @@ def edit_scenario_submit(
uow.flush()
return RedirectResponse(
request.url_for("scenarios.view_scenario", scenario_id=scenario_id),
request.url_for("scenarios.view_scenario", scenario_id=scenario.id),
status_code=status.HTTP_303_SEE_OTHER,
)