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
@@ -158,7 +158,7 @@ export default function TestDetailHeader({
const HOLDABLE_STATES: TestState[] = ["draft", "red_executing", "blue_evaluating"];
const canHold =
HOLDABLE_STATES.includes(test.state) &&
(role === "red_tech" || role === "blue_tech" || role === "admin");
(role === "red_tech" || role === "blue_tech");
const renderActions = () => {
const buttons: React.ReactNode[] = [];
@@ -224,7 +224,7 @@ export default function TestDetailHeader({
// Red Lead assigned to review this submission -> Review Red Submission
if (
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(
<button
@@ -241,7 +241,7 @@ export default function TestDetailHeader({
// Blue Lead assigned to review this submission -> Review Blue Submission
if (
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(
<button
@@ -299,7 +299,7 @@ export default function TestDetailHeader({
// Red Lead in in_review -> Validate Red
if (
test.state === "in_review" &&
(role === "red_lead" || role === "admin") &&
role === "red_lead" &&
!test.red_validation_status
) {
buttons.push(
@@ -317,7 +317,7 @@ export default function TestDetailHeader({
// Blue Lead in in_review -> Validate Blue
if (
test.state === "in_review" &&
(role === "blue_lead" || role === "admin") &&
role === "blue_lead" &&
!test.blue_validation_status
) {
buttons.push(
@@ -336,18 +336,16 @@ export default function TestDetailHeader({
if (test.state === "disputed") {
const isApproving =
(role === "red_lead" && test.red_validation_status === "approved") ||
(role === "blue_lead" && test.blue_validation_status === "approved") ||
(role === "admin" && test.red_validation_status === "approved");
(role === "blue_lead" && test.blue_validation_status === "approved");
const isRejecting =
(role === "red_lead" && test.red_validation_status === "rejected") ||
(role === "blue_lead" && test.blue_validation_status === "rejected") ||
(role === "admin" && test.blue_validation_status === "rejected");
(role === "blue_lead" && test.blue_validation_status === "rejected");
const rejectingSide: "red" | "blue" =
test.red_validation_status === "rejected" ? "red" : "blue";
if (isApproving || role === "admin") {
if (isApproving) {
buttons.push(
<div key="disputed-approver" className="flex flex-col items-end gap-1.5">
<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 (
test.state === "rejected" &&
(role === "red_lead" || role === "blue_lead" || role === "admin")
(role === "red_lead" || role === "blue_lead")
) {
buttons.push(
<button
@@ -539,7 +537,7 @@ export default function TestDetailHeader({
</span>
<DataClassificationBadge
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}
onChange={onUpdateClassification}
/>
@@ -547,29 +545,31 @@ export default function TestDetailHeader({
<p className="mt-1 text-sm text-gray-400">
Created {formatDate(test.created_at)}
</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()}
</div>
</div>
<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()}
{renderActions()}
</div>