Both backend (PATCH /users/{id}) and frontend (Edit User modal) now block
an admin from changing their own primary role away from admin or removing
admin from their own extra_roles, closing off a self-lockout scenario.
Let each mail client render with its own default font (Calibri/Aptos/etc)
instead of a forced web-safe stack that read heavier/less familiar than
the plain-text emails.
- All webhook emails now render as branded HTML (dark header, inline base64
Aegis logo, card layout, CTA-button links) instead of plain text.
- Wired the 7 remaining notification-preference keys that had no trigger:
stale coverage alerts, campaign-activated assignment emails, generic
test-state-change steps (execution started / blue evaluating / in review),
all-team-validation broadcasts on every lead vote, webhook delivery
failures (3rd consecutive failure), new user registration, and background
job errors (APScheduler global error listener).
- New notify_roles_by_email() helper for role-scoped, preference-gated,
actor-excludable broadcasts.
- Fixed apscheduler.events stubbing gaps in several test files' sys.modules
fakes that broke full-suite collection after adding the APScheduler
error-listener import.
- Renamed the password-setup webhook (email_webhook.*, /system/email-webhook-config)
and made it the single transport for all notification emails.
- New webhook_email_service.py shared by password_setup_service and
notification_service.
- Wired test validated/rejected, campaign completed, and new-MITRE-technique
notifications through the webhook (previously dead SMTP code, never triggered).
- Added POST /system/email-webhook-test to send a real test email.
- Hid the Email/SMTP settings tab from the UI (SMTP code kept intact, unused).
- Redact email_webhook.api_key on config export.
- A lead opening a test with a pending procedure suggestion awaiting
their review now gets a blocking popup (approve/reject only) instead
of discovering it later in a separate queue.
- Users can be granted more than one role via extra_roles; only one is
ever active at a time (no permission mixing) and a top-bar switcher
lets the user swap which one, taking effect immediately since role
is read fresh from the DB on every request.
- The password-setup/reset webhook now posts the agreed Power Automate
contract ({to, subject, body} with the platform's standard greeting
and signature template) and supports an admin-configured API key
sent as an x-api-key header.
Admins now create users with a name + email (no password) and role;
a Send Email action (per-user, also reusable for resets) issues a
one-time token and POSTs it to an admin-configurable webhook URL —
intended for a Power Automate flow that delivers the actual email.
The old admin-sets-the-password flow is kept server-side
(LegacyUserCreateWithPassword) but no longer wired into the UI.
Every user-facing surface (top bar, sidebar, user management, audit
log, assignee pickers, dispute notifications) now shows full_name
instead of username, falling back to username when unset.
Also fixes long attack_procedure/expected_detection/suggested_text
text overflowing its container in the template review panels and
Red/Blue team fields (missing break-words on whitespace-pre-wrap
blocks).
- Campaign creation and generate-from-threat-actor now use the strict
role check — admin no longer gets a free pass into campaign content,
same principle already applied to test-template creation.
- New manager-only DELETE /tests/{id}: removes a standalone test still
in draft (not started, not linked to any campaign).
- Replaced the orange/indigo stand-ins used across team badges, tabs,
action buttons, and timers with true red/blue so Red Team and Blue
Team read unambiguously at a glance.
- TestTemplate/TemplateSuggestion creation and updates now reject any
mitre_technique_id that doesn't match a real, already-synced MITRE
ATT&CK technique. Frontend swaps the free-text ID input for a
technique picker.
- Test Catalog now shows pending template suggestions before the
catalog grid, with full field detail (platform, severity, tool,
atomic ID, source URL, remediation) instead of just name/procedure.
- A manager can edit and directly re-approve a campaign they previously
rejected, instead of needing the original lead to resubmit it.
A manager organizes and validates work, so their own campaigns skip the
draft -> submit -> pending_approval queue and go straight to active with
the start_date they provide (they're the same role that would otherwise
approve it). Manager can also now create tests from the catalog, same as
red_lead/blue_lead.
Leads get a Create Template button that adds directly to the catalog
(existing POST /test-templates). Operators (red_tech/blue_tech) get the
same button, but their proposal now lands in a new template_suggestions
review queue instead — a lead on their team can approve it as-is, edit
fields before approving, or discard it. Modeled on the existing
ProcedureSuggestion review workflow.
- Approve endpoint now only creates Jira tickets immediately when
start_date is now/past; a new periodic job (every 15 min) catches
campaigns whose scheduled start_date has since arrived.
- Recurring campaign clones now go to pending_approval instead of
active, routing through the same manager-approval gate as any other
campaign; managers are notified instead of red_tech.
- Fix UTC conversion for the campaign approval start_date input and
extract shared isoToDatetimeLocal/datetimeLocalToIso helpers.
Manager could not see the Review Queue nav item, load the page, or
call mark-reviewed (red_lead/blue_lead/admin only) — added to the
route guard, sidebar visibility, badge query, and the backend
endpoint's role check.
Webhook Configuration was shown to red_lead/blue_lead in Settings
even though the backend already restricted every webhook endpoint to
admin only — the tab is now admin-only in the UI too, matching what
was already enforced server-side.
Test Catalog's pagination showed only 'Page N' with no way to know
how many pages existed, and the campaign 'Add Test' modal's template
picker had no pagination at all — with 2800+ templates in the
catalog, its flat 100-row fetch made most templates unreachable
unless a search happened to match. Adds GET /test-templates/count
(open to any authenticated user, mirrors the list endpoint's filters)
and wires real 'Page N of M' + total-available counts into both.
The validated/rejected Jira comment always said 'by both leads' and
never carried the manager's own notes, even when a manager had just
overridden two disagreeing votes — misleading, since that combination
of statuses is only reachable via a manager decision. Now detected
from the disagreement itself and worded accordingly, with the
manager's notes included via the existing extra-data mechanism.
Also reworks the Summary tab: Final Result moves to the top (was the
very last thing on the page) and now shows Containment alongside
Detection — previously the only place Containment appeared at all was
inside the editable Blue tab. Both team cards now show the validating
lead's notes, not just the approved/rejected badge, and flag when a
manager's decision resolved a disagreement between the two leads.
'manager' has been a fully-functional role throughout the app —
notifications, operator assignment, dispute mediation — but was
missing from both the user creation/update whitelist and the SSO
auto-provisioning role list, so a manager account could never
actually be created.
Request Discussion only ever nudged the peer lead, and the one-time
manager notification when a dispute starts had no real follow-up
action attached to it — a manager could be told a dispute existed but
had no way to actually do anything about it. Adds:
- POST /tests/{id}/escalate-to-manager — either lead can explicitly
ask managers to step in, distinct from the passive initial notice.
- POST /tests/{id}/manager-resolve-dispute — a manager decides the
final outcome (validated/rejected) directly, bypassing both leads'
votes entirely, reusing the same dual-validation event dispatch so
Jira/notifications behave like any other resolution.
Both leads are notified of the manager's final call, win or lose.
Leads can now edit Expected Detection in the create-from-template
form, same as Suggested Attack Procedure — the edit seeds the new
test's detect_procedure via a detect_procedure_override, without
touching the template itself. The template only updates later through
the existing procedure-suggestion approval flow, once a round is
actually submitted — same mechanism as the red side, no new bypass.
Two overlapping TestTemplate fields (expected_detection: narrative
guidance from imports/leads; detect_suggested_procedure: concrete
commands from approved suggestions) are now one. Blue-side procedure
suggestions target expected_detection directly, appending onto
whatever is already there rather than overwriting it — same
merge-not-overwrite behavior already used for the red side. Existing
detect_suggested_procedure data is folded into expected_detection
before the column is dropped.
Was rendering the raw Python enum repr (TestResult.detected) instead
of the plain value, the only branch in _build_state_comment that
skipped _enum_value(). Caught while verifying the system-gap fix on a
live ticket.
flag_blue_review_gap saved system_gaps on the test but the Jira
in_review comment never mentioned it, so a flagged capability gap was
silently invisible in Jira. Now included in the comment and tagged
with a system-gap label for filtering.
Also surfaces TestTemplate.detect_suggested_procedure as a read-only
reference in the Test Catalog's create-from-template modal — it was
only ever shown in the admin template editor, not here, so an approved
suggestion appeared to vanish when viewed from the catalog.
Blue's detect_procedure extraction had zero Blue-team-oriented tool
names in its known-binaries list — sysmon.exe, one of the most common
Windows detection tools, silently produced no suggestion at all.
Verified against the exact production input that surfaced this.
canHold checked role OR role instead of matching the current phase, so
a red_tech still saw (and could call) Hold/Resume on a test already
sitting in blue_evaluating. Fixed on both frontend and backend — the
API had the same gap, not just the button visibility.
Approving a suggestion was replacing the template's whole procedure
field with just that submission's extracted text, silently dropping
whatever an earlier approved round had already added. Now it appends
only the genuinely new lines, so templates accumulate commands across
rounds instead of losing them.
When Red or Blue submits a round for a test created from a template,
any extractable command in their procedure text is proposed as an
update to the template's suggested procedure — never written
automatically. Adds GET/approve/reject endpoints scoped so a lead only
reviews their own team's suggestions; approving writes the suggested
text into the template, rejecting leaves it untouched.
Blue Team gets a detect_procedure field on Test (what they actually
did to detect the attack), Blue's counterpart to Red's procedure_text.
It's seeded from a new detect_suggested_procedure field on
TestTemplate at test-creation time, so a junior who later reuses the
same template starts with prior guidance instead of a blank field.
Also adds the procedure_suggestions review table and Test.source_template_id,
laying the groundwork for suggesting template improvements from filled-in
procedure fields (reviewed and approved by a lead, never auto-written).
detect_procedure is archived (not cleared) on Blue reopen, matching
blue_summary, and now appears in the Jira round-archived and blue_review
comments alongside the existing detection/containment fields.
Extracts the actual command(s)/query out of a free-text procedure
field, discarding narrative and pasted output, for use in building
procedure-improvement suggestions from operator submissions.
Red and Blue could not see each other's real field data until both
reviews passed, showing a placeholder message instead. This kept
detection testing blind to what Red actually did; both sides now see
the full, live test data at all times.
Confirmed via issue_editmeta and a real production failure log: Time to
Detect / Time to Contain are Jira datetime fields despite the name, not
numeric duration fields. Pushing a computed hour count made Jira reject
the entire fields payload (validated atomically), silently sinking BT End
Date and Attack Detected on the same call even though only these two
fields were actually invalid. Now pushes the real detection_time/
containment_time timestamps. Removed the now-unused _compute_hours.
red_lead/blue_lead never saw a plain "All Tests" list — they were always
routed into their own review two-queue view or "My Tasks", with no way to
see every test and who's actually working it. Adds an always-visible
section at the end of the Tests page for leads showing every test with
both Red and Blue assignee columns, so they can monitor the queues and
spot a stuck ticket or an overloaded operator.
The list endpoint (GET /tests) now joinedloads the assignee relationships
and resolves usernames the same way the detail endpoint already does, so
the new section doesn't depend on GET /users/operators (lead/manager-only).
- BT Start Date had the same bug RT Start Date did: pushed on every round,
clobbering round 1's real pickup date. Now only pushed on round 1,
mirroring the "first round survives every reopen" rule.
- push_test_event's assignment branches (red_executing, red/blue reviewer,
blue_evaluating reassignment) only read the cached jira_account_id and
silently no-op'd if it was never populated — which is exactly what
happened to a blue_lead who hadn't logged in since Jira was configured,
making it look like Blue's reviewer reassignment was broken when Red's
wasn't purely because that particular red_lead happened to have logged
in already. Extracted the live-lookup fallback that only
push_assignee_update had into a shared _resolve_jira_account_id(), used
by every assignment call site now.
- Tests created without a campaign parent ticket now get a "standalone-test"
label, so they're identifiable in Jira without cross-referencing Aegis.
- The comment posted on every red/blue submission was a generic one-liner
with no data — only a reopened round got a full data dump (via
push_round_archived), so a round that was approved outright left no
record in Jira of what was actually done. Now every submission comment
includes the round number, procedure/tool/attack success (red) or
detection/containment result (blue), and summary.
- RT/BT Start/End Date are genuine Jira "datetime" custom fields (confirmed
via issue_editmeta), not plain dates. Pushing a bare "YYYY-MM-DD" string
made Jira default the time-of-day to midnight, so RT Start Date and RT
End Date ended up showing the same meaningless midnight timestamp instead
of the actual execution window. Now sends a full ISO datetime.
- The test detail page showed "RT: Unassigned" for the operator who WAS
correctly assigned, because GET /users/operators (the only source
AssigneeControl used to resolve an assignee ID into a username) is
restricted to leads/managers — a plain operator has no other way to
resolve their own ID. TestOut now resolves and includes the assignee's
username directly (red/blue tech + reviewer), so the badge no longer
depends on a permission-gated list the viewer might not have access to.
- execution_start_time/execution_end_time/detection_time/containment_time
were captured from a datetime-local input with no local->UTC conversion,
then stored/displayed as if already UTC — off by the browser's offset.
Frontend now converts properly in both directions; backend schemas
defensively normalize any tz-aware input to naive UTC as well.
- push_rt_submitted was pushing datetime.utcnow() (whenever the submit
button was clicked) into Jira's RT Start/End Date fields instead of the
operator-entered execution window. Now pushes the *first* round's
execution start (recovered from round_history across reopens) and the
*current* round's execution end. Removed push_rt_started, which only
ever pushed a meaningless click-timestamp.
- Reopening a test now adds a "reopened" Jira label (read-modify-write so
existing labels aren't clobbered), on top of the existing round-archived
comment and status push.
Only the per-round verdict/timing fields (attack_success, execution
start/end for red; detection_result, containment_result, detection/
containment time for blue) get cleared on reopen — the operator must
answer those fresh for each round, and the prior round's answer stays
visible via round_history + the archived Phase Timeline card. Free-text
fields (procedure_text, tool_used, red_summary, blue_summary) are no
longer cleared: the operator edits them in place for the new round
instead of retyping everything from scratch.
- Reopening a test for rework (red or blue) now archives the round that's
ending into a new test_round_history table before resetting the live
fields — procedure/results/detection data is preserved instead of
overwritten, and Phase Timeline renders every past round alongside the
current one, so Blue Team keeps visibility into earlier Red attempts.
- Each archived round also posts a permanent Jira comment (push_round_archived)
since Jira's custom fields only ever show the latest value once the next
round resubmits — the comment thread is what preserves full history there.
- push_pause_event no longer transitions the Jira issue to "On Hold" — a
timer pause is not a real Hold, and flipping Jira status for it was
misleading. Only the explicit Hold action (push_hold_event) does that now.
- reopen_red_review starts the timer paused (paused_at set) instead of
immediately running, since the operator hasn't resumed working yet —
blue already did this via blue_work_started_at, red needed the same
behavior via the existing pause/resume mechanism.
- update_test_red/update_test_blue now lock edits to the actual assigned
operator for leads too, not just techs — a lead could previously edit
another operator's in-progress test. Mirrored in TeamTabs.tsx.
- push_test_event reassigns the Jira ticket to the original operator
(not the reopening lead) when a test returns to red_executing or
blue_evaluating for rework, instead of leaving it on the lead.
Admin still bypassed require_any_role (non-strict) on the pure
operator actions: start-execution, submit-red, start-blue-work,
submit-blue, pause/resume-timer, and the red/blue field-edit + general
test create/update/remediation/import-rt/template endpoints all used
the loose dependency even where admin wasn't in the role tuple.
Switched every one of these to require_any_role_strict, matching the
review/validate/dispute/hold/assign endpoints already fixed earlier.
Frontend: removed every remaining role === "admin" disjunct gating
Start Execution, Submit to Blue Team, blue evaluating actions, timer
control, red/blue field editing, test creation, and template creation.
Team-blindness visibility (isBlind) deliberately keeps the admin
bypass — admin still sees both sides unmasked for oversight, since
that's visibility, not operating.
Updated ~20 tests whose fixtures used the admin account as a
convenience shortcut to create/drive tests through the workflow —
switched them to a red_lead account, fixing an incidental
fixture-resolution-order cookie bug along the way (client's cookie
jar prefers the last-logged-in role's cookie over any Bearer header
explicitly passed to a later request).
The red_lead reviewer stayed assigned in Jira through blue_evaluating
(and the blue_lead reviewer through in_review) since push_test_event
never touched the assignee on those transitions — only red_executing
and the review gates did. Both are hand-offs with no single owner yet,
so the assignee is cleared instead of left stale.
Also fixes push_bt_work_started never assigning the blue_tech who
actually picks up the queued test — it only moved the status to
In Progress before.
Leads get the same two-queue view operators already have: 'Available
to Review' (pending reviews currently assigned to a peer lead) and
'My Assigned Reviews' (assigned to me), replacing the old single
'My Reviews' toggle. Since the load-balanced auto-assignment always
picks a reviewer immediately, 'available' means peer-assigned reviews
a lead could pick up if the assignee can't get to them, not unclaimed
ones (there aren't any).
POST /tests/{id}/assign now also accepts red_reviewer_assignee /
blue_reviewer_assignee, validated against the matching lead role and
synced to Jira the same way operator assignment already is. The
AssigneeControl UI gained a 'reviewer' kind (leads-only picker) shown
on the test detail header while a test sits in red_review/blue_review
— this also fixes the reviewer assignment being invisible in Aegis
even though it was already being pushed to Jira correctly.
- Fix the red/blue draft form losing unsaved fields (e.g. execution_start_time) whenever an evidence upload refetches the test — the hydration effect now runs once per test, not on every refetch.
- Add color-scheme: dark so native date/time picker popups match the app theme instead of rendering white.
- Fix _STATE_TO_JIRA_STATUS: real Jira statuses are 'To Do' and 'Red Team test review', not 'To-Do' and 'RT Test Review' — confirmed live against the actual workflow transitions, which is why Submit to Blue Team never moved the ticket past 'In Progress'.
- Tempo worklog sync was silently blocked by TEMPO_ENABLED defaulting to False with no admin-facing toggle, even after configuring a Tempo token via Settings. Now bypassed once an admin or personal token is actually configured, mirroring Jira's DB-backed enabled flag.
- Hold now actually pauses the running phase timer (paused_at), and Resume accumulates the held duration into red/blue_paused_seconds — previously the timer kept counting through a hold.
lookup_user_jira_account_id() only ever searched by Aegis login email,
ignoring the jira_email override field that exists precisely for users
whose corporate Atlassian email differs from their Aegis login email
(the rest of jira_service.py already documents and uses this priority
order). Found via jesus-huertas, whose real Jira account uses a
different email than his Aegis account.
Admin can no longer be picked as a red_tech/blue_tech assignee — it
administers the site, it doesn't operate tests. Applied to the
eligible-assignee list on both the picker UI and the assign endpoint's
validation, and dropped from the GET /users/operators pool.
push_assignee_update() now falls back to a fresh Jira account-id lookup
when the assignee hasn't logged in yet (so jira_account_id is still
empty), instead of silently no-op'ing — assignment now syncs to Jira
immediately regardless of whether the assignee has ever logged into
Aegis before.
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.
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.
Adds GET /users/operators (leads+admin) and wires a lead-only assign
control into the test detail header, calling the existing but
previously unreachable POST /tests/{id}/assign endpoint. Manual
assignment now also pushes the Jira ticket assignee immediately
instead of waiting for the operator to start execution.
Also adds test.platform as a kebab-case label on Jira ticket creation.
Jira Cloud's REST API rejects a bare string for select-type custom
fields with 'Specify a valid id or name for <field>' — confirmed live
against the real Jira instance. This silently broke Severity, Data
Sensitivity, Attack success, Attack detected, and Attack contained on
every ticket create/update, masked until now by the non-fatal
exception handling around each call.
The auto-create-ticket call was sending customfield_11233 (a field that
doesn't exist in the project) instead of the real Data Sensitivity field
customfield_11814, which made Jira reject the WHOLE issue and silently
block ticket creation for every new test.
Also replaces Aegis's invented 4-tier data_classification scheme
(public_release/general_use/confidential/restricted) with the 6 values
Jira's Data Sensitivity field actually uses (public/general_use/
internal_use_only/trusted_people/customer_content/pii), since that is
the classification the organization actually applies. Includes a data
migration remapping existing rows and a defensive retry if Jira ever
rejects an unscreened custom field again.
atlassian-python-api takes limit, not maxResults. The TypeError was
silently swallowed at DEBUG level, so every non-admin user's Jira
account-id lookup failed even with a valid email and working admin
Jira connection.
The Coverage Matrix heatmap only ever showed the bare MITRE ID on each
cell (name only via hover tooltip), and colored cells from a continuous
score -> hex gradient with no discrete status concept — unlike the
Techniques page, which showed ID+name inline with a fixed 5-color status
palette. Unifying the two pages' visual style requires both pieces of
data to exist server-side.
- Adds "name" to all 4 layer builders (coverage/threat-actor/detection-
rules/campaign) — harmless extra field for Navigator exports, lets the
frontend show it inline.
- Adds "status" (the real TechniqueStatus value) to the coverage and
threat-actor layers specifically — the only two backed by an actual
TechniqueStatus concept; detection-rules/campaign scores don't
correspond to one and don't get this field.
GET /tests caps limit at 200, so any page relying on len(results) for a
"total" count (e.g. Validated Tests) silently under-reports once there
are more matches than the page size — the 200 shown was a page cap, not
the real total.
- Adds GET /tests/count, sharing its query-building with list_tests()
via a new _build_test_query() so the two can't drift apart.
- Adds technique_search (free-text on MITRE ID or name), attack_success,
detection_result, and validated_from/validated_to filters, so callers
aren't limited to state/technique_id/platform/creator.