feat(permissions): admin no longer acts on the test workflow; managers coordinate operators
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled

Admin administers the site — it no longer has any override on
validate-red/blue, review-red/blue, resolve-dispute, request-discussion,
reopen, hold, resume, assign-operators, or classification-update.
Introduces require_any_role_strict(), a role dependency without the
global admin bypass, so these specific endpoints truly exclude admin
instead of only removing it from the (redundant) role tuple.

Managers gain the ability to assign red_tech/blue_tech operators
(POST /tests/{id}/assign, GET /users/operators) alongside leads, since
that's coordination, not resolving a ticket.

Also enlarges and repositions the operator-assignment controls next
to the Start Execution button, and fixes a literal '\u2014' rendering
as text instead of an em dash in the test detail technique line.
This commit is contained in:
kitos
2026-07-10 10:28:04 +02:00
parent f32f636f05
commit 58c0143304
15 changed files with 217 additions and 88 deletions
+19 -19
View File
@@ -42,7 +42,7 @@ from sqlalchemy.orm import Session
from app.database import get_db
# Import get_current_user, require_any_role from app.dependencies.auth
from app.dependencies.auth import get_current_user, require_any_role
from app.dependencies.auth import get_current_user, require_any_role, require_any_role_strict
# Import UnitOfWork from app.domain.unit_of_work
from app.domain.unit_of_work import UnitOfWork
@@ -595,8 +595,8 @@ def update_test_classification(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role(
"admin", "manager", "red_tech", "red_lead", "blue_tech", "blue_lead",
current_user: User = Depends(require_any_role_strict(
"manager", "red_tech", "red_lead", "blue_tech", "blue_lead",
)),
) -> TestOut:
"""Update the data classification label for a test.
@@ -964,12 +964,12 @@ def review_red(
test_id: uuid.UUID,
payload: TestRedReview,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "admin")),
current_user: User = Depends(require_any_role_strict("red_lead")),
) -> TestOut:
"""Assigned Red Lead approves or reopens a test sitting in red_review."""
test = crud_get_test_or_raise(db, test_id)
if current_user.role != "admin" and test.red_reviewer_assignee != current_user.id:
if test.red_reviewer_assignee != current_user.id:
raise HTTPException(status_code=403, detail="You are not the assigned reviewer for this test")
with UnitOfWork(db) as uow:
@@ -994,12 +994,12 @@ def review_blue(
test_id: uuid.UUID,
payload: TestBlueReview,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("blue_lead", "admin")),
current_user: User = Depends(require_any_role_strict("blue_lead")),
) -> TestOut:
"""Assigned Blue Lead approves, reopens, or flags a capability gap on a test in blue_review."""
test = crud_get_test_or_raise(db, test_id)
if current_user.role != "admin" and test.blue_reviewer_assignee != current_user.id:
if test.blue_reviewer_assignee != current_user.id:
raise HTTPException(status_code=403, detail="You are not the assigned reviewer for this test")
with UnitOfWork(db) as uow:
@@ -1109,7 +1109,7 @@ def validate_red(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead")),
current_user: User = Depends(require_any_role_strict("red_lead")),
) -> TestOut:
"""Red Lead approves or rejects the red side of a test.
@@ -1166,7 +1166,7 @@ def validate_blue(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("blue_lead")),
current_user: User = Depends(require_any_role_strict("blue_lead")),
) -> TestOut:
"""Blue Lead approves or rejects the blue side of a test.
@@ -1218,7 +1218,7 @@ def resolve_dispute(
test_id: uuid.UUID,
payload: TestResolveDispute,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead", "admin")),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
) -> TestOut:
"""The lead who approved flips their vote to reject, choosing which team must redo the work."""
test = crud_get_test_with_technique(db, test_id)
@@ -1242,7 +1242,7 @@ def reopen(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
) -> TestOut:
"""Reopen a rejected test, moving it back to ``draft``.
@@ -1269,7 +1269,7 @@ def reopen(
# ---------------------------------------------------------------------------
# POST /tests/{id}/assign — assign red_tech / blue_tech operators (leads + admin)
# POST /tests/{id}/assign — assign red_tech / blue_tech operators (leads + managers)
# ---------------------------------------------------------------------------
@@ -1278,9 +1278,9 @@ def assign_test_operators(
test_id: uuid.UUID,
payload: TestAssign,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("admin", "red_lead", "blue_lead")),
current_user: User = Depends(require_any_role_strict("manager", "red_lead", "blue_lead")),
):
"""Assign red_tech and/or blue_tech operators to a test. Admin/leads only."""
"""Assign red_tech and/or blue_tech operators to a test. Leads/managers only — not admin, who administers the site rather than coordinating operators."""
test = crud_get_test_or_raise(db, test_id)
newly_assigned: User | None = None
@@ -1332,7 +1332,7 @@ def hold_test(
test_id: uuid.UUID,
payload: TestHold,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "admin")),
current_user: User = Depends(require_any_role_strict("red_tech", "blue_tech")),
):
"""Place a test on hold with a mandatory reason. Posts comment + transitions Jira."""
from datetime import datetime as _dt
@@ -1372,7 +1372,7 @@ def hold_test(
def resume_test(
test_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "admin")),
current_user: User = Depends(require_any_role_strict("red_tech", "blue_tech")),
):
"""Resume a test that was placed on hold."""
from app.services.jira_service import push_hold_event
@@ -1644,7 +1644,7 @@ def sync_tempo(
def request_discussion(
test_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead", "admin")),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
):
"""Called when the approving lead confirms their vote in a disputed test.
@@ -1663,12 +1663,12 @@ def request_discussion(
role = current_user.role
# Identify who the "other lead" is (the one who rejected)
if (role in ("red_lead", "admin")) and test.red_validation_status == "approved":
if role == "red_lead" and test.red_validation_status == "approved":
# Red approved, Blue rejected → notify Blue Lead who rejected
rejector_id = test.blue_validated_by
rejector_label = "Blue Lead"
requester_label = "Red Lead"
elif (role in ("blue_lead", "admin")) and test.blue_validation_status == "approved":
elif role == "blue_lead" and test.blue_validation_status == "approved":
# Blue approved, Red rejected → notify Red Lead who rejected
rejector_id = test.red_validated_by
rejector_label = "Red Lead"