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
@@ -0,0 +1,33 @@
"""Admin must be locked out of every Test-workflow *action* endpoint.
Admin administers the site; leads/managers/operators run the test workflow.
The permission dependency runs before any DB lookup, so a random UUID is
enough to prove the 403 — the request never reaches the handler body.
"""
import uuid
import pytest
DUMMY_ID = str(uuid.uuid4())
@pytest.mark.parametrize(
"method,path,payload",
[
("post", f"/api/v1/tests/{DUMMY_ID}/review-red", {"decision": "approve"}),
("post", f"/api/v1/tests/{DUMMY_ID}/review-blue", {"decision": "approve"}),
("post", f"/api/v1/tests/{DUMMY_ID}/validate-red", {"red_validation_status": "approved"}),
("post", f"/api/v1/tests/{DUMMY_ID}/validate-blue", {"blue_validation_status": "approved"}),
("post", f"/api/v1/tests/{DUMMY_ID}/resolve-dispute", {"target_team": "red"}),
("post", f"/api/v1/tests/{DUMMY_ID}/request-discussion", None),
("post", f"/api/v1/tests/{DUMMY_ID}/reopen", None),
("post", f"/api/v1/tests/{DUMMY_ID}/hold", {"reason": "x"}),
("post", f"/api/v1/tests/{DUMMY_ID}/resume", None),
("post", f"/api/v1/tests/{DUMMY_ID}/assign", {"red_tech_assignee": DUMMY_ID}),
("patch", f"/api/v1/tests/{DUMMY_ID}/classification", {"data_classification": "pii"}),
],
)
def test_admin_forbidden(client, auth_headers, method, path, payload):
resp = getattr(client, method)(path, json=payload, headers=auth_headers)
assert resp.status_code == 403, f"{method.upper()} {path} -> {resp.status_code}: {resp.text}"
+27 -1
View File
@@ -1,4 +1,4 @@
"""Tests for POST /tests/{id}/assign — lead/admin manual operator assignment."""
"""Tests for POST /tests/{id}/assign — lead/manager manual operator assignment."""
from unittest.mock import MagicMock, patch
@@ -67,6 +67,32 @@ def test_red_tech_cannot_assign(client, db, red_tech_headers, red_tech_user):
assert resp.status_code == 403
def test_admin_cannot_assign(client, db, auth_headers, admin_user, red_tech_user):
"""Admin administers the site — coordinating operators is a lead/manager call."""
technique = _seed_technique(db)
test = _seed_test(db, technique, admin_user.id)
resp = client.post(
f"/api/v1/tests/{test.id}/assign",
json={"red_tech_assignee": str(red_tech_user.id)},
headers=auth_headers,
)
assert resp.status_code == 403
def test_manager_can_assign(client, db, manager_headers, manager_user, red_tech_user):
technique = _seed_technique(db)
test = _seed_test(db, technique, manager_user.id)
resp = client.post(
f"/api/v1/tests/{test.id}/assign",
json={"red_tech_assignee": str(red_tech_user.id)},
headers=manager_headers,
)
assert resp.status_code == 200, resp.text
assert resp.json()["red_tech_assignee"] == str(red_tech_user.id)
def test_clearing_assignee_does_not_touch_jira(client, db, red_lead_headers, red_lead_user, red_tech_user):
"""Sending null explicitly clears the assignee without a Jira sync call."""
technique = _seed_technique(db)
+22 -1
View File
@@ -61,7 +61,9 @@ def test_create_test_endpoint_uses_tactic_heuristic(client, db, api, auth_header
assert resp.json()["data_classification"] == "internal_use_only"
def test_admin_can_update_classification(client, db, admin_user, admin_token, red_lead_user):
def test_admin_cannot_update_classification(client, db, admin_user, admin_token, red_lead_user):
"""Admin administers the site, not test content — classification is a
lead/manager/operator call."""
technique = _seed_technique(db)
test = Test(
technique_id=technique.id,
@@ -77,6 +79,25 @@ def test_admin_can_update_classification(client, db, admin_user, admin_token, re
json={"data_classification": "pii"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 403
def test_manager_can_update_classification(client, db, manager_user, manager_headers, red_lead_user):
technique = _seed_technique(db)
test = Test(
technique_id=technique.id,
name="Classify me",
created_by=red_lead_user.id,
state=TestState.draft,
)
db.add(test)
db.commit()
response = client.patch(
f"/api/v1/tests/{test.id}/classification",
json={"data_classification": "pii"},
headers=manager_headers,
)
assert response.status_code == 200
assert response.json()["data_classification"] == "pii"
+14 -1
View File
@@ -1,4 +1,4 @@
"""Tests for GET /users/operators — lead/admin-reachable operator picker list."""
"""Tests for GET /users/operators — lead/manager-reachable operator picker list."""
def test_red_lead_can_list_operators(client, red_lead_headers, red_tech_user, blue_tech_user, red_lead_user):
@@ -31,3 +31,16 @@ def test_operators_list_excludes_viewers(client, red_lead_headers, db):
def test_red_tech_cannot_list_operators(client, red_tech_headers):
resp = client.get("/api/v1/users/operators", headers=red_tech_headers)
assert resp.status_code == 403
def test_admin_cannot_list_operators(client, auth_headers):
"""Admin administers the site — coordinating operators is a lead/manager call."""
resp = client.get("/api/v1/users/operators", headers=auth_headers)
assert resp.status_code == 403
def test_manager_can_list_operators(client, manager_headers, red_tech_user):
resp = client.get("/api/v1/users/operators", headers=manager_headers)
assert resp.status_code == 200, resp.text
usernames = {u["username"] for u in resp.json()}
assert "redtech" in usernames
+4 -3
View File
@@ -143,14 +143,15 @@ def test_review_red_reopen_moves_to_red_executing(
assert body["red_review_notes"] == "add more detail"
def test_review_red_admin_can_always_act(
def test_review_red_forbidden_for_admin(
client, db, api, auth_headers, red_tech_headers, red_lead_user, technique
):
"""Admin administers the site, not the test workflow — reviewing is a
red_lead-only action now, with no admin override."""
test_id = _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique)
resp = api("post", f"/api/v1/tests/{test_id}/review-red", auth_headers, json={"decision": "approve"})
assert resp.status_code == 200, resp.text
assert resp.json()["state"] == "blue_evaluating"
assert resp.status_code == 403
# ---------------------------------------------------------------------------
+5 -3
View File
@@ -829,7 +829,9 @@ class TestResolveDispute:
resolve_dispute(db, test, red_lead, "purple")
@patch("app.services.test_workflow_service.log_action")
def test_admin_can_resolve_dispute_regardless_of_vote(self, mock_log):
def test_admin_cannot_resolve_dispute(self, mock_log):
"""Admin administers the site — only the lead who approved may flip
their vote, with no admin override."""
test = _make_test(
TestState.disputed,
red_validation_status="approved",
@@ -839,8 +841,8 @@ class TestResolveDispute:
db = _make_db()
from app.services.test_workflow_service import resolve_dispute
result = resolve_dispute(db, test, admin, "red")
assert result.state == TestState.red_executing
with pytest.raises(InvalidOperationError):
resolve_dispute(db, test, admin, "red")
# ===========================================================================