Compare commits

...

2 Commits

Author SHA1 Message Date
kitos 58c0143304 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.
2026-07-10 10:28:04 +02:00
kitos f32f636f05 fix(jira): expose non-admin jira-config, drop security-test label; catalog paging
GET /system/jira-config was admin-only, so non-admin users hit a 403
and the frontend silently fell back to a hardcoded jira.atlassian.com
base URL when building /browse/{key} links. Opened it to any
authenticated user (still no secrets returned).

Also drops the redundant 'security-test' Jira label, and adds a
page-size selector (10/25/50/100) and a 'no existing test yet' filter
to the Test Catalog.
2026-07-10 10:24:13 +02:00
20 changed files with 294 additions and 105 deletions
+24
View File
@@ -253,6 +253,30 @@ def require_any_role(*roles: str) -> Callable[..., object]:
return role_checker return role_checker
def require_any_role_strict(*roles: str) -> Callable[..., object]:
"""Return a FastAPI dependency that enforces **any** of the given *roles*.
Unlike ``require_any_role``, admins do **not** automatically pass — use
this for actions that belong exclusively to Red/Blue operators, leads,
or managers (e.g. executing, reviewing, validating, or resolving a
disputed test), where "admin" must mean site administration only, not
a backdoor into the test workflow itself.
"""
async def role_checker(
current_user: User = Depends(get_current_user),
) -> User:
if current_user.role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
_check_api_key_scope(current_user, "write")
return current_user
return role_checker
def require_scope(scope: str): def require_scope(scope: str):
"""Return a dependency that enforces the API key carries *scope*. """Return a dependency that enforces the API key carries *scope*.
+5 -3
View File
@@ -17,7 +17,7 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import SessionLocal, get_db from app.database import SessionLocal, get_db
from app.dependencies.auth import require_role from app.dependencies.auth import require_role, get_current_user
from app.models.user import User from app.models.user import User
from app.services.mitre_sync_service import sync_mitre from app.services.mitre_sync_service import sync_mitre
from app.services.intel_service import scan_intel from app.services.intel_service import scan_intel
@@ -285,11 +285,13 @@ _JIRA_KEYS = {
@router.get("/jira-config", response_model=JiraConfigOut) @router.get("/jira-config", response_model=JiraConfigOut)
def get_jira_config( def get_jira_config(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")), current_user: User = Depends(get_current_user),
): ):
"""Return current Jira configuration (merged DB + env). """Return current Jira configuration (merged DB + env).
**Requires** the ``admin`` role. Credential values are never returned. Open to any authenticated user — the frontend needs ``url`` to build
correct ``/browse/{key}`` links for non-admin users too. Credential
values (tokens, admin email) are never returned, only booleans.
""" """
from app.services.jira_service import ( from app.services.jira_service import (
get_jira_url, get_jira_project_key, is_jira_enabled, get_jira_url, get_jira_project_key, is_jira_enabled,
+19 -19
View File
@@ -42,7 +42,7 @@ from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
# Import get_current_user, require_any_role from app.dependencies.auth # 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 # Import UnitOfWork from app.domain.unit_of_work
from app.domain.unit_of_work import UnitOfWork from app.domain.unit_of_work import UnitOfWork
@@ -595,8 +595,8 @@ def update_test_classification(
# Entry: db # Entry: db
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: current_user # Entry: current_user
current_user: User = Depends(require_any_role( current_user: User = Depends(require_any_role_strict(
"admin", "manager", "red_tech", "red_lead", "blue_tech", "blue_lead", "manager", "red_tech", "red_lead", "blue_tech", "blue_lead",
)), )),
) -> TestOut: ) -> TestOut:
"""Update the data classification label for a test. """Update the data classification label for a test.
@@ -964,12 +964,12 @@ def review_red(
test_id: uuid.UUID, test_id: uuid.UUID,
payload: TestRedReview, payload: TestRedReview,
db: Session = Depends(get_db), 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: ) -> TestOut:
"""Assigned Red Lead approves or reopens a test sitting in red_review.""" """Assigned Red Lead approves or reopens a test sitting in red_review."""
test = crud_get_test_or_raise(db, test_id) 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") raise HTTPException(status_code=403, detail="You are not the assigned reviewer for this test")
with UnitOfWork(db) as uow: with UnitOfWork(db) as uow:
@@ -994,12 +994,12 @@ def review_blue(
test_id: uuid.UUID, test_id: uuid.UUID,
payload: TestBlueReview, payload: TestBlueReview,
db: Session = Depends(get_db), 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: ) -> TestOut:
"""Assigned Blue Lead approves, reopens, or flags a capability gap on a test in blue_review.""" """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) 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") raise HTTPException(status_code=403, detail="You are not the assigned reviewer for this test")
with UnitOfWork(db) as uow: with UnitOfWork(db) as uow:
@@ -1109,7 +1109,7 @@ def validate_red(
# Entry: db # Entry: db
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: current_user # Entry: current_user
current_user: User = Depends(require_any_role("red_lead")), current_user: User = Depends(require_any_role_strict("red_lead")),
) -> TestOut: ) -> TestOut:
"""Red Lead approves or rejects the red side of a test. """Red Lead approves or rejects the red side of a test.
@@ -1166,7 +1166,7 @@ def validate_blue(
# Entry: db # Entry: db
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: current_user # Entry: current_user
current_user: User = Depends(require_any_role("blue_lead")), current_user: User = Depends(require_any_role_strict("blue_lead")),
) -> TestOut: ) -> TestOut:
"""Blue Lead approves or rejects the blue side of a test. """Blue Lead approves or rejects the blue side of a test.
@@ -1218,7 +1218,7 @@ def resolve_dispute(
test_id: uuid.UUID, test_id: uuid.UUID,
payload: TestResolveDispute, payload: TestResolveDispute,
db: Session = Depends(get_db), 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: ) -> TestOut:
"""The lead who approved flips their vote to reject, choosing which team must redo the work.""" """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) test = crud_get_test_with_technique(db, test_id)
@@ -1242,7 +1242,7 @@ def reopen(
# Entry: db # Entry: db
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: current_user # 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: ) -> TestOut:
"""Reopen a rejected test, moving it back to ``draft``. """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, test_id: uuid.UUID,
payload: TestAssign, payload: TestAssign,
db: Session = Depends(get_db), 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) test = crud_get_test_or_raise(db, test_id)
newly_assigned: User | None = None newly_assigned: User | None = None
@@ -1332,7 +1332,7 @@ def hold_test(
test_id: uuid.UUID, test_id: uuid.UUID,
payload: TestHold, payload: TestHold,
db: Session = Depends(get_db), 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.""" """Place a test on hold with a mandatory reason. Posts comment + transitions Jira."""
from datetime import datetime as _dt from datetime import datetime as _dt
@@ -1372,7 +1372,7 @@ def hold_test(
def resume_test( def resume_test(
test_id: uuid.UUID, test_id: uuid.UUID,
db: Session = Depends(get_db), 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.""" """Resume a test that was placed on hold."""
from app.services.jira_service import push_hold_event from app.services.jira_service import push_hold_event
@@ -1644,7 +1644,7 @@ def sync_tempo(
def request_discussion( def request_discussion(
test_id: uuid.UUID, test_id: uuid.UUID,
db: Session = Depends(get_db), 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. """Called when the approving lead confirms their vote in a disputed test.
@@ -1663,12 +1663,12 @@ def request_discussion(
role = current_user.role role = current_user.role
# Identify who the "other lead" is (the one who rejected) # 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 # Red approved, Blue rejected → notify Blue Lead who rejected
rejector_id = test.blue_validated_by rejector_id = test.blue_validated_by
rejector_label = "Blue Lead" rejector_label = "Blue Lead"
requester_label = "Red 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 # Blue approved, Red rejected → notify Red Lead who rejected
rejector_id = test.red_validated_by rejector_id = test.red_validated_by
rejector_label = "Red Lead" rejector_label = "Red Lead"
+6 -6
View File
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
# Import require_role from app.dependencies.auth # Import require_role from app.dependencies.auth
from app.dependencies.auth import require_role, require_any_role from app.dependencies.auth import require_role, require_any_role_strict
# Import UnitOfWork from app.domain.unit_of_work # Import UnitOfWork from app.domain.unit_of_work
from app.domain.unit_of_work import UnitOfWork from app.domain.unit_of_work import UnitOfWork
@@ -156,13 +156,13 @@ def create_user_route(
@router.get("/operators", response_model=list[UserOperatorOut]) @router.get("/operators", response_model=list[UserOperatorOut])
def list_operators_route( def list_operators_route(
db: Session = Depends(get_db), 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")),
) -> list[UserOperatorOut]: ) -> list[UserOperatorOut]:
"""Return active red/blue operators and leads, for lead/admin assignment pickers. """Return active red/blue operators and leads, for lead/manager assignment pickers.
Deliberately excludes viewers and managers, and returns only Not reachable by admin — admin administers the site, leads/managers
id/username/role — no emails or tokens — since this is reachable by coordinate operators. Returns only id/username/role — no emails or
non-admin leads. tokens — since this is reachable by non-admin leads.
""" """
return ( return (
db.query(User) db.query(User)
+1 -1
View File
@@ -700,7 +700,7 @@ def auto_create_test_issue(
poc = test.procedure_text or "N/A" poc = test.procedure_text or "N/A"
severity = _technique_severity(technique).capitalize() severity = _technique_severity(technique).capitalize()
labels = ["aegis", "security-test", mitre_id.replace(".", "-")] labels = ["aegis", mitre_id.replace(".", "-")]
if test.platform: if test.platform:
# Jira labels can't contain whitespace — normalize to kebab-case. # Jira labels can't contain whitespace — normalize to kebab-case.
labels.append(re.sub(r"[^a-z0-9_-]+", "-", test.platform.lower()).strip("-")) labels.append(re.sub(r"[^a-z0-9_-]+", "-", test.platform.lower()).strip("-"))
@@ -1228,7 +1228,6 @@ def resolve_dispute(db: Session, test: Test, user: User, target_team: str, notes
if target_team not in ("red", "blue"): if target_team not in ("red", "blue"):
raise InvalidOperationError("target_team must be 'red' or 'blue'") raise InvalidOperationError("target_team must be 'red' or 'blue'")
if user.role != "admin":
is_red_approver = user.role == "red_lead" and test.red_validation_status == "approved" is_red_approver = user.role == "red_lead" and test.red_validation_status == "approved"
is_blue_approver = user.role == "blue_lead" and test.blue_validation_status == "approved" is_blue_approver = user.role == "blue_lead" and test.blue_validation_status == "approved"
if not (is_red_approver or is_blue_approver): if not (is_red_approver or is_blue_approver):
@@ -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 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 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): 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.""" """Sending null explicitly clears the assignee without a Jira sync call."""
technique = _seed_technique(db) 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" 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) technique = _seed_technique(db)
test = Test( test = Test(
technique_id=technique.id, 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"}, json={"data_classification": "pii"},
headers={"Authorization": f"Bearer {admin_token}"}, 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.status_code == 200
assert response.json()["data_classification"] == "pii" assert response.json()["data_classification"] == "pii"
@@ -0,0 +1,21 @@
"""GET /system/jira-config must be readable by any authenticated user.
Regression: it used to be admin-only, so non-admin users (e.g. red_lead)
got a 403 and the frontend silently fell back to a hardcoded
"https://jira.atlassian.com" base URL when building ticket links.
"""
def test_non_admin_can_read_jira_config(client, red_lead_headers):
resp = client.get("/api/v1/system/jira-config", headers=red_lead_headers)
assert resp.status_code == 200, resp.text
assert "url" in resp.json()
def test_non_admin_cannot_update_jira_config(client, red_lead_headers):
resp = client.patch(
"/api/v1/system/jira-config",
json={"url": "https://evil.example.com"},
headers=red_lead_headers,
)
assert resp.status_code == 403
+2 -2
View File
@@ -414,11 +414,11 @@ def test_auto_create_test_issue_adds_platform_label(
jira_service.auto_create_test_issue(db, test, actor, technique=technique) jira_service.auto_create_test_issue(db, test, actor, technique=technique)
labels = mock_jira.issue_create.call_args.kwargs["fields"]["labels"] labels = mock_jira.issue_create.call_args.kwargs["fields"]["labels"]
assert ["aegis", "security-test", "T1003-006"] == labels[:3] assert ["aegis", "T1003-006"] == labels[:2]
if expected_label: if expected_label:
assert expected_label in labels assert expected_label in labels
else: else:
assert len(labels) == 3 assert len(labels) == 2
def _make_user(**overrides): def _make_user(**overrides):
+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): 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): def test_red_tech_cannot_list_operators(client, red_tech_headers):
resp = client.get("/api/v1/users/operators", headers=red_tech_headers) resp = client.get("/api/v1/users/operators", headers=red_tech_headers)
assert resp.status_code == 403 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" 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 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) 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"}) 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.status_code == 403
assert resp.json()["state"] == "blue_evaluating"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+5 -3
View File
@@ -829,7 +829,9 @@ class TestResolveDispute:
resolve_dispute(db, test, red_lead, "purple") resolve_dispute(db, test, red_lead, "purple")
@patch("app.services.test_workflow_service.log_action") @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( test = _make_test(
TestState.disputed, TestState.disputed,
red_validation_status="approved", red_validation_status="approved",
@@ -839,8 +841,8 @@ class TestResolveDispute:
db = _make_db() db = _make_db()
from app.services.test_workflow_service import resolve_dispute from app.services.test_workflow_service import resolve_dispute
result = resolve_dispute(db, test, admin, "red") with pytest.raises(InvalidOperationError):
assert result.state == TestState.red_executing resolve_dispute(db, test, admin, "red")
# =========================================================================== # ===========================================================================
+1 -1
View File
@@ -179,7 +179,7 @@ export async function updateTestClassification(
return data; return data;
} }
/** Assign red_tech/blue_tech operators to a test (leads + admin only). */ /** Assign red_tech/blue_tech operators to a test (leads + managers only). */
export async function assignTestOperators( export async function assignTestOperators(
testId: string, testId: string,
payload: { red_tech_assignee?: string | null; blue_tech_assignee?: string | null }, payload: { red_tech_assignee?: string | null; blue_tech_assignee?: string | null },
+1 -1
View File
@@ -36,7 +36,7 @@ export interface OperatorOut {
role: string; role: string;
} }
/** Fetch red/blue operators + leads for assignment pickers (leads + admin). */ /** Fetch red/blue operators + leads for assignment pickers (leads + managers). */
export async function getOperators(): Promise<OperatorOut[]> { export async function getOperators(): Promise<OperatorOut[]> {
const { data } = await client.get<OperatorOut[]>("/users/operators"); const { data } = await client.get<OperatorOut[]>("/users/operators");
return data; return data;
@@ -9,11 +9,13 @@ interface Props {
canEdit: boolean; canEdit: boolean;
isSaving: boolean; isSaving: boolean;
onAssign: (userId: string | null) => void; onAssign: (userId: string | null) => void;
/** "sm" = compact pill (default), "lg" = full-size button matching the action bar. */
size?: "sm" | "lg";
} }
const SIDE_STYLE = { const SIDE_STYLE = {
red: "border-orange-500/30 bg-orange-900/20 text-orange-400", red: "border-orange-500/40 bg-orange-900/20 text-orange-400 hover:bg-orange-900/40",
blue: "border-indigo-500/30 bg-indigo-900/20 text-indigo-400", blue: "border-indigo-500/40 bg-indigo-900/20 text-indigo-400 hover:bg-indigo-900/40",
}; };
const SIDE_ROLES: Record<"red" | "blue", string[]> = { const SIDE_ROLES: Record<"red" | "blue", string[]> = {
@@ -21,20 +23,28 @@ const SIDE_ROLES: Record<"red" | "blue", string[]> = {
blue: ["blue_tech", "blue_lead", "admin"], blue: ["blue_tech", "blue_lead", "admin"],
}; };
/** Lead/admin picker for the red_tech_assignee / blue_tech_assignee fields. */ /** Lead/manager picker for the red_tech_assignee / blue_tech_assignee fields. */
export default function AssigneeControl({ side, assigneeId, operators, canEdit, isSaving, onAssign }: Props) { export default function AssigneeControl({
side, assigneeId, operators, canEdit, isSaving, onAssign, size = "sm",
}: Props) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const current = operators.find((o) => o.id === assigneeId); const current = operators.find((o) => o.id === assigneeId);
const label = current ? current.username : "Unassigned"; const label = current ? current.username : "Unassigned";
const eligible = operators.filter((o) => SIDE_ROLES[side].includes(o.role)); const eligible = operators.filter((o) => SIDE_ROLES[side].includes(o.role));
const sideLabel = side === "red" ? "RT" : "BT";
const badgeClass = `inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${SIDE_STYLE[side]}`; const badgeClass =
size === "lg"
? `flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium ${SIDE_STYLE[side]}`
: `inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${SIDE_STYLE[side]}`;
const iconSize = size === "lg" ? "h-4 w-4" : "h-2.5 w-2.5";
const chevronSize = size === "lg" ? "h-3.5 w-3.5" : "h-2.5 w-2.5";
if (!canEdit) { if (!canEdit) {
return ( return (
<span className={badgeClass}> <span className={badgeClass}>
<UserCircle2 className="h-2.5 w-2.5" /> <UserCircle2 className={iconSize} />
{side === "red" ? "RT" : "BT"}: {label} {sideLabel}: {label}
</span> </span>
); );
} }
@@ -43,15 +53,15 @@ export default function AssigneeControl({ side, assigneeId, operators, canEdit,
<div className="relative inline-block"> <div className="relative inline-block">
<button <button
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
className={`${badgeClass} transition-colors hover:opacity-80`} className={`${badgeClass} transition-colors`}
> >
<UserCircle2 className="h-2.5 w-2.5" /> <UserCircle2 className={iconSize} />
{side === "red" ? "RT" : "BT"}: {label} {sideLabel}: {label}
{expanded ? <ChevronUp className="h-2.5 w-2.5" /> : <ChevronDown className="h-2.5 w-2.5" />} {expanded ? <ChevronUp className={chevronSize} /> : <ChevronDown className={chevronSize} />}
</button> </button>
{expanded && ( {expanded && (
<div className="absolute left-0 top-full z-20 mt-1 w-48 rounded-lg border border-gray-700 bg-gray-900 p-1.5 shadow-xl"> <div className="absolute right-0 top-full z-20 mt-1 w-52 rounded-lg border border-gray-700 bg-gray-900 p-1.5 shadow-xl">
{isSaving ? ( {isSaving ? (
<div className="flex items-center justify-center py-2"> <div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin text-gray-500" /> <Loader2 className="h-4 w-4 animate-spin text-gray-500" />
@@ -158,7 +158,7 @@ export default function TestDetailHeader({
const HOLDABLE_STATES: TestState[] = ["draft", "red_executing", "blue_evaluating"]; const HOLDABLE_STATES: TestState[] = ["draft", "red_executing", "blue_evaluating"];
const canHold = const canHold =
HOLDABLE_STATES.includes(test.state) && HOLDABLE_STATES.includes(test.state) &&
(role === "red_tech" || role === "blue_tech" || role === "admin"); (role === "red_tech" || role === "blue_tech");
const renderActions = () => { const renderActions = () => {
const buttons: React.ReactNode[] = []; const buttons: React.ReactNode[] = [];
@@ -224,7 +224,7 @@ export default function TestDetailHeader({
// Red Lead assigned to review this submission -> Review Red Submission // Red Lead assigned to review this submission -> Review Red Submission
if ( if (
test.state === "red_review" && test.state === "red_review" &&
(role === "admin" || (role === "red_lead" && test.red_reviewer_assignee === user?.id)) role === "red_lead" && test.red_reviewer_assignee === user?.id
) { ) {
buttons.push( buttons.push(
<button <button
@@ -241,7 +241,7 @@ export default function TestDetailHeader({
// Blue Lead assigned to review this submission -> Review Blue Submission // Blue Lead assigned to review this submission -> Review Blue Submission
if ( if (
test.state === "blue_review" && test.state === "blue_review" &&
(role === "admin" || (role === "blue_lead" && test.blue_reviewer_assignee === user?.id)) role === "blue_lead" && test.blue_reviewer_assignee === user?.id
) { ) {
buttons.push( buttons.push(
<button <button
@@ -299,7 +299,7 @@ export default function TestDetailHeader({
// Red Lead in in_review -> Validate Red // Red Lead in in_review -> Validate Red
if ( if (
test.state === "in_review" && test.state === "in_review" &&
(role === "red_lead" || role === "admin") && role === "red_lead" &&
!test.red_validation_status !test.red_validation_status
) { ) {
buttons.push( buttons.push(
@@ -317,7 +317,7 @@ export default function TestDetailHeader({
// Blue Lead in in_review -> Validate Blue // Blue Lead in in_review -> Validate Blue
if ( if (
test.state === "in_review" && test.state === "in_review" &&
(role === "blue_lead" || role === "admin") && role === "blue_lead" &&
!test.blue_validation_status !test.blue_validation_status
) { ) {
buttons.push( buttons.push(
@@ -336,18 +336,16 @@ export default function TestDetailHeader({
if (test.state === "disputed") { if (test.state === "disputed") {
const isApproving = const isApproving =
(role === "red_lead" && test.red_validation_status === "approved") || (role === "red_lead" && test.red_validation_status === "approved") ||
(role === "blue_lead" && test.blue_validation_status === "approved") || (role === "blue_lead" && test.blue_validation_status === "approved");
(role === "admin" && test.red_validation_status === "approved");
const isRejecting = const isRejecting =
(role === "red_lead" && test.red_validation_status === "rejected") || (role === "red_lead" && test.red_validation_status === "rejected") ||
(role === "blue_lead" && test.blue_validation_status === "rejected") || (role === "blue_lead" && test.blue_validation_status === "rejected");
(role === "admin" && test.blue_validation_status === "rejected");
const rejectingSide: "red" | "blue" = const rejectingSide: "red" | "blue" =
test.red_validation_status === "rejected" ? "red" : "blue"; test.red_validation_status === "rejected" ? "red" : "blue";
if (isApproving || role === "admin") { if (isApproving) {
buttons.push( buttons.push(
<div key="disputed-approver" className="flex flex-col items-end gap-1.5"> <div key="disputed-approver" className="flex flex-col items-end gap-1.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -401,10 +399,10 @@ export default function TestDetailHeader({
} }
} }
// Leads/admin on rejected -> Reopen // Leads on rejected -> Reopen
if ( if (
test.state === "rejected" && test.state === "rejected" &&
(role === "red_lead" || role === "blue_lead" || role === "admin") (role === "red_lead" || role === "blue_lead")
) { ) {
buttons.push( buttons.push(
<button <button
@@ -539,7 +537,7 @@ export default function TestDetailHeader({
</span> </span>
<DataClassificationBadge <DataClassificationBadge
value={test.data_classification} value={test.data_classification}
canEdit={["admin", "manager", "red_tech", "red_lead", "blue_tech", "blue_lead"].includes(role)} canEdit={["manager", "red_tech", "red_lead", "blue_tech", "blue_lead"].includes(role)}
isSaving={isUpdatingClassification} isSaving={isUpdatingClassification}
onChange={onUpdateClassification} onChange={onUpdateClassification}
/> />
@@ -547,29 +545,31 @@ export default function TestDetailHeader({
<p className="mt-1 text-sm text-gray-400"> <p className="mt-1 text-sm text-gray-400">
Created {formatDate(test.created_at)} Created {formatDate(test.created_at)}
</p> </p>
<div className="mt-1.5 flex items-center gap-1.5">
<AssigneeControl
side="red"
assigneeId={test.red_tech_assignee}
operators={operators}
canEdit={role === "red_lead" || role === "admin"}
isSaving={isAssigningOperator}
onAssign={(userId) => onAssignOperator("red", userId)}
/>
<AssigneeControl
side="blue"
assigneeId={test.blue_tech_assignee}
operators={operators}
canEdit={role === "blue_lead" || role === "admin"}
isSaving={isAssigningOperator}
onAssign={(userId) => onAssignOperator("blue", userId)}
/>
</div>
{renderValidationIndicators()} {renderValidationIndicators()}
</div> </div>
</div> </div>
<div className="flex flex-col items-end gap-2"> <div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-2">
<AssigneeControl
side="red"
assigneeId={test.red_tech_assignee}
operators={operators}
canEdit={role === "red_lead" || role === "manager"}
isSaving={isAssigningOperator}
onAssign={(userId) => onAssignOperator("red", userId)}
size="lg"
/>
<AssigneeControl
side="blue"
assigneeId={test.blue_tech_assignee}
operators={operators}
canEdit={role === "blue_lead" || role === "manager"}
isSaving={isAssigningOperator}
onAssign={(userId) => onAssignOperator("blue", userId)}
size="lg"
/>
</div>
{renderLiveTimer()} {renderLiveTimer()}
{renderActions()} {renderActions()}
</div> </div>
+45 -8
View File
@@ -19,7 +19,8 @@ import { useAuth } from "../context/AuthContext";
// ── Constants ────────────────────────────────────────────────────── // ── Constants ──────────────────────────────────────────────────────
const PAGE_SIZE = 12; const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
const DEFAULT_PAGE_SIZE = 25;
const SOURCE_OPTIONS = [ const SOURCE_OPTIONS = [
{ value: "", label: "All Sources" }, { value: "", label: "All Sources" },
@@ -89,7 +90,9 @@ export default function TestCatalogPage() {
const [source, setSource] = useState(searchParams.get("source") || ""); const [source, setSource] = useState(searchParams.get("source") || "");
const [platform, setPlatform] = useState(searchParams.get("platform") || ""); const [platform, setPlatform] = useState(searchParams.get("platform") || "");
const [severity, setSeverity] = useState(searchParams.get("severity") || ""); const [severity, setSeverity] = useState(searchParams.get("severity") || "");
const [hideCovered, setHideCovered] = useState(searchParams.get("hide_covered") === "1");
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
// Build filters // Build filters
const filters = { const filters = {
@@ -97,15 +100,21 @@ export default function TestCatalogPage() {
source: source || undefined, source: source || undefined,
platform: platform || undefined, platform: platform || undefined,
severity: severity || undefined, severity: severity || undefined,
offset: page * PAGE_SIZE, offset: page * pageSize,
limit: PAGE_SIZE, limit: pageSize,
}; };
const { data: templates = [], isLoading } = useQuery({ const { data: allTemplates = [], isLoading } = useQuery({
queryKey: ["test-templates", filters], queryKey: ["test-templates", filters],
queryFn: () => getTemplates(filters), queryFn: () => getTemplates(filters),
}); });
// "No tests yet" is a client-side filter — existing_test_count is already
// returned per-template, no need for a separate backend round-trip.
const templates = hideCovered
? allTemplates.filter((t) => t.existing_test_count === 0)
: allTemplates;
// ── Filter handlers ────────────────────────────────────────────── // ── Filter handlers ──────────────────────────────────────────────
const applyFilters = () => { const applyFilters = () => {
@@ -115,6 +124,7 @@ export default function TestCatalogPage() {
if (source) params.set("source", source); if (source) params.set("source", source);
if (platform) params.set("platform", platform); if (platform) params.set("platform", platform);
if (severity) params.set("severity", severity); if (severity) params.set("severity", severity);
if (hideCovered) params.set("hide_covered", "1");
setSearchParams(params); setSearchParams(params);
}; };
@@ -123,11 +133,12 @@ export default function TestCatalogPage() {
setSource(""); setSource("");
setPlatform(""); setPlatform("");
setSeverity(""); setSeverity("");
setHideCovered(false);
setPage(0); setPage(0);
setSearchParams({}); setSearchParams({});
}; };
const hasActiveFilters = search || source || platform || severity; const hasActiveFilters = search || source || platform || severity || hideCovered;
// ── Render ─────────────────────────────────────────────────────── // ── Render ───────────────────────────────────────────────────────
@@ -220,6 +231,17 @@ export default function TestCatalogPage() {
</button> </button>
)} )}
</div> </div>
{/* No-coverage-yet toggle */}
<label className="mt-3 flex w-fit items-center gap-2 text-sm text-gray-400">
<input
type="checkbox"
checked={hideCovered}
onChange={(e) => { setHideCovered(e.target.checked); setPage(0); }}
className="h-4 w-4 rounded border-gray-600 bg-gray-800 text-cyan-500 focus:ring-cyan-500"
/>
Only show techniques with no existing test
</label>
</div> </div>
{/* Results */} {/* Results */}
@@ -253,11 +275,26 @@ export default function TestCatalogPage() {
{/* Pagination */} {/* Pagination */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Showing {page * PAGE_SIZE + 1} Showing {page * pageSize + 1}
{" - "} {" - "}
{page * PAGE_SIZE + templates.length} {page * pageSize + allTemplates.length}
{hideCovered && templates.length !== allTemplates.length && ` (${templates.length} without existing tests)`}
</p> </p>
<label className="flex items-center gap-1.5 text-sm text-gray-500">
Show
<select
value={pageSize}
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(0); }}
className="rounded-lg border border-gray-700 bg-gray-800 px-2 py-1 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
{PAGE_SIZE_OPTIONS.map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => setPage((p) => Math.max(0, p - 1))} onClick={() => setPage((p) => Math.max(0, p - 1))}
@@ -270,7 +307,7 @@ export default function TestCatalogPage() {
<span className="text-sm text-gray-400">Page {page + 1}</span> <span className="text-sm text-gray-400">Page {page + 1}</span>
<button <button
onClick={() => setPage((p) => p + 1)} onClick={() => setPage((p) => p + 1)}
disabled={templates.length < PAGE_SIZE} disabled={allTemplates.length < pageSize}
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-40 transition-colors" className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-40 transition-colors"
> >
Next Next
+2 -2
View File
@@ -135,7 +135,7 @@ export default function TestDetailPage() {
enabled: !!testId && !!test && (test.retest_of !== null || test.retest_count > 0), enabled: !!testId && !!test && (test.retest_of !== null || test.retest_count > 0),
}); });
const canAssignOperators = ["red_lead", "blue_lead", "admin"].includes(user?.role ?? ""); const canAssignOperators = ["red_lead", "blue_lead", "manager"].includes(user?.role ?? "");
const { data: operators = [] } = useQuery({ const { data: operators = [] } = useQuery({
queryKey: ["operators"], queryKey: ["operators"],
queryFn: getOperators, queryFn: getOperators,
@@ -629,7 +629,7 @@ export default function TestDetailPage() {
</span> </span>
{test.technique_name && ( {test.technique_name && (
<span className="text-xs text-gray-400 group-hover:text-gray-300 transition-colors"> <span className="text-xs text-gray-400 group-hover:text-gray-300 transition-colors">
\u2014 {test.technique_name} \ {test.technique_name}
</span> </span>
)} )}
</button> </button>