Compare commits

92 Commits

Author SHA1 Message Date
kitos 99e8feff48 feat(evidence): allow .exe uploads for Red-team artifacts shared with Blue
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
The evidence upload extension whitelist blocked .exe entirely, but sharing
a Red team payload binary with Blue for detection analysis is a normal
part of this platform's workflow.
2026-07-27 14:40:45 +02:00
kitos 8985eeaa03 feat(users): automatically send the set-password email on user creation
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 used to have to click 'Send Email' separately after creating a
passwordless user. Now the first set-password email is sent right away
as part of creation — best-effort: if the webhook isn't configured or
the send fails, the user is still created successfully, and the existing
'Send Email' button remains available to retry with proper error
surfacing.
2026-07-27 11:31:24 +02:00
kitos f13764d9e2 fix(email): detect and surface webhook send failures instead of claiming success
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
send_webhook_email() only returned False on a network exception — a
webhook that responded with a non-2xx status (bad URL, wrong API key,
misconfigured Power Automate flow) was still reported as delivered. Now
checks response.ok and returns False on rejection too.

send_password_setup_email() also never checked the return value at all,
so the admin's 'Send Email' action (including resending a password-reset
email after the user already set their password) always showed success
even when nothing was actually sent. Now raises a clear error instead.
2026-07-24 16:45:32 +02:00
kitos e951567ba1 fix(scores,reports): fix scores/history 500, disable unfinished report generation cleanly
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
- /scores/history was annotated -> dict but the service actually returns
  a list, so FastAPI's response validation raised an unhandled exception,
  surfacing as a raw 500 on every call. Fixed the annotation.
- The professional_reports.py endpoints (PDF/DOCX/HTML generation) back
  a Reports feature the frontend already hides entirely (unfinished).
  Hitting them directly fell through to whatever the render pipeline
  raised (e.g. missing weasyprint system deps) as an unhelpful 500.
  Added a REPORTS_ENABLED setting (off by default) so they now return a
  clean 503 instead, without removing the routes or their test coverage.
2026-07-24 15:24:06 +02:00
kitos 07403cbd9d fix(seed): don't create a duplicate admin on restart after email migration
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
seed_admin()'s existence check matched on a freshly-computed email/username
identifier — after the email-unique-identifier migration backfills an
older install's admin email to its bare username (e.g. 'administrator'),
that no longer matches the '...@localhost' placeholder computed when
ADMIN_EMAIL isn't set, so every container restart created a new duplicate
admin. Now skips seeding whenever ANY admin role already exists.
2026-07-23 15:24:59 +02:00
kitos 504dfc52f5 feat(auth): make email the unique login identifier
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
Login is now by email, not username. username still exists internally
(JWT sub claim, audit logs, Jira actor attribution, SSO provisioning all
still key off it) but is now always kept equal to email everywhere a user
is created or their email changes — never a separately-chosen value.

- User.email is now unique + NOT NULL (migration b067 backfills any
  missing/blank email from username first, so existing rows — notably
  the seeded admin, which historically had none — never violate it).
- /auth/login and the (unused but updated for consistency)
  authenticate_user() now query by email.
- create_user (legacy, unreferenced but kept) and
  create_user_without_password both derive username from email.
- update_user keeps username in sync when email changes, and rejects
  duplicate emails.
- seed.py reads ADMIN_EMAIL (new env var, wired through install.sh and
  docker-compose.prod.yml) for the initial admin; falls back to an
  email-shaped ADMIN_USERNAME or a placeholder that's flagged for the
  operator to fix.
- admin_config.py's import bundle now matches/creates users by email,
  skipping (not crashing on) entries with no email.
- sso_service.py always sets username = email for SSO-provisioned users.
- LoginPage/auth.ts updated to email input/copy (wire field name stays
  'username' — that's the OAuth2PasswordRequestForm spec, not the value).
2026-07-23 14:39:36 +02:00
kitos 0a6cb5510c fix(install): generate PLATFORM_URL in install.sh, backfill on existing .env
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
Fresh installs failed at 'docker compose up' with 'required variable
PLATFORM_URL is missing a value' — the install script computes ORIGIN_URL
for CORS_ORIGINS already but never wrote PLATFORM_URL, which the backend
now requires with no default. Also backfills it into an existing .env
(from CORS_ORIGINS) when the user chooses to keep their current config,
so upgrading an older install in place doesn't hit the same error.
2026-07-23 10:38:13 +02:00
kitos d932134975 feat(users): admin can permanently delete users, fix set-password redirect bug
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
- New DELETE /users/{id}: hard-deletes a user only if they have zero
  activity footprint (no tests, evidence, worklogs, audit entries, Jira
  links, procedure/template suggestions, reviewed imports) — otherwise
  rejects with a clear message to deactivate instead, to keep the audit
  trail intact. Cleans up the user's own notifications/password-setup
  tokens as part of deletion. Self-delete is blocked. Wired to a trash
  icon in the Users page with a confirm dialog.
- Registered the EvaluationImport model in app/models/__init__.py — it
  was missing, so its table never existed in the SQLite test DB; only
  surfaced once the new delete-user footprint check queried it.
- Fixed a redirect bug: the axios response interceptor's on-401 redirect
  to /login didn't exempt /set-password, so the auth provider's routine
  mount-time /auth/me check (401 for a logged-out visitor) bounced anyone
  opening their emailed set-password link away before they could use it.
2026-07-23 08:58:17 +02:00
kitos 545a84b137 fix(config): require PLATFORM_URL explicitly, no hardcoded domain default
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
The previous fix defaulted PLATFORM_URL to this deployment's own domain
directly in docker-compose.prod.yml — any other deployment of this repo
would silently inherit it if they forgot to set their own. Now there is
no default at all: the compose file requires the env var to be set, and
the backend refuses to start in production if PLATFORM_URL still equals
the dev value, mirroring the existing SECRET_KEY enforcement.
2026-07-22 16:19:27 +02:00
kitos 3cd0f6fd99 fix(users): admin can never strip their own admin permission
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
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.
2026-07-22 10:52:46 +02:00
kitos 66951086fb fix(auth): refresh cached user after self-role edit so RoleSwitcher shows up
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
extra_roles changes made to the currently-logged-in admin's own account
weren't reflected until logout/login, since AuthContext only fetches
/users/me once at mount. Now refetches it when the edited user is the
caller themselves.
2026-07-22 09:41:52 +02:00
kitos 06c913955c fix(campaigns,ui): remove admin from campaign-from-threat-actor gate, bump red/blue label saturation
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
- ThreatActorDetailPage wrongly let admins see/click 'Generate Campaign' —
  the backend already rejects admin there (require_any_role_strict), but
  the button was still shown, exposing a dead-end action. Now matches
  CampaignsPage's own admin-excluded gate.
- Red/blue team badges (role pills, team tabs, assignee controls, test
  state badges) bumped from -400 text/-900/50 bg to -500 text/-900/70 bg
  for more saturated, readable team colors.
2026-07-20 16:18:27 +02:00
kitos e4f768962f fix(email): use client-native fonts instead of forcing Segoe UI/Arial
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
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.
2026-07-20 16:08:51 +02:00
kitos d25d876c2c fix(email): wire PLATFORM_URL into prod compose, was always falling back to localhost
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
Set-password links in every password-setup/reset email pointed at
http://localhost:5173 in production because PLATFORM_URL was never mapped
in docker-compose.prod.yml's backend environment block — the app silently
used its dev default. Now defaults to the real domain from CORS_ORIGINS.
2026-07-20 14:00:26 +02:00
kitos 91442ede60 feat(email): HTML branded templates with inline logo, wire remaining notification types
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
- 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.
2026-07-20 11:49:21 +02:00
kitos 1d0d880929 feat(email): generic webhook for all notification emails, hide SMTP UI
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
- 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.
2026-07-20 10:24:15 +02:00
kitos bbe7f49c86 feat(tests,users): blocking procedure-suggestion review on test detail, multi-role switcher, Power Automate webhook payload
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
- 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.
2026-07-20 09:29:36 +02:00
kitos 4dea19cae9 feat(users): passwordless user creation via emailed set-password link, display full name instead of username
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
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).
2026-07-17 16:58:25 +02:00
kitos 60ab2e558f fix(campaigns,tests): admin cannot create campaigns, manager can delete unstarted tests, intensify red/blue team colors
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
- 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.
2026-07-17 14:34:14 +02:00
kitos 82ac9c7014 feat(templates,campaigns): validate MITRE technique IDs, surface template suggestions first, manager can re-approve rejected campaigns
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
- 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.
2026-07-16 14:29:01 +02:00
kitos 4809c4a662 feat(manager): allow manager to create auto-approved campaigns and tests from templates
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
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.
2026-07-16 12:57:20 +02:00
kitos c753797019 feat(test-catalog): add template creation and lead-approval workflow
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
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.
2026-07-16 12:08:23 +02:00
kitos cf4a6c3cde fix(campaigns): defer Jira ticket creation to start_date, gate recurring campaigns behind manager approval
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
- 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.
2026-07-16 11:09:10 +02:00
kitos 0b60531677 fix(users): grant manager Review Queue access, restrict Webhooks to admin
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
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.
2026-07-16 10:31:21 +02:00
kitos 0002601b50 feat(templates): add pagination with total counts to catalog and campaign picker
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
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.
2026-07-16 10:01:20 +02:00
kitos 8493a6a764 feat(nav): hide unfinished Reports section platform-wide
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
Route and nav link removed (not the page/component files, kept as a
base to resume later) — navigating to /reports now falls through to
the catch-all redirect to /dashboard for every role, including admin.

fix(campaigns): show all non-campaign tests when adding to a campaign

The 'Existing Test' picker only ever queried state=draft, but the
backend places no restriction on a test's own state when adding it to
a campaign (only the campaign's own status matters) — tests already
in progress or completed were invisible here for no real reason.
2026-07-16 08:32:31 +02:00
kitos 8d9e25703d fix(campaigns): make Expected Detection editable when adding a test to a campaign
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
Was a read-only reference shown only when the template already had
content, unlike the standalone create-from-template form where it's
an editable field feeding detect_procedure on the new test. Brought
the campaign flow in line with that so leads get the same editing
capability regardless of whether the test is going into a campaign.
2026-07-15 16:49:43 +02:00
kitos 68ab6406d4 fix(jira,tests): clarify manager-resolved disputes in Jira and the Summary tab
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
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.
2026-07-15 15:26:00 +02:00
kitos 09553f5c42 fix(users): add manager to the valid-role whitelist
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
'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.
2026-07-15 14:16:27 +02:00
kitos 3a01facd46 feat(tests): let managers escalate and directly resolve disputed tests
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
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.
2026-07-15 13:03:01 +02:00
kitos 32f4fd25bd fix(tests): surface cross-validation and disputed tests in the review queue
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
Cross-validation (in_review) tests awaiting a lead's vote were only
reachable via the 'My Tasks' toggle, so the default review queue view
never showed them despite being exactly the kind of work a lead
expects to find there. Added an 'Awaiting My Validation' section to
the review queue, and a standalone 'Disputed' section — always
fetched independently of other filters, shown above everything else,
and only rendered when non-empty — since disputed tests need the
most urgent attention.
2026-07-15 11:41:29 +02:00
kitos 8973f199b8 feat(tests): make Expected Detection editable when creating a test from template
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
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.
2026-07-15 11:05:41 +02:00
kitos 60f9464ec5 refactor(tests): merge detect_suggested_procedure into expected_detection
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
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.
2026-07-15 10:29:13 +02:00
kitos 7416d1688f fix(jira): unwrap detection_result enum in the in_review comment
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
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.
2026-07-15 09:57:46 +02:00
kitos 44fdb4dbd3 fix(jira,tests): push system gaps to Jira, show detect suggested procedure in catalog
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
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.
2026-07-15 08:56:19 +02:00
kitos 11080bd627 fix(tests): recognize sysmon, wevtutil, auditpol as detection commands
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
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.
2026-07-14 17:24:14 +02:00
kitos 1b7fd6fb36 fix(tests): restrict On Hold/Resume to whichever team currently owns the test
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.
2026-07-14 17:24:05 +02:00
kitos 8fcd733d4a fix(tests): merge approved procedure suggestions instead of overwriting
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
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.
2026-07-14 16:25:24 +02:00
kitos 4692a8b93e feat(tests): add Detect Procedure UI, template suggested-procedure editing, lead review panel
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
Blue Team gets a Detect Procedure textarea in the test detail view,
mirroring Red's Attack Procedure field. Template create/edit forms
gain a Detect Suggested Procedure field for leads to curate directly.
Leads also get a Procedure Suggestions panel on the Tests page,
scoped to their own team by the backend, to approve or reject
suggestions extracted from submitted rounds.
2026-07-14 15:30:47 +02:00
kitos 7ec3c5bc8b feat(tests): auto-propose procedure suggestions from submitted rounds, add lead review workflow
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.
2026-07-14 15:22:03 +02:00
kitos 8bdbe48fbe feat(tests): add Blue detect_procedure field mapped from template suggestion
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.
2026-07-14 14:58:29 +02:00
kitos fd94e55799 feat(tests): add regex-based command extraction heuristic
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.
2026-07-14 13:36:25 +02:00
kitos f87959369b fix(tests): remove blind visibility, both teams always see each other's fields
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.
2026-07-14 13:36:19 +02:00
kitos bdc6579c10 fix(jira): stop sending numeric hours to Time to Detect/Contain (datetime fields)
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
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.
2026-07-14 12:48:14 +02:00
kitos 92d65374ef fix(tests): stop requiring Containment Time when result is Not Contained
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
Containment Time was gated on detection alone, so picking "Not Contained"
still showed the field and required it before saving — there's no
containment moment to record when nothing was contained. Now only shown
and required when the result is Contained or Partially Contained; a
stale value from a prior Contained selection is cleared when the result
changes away from it, so it can't get silently resubmitted once hidden.
2026-07-14 12:00:53 +02:00
kitos 9d66c30127 feat(tests): add team queue overview section for leads
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
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).
2026-07-14 11:03:17 +02:00
kitos c6bdded3f6 fix(jira): keep BT Start Date across reopens, resolve Jira accounts on the fly everywhere
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
- 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.
2026-07-14 10:23:30 +02:00
kitos 814cfa9671 feat(jira): label standalone tests, include round data in submission comments
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
- 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.
2026-07-14 08:37:40 +02:00
kitos 6d6f87b968 fix(jira,tests): send real datetime to RT/BT date fields, resolve assignee usernames for 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
- 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.
2026-07-13 16:53:31 +02:00
kitos 4221d2858b fix(jira): store execution times as real UTC, fix RT date fields, label reopens
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
- 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.
2026-07-13 14:57:42 +02:00
kitos b48de743b6 fix(tests): keep free-text round fields in place on reopen, only blank verdicts
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
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.
2026-07-13 12:46:46 +02:00
kitos 8fe38dc661 feat(tests): archive round history on reopen, stop pausing from setting Jira On Hold
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
- 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.
2026-07-13 12:08:45 +02:00
kitos 0bb34f6834 fix(tests): pause reopen timer, lock edits to assignee, restore Jira operator on reopen
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
- 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.
2026-07-13 10:53:10 +02:00
kitos a3f86c7b31 fix(permissions): admin can no longer operate tests — start/submit/edit/create, view only
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 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).
2026-07-13 09:27:43 +02:00
kitos 337faf824d fix(jira): clear stale assignee on hand-off to blue team / dual validation
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
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.
2026-07-10 16:49:16 +02:00
kitos 0fc2427f71 feat(review): review queue for leads + manual reviewer reassignment
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
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.
2026-07-10 16:19:14 +02:00
kitos be2a3fdced fix: multiple test-workflow bugs — draft wipe, dark date pickers, Jira status strings, Tempo gate, hold timer
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
- 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.
2026-07-10 15:24:19 +02:00
kitos 997b38d333 fix(ui): fit all 9 test-state stat cards in a single row on Tests page
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
Shrinks padding, icon size, and font sizes, and bumps the grid to 9
columns at the lg breakpoint so Draft through Disputed no longer wrap
to a second row.
2026-07-10 14:08:29 +02:00
kitos 1535ddaa5d fix(jira): honor jira_email override in account-id lookup, not just email
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
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.
2026-07-10 13:42:33 +02:00
kitos a46aa157f3 fix(assign): exclude admin from assignable operators; sync Jira on assign even for first-time users
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 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.
2026-07-10 13:08:23 +02:00
kitos 5d22514f7f fix(ui): remove stray backslash left before the em dash in technique line
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
The previous fix for the literal '\u2014' JSX text bug only replaced
the escape sequence with a real em dash but left the leading backslash
character behind, so it rendered as '\—' instead of '—'.
2026-07-10 12:39:47 +02:00
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
kitos 2afb886cd9 feat(assign): let leads manually assign operators + sync Jira; send platform as Jira label
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
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.
2026-07-10 08:44:14 +02:00
kitos 119db6f91d fix(jira): wrap select-field values in {value: ...} for Jira REST API
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
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.
2026-07-09 17:08:53 +02:00
kitos d726e3adfe fix(jira): correct Data Sensitivity field ID and replicate Jira's real classification scheme
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
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.
2026-07-09 16:33:41 +02:00
kitos 512b682cc5 fix(jira): correct user_find_by_user_string kwarg breaking account-id auto-detection
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
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.
2026-07-09 15:26:36 +02:00
kitos 8031682e9c feat(matrix): unify Techniques and Coverage Matrix into one page
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
Techniques (/techniques) and Coverage Matrix (/matrix) showed the same
underlying data (Technique.status_global) with completely different
visual styles — discrete Tailwind status colors + ID+name cells on
Techniques, vs. a continuous score-gradient hex color + ID-only cells
(name via tooltip) on the heatmap. Coverage Matrix also has 3 layers
(Threat Actor, Detection Rules, Campaign) with no Techniques equivalent,
so Techniques was the strict subset — merged into Coverage Matrix rather
than the other way around.

- HeatmapCell now uses the same discrete status-color palette as the old
  TechniqueCell (cellColors.ts), shows the technique name inline, and
  keeps zoom/virtualization/hover-tooltip untouched.
- HeatmapLegend restyled to match (swatch + label + description per
  tier, review_required as an overlay badge) instead of the old
  hex-gradient bar.
- MatrixPage gains a Matrix/List view toggle and a status filter
  (coverage/threat-actor layers only) — both ported from the old
  Techniques page — so nothing it offered is lost.
- /techniques now redirects to /matrix; removed TechniquesPage,
  AttackMatrix, TechniqueCell (fully superseded). /techniques/:mitreId
  (detail) and /techniques/review-queue are untouched.

Known pre-existing limitation carried over, not introduced or fixed
here: the heatmap only places a multi-tactic technique under its first
tactic column (_format_tactic takes tactic_str.split(",")[0]), while the
old Techniques matrix placed it under every tactic it belongs to.
2026-07-09 14:19:38 +02:00
kitos 0de9f4a6c0 feat(heatmap): expose technique name + real status on every layer
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.
2026-07-09 14:12:58 +02:00
kitos 4e31359fca feat(frontend): add technique/platform/outcome/date filters to Validated Tests
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
The "200" badge on the Validated Tests page was a hardcoded query limit,
not the real total — silently hiding results beyond the 200th once there
were more validated tests than that. Now shows the true total via the new
/tests/count endpoint, and warns when the fetched page doesn't cover all
matches.

Adds filter controls beyond the existing name/technique text search:
technique (MITRE ID or name), platform, attack outcome, detection
outcome, and a validated-date range — all pushed down to the server via
the new GET /tests filters instead of only ever searching the current
page client-side.
2026-07-09 12:45:54 +02:00
kitos 226869d308 feat(tests): richer server-side filters + true total count for GET /tests
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.
2026-07-09 12:45:05 +02:00
kitos adae7e617e fix(jira): report which admin config field is actually missing
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
POST /system/jira-test bundled 4 independent checks (enabled, url,
admin_email, admin_api_token) behind has_admin_jira_configured() but
always blamed "Admin Email and Admin API Token" regardless of which one
failed. Found via live QA: URL/email/token were all correctly saved, but
the "Enable Jira Integration" toggle was never switched on — the error
message pointed at the wrong fields, making it look like the already-set
credentials were missing.
2026-07-09 12:02:04 +02:00
kitos 4ddd7f9ec3 feat(jira): sync the full Purple Team workflow status to Jira
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
Previously only red_executing (-> "In Progress") ever changed the Jira
issue's status; every other Aegis transition (red_review, blue_evaluating,
blue_review, in_review, validated, rejected, disputed) only posted a
comment, leaving the Jira board stuck on whatever status it started with.

Expands push_test_event() to a full TestState -> Jira-status table
covering the whole lifecycle (To-Do -> In Progress -> RT Test Review ->
Queued Blue Team -> In Progress -> Blue Team Test Review -> Validation ->
Done/Rejected/Dispute), matching the Purple Team Jira workflow. Adds two
new lifecycle hooks that the generic dispatch can't handle correctly:

- push_bt_work_started(): blue_evaluating covers both "queued, unclaimed"
  and "actively being worked" — needs an explicit push when the blue tech
  clicks Start Evaluation, or it never leaves "Queued Blue Team".
- disputed state push: a conflicting lead vote previously produced zero
  Jira signal at all.

Also fills two real gaps: reopen_red_review and reopen_blue_review never
synced anything to Jira before. Their target status differs on purpose —
reopen_red_review pushes "In Progress" (Aegis resumes the same operator
immediately, no re-claim), while reopen_blue_review pushes "Queued Blue
Team" (Aegis clears blue_work_started_at, forcing a fresh "Start
Evaluation" click) — verified against the actual field-reset behavior in
test_entity.py rather than assumed symmetry between the two teams.
2026-07-08 13:39:15 +02:00
kitos ccc99439d5 docs: add Jira/Tempo service account requirements and use cases
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
Requirements + use-case document to support requesting a dedicated
Jira/Tempo service account for the Purple Team project, to replace the
personal-employee-account credentials currently configured as Aegis's
Jira integration identity. Grounded in the actual implemented
integration (ticket lifecycle sync, Tempo worklog attribution, account
auto-discovery) rather than the aspirational design.
2026-07-08 11:30:33 +02:00
kitos df49d336f7 fix(security): stop leaking live Jira/Tempo admin tokens in config export
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
_REDACTED_KEYS listed jira.api_token/jira.password/tempo.api_token, but
routers/system.py actually writes admin credentials under
jira.admin_api_token and tempo.admin_token. The mismatch meant every
config export bundle included the live Jira and Tempo admin API tokens
in plaintext instead of redacting them. Found while researching the
Jira/Tempo integration for a service-account request; added a
regression test.
2026-07-08 11:08:09 +02:00
kitos 0b6a664651 test(admin): cover config export/import bundle (Block 4)
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
The "Export Configuration" admin feature (GET/POST /admin/export-config,
/admin/import-config) was already fully implemented — admin-gated,
redacts secrets, scoped to genuine config (settings/webhooks/SSO/scoring/
custom templates/users) rather than bulk data — but had zero test
coverage for an admin-sensitive data-migration endpoint.

Adds coverage for: admin-only gating on both endpoints, secret redaction
(SMTP password, SSO private key), custom-template-only scoping, safe user
import (forced password reset, no secret restoration from "[REDACTED]"
placeholders), and round-trip import idempotency. All pass against the
existing implementation — no bugs found.
2026-07-08 10:15:11 +02:00
kitos 8cf8001de5 feat(frontend): My Reviews queue, duplicate-test badge, tactic order, legend + tooltip fixes
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
- TestsPage gains a "My Reviews" toggle for red_lead/blue_lead, separate
  from "My Tasks" — filters by reviewer_id at the red_review/blue_review
  gate instead of the unrelated in_review validation stage
- Test Catalog cards now warn when a technique already has existing
  tests, using the backend's new existing_test_count
- ExecutiveDashboardPage drops its duplicate client-side tactic-order
  sort now that the backend returns canonical order; time-range filter
  now notes which sections it actually scopes
- HeatmapLegend adds the missing review_required swatch (as an overlay
  indicator, matching how it's actually rendered on cells) and hover
  tooltips reusing StatusBadge's copy instead of being unexplained
  static color chips
- Fix a real React key bug in ControlsTable: rows were grouped in a
  keyless shorthand fragment, which can misreconcile when the table is
  filtered/sorted; switched to a keyed Fragment
- Minor: CompliancePage summary-card grid no longer strands a lone card
  on tablet widths
2026-07-08 08:46:24 +02:00
kitos 1c8fa436ab feat(backend): enforce campaign scheduling, add reviewer queue filter, tactic order, duplicate-test counts
- start_execution now blocks a test from starting before its campaign's
  start_date, closing a gap where the field was persisted but never
  enforced
- GET /tests gains a reviewer_id "my reviews" filter for the red_review/
  blue_review lead-gate stage, distinct from pending_validation_side
  (which only ever covered the later in_review stage and ignored
  assignment entirely)
- get_coverage_by_tactic now returns all 14 MITRE tactics in canonical
  kill-chain order instead of an alphabetical, partial list — the
  regular Dashboard and Executive Dashboard both consume this endpoint
  and previously disagreed on order/completeness
- test-template listing now includes existing_test_count per technique
  so the catalog can warn before creating a likely-duplicate test
2026-07-08 08:46:09 +02:00
kitos 21c6febd22 fix(frontend): render markdown descriptions properly, fix tooltip/banner wording
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
- Wrap 8 previously-raw description fields in MarkdownText so imported
  markdown (links, bold, lists, MITRE citations) renders instead of
  showing literal syntax: campaign cards, D3FEND countermeasure
  descriptions, compliance framework/control descriptions, detection
  rule descriptions, threat-actor and technique reference descriptions,
  and the RT-import preview description
- Fix the "Validated" status tooltip claiming a "≥2 tests" threshold
  when the real rule is ≥1 validated test with full detection; reworded
  the adjacent "Partial" tooltip for the same clarity
- Generalize the technique review_required banner text away from
  "MITRE ATT&CK sync" — review can also be triggered by Atomic/Caldera/
  Elastic/Sigma/LOLBAS imports, intel scans, and stale-detection checks
2026-07-07 16:30:27 +02:00
kitos c234fd64c2 feat(d3fend): flag review_required on new ATT&CK-D3FEND mappings
Mirrors the existing Atomic/Caldera/Elastic/Sigma/LOLBAS import pattern —
D3FEND mapping import was the one source silently skipping the
review_required flag, so leads never got prompted to review techniques
that only gained new D3FEND coverage.
2026-07-07 16:20:01 +02:00
kitos b6da0e22c2 feat(jira): push Attack Contained field (customfield_11885) on BT submit
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
Maps Test.containment_result (contained/partially_contained/not_contained)
to Yes/Partial/No, mirroring the existing Attack Success/Attack Detected
field push in push_bt_submitted. Field ID was the last missing piece
blocking this from Block 4.
2026-07-07 15:46:17 +02:00
kitos 1ddbd6989f fix(frontend): hide viewer-only risk-compute UI, proactively renew session
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
- Executive Dashboard no longer auto-triggers POST /risk/compute or shows
  the Refresh scores button for roles the backend rejects (viewer), which
  was causing a 403 on load
- AuthContext now proactively renews the session cookie every 20 minutes
  while the app is open, so an actively-used session doesn't rely solely
  on the reactive on-401 refresh racing against its own token's expiry
2026-07-07 13:42:54 +02:00
kitos bd26b09827 fix(backend): resolve refresh-token expiry deadlock, missing Jira on normal campaign approval, discarded threat-actor campaign start_date
- /auth/refresh now allows a short grace window past expiry and checks
  the blacklist, so an active session's silent refresh no longer fails
  the instant its own token expires
- normal manager /approve flow now creates Jira tickets for the campaign
  and its already-linked tests, matching the admin-only /activate path
- GenerateFromActorPayload now accepts start_date and threads it through
  to the new campaign instead of silently discarding it
2026-07-07 13:42:26 +02:00
kitos fefe70baed Merge branch 'main' of https://git.undiamagico.es/kitos/Aegis into claude/adoring-vaughan-273f91
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
2026-07-07 11:30:09 +02:00
kitos 1519ccc95c feat(tests): update data classification UI to org's 4-tier scheme 2026-07-07 11:16:45 +02:00
kitos a66fefa30b feat(classification): org data classification scheme, tactic-based initial default, operator edit rights 2026-07-07 11:16:35 +02:00
kitos b6b674d620 fix(deps): regenerate lock with babel/core 7.29.7 and esbuild 0.28.1 (overrides applied)
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
2026-07-07 11:01:28 +02:00
kitos 42b05379f7 fix(deps): revert babel/core lock to 7.29.0 (npm ci fails with manual sub-dep mismatch)
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
2026-07-07 10:47:23 +02:00
kitos d6fbc2ffe8 fix(deps): bump starlette>=1.3.1, pydantic-settings>=2.14.2; override babel/core>=7.29.6, esbuild>=0.28.1
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
2026-07-07 10:30:15 +02:00
kitos 949e5d42c4 fix(migration): cast CASE expression to enum type in b058 UPDATE statement
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
2026-07-06 17:01:25 +02:00
kitos c0a88fc489 feat(tests): attack_success 3-value UI, data classification badge, reorder containment options
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
2026-07-06 16:19:50 +02:00
kitos 022c1c756a feat(jira): pause/hold status mapping, RT/BT dates, TTP, attack_success 3-value field, hours 2026-07-06 16:19:39 +02:00
213 changed files with 13938 additions and 1960 deletions
+10
View File
@@ -23,7 +23,10 @@ TOKEN_EXPIRE_MINUTES=60
# ── Initial Admin Account ────────────────────────────────────────────────────
# If ADMIN_PASSWORD is empty, a random password is auto-generated and
# printed to the backend container logs on first startup.
# ADMIN_EMAIL is what you actually log in with (email is the unique
# identifier) — set it to a real address you control.
ADMIN_USERNAME=admin
ADMIN_EMAIL=
ADMIN_PASSWORD=
# ── MinIO Object Storage ─────────────────────────────────────────────────────
@@ -39,6 +42,13 @@ CORS_ORIGINS=https://your-domain.com
# ── Frontend ─────────────────────────────────────────────────────────────────
FRONTEND_PORT=80
# ── Emails ────────────────────────────────────────────────────────────────────
# Base URL used to build links in outbound emails (set-password, etc).
# REQUIRED in production — must be THIS deployment's real public frontend
# URL. There is no safe default (it's unique per deployment); the backend
# refuses to start without it when AEGIS_ENV=production.
PLATFORM_URL=https://your-domain.com
# ── Environment flag ─────────────────────────────────────────────────────────
# Set to "production" for production deployments (enforces SECRET_KEY, etc.)
AEGIS_ENV=production
@@ -0,0 +1,48 @@
"""Convert tests.attack_success from Boolean to a 3-value enum.
Revision ID: b058
Revises: b057
Create Date: 2026-07-06
"""
from alembic import op
import sqlalchemy as sa
revision = "b058"
down_revision = "b057"
branch_labels = None
depends_on = None
_ENUM_NAME = "attacksuccessresult"
def upgrade() -> None:
attack_success_enum = sa.Enum(
"successful", "not_successful", "partially_successful", name=_ENUM_NAME
)
attack_success_enum.create(op.get_bind(), checkfirst=True)
op.add_column("tests", sa.Column("attack_success_new", attack_success_enum, nullable=True))
op.execute(
"UPDATE tests SET attack_success_new = (CASE "
"WHEN attack_success = true THEN 'successful' "
"WHEN attack_success = false THEN 'not_successful' "
f"ELSE NULL END)::{_ENUM_NAME}"
)
op.drop_column("tests", "attack_success")
op.alter_column("tests", "attack_success_new", new_column_name="attack_success")
def downgrade() -> None:
op.add_column("tests", sa.Column("attack_success_bool", sa.Boolean(), nullable=True))
op.execute(
"UPDATE tests SET attack_success_bool = CASE "
"WHEN attack_success = 'successful' THEN true "
"WHEN attack_success = 'not_successful' THEN false "
"ELSE NULL END"
)
op.drop_column("tests", "attack_success")
op.alter_column("tests", "attack_success_bool", new_column_name="attack_success")
sa.Enum(name=_ENUM_NAME).drop(op.get_bind(), checkfirst=True)
@@ -0,0 +1,41 @@
"""Rename data_classification values to match the org's real scheme.
public -> public_release, internal -> general_use, sensitive -> confidential.
restricted is unchanged. Applies to tests, campaigns, and evidences tables
(all plain String(20) columns — no native enum type to alter).
Revision ID: b059
Revises: b058
Create Date: 2026-07-07
"""
from alembic import op
revision = "b059"
down_revision = "b058"
branch_labels = None
depends_on = None
_TABLES = ("tests", "campaigns", "evidences")
_RENAMES_UP = {
"public": "public_release",
"internal": "general_use",
"sensitive": "confidential",
}
_RENAMES_DOWN = {v: k for k, v in _RENAMES_UP.items()}
def upgrade() -> None:
for table in _TABLES:
for old, new in _RENAMES_UP.items():
op.execute(f"UPDATE {table} SET data_classification = '{new}' WHERE data_classification = '{old}'")
op.alter_column(table, "data_classification", server_default="confidential")
def downgrade() -> None:
for table in _TABLES:
for new, old in _RENAMES_DOWN.items():
op.execute(f"UPDATE {table} SET data_classification = '{old}' WHERE data_classification = '{new}'")
op.alter_column(table, "data_classification", server_default="internal")
@@ -0,0 +1,47 @@
"""Rename data_classification values to match Jira's real Data Sensitivity field.
Jira's actual "Data Sensitivity" custom field (customfield_11814) has 6
options — Public, General Use, Internal Use Only, Trusted People, Customer
Content, PII — not the 4-tier scheme Aegis invented independently. This
migration replicates Jira's scheme so ticket sync stops guessing at a
mapping. Applies to tests, campaigns, and evidences tables (all plain
String(20) columns — no native enum type to alter).
public_release -> public, confidential -> internal_use_only,
restricted -> pii. general_use is unchanged.
Revision ID: b060
Revises: b059
Create Date: 2026-07-09
"""
from alembic import op
revision = "b060"
down_revision = "b059"
branch_labels = None
depends_on = None
_TABLES = ("tests", "campaigns", "evidences")
_RENAMES_UP = {
"public_release": "public",
"confidential": "internal_use_only",
"restricted": "pii",
}
_RENAMES_DOWN = {v: k for k, v in _RENAMES_UP.items()}
def upgrade() -> None:
for table in _TABLES:
for old, new in _RENAMES_UP.items():
op.execute(f"UPDATE {table} SET data_classification = '{new}' WHERE data_classification = '{old}'")
op.alter_column(table, "data_classification", server_default="internal_use_only")
def downgrade() -> None:
for table in _TABLES:
for new, old in _RENAMES_DOWN.items():
op.execute(f"UPDATE {table} SET data_classification = '{old}' WHERE data_classification = '{new}'")
op.alter_column(table, "data_classification", server_default="confidential")
@@ -0,0 +1,64 @@
"""Archive round data on reopen instead of overwriting it.
Reopening a test for rework used to reset the live procedure/result fields
in place, losing the prior round's data (and clobbering the Phase Timeline,
which read those same mutable columns). This adds an append-only
``test_round_history`` table that snapshots a round's fields right before
they get reset, plus per-team round counters on ``tests`` so each round is
numbered.
Revision ID: b061
Revises: b060
Create Date: 2026-07-13
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "b061"
down_revision = "b060"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("tests", sa.Column("red_round_number", sa.Integer(), nullable=False, server_default="1"))
op.add_column("tests", sa.Column("blue_round_number", sa.Integer(), nullable=False, server_default="1"))
op.create_table(
"test_round_history",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("test_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tests.id"), nullable=False),
sa.Column("team", sa.String(10), nullable=False),
sa.Column("round_number", sa.Integer(), nullable=False),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("ended_at", sa.DateTime(), nullable=True),
sa.Column("paused_seconds", sa.Integer(), nullable=True),
sa.Column("procedure_text", sa.Text(), nullable=True),
sa.Column("tool_used", sa.String(), nullable=True),
# Plain strings, not the shared Postgres enum types tests.* uses —
# this is an archive table with no DB-level constraint needs, and
# reusing those native type names here would make Alembic try to
# (re)create them.
sa.Column("attack_success", sa.String(), nullable=True),
sa.Column("red_summary", sa.Text(), nullable=True),
sa.Column("execution_start_time", sa.DateTime(), nullable=True),
sa.Column("execution_end_time", sa.DateTime(), nullable=True),
sa.Column("detection_result", sa.String(), nullable=True),
sa.Column("containment_result", sa.String(), nullable=True),
sa.Column("detection_time", sa.DateTime(), nullable=True),
sa.Column("containment_time", sa.DateTime(), nullable=True),
sa.Column("blue_summary", sa.Text(), nullable=True),
sa.Column("review_notes", sa.Text(), nullable=True),
sa.Column("reviewed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("archived_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_test_round_history_test_id", "test_round_history", ["test_id"])
def downgrade() -> None:
op.drop_index("ix_test_round_history_test_id", table_name="test_round_history")
op.drop_table("test_round_history")
op.drop_column("tests", "blue_round_number")
op.drop_column("tests", "red_round_number")
@@ -0,0 +1,58 @@
"""Add detect_procedure fields and procedure_suggestions review table.
Blue Team gets a "Detect Procedure" field (what they actually did to
detect the attack) mirroring Red's procedure_text, plus a matching
"suggested" field on test_templates. Commands extracted from either
side's procedure text are proposed as template improvements via a new
procedure_suggestions table, reviewed and approved/rejected by a lead
rather than written automatically.
Revision ID: b062
Revises: b061
Create Date: 2026-07-14
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "b062"
down_revision = "b061"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("tests", sa.Column("detect_procedure", sa.Text(), nullable=True))
op.add_column(
"tests",
sa.Column("source_template_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("test_templates.id"), nullable=True),
)
op.add_column("test_templates", sa.Column("detect_suggested_procedure", sa.Text(), nullable=True))
op.add_column("test_round_history", sa.Column("detect_procedure", sa.Text(), nullable=True))
op.create_table(
"procedure_suggestions",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("template_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("test_templates.id"), nullable=False),
sa.Column("team", sa.String(10), nullable=False),
sa.Column("suggested_text", sa.Text(), nullable=False),
sa.Column("source_test_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tests.id"), nullable=True),
sa.Column("submitted_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("status", sa.String(10), nullable=False, server_default="pending"),
sa.Column("reviewed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("reviewed_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_procedure_suggestions_template_id", "procedure_suggestions", ["template_id"])
op.create_index("ix_procedure_suggestions_status", "procedure_suggestions", ["status"])
def downgrade() -> None:
op.drop_index("ix_procedure_suggestions_status", table_name="procedure_suggestions")
op.drop_index("ix_procedure_suggestions_template_id", table_name="procedure_suggestions")
op.drop_table("procedure_suggestions")
op.drop_column("test_round_history", "detect_procedure")
op.drop_column("test_templates", "detect_suggested_procedure")
op.drop_column("tests", "source_template_id")
op.drop_column("tests", "detect_procedure")
@@ -0,0 +1,40 @@
"""Merge TestTemplate.detect_suggested_procedure into expected_detection.
Two overlapping fields on TestTemplate — expected_detection (narrative
guidance, populated by imports and leads since long before this feature)
and detect_suggested_procedure (concrete commands, populated only via the
procedure-suggestion approval workflow) — are consolidated into one:
expected_detection. Any existing detect_suggested_procedure text is
appended (not overwritten) onto expected_detection before the column is
dropped, so nothing already approved is lost.
Revision ID: b063
Revises: b062
Create Date: 2026-07-15
"""
import sqlalchemy as sa
from alembic import op
revision = "b063"
down_revision = "b062"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"""
UPDATE test_templates
SET expected_detection = CASE
WHEN expected_detection IS NULL OR expected_detection = '' THEN detect_suggested_procedure
ELSE expected_detection || E'\n' || detect_suggested_procedure
END
WHERE detect_suggested_procedure IS NOT NULL AND detect_suggested_procedure != ''
"""
)
op.drop_column("test_templates", "detect_suggested_procedure")
def downgrade() -> None:
op.add_column("test_templates", sa.Column("detect_suggested_procedure", sa.Text(), nullable=True))
@@ -0,0 +1,54 @@
"""Add template_suggestions table for operator template proposals.
Leads can already create a TestTemplate directly (POST /test-templates).
Operators (red_tech/blue_tech) get the same "create template" action, but
their proposal lands here pending a lead's review instead of the live
catalog.
Revision ID: b064
Revises: b063
Create Date: 2026-07-16
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "b064"
down_revision = "b063"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"template_suggestions",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("mitre_technique_id", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("source", sa.String(), nullable=False, server_default="custom"),
sa.Column("source_url", sa.String(), nullable=True),
sa.Column("attack_procedure", sa.Text(), nullable=True),
sa.Column("expected_detection", sa.Text(), nullable=True),
sa.Column("platform", sa.String(), nullable=True),
sa.Column("tool_suggested", sa.String(), nullable=True),
sa.Column("severity", sa.String(), nullable=True),
sa.Column("atomic_test_id", sa.String(), nullable=True),
sa.Column("suggested_remediation", sa.Text(), nullable=True),
sa.Column("team", sa.String(10), nullable=False),
sa.Column("submitted_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("status", sa.String(10), nullable=False, server_default="pending"),
sa.Column("reviewed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("reviewed_at", sa.DateTime(), nullable=True),
sa.Column("created_template_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("test_templates.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_template_suggestions_status", "template_suggestions", ["status"])
op.create_index("ix_template_suggestions_team", "template_suggestions", ["team"])
def downgrade() -> None:
op.drop_index("ix_template_suggestions_team", table_name="template_suggestions")
op.drop_index("ix_template_suggestions_status", table_name="template_suggestions")
op.drop_table("template_suggestions")
@@ -0,0 +1,43 @@
"""Add users.full_name and a password_setup_tokens table.
Users are now created with a name + email instead of a directly-set
password — the admin's "Send Email" action issues a one-time token (this
table) for the user to set their own password via a webhook-delivered
link. The same mechanism is reused for password resets.
Revision ID: b065
Revises: b064
Create Date: 2026-07-17
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "b065"
down_revision = "b064"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("full_name", sa.String(), nullable=True))
op.create_table(
"password_setup_tokens",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False),
sa.Column("token", sa.String(128), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("used_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_password_setup_tokens_token", "password_setup_tokens", ["token"], unique=True)
op.create_index("ix_password_setup_tokens_user_id", "password_setup_tokens", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_password_setup_tokens_user_id", table_name="password_setup_tokens")
op.drop_index("ix_password_setup_tokens_token", table_name="password_setup_tokens")
op.drop_table("password_setup_tokens")
op.drop_column("users", "full_name")
@@ -0,0 +1,32 @@
"""Add users.extra_roles for multi-role support.
A user can be granted more than one role, but only ever acts under a
single "active" role at a time (``users.role``, unchanged — every
existing permission check keeps reading it as-is). ``extra_roles`` holds
the *other* roles a user can switch into via the top-bar role switcher;
switching swaps the active role with one from this list.
Revision ID: b066
Revises: b065
Create Date: 2026-07-20
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "b066"
down_revision = "b065"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("extra_roles", postgresql.JSONB(), nullable=False, server_default="[]"),
)
def downgrade() -> None:
op.drop_column("users", "extra_roles")
@@ -0,0 +1,33 @@
"""Make users.email the unique login identifier (NOT NULL + unique index).
Backfills any missing/blank email from username first, so existing rows
(e.g. the seeded admin, which historically had no email) never violate
the new constraint.
Revision ID: b067
Revises: b066
Create Date: 2026-07-23
"""
from alembic import op
import sqlalchemy as sa
revision = "b067"
down_revision = "b066"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"UPDATE users SET email = username WHERE email IS NULL OR email = ''"
)
op.alter_column("users", "email", existing_type=sa.String(), nullable=False)
op.create_unique_constraint("uq_users_email", "users", ["email"])
op.create_index("ix_users_email", "users", ["email"])
def downgrade() -> None:
op.drop_index("ix_users_email", table_name="users")
op.drop_constraint("uq_users_email", "users", type_="unique")
op.alter_column("users", "email", existing_type=sa.String(), nullable=True)
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+16
View File
@@ -107,6 +107,11 @@ class Settings(BaseSettings):
STALE_THRESHOLD_DAYS: int = 365 # days before coverage is considered stale
# ── Reporting ─────────────────────────────────────────────────────
# PDF/DOCX/HTML report generation (professional_reports router) is
# unfinished — the frontend hides the Reports UI entirely. Defaults
# off so a fresh deploy returns a clean 503 instead of a raw 500 from
# a half-working render pipeline (e.g. missing weasyprint system deps).
REPORTS_ENABLED: bool = False
REPORT_TEMPLATES_DIR: str = "app/templates/reports"
# Assign REPORT_OUTPUT_DIR = "/tmp/aegis_reports"
REPORT_OUTPUT_DIR: str = "/app/reports"
@@ -211,3 +216,14 @@ if _is_production:
f"Set a strong value via the {name} environment variable "
f"before running in production."
)
# PLATFORM_URL has no safe production default — it's baked into every
# emailed link (set-password, notifications). Falling back silently to
# the dev value (or to some other deployment's hardcoded domain) would
# ship broken/wrong links without anyone noticing.
if settings.PLATFORM_URL == "http://localhost:5173":
raise RuntimeError(
"CRITICAL: PLATFORM_URL is not configured. Set it to this "
"deployment's real public frontend URL via the PLATFORM_URL "
"environment variable before running in production."
)
+24
View File
@@ -253,6 +253,30 @@ def require_any_role(*roles: str) -> Callable[..., object]:
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):
"""Return a dependency that enforces the API key carries *scope*.
+24 -8
View File
@@ -81,15 +81,31 @@ class ContainmentResult(str, enum.Enum):
not_contained = "not_contained"
class AttackSuccessResult(str, enum.Enum):
"""Outcome of a red-team attack from a success perspective."""
successful = "successful"
not_successful = "not_successful"
partially_successful = "partially_successful"
# Define class DataClassification
class DataClassification(str, enum.Enum):
"""Data sensitivity classification levels for compliance and retention policies."""
"""Data sensitivity classification levels for compliance and retention policies.
Matches Jira's "Data Sensitivity" field (customfield_11814) exactly, since
that is the organization's actual data protection scheme:
- public: approved for external/public release
- general_use: general internal information, no special restriction
- internal_use_only: internal use only
- trusted_people: shared only with a limited/trusted audience
- customer_content: contains customer data
- pii: contains personally identifiable information
"""
# Assign public = "public"
public = "public"
# Assign internal = "internal"
internal = "internal"
# Assign sensitive = "sensitive"
sensitive = "sensitive"
# Assign restricted = "restricted"
restricted = "restricted"
general_use = "general_use"
internal_use_only = "internal_use_only"
trusted_people = "trusted_people"
customer_content = "customer_content"
pii = "pii"
+25
View File
@@ -414,10 +414,14 @@ class TestEntity:
Called when the assigned Red Lead sends the work back for rework.
Resets the red-phase timer for a fresh attempt (the first attempt's
worklog was already recorded at submit time, so this loses nothing).
Starts the timer PAUSED — the operator hasn't resumed working yet,
just because the lead reopened it. It only starts counting once they
explicitly resume it, same as coming back from a hold.
"""
self._transition(TestState.red_executing)
self.red_started_at = datetime.utcnow()
self.red_paused_seconds = 0
self.paused_at = self.red_started_at
self._events.append(DomainEvent("red_review_reopened"))
# Define function submit_blue_evidence
@@ -668,6 +672,27 @@ class TestEntity:
self._events.append(DomainEvent("dispute_resolved_to_rework", {"target": target}))
def resolve_dispute_by_manager(self, outcome: str) -> None:
"""A manager makes the final call on a disputed test, ending the standoff.
Unlike ``resolve_dispute_reject`` (a lead flipping their own vote),
this bypasses both leads entirely — the manager's decision is final
and terminal, going straight to ``validated`` or the normal
``rejected`` dead-end (which any lead can later reopen via the
standard reopen-to-draft flow, same as any other rejection).
Args:
outcome (str): ``"validated"`` or ``"rejected"``.
"""
if outcome not in ("validated", "rejected"):
raise InvalidOperationError("outcome must be 'validated' or 'rejected'")
target_state = TestState.validated if outcome == "validated" else TestState.rejected
self._transition(target_state)
self._events.append(
DomainEvent("dual_validation_approved" if outcome == "validated" else "dual_validation_rejected")
)
# -- Private -------------------------------------------------------
def _auto_resume(self) -> int:
+49 -2
View File
@@ -16,6 +16,7 @@ from datetime import datetime, timedelta, timezone
# Import BackgroundScheduler from apscheduler.schedulers.background
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.events import EVENT_JOB_ERROR
# Import SessionLocal from app.database
from app.database import SessionLocal
@@ -27,7 +28,10 @@ from app.jobs.jira_sync_job import sync_all_jira_links
from app.jobs.retention_job import run_retention_job
# Import check_and_run_recurring_campaigns from app.services.campaign_scheduler_service
from app.services.campaign_scheduler_service import check_and_run_recurring_campaigns
from app.services.campaign_scheduler_service import (
check_and_run_recurring_campaigns,
sync_due_campaign_jira_tickets,
)
# Import scan_intel from app.services.intel_service
from app.services.intel_service import scan_intel
@@ -57,6 +61,27 @@ logger = logging.getLogger(__name__)
scheduler = BackgroundScheduler()
def _on_job_error(event) -> None:
"""Email admins (opted-in) whenever a scheduled background job raises."""
db = SessionLocal()
try:
from app.services.notification_service import notify_roles_by_email
notify_roles_by_email(
db, roles=["admin"],
preference_key="email_on_system_errors",
subject=f"Aegis Background Job Failed: {event.job_id}",
message=(
f'The scheduled job "{event.job_id}" raised an exception and did '
f"not complete:\n\n{event.exception}"
),
)
db.commit()
except Exception:
logger.exception("Failed to dispatch system-error notification")
finally:
db.close()
# ---------------------------------------------------------------------------
# Job functions
# ---------------------------------------------------------------------------
@@ -164,6 +189,19 @@ def _run_recurring_campaigns() -> None:
db.close()
def _run_due_campaign_jira_sync() -> None:
"""Create Jira tickets for campaigns whose scheduled start_date has arrived."""
logger.info("Scheduled due-campaign Jira sync starting...")
db = SessionLocal()
try:
processed = sync_due_campaign_jira_tickets(db)
logger.info("Due-campaign Jira sync finished — processed %d campaigns", processed)
except Exception:
logger.exception("Due-campaign Jira sync failed")
finally:
db.close()
def _run_intel_scan() -> None:
"""Execute an intel scan inside its own DB session."""
# Log info: "Scheduled intel scan job starting..."
@@ -424,6 +462,7 @@ def start_scheduler() -> None:
Neither job fires immediately on startup.
"""
scheduler.add_listener(_on_job_error, EVENT_JOB_ERROR)
# Call scheduler.add_job()
scheduler.add_job(
_run_mitre_sync,
@@ -497,6 +536,14 @@ def start_scheduler() -> None:
# Keyword argument: replace_existing
replace_existing=True,
)
scheduler.add_job(
_run_due_campaign_jira_sync,
trigger="interval",
minutes=15,
id="due_campaign_jira_sync",
name="Due campaign Jira ticket sync (every 15 min)",
replace_existing=True,
)
# Call scheduler.add_job()
scheduler.add_job(
sync_all_jira_links,
@@ -605,7 +652,7 @@ def start_scheduler() -> None:
# Literal argument value
"notification_cleanup (24h), weekly_snapshot (Sundays 00:00), "
# Literal argument value
"recurring_campaigns (daily), jira_sync (1h), "
"recurring_campaigns (daily), due_campaign_jira_sync (15m), jira_sync (1h), "
# Literal argument value
"osint_enrichment (weekly), stale_detection (daily), "
"retention_policies (daily), data_sources_sync (6h), "
+4
View File
@@ -43,6 +43,8 @@ from app.routers import techniques as techniques_router
from app.routers import tests as tests_router
from app.routers import evidence as evidence_router
from app.routers import test_templates as test_templates_router
from app.routers import procedure_suggestions as procedure_suggestions_router
from app.routers import template_suggestions as template_suggestions_router
from app.routers import system as system_router
from app.routers import metrics as metrics_router
from app.routers import users as users_router
@@ -213,6 +215,8 @@ app.include_router(tests_router.router, prefix="/api/v1")
app.include_router(evidence_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(test_templates_router.router, prefix="/api/v1")
app.include_router(procedure_suggestions_router.router, prefix="/api/v1")
app.include_router(template_suggestions_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(system_router.router, prefix="/api/v1")
# Call app.include_router()
+10
View File
@@ -43,8 +43,13 @@ from app.models.evidence import Evidence
from app.models.intel import IntelItem
from app.models.technique import Technique
from app.models.test import Test
from app.models.test_round_history import TestRoundHistory
from app.models.test_template import TestTemplate
from app.models.procedure_suggestion import ProcedureSuggestion
from app.models.template_suggestion import TemplateSuggestion
from app.models.user import User
from app.models.password_setup_token import PasswordSetupToken
from app.models.evaluation_import import EvaluationImport
# Assign __all__ = [
__all__ = [
@@ -86,4 +91,9 @@ __all__ = [
"SsoConfig",
"AlertRule",
"AlertInstance",
"TestRoundHistory",
"ProcedureSuggestion",
"TemplateSuggestion",
"PasswordSetupToken",
"EvaluationImport",
]
+2 -2
View File
@@ -90,8 +90,8 @@ class Campaign(Base):
tags = Column(JSONB, nullable=True, default=[])
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
data_classification = Column(String(20), nullable=False, server_default="internal")
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
# Recurring scheduling fields
is_recurring = Column(Boolean, default=False)
+1
View File
@@ -7,6 +7,7 @@ working with ``from app.models.enums import ...``.
# Import # noqa: F401 from app.domain.enums
from app.domain.enums import ( # noqa: F401
AttackSuccessResult,
ContainmentResult,
DataClassification,
TeamSide,
+2 -2
View File
@@ -50,8 +50,8 @@ class Evidence(Base):
team = Column(Enum(TeamSide, name="teamside"), nullable=False, default=TeamSide.red)
# Assign notes = Column(Text, nullable=True)
notes = Column(Text, nullable=True)
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
data_classification = Column(String(20), nullable=False, server_default="internal")
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
# Relationships
test = relationship("Test", back_populates="evidences")
@@ -0,0 +1,30 @@
"""SQLAlchemy model for one-time password setup/reset tokens.
Issued when an admin sends a "set your password" email — a passwordless
user (freshly created, or one whose password an admin wants to reset)
uses the token to pick their own password without ever having a temporary
one shared with them.
"""
import uuid
from sqlalchemy import Column, DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
class PasswordSetupToken(Base):
"""A one-time token letting its owning user set their own password."""
__tablename__ = "password_setup_tokens"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
token = Column(String(128), unique=True, nullable=False, index=True)
expires_at = Column(DateTime, nullable=False)
used_at = Column(DateTime, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("User", foreign_keys=[user_id])
@@ -0,0 +1,41 @@
"""SQLAlchemy model for procedure-improvement suggestions.
When an operator submits a round with a filled-in procedure field
(``procedure_text`` for Red, ``detect_procedure`` for Blue), the command(s)
in it are extracted heuristically and proposed as an update to the
originating template's suggested-procedure field. Nothing is written to
the template automatically — a lead reviews and approves or rejects each
suggestion, so a junior who later picks up the same template only ever
sees vetted guidance.
"""
import uuid
from sqlalchemy import Column, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
class ProcedureSuggestion(Base):
"""A proposed update to a TestTemplate's suggested procedure, pending lead review."""
__tablename__ = "procedure_suggestions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
template_id = Column(UUID(as_uuid=True), ForeignKey("test_templates.id"), nullable=False)
team = Column(String(10), nullable=False) # "red" or "blue"
suggested_text = Column(Text, nullable=False)
source_test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=True)
submitted_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
status = Column(String(10), nullable=False, default="pending", server_default="pending") # pending/approved/rejected
reviewed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
reviewed_at = Column(DateTime, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
template = relationship("TestTemplate", foreign_keys=[template_id])
source_test = relationship("Test", foreign_keys=[source_test_id])
submitter = relationship("User", foreign_keys=[submitted_by])
reviewer = relationship("User", foreign_keys=[reviewed_by])
+53
View File
@@ -0,0 +1,53 @@
"""SQLAlchemy model for operator-proposed test templates, pending lead review.
Leads create templates directly (see TestTemplateCreate router). An
operator (red_tech/blue_tech) gets the same "create template" action, but
their submission lands here instead of the live catalog — a lead on their
team reviews it, optionally edits any field, and either approves it (which
creates the real TestTemplate) or discards it.
"""
import uuid
from sqlalchemy import Column, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
class TemplateSuggestion(Base):
"""A proposed new TestTemplate submitted by an operator, pending lead review."""
__tablename__ = "template_suggestions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Proposed TestTemplate fields — mirrors TestTemplateCreate.
mitre_technique_id = Column(String, nullable=False)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
source = Column(String, nullable=False, default="custom", server_default="custom")
source_url = Column(String, nullable=True)
attack_procedure = Column(Text, nullable=True)
expected_detection = Column(Text, nullable=True)
platform = Column(String, nullable=True)
tool_suggested = Column(String, nullable=True)
severity = Column(String, nullable=True)
atomic_test_id = Column(String, nullable=True)
suggested_remediation = Column(Text, nullable=True)
# "red" or "blue" — derived from the submitter's role, determines which
# lead reviews it (admins can review both).
team = Column(String(10), nullable=False)
submitted_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
status = Column(String(10), nullable=False, default="pending", server_default="pending") # pending/approved/rejected
reviewed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
reviewed_at = Column(DateTime, nullable=True)
created_template_id = Column(UUID(as_uuid=True), ForeignKey("test_templates.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
submitter = relationship("User", foreign_keys=[submitted_by])
reviewer = relationship("User", foreign_keys=[reviewed_by])
created_template = relationship("TestTemplate", foreign_keys=[created_template_id])
+21 -4
View File
@@ -27,7 +27,7 @@ from sqlalchemy.orm import relationship
from app.database import Base
# Import ContainmentResult, TestResult, TestState from app.models.enums
from app.models.enums import ContainmentResult, TestResult, TestState
from app.models.enums import AttackSuccessResult, ContainmentResult, TestResult, TestState
# Define class Test
@@ -59,6 +59,12 @@ class Test(Base):
execution_date = Column(DateTime, nullable=True)
# Assign created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# The template this test was instantiated from, if any (null for
# standalone/manually-created/RT-imported tests). Lets a procedure
# suggestion know exactly which template to propose improving —
# matching purely by technique would be ambiguous when a technique has
# multiple templates.
source_template_id = Column(UUID(as_uuid=True), ForeignKey("test_templates.id"), nullable=True)
# Assign result = Column(Enum(TestResult, name="testresult"), nullable=True)
result = Column(Enum(TestResult, name="testresult"), nullable=True)
# Assign state = Column(Enum(TestState, name="teststate"), default=TestState.draft)
@@ -69,7 +75,7 @@ class Test(Base):
# ── Red Team fields ─────────────────────────────────────────────
red_summary = Column(Text, nullable=True)
# Assign attack_success = Column(Boolean, nullable=True)
attack_success = Column(Boolean, nullable=True)
attack_success = Column(Enum(AttackSuccessResult, name="attacksuccessresult"), nullable=True)
execution_start_time = Column(DateTime, nullable=True)
execution_end_time = Column(DateTime, nullable=True)
# Assign red_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
@@ -83,6 +89,9 @@ class Test(Base):
# ── Blue Team fields ────────────────────────────────────────────
blue_summary = Column(Text, nullable=True)
# What Blue actually did to detect the attack — Blue's counterpart to
# procedure_text. Free text; may be parsed for a procedure suggestion.
detect_procedure = Column(Text, nullable=True)
# Assign detection_result = Column(Enum(TestResult, name="testresult"), nullable=True)
detection_result = Column(Enum(TestResult, name="testresult"), nullable=True)
containment_result = Column(Enum(ContainmentResult, name="containmentresult"), nullable=True)
@@ -108,6 +117,10 @@ class Test(Base):
# Assign blue_paused_seconds = Column(Integer, default=0)
blue_paused_seconds = Column(Integer, default=0)
# ── Round tracking (bumped on each reopen-for-rework) ────────────
red_round_number = Column(Integer, nullable=False, default=1, server_default="1")
blue_round_number = Column(Integer, nullable=False, default=1, server_default="1")
# ── Remediation fields ───────────────────────────────────────────
remediation_steps = Column(Text, nullable=True)
# Assign remediation_status = Column(String, nullable=True) # pending / in_progress / completed ...
@@ -119,13 +132,17 @@ class Test(Base):
retest_of = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=True)
# Assign retest_count = Column(Integer, default=0)
retest_count = Column(Integer, default=0)
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
data_classification = Column(String(20), nullable=False, server_default="internal")
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
# ── Relationships ───────────────────────────────────────────────
technique = relationship("Technique", back_populates="tests")
# Assign evidences = relationship("Evidence", back_populates="test")
evidences = relationship("Evidence", back_populates="test")
round_history = relationship(
"TestRoundHistory", back_populates="test",
order_by="TestRoundHistory.round_number", cascade="all, delete-orphan",
)
# Assign creator = relationship("User", foreign_keys=[created_by])
creator = relationship("User", foreign_keys=[created_by])
# Assign red_validator = relationship("User", foreign_keys=[red_validated_by])
+61
View File
@@ -0,0 +1,61 @@
"""SQLAlchemy model for archived test rounds.
Each row is a snapshot of one team's round of work (procedure/results for
Red, detection/containment for Blue) taken right before a lead reopens the
test for rework. Reopening resets the live fields on ``Test`` for a fresh
attempt, but the prior attempt's data must not be lost — it's archived here
so the full history stays visible (e.g. to Blue Team, who need to see what
Red actually did on earlier rounds, not just the latest one).
"""
import uuid
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
from app.models.enums import AttackSuccessResult, ContainmentResult, TestResult
class TestRoundHistory(Base):
"""Archived snapshot of one red/blue round of a test, taken on reopen."""
__tablename__ = "test_round_history"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=False)
team = Column(String(10), nullable=False) # "red" or "blue"
round_number = Column(Integer, nullable=False)
started_at = Column(DateTime, nullable=True)
ended_at = Column(DateTime, nullable=True)
paused_seconds = Column(Integer, default=0)
# Red-side round output
procedure_text = Column(Text, nullable=True)
tool_used = Column(String, nullable=True)
# native_enum=False — stored as plain VARCHAR, not the shared Postgres
# enum type used by tests.attack_success. This is an archive table with
# no DB-level constraint needs, and reusing the native type name here
# would make Alembic try to (re)create it.
attack_success = Column(Enum(AttackSuccessResult, name="test_round_history_attack_success", native_enum=False), nullable=True)
red_summary = Column(Text, nullable=True)
execution_start_time = Column(DateTime, nullable=True)
execution_end_time = Column(DateTime, nullable=True)
# Blue-side round output
detection_result = Column(Enum(TestResult, name="test_round_history_detection_result", native_enum=False), nullable=True)
containment_result = Column(Enum(ContainmentResult, name="test_round_history_containment_result", native_enum=False), nullable=True)
detection_time = Column(DateTime, nullable=True)
containment_time = Column(DateTime, nullable=True)
blue_summary = Column(Text, nullable=True)
detect_procedure = Column(Text, nullable=True)
# Why the round was closed
review_notes = Column(Text, nullable=True)
reviewed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
archived_at = Column(DateTime(timezone=True), server_default=func.now())
test = relationship("Test", back_populates="round_history")
reviewer = relationship("User", foreign_keys=[reviewed_by])
+6 -2
View File
@@ -41,8 +41,12 @@ class TestTemplate(Base):
source_url = Column(String, nullable=True)
# Assign attack_procedure = Column(Text, nullable=True) # Suggested attack procedure
attack_procedure = Column(Text, nullable=True) # Suggested attack procedure
# Assign expected_detection = Column(Text, nullable=True) # What blue team should detect
expected_detection = Column(Text, nullable=True) # What blue team should detect
# What blue team should detect — narrative guidance from imports/leads,
# plus concrete commands appended via approved procedure suggestions
# (Blue's counterpart to attack_procedure). External syncs never touch
# an existing row (they only insert brand-new ones), so anything added
# here is safe across re-syncs.
expected_detection = Column(Text, nullable=True)
# Assign platform = Column(String, nullable=True) # windows / linux...
platform = Column(String, nullable=True) # windows / linux / macos
# Assign tool_suggested = Column(String, nullable=True)
+15 -3
View File
@@ -26,14 +26,26 @@ class User(Base):
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign username = Column(String, unique=True, nullable=False)
# Internal login identifier — always kept equal to `email` (see below).
# Kept as a separate column (rather than removed) since the JWT `sub`
# claim, audit logs, Jira actor attribution, and SSO provisioning all
# still key off it; changing all of those to read `email` directly
# would be a much larger, riskier refactor for no behavioral gain now
# that the two are always identical.
username = Column(String, unique=True, nullable=False)
# Assign email = Column(String, nullable=True)
email = Column(String, nullable=True)
# The unique identifier a user logs in with. Every user must have one.
email = Column(String, unique=True, nullable=False, index=True)
# Display name shown everywhere in the UI instead of username (which is
# now just an internal login identifier, auto-set to the user's email).
full_name = Column(String, nullable=True)
# Assign hashed_password = Column(String, nullable=False)
hashed_password = Column(String, nullable=False)
# Assign role = Column(String, nullable=False, default="viewer")
role = Column(String, nullable=False, default="viewer")
# Other roles this user can switch into (the currently-active role
# lives in `role` above and is what every permission check reads —
# switching just swaps which one is active, never grants both at once).
extra_roles = Column(JSONB, nullable=False, default=list, server_default="[]")
# Assign is_active = Column(Boolean, default=True)
is_active = Column(Boolean, default=True)
# Assign must_change_password = Column(Boolean, default=True)
+20 -7
View File
@@ -32,9 +32,18 @@ from app.models.webhook_config import WebhookConfig
router = APIRouter(prefix="/admin", tags=["admin"])
# Keys whose values contain secrets and must be redacted in the export
# Keys whose values contain secrets and must be redacted in the export.
# Must be kept in sync with the actual SystemConfig keys written by
# routers/system.py's _JIRA_KEYS map and the SMTP settings section —
# a stale/mismatched name here silently exports a live credential in
# plaintext instead of redacting it.
_REDACTED_KEYS = {
"smtp.password",
"jira.admin_api_token",
"tempo.admin_token",
"email_webhook.api_key",
# Older/alternate key names kept defensively in case a prior schema
# version wrote under these instead.
"jira.api_token",
"jira.password",
"tempo.api_token",
@@ -213,6 +222,7 @@ async def import_config(
"custom_templates": 0,
"users_created": 0,
"users_updated": 0,
"users_skipped_no_email": 0,
}
# ── 1. system_configs ────────────────────────────────────────────
@@ -300,12 +310,16 @@ async def import_config(
summary["custom_templates"] += 1
# ── 6. Users ─────────────────────────────────────────────────────
# Email is the unique login identifier — a bundle entry with no email
# (e.g. exported from an older instance, before this requirement) can't
# be created; it's skipped rather than crashing the whole import.
import secrets as _secrets
for item in bundle.get("users", []):
username = item.get("username")
if not username:
email = item.get("email")
if not email:
summary["users_skipped_no_email"] += 1
continue
existing = db.query(User).filter(User.username == username).first()
existing = db.query(User).filter(User.email == email).first()
if existing:
existing.role = item.get("role", existing.role)
existing.is_active = item.get("is_active", existing.is_active)
@@ -314,14 +328,13 @@ async def import_config(
# Create with random temp password — user must reset on login
temp_pw = _secrets.token_urlsafe(16) + "Aa1!"
new_user = User(
username=username,
username=email,
email=email,
hashed_password=hash_password(temp_pw),
role=item.get("role", "viewer"),
is_active=item.get("is_active", True),
must_change_password=True,
)
if item.get("email") and hasattr(User, "email"):
new_user.email = item["email"]
db.add(new_user)
summary["users_created"] += 1
+76 -12
View File
@@ -16,6 +16,9 @@ from fastapi import APIRouter, Cookie, Depends, Request, Response
# Import OAuth2PasswordRequestForm from fastapi.security
from fastapi.security import OAuth2PasswordRequestForm
# Import datetime, timezone from datetime
from datetime import datetime, timezone
# Import jwt (PyJWT)
import jwt
from jwt.exceptions import PyJWTError as JWTError
@@ -24,7 +27,7 @@ from jwt.exceptions import PyJWTError as JWTError
from sqlalchemy.orm import Session
# Import blacklist_token, create_access_token, verify_pa... from app.auth
from app.auth import blacklist_token, create_access_token, verify_password
from app.auth import blacklist_token, create_access_token, is_token_blacklisted, verify_password
# Import settings from app.config
from app.config import settings
@@ -54,7 +57,7 @@ from app.models.user import User
from app.schemas.auth import TokenResponse, UserOut
# Import PasswordChange from app.schemas.user
from app.schemas.user import PasswordChange
from app.schemas.user import PasswordChange, SetPasswordRequest, SetPasswordTokenValidateOut
# Import log_action from app.services.audit_service
from app.services.audit_service import log_action
@@ -69,6 +72,12 @@ from app.services.auth_service import (
change_password as auth_change_password,
)
from app.domain.errors import EntityNotFoundError
from app.services.password_setup_service import (
consume_token_and_set_password,
validate_token as validate_password_setup_token,
)
# Assign router = APIRouter(prefix="/auth", tags=["auth"])
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -105,8 +114,10 @@ def login(
Rate-limited to **5 attempts per minute per IP**. Failed and successful
logins are recorded in the audit log (SEC-009).
"""
# Assign user = db.query(User).filter(User.username == form_data.username).first()
user = db.query(User).filter(User.username == form_data.username).first()
# OAuth2PasswordRequestForm's field is spec-named "username" but the
# value a user actually types in is their email — email is the unique
# login identifier (username is an internal id, always kept == email).
user = db.query(User).filter(User.email == form_data.username).first()
# Assign target_hash = user.hashed_password if user else _DUMMY_HASH
target_hash = user.hashed_password if user else _DUMMY_HASH
# Assign password_valid = verify_password(form_data.password, target_hash)
@@ -131,7 +142,7 @@ def login(
# Keyword argument: details
details={
# Literal argument value
"username": form_data.username,
"email": form_data.username,
# Literal argument value
"ip": ip,
# Literal argument value
@@ -143,7 +154,7 @@ def login(
# Call uow.commit()
uow.commit()
# Raise BusinessRuleViolation
raise BusinessRuleViolation("Incorrect username or password")
raise BusinessRuleViolation("Incorrect email or password")
# Check: not user.is_active
if not user.is_active:
@@ -165,7 +176,7 @@ def login(
"auth",
str(user.id),
# Keyword argument: details
details={"username": user.username, "ip": ip},
details={"email": user.email, "ip": ip},
# Keyword argument: ip_address
ip_address=ip,
)
@@ -272,19 +283,28 @@ def logout(
return {"detail": "Logged out"}
# A token that has *just* expired can still be refreshed within this window.
# Without this grace period, `/auth/refresh` decodes the same token with the
# same strict expiry check as every other endpoint — so by the time a 401
# triggers a refresh attempt, the refresh call itself has also expired and
# the session is unrecoverable. The grace window only widens the refresh
# check; a brand-new token is always issued with the full expiry.
_REFRESH_GRACE_SECONDS = 15 * 60
@router.post("/refresh", response_model=TokenResponse)
def refresh_token(
response: Response,
aegis_token: str | None = Cookie(None),
db: Session = Depends(get_db),
):
"""Issue a new access token if the current one is valid.
"""Issue a new access token if the current one is valid or recently expired.
Called automatically by the frontend when it detects an expired
session while the user is actively using the app. If the current
cookie token is still valid (not blacklisted, not expired), a fresh
token is issued and the cookie is renewed — keeping the session alive
without requiring re-authentication.
session while the user is actively using the app. Expiry is checked
manually (with `_REFRESH_GRACE_SECONDS` of leeway) instead of relying on
PyJWT's built-in check, so a token that expired moments ago can still
be refreshed. Revoked (blacklisted) tokens are never refreshable.
"""
if not aegis_token:
raise PermissionViolation("No active session")
@@ -294,10 +314,20 @@ def refresh_token(
aegis_token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
options={"verify_exp": False},
)
except JWTError:
raise PermissionViolation("Session expired — please log in again")
jti: str | None = payload.get("jti")
if jti and is_token_blacklisted(jti):
raise PermissionViolation("Session expired — please log in again")
exp = payload.get("exp")
now = datetime.now(timezone.utc).timestamp()
if exp is None or now - exp > _REFRESH_GRACE_SECONDS:
raise PermissionViolation("Session expired — please log in again")
username: str | None = payload.get("sub")
if not username:
raise PermissionViolation("Invalid session")
@@ -359,3 +389,37 @@ def change_password(
# Return {"detail": "Password changed successfully"}
return {"detail": "Password changed successfully"}
# ---------------------------------------------------------------------------
# Set password via a one-time emailed link — public (no auth), rate-limited
# ---------------------------------------------------------------------------
@router.get("/set-password/validate", response_model=SetPasswordTokenValidateOut)
@limiter.limit("20/minute")
def validate_set_password_token(request: Request, token: str, db: Session = Depends(get_db)) -> SetPasswordTokenValidateOut:
"""Check a set-password token before rendering the form. Public endpoint."""
try:
user = validate_password_setup_token(db, token)
except (EntityNotFoundError, BusinessRuleViolation):
return SetPasswordTokenValidateOut(valid=False)
return SetPasswordTokenValidateOut(valid=True, full_name=user.full_name)
@router.post("/set-password")
@limiter.limit("10/minute")
def set_password(request: Request, body: SetPasswordRequest, db: Session = Depends(get_db)) -> dict:
"""Complete a password setup/reset using a one-time token. Public endpoint."""
with UnitOfWork(db) as uow:
user = consume_token_and_set_password(db, body.token, body.new_password)
log_action(
db,
user.id,
"SET_PASSWORD_VIA_TOKEN",
"auth",
str(user.id),
details={},
)
uow.commit()
return {"detail": "Password set successfully — you can now log in"}
+77 -33
View File
@@ -25,7 +25,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
@@ -124,7 +124,7 @@ from app.services.campaign_crud_service import (
from app.services.audit_service import log_action
# Import notify_role from app.services.notification_service
from app.services.notification_service import notify_role
from app.services.notification_service import notify_role, notify_roles_by_email
from app.services.webhook_service import dispatch_webhook
# Assign logger = logging.getLogger(__name__)
@@ -134,6 +134,24 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
def _create_jira_tickets_for_campaign(db: Session, campaign: Campaign, campaign_id: str, user: User) -> None:
"""Create Jira tickets for *campaign* now, if its start_date has arrived.
Shared by both paths that bring a campaign to ``active``: the admin-only
emergency `/activate` override and the normal manager `/approve` flow.
If ``start_date`` is still in the future, ticket creation is skipped
here and left to the periodic ``sync_due_campaign_jira_tickets`` job,
which creates them once that date actually arrives — this is what makes
the campaign's real scheduled date/time authoritative for when tickets
(and their Jira start-date field) appear, instead of always at approval
time.
"""
if campaign.start_date and campaign.start_date > datetime.utcnow():
return
from app.services.jira_service import ensure_campaign_jira_tickets
ensure_campaign_jira_tickets(db, campaign, user)
# ── Pydantic schemas ─────────────────────────────────────────────────
class CampaignCreate(BaseModel):
@@ -153,6 +171,11 @@ class CampaignCreate(BaseModel):
tags: Optional[list[str]] = Field(default_factory=list)
# Assign scheduled_at = None
scheduled_at: Optional[str] = None
# Only honored when the creator is a manager — see create_campaign():
# a manager's own campaign is auto-approved on creation instead of
# going through the draft -> submit -> approve queue, so they need to
# supply the start_date up front instead of via a later /approve call.
start_date: Optional[str] = None
# Define class CampaignUpdate
@@ -296,18 +319,28 @@ def create_campaign(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
# Strict variant — admin must NOT get a free pass here. Admin
# administers the site, not campaign content; the only admin path to
# an active campaign is the emergency /activate override below.
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "manager")),
) -> dict:
"""Create a new campaign.
A manager's campaign is auto-approved on creation — a manager is the
same role that would otherwise approve it, so routing it through the
draft -> submit -> pending_approval queue would just mean approving
their own submission. red_lead/blue_lead campaigns still go through
that queue as before.
Args:
payload (CampaignCreate): Fields for the new campaign (name, type, threat actor, etc.).
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead creating the campaign.
current_user (User): Authenticated red_lead, blue_lead, or manager creating the campaign.
Returns:
dict: Serialised representation of the newly created campaign.
"""
is_manager_create = current_user.role == "manager"
# Open context manager
with UnitOfWork(db) as uow:
# Assign result = crud_create(
@@ -329,6 +362,9 @@ def create_campaign(
tags=payload.tags,
# Keyword argument: scheduled_at
scheduled_at=payload.scheduled_at,
auto_approve=is_manager_create,
start_date=payload.start_date if is_manager_create else None,
approver_id=current_user.id if is_manager_create else None,
)
campaign_id = result["id"]
log_action(
@@ -336,7 +372,7 @@ def create_campaign(
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="create_campaign",
action="create_campaign_auto_approved" if is_manager_create else "create_campaign",
# Keyword argument: entity_type
entity_type="campaign",
entity_id=campaign_id,
@@ -345,6 +381,13 @@ def create_campaign(
# Call uow.commit()
uow.commit()
if is_manager_create:
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
db.refresh(campaign)
# Create Jira tickets now if the manager's chosen start_date is
# already due — mirrors the normal manager /approve path.
_create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user)
# Return result
return result
@@ -391,15 +434,19 @@ def update_campaign(
# 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("red_lead", "blue_lead", "manager")),
) -> dict:
"""Update a campaign. Only allowed in draft or active state.
A manager may only edit a campaign that's sitting in draft with a
rejection_reason set (i.e. one they previously rejected) — see
``update_campaign()``'s ownership check.
Args:
campaign_id (str): UUID string of the campaign to update.
payload (CampaignUpdate): Partial update payload; only set fields are applied.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead performing the update.
current_user (User): Authenticated red_lead, blue_lead, or manager performing the update.
Returns:
dict: Serialised representation of the updated campaign.
@@ -496,8 +543,19 @@ def approve_campaign_endpoint(
entity_id=campaign.id,
details={"start_date": payload.start_date},
)
notify_roles_by_email(
db, roles=["red_tech"],
preference_key="email_on_assigned_to_campaign",
subject=f"Campaign Activated: {campaign.name}",
message=f'Campaign "{campaign.name}" has been approved and activated. You may have tests assigned.',
)
uow.commit()
db.refresh(campaign)
# Create Jira tickets for campaign and its already-linked tests (non-fatal).
# Mirrors the admin-only /activate override — this is the normal path.
_create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user)
return serialize_campaign(db, campaign)
@@ -816,6 +874,12 @@ def activate_campaign(
# Keyword argument: entity_id
entity_id=campaign.id,
)
notify_roles_by_email(
db, roles=["red_tech"],
preference_key="email_on_assigned_to_campaign",
subject=f"Campaign Activated: {campaign.name}",
message=f'Campaign "{campaign.name}" has been activated. You may have tests assigned.',
)
# Call log_action()
log_action(
db,
@@ -837,30 +901,7 @@ def activate_campaign(
# Create Jira tickets for campaign and tests at activation time (non-fatal).
# Campaign ticket is created here if it doesn't already exist (deferred from creation).
try:
from app.services.jira_service import (
auto_create_campaign_issue,
auto_create_test_issue,
get_campaign_jira_key,
get_test_jira_key,
)
campaign_jira_key = get_campaign_jira_key(db, campaign_id)
if not campaign_jira_key:
campaign_jira_key = auto_create_campaign_issue(db, campaign, current_user)
if campaign_jira_key:
for ct in campaign.campaign_tests:
if ct.test and not get_test_jira_key(db, ct.test.id):
auto_create_test_issue(
db, ct.test, current_user,
parent_ticket_override=campaign_jira_key,
campaign_start_date=campaign.start_date,
)
db.commit()
except Exception:
logger.exception(
"Jira ticket creation failed during activation of campaign %s",
campaign_id,
)
_create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user)
return serialize_campaign(db, campaign)
@@ -950,7 +991,7 @@ def get_campaign_progress_endpoint(
# ---------------------------------------------------------------------------
class GenerateFromActorPayload(BaseModel):
pass
start_date: Optional[datetime] = None
@router.post("/from-threat-actor/{actor_id}", status_code=201)
@@ -961,7 +1002,9 @@ def generate_campaign_from_actor(
payload: GenerateFromActorPayload = GenerateFromActorPayload(),
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
# Strict variant — admin must NOT get a free pass here, same as the
# plain create_campaign endpoint above.
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
) -> dict:
"""Auto-generate a campaign from a threat actor's uncovered techniques.
@@ -980,6 +1023,7 @@ def generate_campaign_from_actor(
db,
uuid.UUID(actor_id),
current_user,
start_date=payload.start_date,
)
# Open context manager
@@ -0,0 +1,158 @@
"""Router for procedure-improvement suggestion review.
Endpoints
---------
GET /procedure-suggestions — list pending suggestions (lead/admin)
POST /procedure-suggestions/{id}/approve — write suggestion into template (lead/admin)
POST /procedure-suggestions/{id}/reject — dismiss suggestion (lead/admin)
A red_lead only ever sees/acts on "red" team suggestions, a blue_lead only
"blue" — admins can see and act on both.
"""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from app.database import get_db
from app.dependencies.auth import require_any_role_strict
from app.domain.errors import EntityNotFoundError, InvalidOperationError
from app.domain.unit_of_work import UnitOfWork
from app.models.procedure_suggestion import ProcedureSuggestion
from app.models.test_template import TestTemplate
from app.models.user import User
from app.schemas.procedure_suggestion import ProcedureSuggestionOut
from app.services.audit_service import log_action
from app.services.procedure_suggestion_service import (
approve_suggestion as approve_suggestion_svc,
)
from app.services.procedure_suggestion_service import (
list_pending_suggestions,
)
from app.services.procedure_suggestion_service import (
reject_suggestion as reject_suggestion_svc,
)
from sqlalchemy.orm import Session
router = APIRouter(prefix="/procedure-suggestions", tags=["procedure-suggestions"])
def _team_for_role(user: User) -> str | None:
"""Return the single team a non-admin lead is scoped to, or None for admin (both)."""
if user.role == "red_lead":
return "red"
if user.role == "blue_lead":
return "blue"
return None
def _to_out(suggestion, template_by_id: dict) -> ProcedureSuggestionOut:
out = ProcedureSuggestionOut.model_validate(suggestion)
template = template_by_id.get(suggestion.template_id)
if template is not None:
out.template_name = template.name
out.template_current_text = (
template.attack_procedure if suggestion.team == "red" else template.expected_detection
)
return out
@router.get("", response_model=list[ProcedureSuggestionOut])
def list_suggestions(
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
) -> list[ProcedureSuggestionOut]:
"""List pending procedure suggestions, scoped to the caller's team (admins see both)."""
team = _team_for_role(current_user)
suggestions = list_pending_suggestions(db, team=team)
template_ids = {s.template_id for s in suggestions}
templates = (
db.query(TestTemplate).filter(TestTemplate.id.in_(template_ids)).all() if template_ids else []
)
template_by_id = {t.id: t for t in templates}
return [_to_out(s, template_by_id) for s in suggestions]
@router.get("/for-test/{test_id}", response_model=list[ProcedureSuggestionOut])
def list_suggestions_for_test(
test_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
) -> list[ProcedureSuggestionOut]:
"""List pending suggestions tied to a specific test, scoped to the
caller's team (admins see both) — powers the blocking review popup a
lead sees when opening a test that has one awaiting them."""
team = _team_for_role(current_user)
suggestions = list_pending_suggestions(db, team=team, source_test_id=test_id)
template_ids = {s.template_id for s in suggestions}
templates = (
db.query(TestTemplate).filter(TestTemplate.id.in_(template_ids)).all() if template_ids else []
)
template_by_id = {t.id: t for t in templates}
return [_to_out(s, template_by_id) for s in suggestions]
@router.post("/{suggestion_id}/approve", response_model=ProcedureSuggestionOut)
def approve_suggestion(
suggestion_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
) -> ProcedureSuggestionOut:
"""Approve a suggestion, writing it into its template's suggested-procedure field."""
_check_team_permission(current_user, _team_or_404(db, suggestion_id))
try:
with UnitOfWork(db) as uow:
suggestion = approve_suggestion_svc(db, suggestion_id, current_user)
log_action(
db, user_id=current_user.id, action="approve_procedure_suggestion",
entity_type="procedure_suggestion", entity_id=suggestion.id,
details={"template_id": str(suggestion.template_id), "team": suggestion.team},
)
uow.commit()
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except InvalidOperationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
db.refresh(suggestion)
template = db.query(TestTemplate).filter(TestTemplate.id == suggestion.template_id).first()
return _to_out(suggestion, {template.id: template} if template else {})
@router.post("/{suggestion_id}/reject", response_model=ProcedureSuggestionOut)
def reject_suggestion(
suggestion_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
) -> ProcedureSuggestionOut:
"""Reject a suggestion without touching the template."""
_check_team_permission(current_user, _team_or_404(db, suggestion_id))
try:
with UnitOfWork(db) as uow:
suggestion = reject_suggestion_svc(db, suggestion_id, current_user)
log_action(
db, user_id=current_user.id, action="reject_procedure_suggestion",
entity_type="procedure_suggestion", entity_id=suggestion.id,
details={"template_id": str(suggestion.template_id), "team": suggestion.team},
)
uow.commit()
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except InvalidOperationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
db.refresh(suggestion)
return _to_out(suggestion, {})
def _team_or_404(db: Session, suggestion_id: uuid.UUID) -> str:
"""Look up just the team of a suggestion, for a permission check before mutating it."""
suggestion = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.id == suggestion_id).first()
if suggestion is None:
raise HTTPException(status_code=404, detail="Procedure suggestion not found")
return suggestion.team
def _check_team_permission(user: User, team: str) -> None:
"""Raise 403 if a non-admin lead tries to act on the other team's suggestion."""
scoped_team = _team_for_role(user)
if scoped_team is not None and scoped_team != team:
raise HTTPException(status_code=403, detail=f"Not authorized to review {team} team suggestions")
@@ -40,6 +40,18 @@ def _assert_safe_report_path(filepath: str) -> str:
raise HTTPException(status_code=500, detail="Report generation path error")
return filepath
def _assert_reports_enabled() -> None:
"""Raise a clean 503 while report generation is unfinished/disabled.
The frontend hides the Reports UI entirely; without this guard, hitting
these endpoints directly falls through to whatever the underlying
render pipeline raises (e.g. missing weasyprint system deps) as a raw,
unhelpful 500.
"""
if not settings.REPORTS_ENABLED:
raise HTTPException(status_code=503, detail="Report generation is not yet available on this platform")
# Assign router = APIRouter(prefix="/reports/generate", tags=["professional-reports"])
router = APIRouter(prefix="/reports/generate", tags=["professional-reports"])
@@ -72,6 +84,7 @@ def generate_purple_report(
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
) -> FileResponse:
"""Generate a Purple Team campaign assessment report."""
_assert_reports_enabled()
# Assign filepath = report_generation_service.generate_purple_campaign_report(
filepath = report_generation_service.generate_purple_campaign_report(
db, str(campaign_id), output_format=format,
@@ -102,6 +115,7 @@ def generate_coverage_report(
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
) -> FileResponse:
"""Generate an organization-wide MITRE ATT&CK coverage report."""
_assert_reports_enabled()
# Assign filepath = report_generation_service.generate_coverage_report(
filepath = report_generation_service.generate_coverage_report(
db, output_format=format,
@@ -132,6 +146,7 @@ def generate_executive_report(
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
) -> FileResponse:
"""Generate an executive security summary report."""
_assert_reports_enabled()
# Assign filepath = report_generation_service.generate_executive_summary(
filepath = report_generation_service.generate_executive_summary(
db, output_format=format,
@@ -162,6 +177,7 @@ def generate_quarterly_report(
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
) -> FileResponse:
"""Generate a quarterly security summary report."""
_assert_reports_enabled()
# Assign filepath = report_generation_service.generate_quarterly_summary(
filepath = report_generation_service.generate_quarterly_summary(
db, output_format=format,
@@ -194,6 +210,7 @@ def generate_technique_report(
user: User = Depends(get_current_user),
) -> FileResponse:
"""Generate a detailed report for one MITRE technique."""
_assert_reports_enabled()
# Assign filepath = report_generation_service.generate_technique_detail_report(
filepath = report_generation_service.generate_technique_detail_report(
db, str(technique_id), output_format=format,
+2 -2
View File
@@ -165,7 +165,7 @@ def score_history(
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
) -> list:
"""Get historical score data points (weekly).
Args:
@@ -174,7 +174,7 @@ def score_history(
current_user (User): Authenticated user making the request.
Returns:
dict: Weekly score data points for the requested period.
list: Weekly score data points for the requested period.
"""
# Return get_score_history(db, period)
return get_score_history(db, period)
+108 -8
View File
@@ -17,7 +17,7 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
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.services.mitre_sync_service import sync_mitre
from app.services.intel_service import scan_intel
@@ -285,11 +285,13 @@ _JIRA_KEYS = {
@router.get("/jira-config", response_model=JiraConfigOut)
def get_jira_config(
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).
**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 (
get_jira_url, get_jira_project_key, is_jira_enabled,
@@ -342,6 +344,95 @@ def update_jira_config(
)
class EmailWebhookConfigOut(BaseModel):
configured: bool
url: str
api_key_set: bool
class EmailWebhookConfigUpdate(BaseModel):
url: str
# Optional — omit or send "" to leave the currently-stored key unchanged.
api_key: Optional[str] = None
class EmailWebhookTestRequest(BaseModel):
to: str
@router.get("/email-webhook-config", response_model=EmailWebhookConfigOut)
def get_email_webhook_config(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Return the configured webhook used to send every platform notification
email (password setup/reset, test validated, campaign completed, etc.).
The API key itself is never returned, only whether one is set.
**Requires** the ``admin`` role.
"""
from app.services.webhook_email_service import get_webhook_api_key, get_webhook_url
url = get_webhook_url(db) or ""
api_key_set = bool(get_webhook_api_key(db))
return EmailWebhookConfigOut(configured=bool(url), url=url, api_key_set=api_key_set)
@router.patch("/email-webhook-config", response_model=EmailWebhookConfigOut)
def update_email_webhook_config(
payload: EmailWebhookConfigUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Set the webhook URL (and optionally API key) used to send all
platform notification emails.
**Requires** the ``admin`` role.
"""
from app.services.webhook_email_service import (
get_webhook_api_key,
get_webhook_url,
set_webhook_api_key,
set_webhook_url,
)
set_webhook_url(db, payload.url)
if payload.api_key:
set_webhook_api_key(db, payload.api_key)
db.commit()
url = get_webhook_url(db) or ""
api_key_set = bool(get_webhook_api_key(db))
return EmailWebhookConfigOut(configured=bool(url), url=url, api_key_set=api_key_set)
@router.post("/email-webhook-test")
def test_email_webhook(
payload: EmailWebhookTestRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
) -> dict:
"""Send a real test notification through the configured email webhook.
**Requires** the ``admin`` role.
"""
from app.services.webhook_email_service import send_webhook_email
sent = send_webhook_email(
db,
to=payload.to,
subject="Aegis Email Webhook Test",
message=(
"This is a test notification confirming the email webhook is "
"configured and working correctly."
),
full_name=current_user.full_name,
)
if not sent:
raise HTTPException(status_code=502, detail="Failed to send — check the webhook URL/API key and logs.")
return {"detail": f"Test email sent to {payload.to}"}
@router.post("/jira-test")
def test_jira_connection(
db: Session = Depends(get_db),
@@ -354,7 +445,7 @@ def test_jira_connection(
"""
from app.services.jira_service import (
get_admin_jira_client, get_jira_url, has_admin_jira_configured,
get_admin_jira_email,
get_admin_jira_email, get_admin_jira_api_token, is_jira_enabled,
)
jira_url = get_jira_url(db)
@@ -362,12 +453,21 @@ def test_jira_connection(
return {"status": "error", "message": "Jira URL is not configured.", "jira_url": ""}
if not has_admin_jira_configured(db):
# has_admin_jira_configured() bundles 4 independent checks into one
# bool — report exactly which one(s) are actually missing instead of
# always blaming "Admin Email and Admin API Token" regardless of
# which check failed (e.g. URL/email/token can all be set while the
# "Enable Jira Integration" toggle itself is still off).
missing = []
if not is_jira_enabled(db):
missing.append("Jira integration is not enabled (toggle 'Enable Jira Integration' above)")
if not get_admin_jira_email(db):
missing.append("Admin Email is not set")
if not get_admin_jira_api_token(db):
missing.append("Admin API Token is not set")
return {
"status": "error",
"message": (
"Admin Jira credentials not configured. "
"Set the Admin Email and Admin API Token in the fields above."
),
"message": "Cannot connect: " + "; ".join(missing) + ".",
"jira_url": jira_url,
}
+1 -1
View File
@@ -254,7 +254,7 @@ def review_technique(
# Entry: repo
repo: SATechniqueRepository = Depends(get_technique_repository),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
current_user: User = Depends(require_any_role("red_lead", "blue_lead", "manager")),
) -> TechniqueOut:
"""Mark a technique as reviewed.
+166
View File
@@ -0,0 +1,166 @@
"""Router for operator template-proposal review.
Endpoints
---------
POST /template-suggestions — propose a new template (red_tech/blue_tech)
GET /template-suggestions — list pending suggestions (lead/admin)
POST /template-suggestions/{id}/approve — create the template, with optional edits (lead/admin)
POST /template-suggestions/{id}/reject — discard the suggestion (lead/admin)
A red_lead only ever sees/acts on "red" team suggestions, a blue_lead only
"blue" — admins can see and act on both.
"""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import require_any_role_strict
from app.domain.errors import EntityNotFoundError, InvalidOperationError
from app.domain.unit_of_work import UnitOfWork
from app.models.technique import Technique
from app.models.template_suggestion import TemplateSuggestion
from app.models.user import User
from app.schemas.template_suggestion import (
TemplateSuggestionApprove,
TemplateSuggestionCreate,
TemplateSuggestionOut,
)
from app.services.audit_service import log_action
from app.services.template_suggestion_service import (
approve_template_suggestion as approve_suggestion_svc,
)
from app.services.template_suggestion_service import (
create_template_suggestion as create_suggestion_svc,
)
from app.services.template_suggestion_service import (
list_pending_template_suggestions,
)
from app.services.template_suggestion_service import (
reject_template_suggestion as reject_suggestion_svc,
)
router = APIRouter(prefix="/template-suggestions", tags=["template-suggestions"])
def _team_for_lead(user: User) -> str | None:
"""Return the single team a non-admin lead is scoped to, or None for admin (both)."""
if user.role == "red_lead":
return "red"
if user.role == "blue_lead":
return "blue"
return None
def _to_out(suggestion: TemplateSuggestion) -> TemplateSuggestionOut:
out = TemplateSuggestionOut.model_validate(suggestion)
if suggestion.submitter:
out.submitter_name = suggestion.submitter.username or suggestion.submitter.email
return out
@router.post("", response_model=TemplateSuggestionOut, status_code=201)
def propose_template(
payload: TemplateSuggestionCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_tech", "blue_tech")),
) -> TemplateSuggestionOut:
"""Propose a new test template. Lands in the review queue, not the live catalog."""
with UnitOfWork(db) as uow:
suggestion = create_suggestion_svc(db, current_user, **payload.model_dump())
log_action(
db, user_id=current_user.id, action="propose_test_template",
entity_type="template_suggestion", entity_id=suggestion.id,
details={"name": suggestion.name, "mitre_technique_id": suggestion.mitre_technique_id},
)
uow.commit()
db.refresh(suggestion)
return _to_out(suggestion)
@router.get("", response_model=list[TemplateSuggestionOut])
def list_suggestions(
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
) -> list[TemplateSuggestionOut]:
"""List pending template suggestions, scoped to the caller's team (admins see both)."""
team = _team_for_lead(current_user)
suggestions = list_pending_template_suggestions(db, team=team)
return [_to_out(s) for s in suggestions]
@router.post("/{suggestion_id}/approve", response_model=TemplateSuggestionOut)
def approve_suggestion(
suggestion_id: uuid.UUID,
payload: TemplateSuggestionApprove | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
) -> TemplateSuggestionOut:
"""Approve a suggestion, optionally editing fields, creating the real template."""
_check_team_permission(current_user, _team_or_404(db, suggestion_id))
overrides = payload.model_dump(exclude_unset=True) if payload else {}
try:
with UnitOfWork(db) as uow:
suggestion, template = approve_suggestion_svc(db, suggestion_id, current_user, overrides)
if template.mitre_technique_id:
technique = (
db.query(Technique)
.filter(Technique.mitre_id == template.mitre_technique_id)
.first()
)
if technique:
technique.review_required = True
log_action(
db, user_id=current_user.id, action="approve_template_suggestion",
entity_type="template_suggestion", entity_id=suggestion.id,
details={"created_template_id": str(template.id), "team": suggestion.team},
)
uow.commit()
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except InvalidOperationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
db.refresh(suggestion)
return _to_out(suggestion)
@router.post("/{suggestion_id}/reject", response_model=TemplateSuggestionOut)
def reject_suggestion(
suggestion_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
) -> TemplateSuggestionOut:
"""Discard a suggestion without creating a template."""
_check_team_permission(current_user, _team_or_404(db, suggestion_id))
try:
with UnitOfWork(db) as uow:
suggestion = reject_suggestion_svc(db, suggestion_id, current_user)
log_action(
db, user_id=current_user.id, action="reject_template_suggestion",
entity_type="template_suggestion", entity_id=suggestion.id,
details={"team": suggestion.team},
)
uow.commit()
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except InvalidOperationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
db.refresh(suggestion)
return _to_out(suggestion)
def _team_or_404(db: Session, suggestion_id: uuid.UUID) -> str:
"""Look up just the team of a suggestion, for a permission check before mutating it."""
suggestion = db.query(TemplateSuggestion).filter(TemplateSuggestion.id == suggestion_id).first()
if suggestion is None:
raise HTTPException(status_code=404, detail="Template suggestion not found")
return suggestion.team
def _check_team_permission(user: User, team: str) -> None:
"""Raise 403 if a non-admin lead tries to act on the other team's suggestion."""
scoped_team = _team_for_lead(user)
if scoped_team is not None and scoped_team != team:
raise HTTPException(status_code=403, detail=f"Not authorized to review {team} team suggestions")
+38 -8
View File
@@ -37,8 +37,8 @@ from sqlalchemy.orm import Session
# Import get_db from app.database
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
# Import get_current_user, require_any_role_strict from app.dependencies.auth
from app.dependencies.auth import get_current_user, require_any_role_strict
# Import UnitOfWork from app.domain.unit_of_work
from app.domain.unit_of_work import UnitOfWork
@@ -58,6 +58,7 @@ from app.services.audit_service import log_action
# Import from app.services.test_template_service
from app.services.test_template_service import (
bulk_activate,
count_templates,
get_template_or_raise,
get_template_stats,
list_templates,
@@ -156,6 +157,35 @@ def _list_templates_handler(
)
# ---------------------------------------------------------------------------
# GET /test-templates/count — total matching a filter, for pagination
# ---------------------------------------------------------------------------
@router.get("/count")
def _count_templates_handler(
source: Optional[str] = Query(None),
platform: Optional[str] = Query(None),
severity: Optional[str] = Query(None),
mitre_technique_id: Optional[str] = Query(None),
search: Optional[str] = Query(None),
is_active: Optional[bool] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
"""Return the total count of templates matching the same filters as the
list endpoint, so the catalog UI can show a true page count."""
return {"total": count_templates(
db,
source=source,
platform=platform,
severity=severity,
mitre_technique_id=mitre_technique_id,
search=search,
is_active=is_active,
)}
# ---------------------------------------------------------------------------
# GET /test-templates/stats — catalog statistics (admin)
# ---------------------------------------------------------------------------
@@ -167,7 +197,7 @@ def template_stats(
# 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")),
) -> dict:
"""Return catalog statistics: active, by_source, by_platform.
@@ -195,7 +225,7 @@ def bulk_activate_templates(
# 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")),
) -> dict:
"""Set all templates to active or inactive.
@@ -317,7 +347,7 @@ def create_template(
# 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")),
) -> TestTemplateOut:
"""Create a custom test template.
@@ -386,7 +416,7 @@ def update_template(
# 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")),
) -> TestTemplateOut:
"""Update fields of an existing test template.
@@ -439,7 +469,7 @@ def toggle_template_active(
# 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")),
) -> TestTemplateOut:
"""Toggle a template between active and inactive (is_active = not is_active).
@@ -491,7 +521,7 @@ def delete_template(
# 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")),
) -> dict:
"""Soft-delete a test template by setting ``is_active=False``.
+298 -107
View File
@@ -41,15 +41,15 @@ from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import get_current_user, require_any_role, require_role from app.dependencies.auth
from app.dependencies.auth import get_current_user, require_any_role, require_role
# Import get_current_user, require_any_role from app.dependencies.auth
from app.dependencies.auth import get_current_user, require_any_role_strict
# Import UnitOfWork from app.domain.unit_of_work
from app.domain.unit_of_work import UnitOfWork
# Import limiter from app.limiter
from app.limiter import limiter
from app.models.enums import TestState, TestResult, TeamSide
from app.models.enums import AttackSuccessResult, TestState, TestResult, TeamSide
from app.models.evidence import Evidence
from app.storage import upload_file
from app.models.technique import Technique
@@ -65,6 +65,7 @@ from app.schemas.test import (
TestClassificationUpdate,
TestCreate,
TestHold,
TestManagerResolveDispute,
TestOut,
TestRedReview,
TestRedUpdate,
@@ -86,12 +87,18 @@ from app.services.webhook_service import dispatch_webhook
from app.services.test_crud_service import (
create_test as crud_create_test,
)
from app.services.test_crud_service import determine_initial_classification
# Import from app.services.test_crud_service
from app.services.test_crud_service import (
create_test_from_template as crud_create_from_template,
)
# Import from app.services.test_crud_service
from app.services.test_crud_service import (
delete_test as crud_delete_test,
)
# Import from app.services.test_crud_service
from app.services.test_crud_service import (
get_test_detail as crud_get_test_detail,
@@ -117,6 +124,11 @@ from app.services.test_crud_service import (
list_tests as crud_list_tests,
)
# Import from app.services.test_crud_service
from app.services.test_crud_service import (
count_tests as crud_count_tests,
)
# Import from app.services.test_crud_service
from app.services.test_crud_service import (
update_test as crud_update_test,
@@ -146,6 +158,7 @@ from app.services.test_workflow_service import (
validate_as_red_lead as wf_validate_red,
validate_as_blue_lead as wf_validate_blue,
resolve_dispute as wf_resolve_dispute,
resolve_dispute_by_manager as wf_resolve_dispute_by_manager,
reopen_test as wf_reopen,
handle_remediation_completed as wf_handle_remediation,
get_retest_chain as wf_get_retest_chain,
@@ -157,58 +170,19 @@ from app.services.test_workflow_service import (
router = APIRouter(prefix="/tests", tags=["tests"])
# ---------------------------------------------------------------------------
# Blind visibility — hide the other team's fields until both reviews pass
# ---------------------------------------------------------------------------
_RED_ONLY_FIELDS = [
"procedure_text", "tool_used", "attack_success",
"execution_start_time", "execution_end_time", "red_summary",
"red_validation_status", "red_validated_by", "red_validated_at", "red_validation_notes",
]
_BLUE_ONLY_FIELDS = [
"detection_result", "containment_result", "detection_time", "containment_time",
"blue_summary", "blue_validation_status", "blue_validated_by", "blue_validated_at",
"blue_validation_notes", "system_gaps",
]
_BLIND_STATES = {"draft", "red_executing", "red_review", "blue_evaluating", "blue_review"}
def _mask_for_team_blindness(test_out: TestOut, *, viewer_role: str) -> TestOut:
"""Null out the other team's fields while the test is still blind.
admin and viewer are never blinded. Once the test reaches in_review or
beyond, both sides see everything (existing cross-validation behavior).
"""
if viewer_role in ("admin", "viewer"):
return test_out
test_state = test_out.state.value if hasattr(test_out.state, "value") else str(test_out.state)
if test_state not in _BLIND_STATES:
return test_out
if viewer_role in ("blue_tech", "blue_lead"):
hide_fields = _RED_ONLY_FIELDS
elif viewer_role in ("red_tech", "red_lead"):
hide_fields = _BLUE_ONLY_FIELDS
else:
return test_out
return test_out.model_copy(update={f: None for f in hide_fields})
# ---------------------------------------------------------------------------
# GET /tests — list with filters
# ---------------------------------------------------------------------------
@router.get("", response_model=list[TestOut])
# Define function list_tests
def list_tests(
def _test_list_filter_params(
# Entry: state
state: Optional[str] = Query(None, description="Filter by test state"),
# Entry: technique_id
technique_id: Optional[uuid.UUID] = Query(None, description="Filter by technique"),
technique_search: Optional[str] = Query(
None, description="Free-text filter on technique MITRE ID or name"
),
# Entry: platform
platform: Optional[str] = Query(None, description="Filter by platform"),
# Entry: created_by
@@ -217,9 +191,59 @@ def list_tests(
pending_validation_side: Optional[str] = Query(
None, description="Filter in_review tests pending validation on 'red' or 'blue' side"
),
reviewer_id: Optional[uuid.UUID] = Query(
None,
description="\"My reviews\" queue — filter red_review/blue_review tests assigned to this reviewer",
),
not_in_any_campaign: bool = Query(
False, description="Only return tests not linked to any campaign"
),
attack_success: Optional[str] = Query(None, description="Filter by attack success outcome"),
detection_result: Optional[str] = Query(None, description="Filter by detection outcome"),
validated_from: Optional[datetime] = Query(
None, description="Only tests validated on/after this date"
),
validated_to: Optional[datetime] = Query(
None, description="Only tests validated on/before this date"
),
) -> dict:
"""Shared filter params for GET /tests and GET /tests/count, so the two
endpoints can never silently drift out of sync with each other."""
return {
"state": state,
"technique_id": technique_id,
"technique_search": technique_search,
"platform": platform,
"created_by": created_by,
"pending_validation_side": pending_validation_side,
"reviewer_id": reviewer_id,
"not_in_any_campaign": not_in_any_campaign,
"attack_success": attack_success,
"detection_result": detection_result,
"validated_from": validated_from,
"validated_to": validated_to,
}
@router.get("/count")
def count_tests(
filters: dict = Depends(_test_list_filter_params),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
"""Return the true total of tests matching the given filters.
GET /tests caps `limit` at 200, so pages that show "N results" need
this to report the real total rather than len(page) — which silently
under-reports whenever there are more matches than the page size.
"""
return {"total": crud_count_tests(db, **filters)}
@router.get("", response_model=list[TestOut])
# Define function list_tests
def list_tests(
filters: dict = Depends(_test_list_filter_params),
offset: int = Query(0, ge=0),
# Entry: limit
limit: int = Query(50, ge=1, le=200),
@@ -231,12 +255,7 @@ def list_tests(
"""Return a paginated list of tests, optionally filtered by state, technique, platform or creator.
Args:
state (Optional[str]): Filter by test state (e.g. ``draft``, ``validated``).
technique_id (Optional[uuid.UUID]): Filter tests belonging to a specific technique.
platform (Optional[str]): Filter by target platform (e.g. ``windows``, ``linux``).
created_by (Optional[uuid.UUID]): Filter by the UUID of the creator.
pending_validation_side (Optional[str]): Filter ``in_review`` tests pending validation
on ``'red'`` or ``'blue'`` side.
filters (dict): Shared filter params — see ``_test_list_filter_params``.
offset (int): Number of records to skip for pagination.
limit (int): Maximum number of records to return.
db (Session): SQLAlchemy database session.
@@ -248,17 +267,7 @@ def list_tests(
# Return crud_list_tests(
return crud_list_tests(
db,
# Keyword argument: state
state=state,
# Keyword argument: technique_id
technique_id=technique_id,
# Keyword argument: platform
platform=platform,
# Keyword argument: created_by
created_by=created_by,
# Keyword argument: pending_validation_side
pending_validation_side=pending_validation_side,
not_in_any_campaign=not_in_any_campaign,
**filters,
offset=offset,
# Keyword argument: limit
limit=limit,
@@ -289,7 +298,7 @@ def create_test(
# 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:
"""Create a new test linked to an existing technique.
@@ -369,7 +378,7 @@ def create_test_from_template(
# 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", "manager")),
) -> TestOut:
"""Instantiate a real Test from an existing TestTemplate.
@@ -400,6 +409,7 @@ def create_test_from_template(
platform_override=payload.platform,
procedure_text_override=payload.procedure_text,
tool_used_override=payload.tool_used,
detect_procedure_override=payload.detect_procedure,
)
# Call log_action()
log_action(
@@ -462,12 +472,11 @@ def get_test(
Returns:
TestOut: Full test detail including split red/blue evidence lists.
Fields belonging to the other team are nulled out while the
test is blind (see :func:`_mask_for_team_blindness`).
Both teams see each other's fields regardless of state — Red
and Blue are meant to see each other's work, not test blind.
"""
test = crud_get_test_detail(db, test_id)
test_out = TestOut.model_validate(test)
return _mask_for_team_blindness(test_out, viewer_role=current_user.role)
return TestOut.model_validate(test)
# ---------------------------------------------------------------------------
@@ -485,11 +494,12 @@ def update_test(
# 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:
"""Update one or more fields of an existing test.
Only leads or admins can update general test fields.
Only leads can update general test fields — admin administers the
site, not test content.
The test must be in ``draft`` or ``rejected`` state.
Args:
@@ -538,6 +548,38 @@ def update_test(
return test
@router.delete("/{test_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_test(
test_id: uuid.UUID,
db: Session = Depends(get_db),
# manager-only, and admin does NOT get a free pass — this is a
# queue-cleanup action for the manager role specifically, not a site
# administration task.
current_user: User = Depends(require_any_role_strict("manager")),
) -> None:
"""Delete a standalone test that hasn't started yet.
Only tests still in ``draft`` state and not linked to any campaign can
be deleted this way.
Args:
test_id (uuid.UUID): Primary key of the test to delete.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated manager performing the deletion.
"""
with UnitOfWork(db) as uow:
crud_delete_test(db, test_id)
log_action(
db,
user_id=current_user.id,
action="delete_test",
entity_type="test",
entity_id=test_id,
details={},
)
uow.commit()
# ---------------------------------------------------------------------------
# PATCH /tests/{id}/classification — admin data classification
# ---------------------------------------------------------------------------
@@ -553,9 +595,14 @@ def update_test_classification(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_role("admin")),
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 (admin only).
"""Update the data classification label for a test.
The initial classification is a best-effort default based on the
technique's tactic — any test participant or admin can correct it.
Args:
test_id (uuid.UUID): Primary key of the test to classify.
@@ -611,7 +658,7 @@ def update_test_red(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_tech", "red_lead")),
current_user: User = Depends(require_any_role_strict("red_tech", "red_lead")),
) -> TestOut:
"""Red Team updates their fields (allowed in ``draft`` and ``red_executing``).
@@ -624,11 +671,13 @@ def update_test_red(
Returns:
TestOut: The updated test with refreshed red-team field values.
"""
# Assignee lock: red_tech cannot work a test assigned to someone else
# Assignee lock: only the operator actually assigned to this test may
# edit it — applies to red_lead too, not just red_tech, since a lead
# editing another operator's in-progress test is exactly as wrong as
# a different tech doing it.
_pre_test = crud_get_test_or_raise(db, test_id)
if (
_pre_test.red_tech_assignee is not None
and current_user.role == "red_tech"
and _pre_test.red_tech_assignee != current_user.id
):
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
@@ -677,7 +726,7 @@ def update_test_blue(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
current_user: User = Depends(require_any_role_strict("blue_tech", "blue_lead")),
) -> TestOut:
"""Blue Team updates their fields (allowed only in ``blue_evaluating``).
@@ -690,11 +739,11 @@ def update_test_blue(
Returns:
TestOut: The updated test with refreshed blue-team field values.
"""
# Assignee lock: blue_tech cannot work a test assigned to someone else
# Assignee lock: only the operator actually assigned to this test may
# edit it — applies to blue_lead too, not just blue_tech.
_pre_test = crud_get_test_or_raise(db, test_id)
if (
_pre_test.blue_tech_assignee is not None
and current_user.role == "blue_tech"
and _pre_test.blue_tech_assignee != current_user.id
):
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
@@ -741,7 +790,7 @@ def start_execution(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_tech", "red_lead")),
current_user: User = Depends(require_any_role_strict("red_tech", "red_lead")),
) -> TestOut:
"""Move a test from ``draft`` to ``red_executing``.
@@ -792,7 +841,7 @@ def submit_red(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_tech", "red_lead")),
current_user: User = Depends(require_any_role_strict("red_tech", "red_lead")),
) -> TestOut:
"""Red Team finalises — move from ``red_executing`` to ``blue_evaluating``.
@@ -840,7 +889,7 @@ def submit_blue(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
current_user: User = Depends(require_any_role_strict("blue_tech", "blue_lead")),
) -> TestOut:
"""Blue Team finalises — move from ``blue_evaluating`` to ``in_review``.
@@ -884,7 +933,7 @@ def submit_blue(
def start_blue_work(
test_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
current_user: User = Depends(require_any_role_strict("blue_tech", "blue_lead")),
):
"""Blue tech picks up the test to start evaluating. Sets the Tempo timer start."""
test = crud_get_test_or_raise(db, test_id)
@@ -917,12 +966,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:
@@ -947,12 +996,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:
@@ -982,7 +1031,7 @@ def pause_timer(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead")),
current_user: User = Depends(require_any_role_strict("red_tech", "blue_tech", "red_lead", "blue_lead")),
) -> TestOut:
"""Pause the running timer for the current phase (red_executing or blue_evaluating).
@@ -1021,7 +1070,7 @@ def resume_timer(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead")),
current_user: User = Depends(require_any_role_strict("red_tech", "blue_tech", "red_lead", "blue_lead")),
) -> TestOut:
"""Resume the paused timer for the current phase.
@@ -1062,7 +1111,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.
@@ -1119,7 +1168,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.
@@ -1171,7 +1220,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)
@@ -1182,6 +1231,78 @@ def resolve_dispute(
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/escalate-to-manager — disputed: notify managers to step in
# ---------------------------------------------------------------------------
@router.post("/{test_id}/escalate-to-manager")
def escalate_to_manager(
test_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
):
"""Either lead on a disputed test can escalate it for a manager to decide.
Unlike the automatic manager notification sent when a test first becomes
disputed, this is an explicit, repeatable nudge — a lead pulls this when
peer discussion (Request Discussion) isn't going anywhere.
"""
from app.services.notification_service import notify_role_with_email
test = crud_get_test_or_raise(db, test_id)
if test.state.value != "disputed":
from app.domain.errors import BusinessRuleViolation
raise BusinessRuleViolation("Test is not in disputed state")
try:
notify_role_with_email(
db,
role="manager",
type="validation_disputed",
title="Dispute escalated — needs your decision",
message=(
f'{current_user.username} escalated test "{test.name}" — the leads could not '
f'resolve their disagreement. Please review and decide the final outcome.'
),
entity_type="test",
entity_id=test.id,
)
except Exception as e:
import logging
logging.getLogger(__name__).warning("Failed to notify managers of escalation: %s", e)
log_action(
db, user_id=current_user.id, action="escalate_dispute_to_manager",
entity_type="test", entity_id=test.id, details={"test_name": test.name},
)
db.commit()
return {"status": "escalated", "message": "Managers have been notified"}
# ---------------------------------------------------------------------------
# POST /tests/{id}/manager-resolve-dispute — manager makes the final call
# ---------------------------------------------------------------------------
@router.post("/{test_id}/manager-resolve-dispute", response_model=TestOut)
def manager_resolve_dispute(
test_id: uuid.UUID,
payload: TestManagerResolveDispute,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("manager")),
) -> TestOut:
"""A manager decides the final outcome of a disputed test directly."""
test = crud_get_test_with_technique(db, test_id)
with UnitOfWork(db) as uow:
test = wf_resolve_dispute_by_manager(db, test, current_user, payload.outcome, notes=payload.notes)
uow.commit()
db.refresh(test)
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/reopen — rejected → draft
# ---------------------------------------------------------------------------
@@ -1195,7 +1316,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``.
@@ -1222,7 +1343,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)
# ---------------------------------------------------------------------------
@@ -1231,35 +1352,68 @@ 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/blue tech operators and/or reviewers to a test. Leads/managers only — not admin, who administers the site rather than coordinating people."""
test = crud_get_test_or_raise(db, test_id)
newly_assigned: list[User] = []
if payload.red_tech_assignee is not None:
u = db.query(User).filter(User.id == payload.red_tech_assignee).first()
if not u or u.role not in ("red_tech", "red_lead", "admin"):
if not u or u.role not in ("red_tech", "red_lead"):
raise HTTPException(status_code=400, detail="Invalid red tech assignee")
test.red_tech_assignee = payload.red_tech_assignee
newly_assigned.append(u)
if payload.blue_tech_assignee is not None:
u = db.query(User).filter(User.id == payload.blue_tech_assignee).first()
if not u or u.role not in ("blue_tech", "blue_lead", "admin"):
if not u or u.role not in ("blue_tech", "blue_lead"):
raise HTTPException(status_code=400, detail="Invalid blue tech assignee")
test.blue_tech_assignee = payload.blue_tech_assignee
newly_assigned.append(u)
if payload.red_reviewer_assignee is not None:
u = db.query(User).filter(User.id == payload.red_reviewer_assignee).first()
if not u or u.role != "red_lead":
raise HTTPException(status_code=400, detail="Invalid red reviewer — must be a red_lead")
test.red_reviewer_assignee = payload.red_reviewer_assignee
newly_assigned.append(u)
if payload.blue_reviewer_assignee is not None:
u = db.query(User).filter(User.id == payload.blue_reviewer_assignee).first()
if not u or u.role != "blue_lead":
raise HTTPException(status_code=400, detail="Invalid blue reviewer — must be a blue_lead")
test.blue_reviewer_assignee = payload.blue_reviewer_assignee
newly_assigned.append(u)
# Handle intentional null (clearing) — model_fields_set tracks which keys were sent
if "red_tech_assignee" in payload.model_fields_set and payload.red_tech_assignee is None:
test.red_tech_assignee = None
if "blue_tech_assignee" in payload.model_fields_set and payload.blue_tech_assignee is None:
test.blue_tech_assignee = None
if "red_reviewer_assignee" in payload.model_fields_set and payload.red_reviewer_assignee is None:
test.red_reviewer_assignee = None
if "blue_reviewer_assignee" in payload.model_fields_set and payload.blue_reviewer_assignee is None:
test.blue_reviewer_assignee = None
log_action(db, current_user.id, "assign_test", str(test_id), {
"red_tech_assignee": str(payload.red_tech_assignee) if payload.red_tech_assignee else None,
"blue_tech_assignee": str(payload.blue_tech_assignee) if payload.blue_tech_assignee else None,
"red_reviewer_assignee": str(payload.red_reviewer_assignee) if payload.red_reviewer_assignee else None,
"blue_reviewer_assignee": str(payload.blue_reviewer_assignee) if payload.blue_reviewer_assignee else None,
})
db.commit()
db.refresh(test)
if newly_assigned:
from app.services.jira_service import push_assignee_update
for assignee in newly_assigned:
try:
push_assignee_update(db, test, assignee)
db.commit()
except Exception: # nosec B110
pass # jira_service already logs warnings internally
return test
@@ -1268,12 +1422,27 @@ def assign_test_operators(
# ---------------------------------------------------------------------------
def _check_hold_permission(current_user: User, test) -> None:
"""Only whichever team currently owns the test may hold/resume it.
``require_any_role_strict("red_tech", "blue_tech")`` alone lets either
role through regardless of phase — a red_tech could otherwise hold (or
resume) a test that has already moved into blue_evaluating.
"""
expected_role = "blue_tech" if test.state == TestState.blue_evaluating else "red_tech"
if current_user.role != expected_role:
raise HTTPException(
status_code=403,
detail=f"Only {expected_role.replace('_', ' ')} can do this while the test is in '{test.state}'.",
)
@router.post("/{test_id}/hold", response_model=TestOut)
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
@@ -1288,6 +1457,8 @@ def hold_test(
detail=f"Cannot hold a test in state '{test.state}'. Only pre-validation states can be held.",
)
_check_hold_permission(current_user, test)
if test.is_on_hold:
raise HTTPException(status_code=400, detail="Test is already on hold")
@@ -1295,6 +1466,10 @@ def hold_test(
test.hold_reason = payload.reason
test.held_at = _dt.utcnow()
# A running phase timer must stop counting while on hold.
if test.state in (TestState.red_executing, TestState.blue_evaluating) and test.paused_at is None:
test.paused_at = test.held_at
log_action(db, current_user.id, "hold_test", str(test_id), {"reason": payload.reason})
db.commit()
db.refresh(test)
@@ -1313,9 +1488,10 @@ 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 datetime import datetime as _dt
from app.services.jira_service import push_hold_event
test = crud_get_test_or_raise(db, test_id)
@@ -1323,10 +1499,22 @@ def resume_test(
if not test.is_on_hold:
raise HTTPException(status_code=400, detail="Test is not on hold")
_check_hold_permission(current_user, test)
test.is_on_hold = False
test.hold_reason = None
test.held_at = None
# Resume the phase timer that hold_test paused, accumulating the held
# duration the same way pause/resume-timer does.
if test.paused_at is not None:
held_seconds = max(int((_dt.utcnow() - test.paused_at).total_seconds()), 0)
if test.state == TestState.red_executing:
test.red_paused_seconds = (test.red_paused_seconds or 0) + held_seconds
elif test.state == TestState.blue_evaluating:
test.blue_paused_seconds = (test.blue_paused_seconds or 0) + held_seconds
test.paused_at = None
log_action(db, current_user.id, "resume_test", str(test_id), {})
db.commit()
db.refresh(test)
@@ -1351,7 +1539,7 @@ def update_remediation(
# 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:
"""Update remediation fields on a test.
@@ -1585,7 +1773,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.
@@ -1604,12 +1792,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"
@@ -1626,6 +1814,7 @@ def request_discussion(
if rejector_id else None
)
rejector_name = rejector.username if rejector else rejector_label
rejector_full_name = getattr(rejector, "full_name", None) if rejector else None
rejector_email = getattr(rejector, "email", None) if rejector else None
# Notify the rejecting lead
@@ -1664,6 +1853,7 @@ def request_discussion(
"status": "notification_sent",
"message": f"Discussion request sent to {rejector_name}",
"rejector_username": rejector_name,
"rejector_full_name": rejector_full_name,
"rejector_email": rejector_email,
"rejector_role": rejector_label,
}
@@ -1687,7 +1877,7 @@ class RTEvidenceEntry(BaseModel):
class RTTechniqueEntry(BaseModel):
mitre_id: str
result: str # "detected" | "not_detected" | "partially_detected"
attack_success: bool = True
attack_success: AttackSuccessResult = AttackSuccessResult.successful
platform: Optional[str] = None
notes: Optional[str] = None
evidence: list[RTEvidenceEntry] # REQUIRED — at least one image per technique
@@ -1705,13 +1895,13 @@ class RTImportPayload(BaseModel):
def import_rt(
payload: RTImportPayload,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead")),
current_user: User = Depends(require_any_role_strict("red_lead")),
):
"""Import results from a real Red Team engagement.
Creates one Test record per technique in ``validated`` state (bypassing
the normal Red/Blue workflow) and immediately recalculates coverage metrics.
Requires ``red_lead`` or ``admin`` role.
Requires ``red_lead`` — admin administers the site, not test content.
"""
# Pre-validate: every technique must include at least one evidence image
for entry in payload.techniques:
@@ -1787,6 +1977,7 @@ def import_rt(
# validation_status pending so Blue Lead must confirm
detection_result=detection_result,
blue_validation_status=None,
data_classification=determine_initial_classification(technique),
# Timing
execution_date=exec_date_str,
created_at=datetime.utcnow(),
+183 -13
View File
@@ -1,5 +1,8 @@
"""User management router (admin only)."""
# Import logging
import logging
# Import uuid
import uuid
@@ -13,28 +16,42 @@ from sqlalchemy.orm import Session
from app.database import get_db
# Import require_role from app.dependencies.auth
from app.dependencies.auth import require_role
from app.dependencies.auth import require_role, require_any_role_strict
# Import UnitOfWork from app.domain.unit_of_work
from app.domain.unit_of_work import UnitOfWork
from app.domain.errors import BusinessRuleViolation
# Import User from app.models.user
from app.models.user import User
from app.dependencies.auth import get_current_user
from app.schemas.user import UserCreate, UserUpdate, UserOut, UserPreferencesUpdate
from app.schemas.user import (
UserCreate,
UserUpdate,
UserOut,
UserPreferencesUpdate,
UserOperatorOut,
SendPasswordEmailOut,
SwitchRoleRequest,
)
from app.services.audit_service import log_action
# Import from app.services.user_service
from app.services.user_service import (
create_user,
create_user_without_password,
delete_user,
get_user_or_raise,
list_users,
switch_active_role,
update_user,
)
from app.services.password_setup_service import send_password_setup_email
# Assign router = APIRouter(prefix="/users", tags=["users"])
router = APIRouter(prefix="/users", tags=["users"])
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# PATCH /users/me/preferences — update current user preferences
@@ -77,6 +94,39 @@ def get_me(
return current_user
# ---------------------------------------------------------------------------
# POST /users/me/switch-role — swap the caller's active role
# ---------------------------------------------------------------------------
@router.post("/me/switch-role", response_model=UserOut)
def switch_my_role(
payload: SwitchRoleRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> UserOut:
"""Switch the caller's active role to one of their granted extra_roles.
Roles never mix — this changes which single role is active, it never
grants permissions from more than one role at once. Takes effect
immediately since every permission check reads ``role`` fresh from the
DB on each request.
"""
with UnitOfWork(db) as uow:
user = switch_active_role(db, current_user, payload.role)
log_action(
db,
user_id=current_user.id,
action="switch_role",
entity_type="user",
entity_id=current_user.id,
details={"new_role": payload.role},
)
uow.commit()
db.refresh(user)
return user
# ---------------------------------------------------------------------------
# GET /users — list all users
# ---------------------------------------------------------------------------
@@ -110,19 +160,17 @@ def create_user_route(
# Entry: current_user
current_user: User = Depends(require_role("admin")),
) -> UserOut:
"""Create a new user. **Requires admin role.**."""
"""Create a new user. **Requires admin role.**.
No password is set — use ``POST /users/{id}/send-password-email``
afterward to let the user set their own via a one-time link.
"""
# Open context manager
with UnitOfWork(db) as uow:
# Assign user = create_user(
user = create_user(
user = create_user_without_password(
db,
# Keyword argument: username
username=payload.username,
# Keyword argument: email
full_name=payload.full_name,
email=payload.email,
# Keyword argument: password
password=payload.password,
# Keyword argument: role
role=payload.role,
)
# Call log_action()
@@ -137,17 +185,68 @@ def create_user_route(
# Keyword argument: entity_id
entity_id=user.id,
# Keyword argument: details
details={"username": user.username, "role": user.role},
details={"full_name": user.full_name, "email": user.email, "role": user.role},
)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(user)
# Send the set-password email right away — best-effort. If the webhook
# isn't configured or rejects the request, the user is still created
# successfully; the admin can retry via the "Send Email" button, which
# surfaces the failure properly (unlike this automatic first attempt).
try:
with UnitOfWork(db) as uow:
send_password_setup_email(db, user)
uow.commit()
except Exception:
logger.warning("Automatic set-password email failed for new user %s", user.id, exc_info=True)
from app.services.notification_service import notify_roles_by_email
notify_roles_by_email(
db, roles=["admin"],
preference_key="email_on_new_users",
subject=f"New User Created: {user.full_name or user.email}",
message=(
f'A new user account was created: {user.full_name or user.email} '
f"({user.email}), role: {user.role}."
),
exclude_user_id=current_user.id,
)
# Return user
return user
# ---------------------------------------------------------------------------
# GET /users/operators — minimal operator/lead list for assignment pickers
# ---------------------------------------------------------------------------
@router.get("/operators", response_model=list[UserOperatorOut])
def list_operators_route(
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("manager", "red_lead", "blue_lead")),
) -> list[UserOperatorOut]:
"""Return active red/blue operators and leads, for lead/manager assignment pickers.
Not reachable by admin — admin administers the site, leads/managers
coordinate operators, and admin cannot itself be assigned as an
operator either. Returns only id/username/role — no emails or
tokens — since this is reachable by non-admin leads.
"""
return (
db.query(User)
.filter(
User.role.in_(["red_tech", "red_lead", "blue_tech", "blue_lead"]),
User.is_active.is_(True),
)
.order_by(User.username)
.all()
)
# ---------------------------------------------------------------------------
# GET /users/{id} — get a single user
# ---------------------------------------------------------------------------
@@ -188,6 +287,16 @@ def update_user_route(
"""Update one or more fields of an existing user. **Requires admin role.**."""
# Assign update_data = payload.model_dump(exclude_unset=True)
update_data = payload.model_dump(exclude_unset=True)
# An admin can never strip their own admin permission — otherwise a
# careless self-edit (or a compromised session) could lock every admin
# out of the platform with no way back in.
if user_id == current_user.id and current_user.role == "admin":
final_role = update_data.get("role", current_user.role)
final_extra_roles = update_data.get("extra_roles", current_user.extra_roles or [])
if "admin" not in {final_role, *(final_extra_roles or [])}:
raise BusinessRuleViolation("You cannot remove your own admin permission.")
# Open context manager
with UnitOfWork(db) as uow:
# Assign user = update_user(db, user_id, **update_data)
@@ -213,3 +322,64 @@ def update_user_route(
# Return user
return user
# ---------------------------------------------------------------------------
# DELETE /users/{id} — permanently delete a user
# ---------------------------------------------------------------------------
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user_route(
user_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
) -> None:
"""Permanently delete a user. **Requires admin role.**.
Only users with no activity footprint (no tests, evidence, worklogs,
audit entries, etc.) can be hard-deleted — anyone else must be
deactivated instead, to preserve the audit trail.
"""
with UnitOfWork(db) as uow:
delete_user(db, user_id, current_user.id)
log_action(
db,
user_id=current_user.id,
action="delete_user",
entity_type="user",
entity_id=user_id,
details={},
)
uow.commit()
# ---------------------------------------------------------------------------
# POST /users/{id}/send-password-email — set-password / reset link
# ---------------------------------------------------------------------------
@router.post("/{user_id}/send-password-email", response_model=SendPasswordEmailOut)
def send_password_email_route(
user_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
) -> SendPasswordEmailOut:
"""Email a one-time set-password link to a user. **Requires admin role.**.
Works for both a freshly-created passwordless user and an existing
one whose password needs resetting — same link, same flow either way.
"""
user = get_user_or_raise(db, user_id)
with UnitOfWork(db) as uow:
send_password_setup_email(db, user)
log_action(
db,
user_id=current_user.id,
action="send_password_setup_email",
entity_type="user",
entity_id=user.id,
details={},
)
uow.commit()
return SendPasswordEmailOut(detail=f"Password setup email sent to {user.email}")
+1
View File
@@ -21,6 +21,7 @@ class AuditLogOut(BaseModel):
user_id: uuid.UUID | None = None
# Assign username = None # Populated from user relationship
username: str | None = None # Populated from user relationship
full_name: str | None = None # Populated from user relationship
# action: str
action: str
# Assign entity_type = None
+2
View File
@@ -39,10 +39,12 @@ class UserOut(BaseModel):
id: uuid.UUID
# username: str
username: str
full_name: str | None = None
# Assign email = None
email: str | None = None
# role: str
role: str
extra_roles: list[str] = []
# is_active: bool
is_active: bool
# Assign must_change_password = True
@@ -0,0 +1,28 @@
"""Pydantic schemas for procedure-suggestion review endpoints."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class ProcedureSuggestionOut(BaseModel):
"""A proposed template procedure update, pending or reviewed."""
id: uuid.UUID
template_id: uuid.UUID
team: str
suggested_text: str
source_test_id: uuid.UUID | None = None
submitted_by: uuid.UUID | None = None
status: str
reviewed_by: uuid.UUID | None = None
reviewed_at: datetime | None = None
created_at: datetime | None = None
# Populated for display — the template name and its current suggested
# value, so a lead can compare without a second lookup.
template_name: str | None = None
template_current_text: str | None = None
model_config = ConfigDict(from_attributes=True)
@@ -0,0 +1,72 @@
"""Pydantic schemas for the TemplateSuggestion (operator template proposal) workflow."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class TemplateSuggestionCreate(BaseModel):
"""Payload for an operator proposing a new test template."""
mitre_technique_id: str
name: str
description: str | None = None
source: str = "custom"
source_url: str | None = None
attack_procedure: str | None = None
expected_detection: str | None = None
platform: str | None = None
tool_suggested: str | None = None
severity: str | None = None
atomic_test_id: str | None = None
suggested_remediation: str | None = None
class TemplateSuggestionApprove(BaseModel):
"""Optional field overrides a lead can apply while approving a suggestion.
Any field left unset falls back to what the operator originally
submitted — the lead only needs to pass the fields they want to change.
"""
mitre_technique_id: str | None = None
name: str | None = None
description: str | None = None
source: str | None = None
source_url: str | None = None
attack_procedure: str | None = None
expected_detection: str | None = None
platform: str | None = None
tool_suggested: str | None = None
severity: str | None = None
atomic_test_id: str | None = None
suggested_remediation: str | None = None
class TemplateSuggestionOut(BaseModel):
"""Full representation of a pending/reviewed template suggestion."""
id: uuid.UUID
mitre_technique_id: str
name: str
description: str | None = None
source: str
source_url: str | None = None
attack_procedure: str | None = None
expected_detection: str | None = None
platform: str | None = None
tool_suggested: str | None = None
severity: str | None = None
atomic_test_id: str | None = None
suggested_remediation: str | None = None
team: str
submitted_by: uuid.UUID | None = None
submitter_name: str | None = None
status: str
reviewed_by: uuid.UUID | None = None
reviewed_at: datetime | None = None
created_template_id: uuid.UUID | None = None
created_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
+107 -9
View File
@@ -4,15 +4,29 @@
import uuid
# Import datetime from datetime
from datetime import datetime
from datetime import datetime, timezone
from pydantic import BaseModel, ConfigDict, model_validator
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
# Import DataClassification from app.domain.enums
from app.domain.enums import ContainmentResult, DataClassification
from app.domain.enums import AttackSuccessResult, ContainmentResult, DataClassification
from app.models.enums import TestResult, TestState
from app.schemas.evidence import EvidenceOut
def _to_naive_utc(v: datetime | None) -> datetime | None:
"""Normalize an incoming datetime to naive UTC.
Every timestamp column in this app is naive-UTC (set via
``datetime.utcnow()`` server-side). Clients are expected to send a
proper UTC ISO string (with a ``Z``/offset), but if one slips through
tz-aware, convert it rather than let it get silently mis-stored as if
its clock digits were already UTC.
"""
if v is not None and v.tzinfo is not None:
return v.astimezone(timezone.utc).replace(tzinfo=None)
return v
# ── Create ──────────────────────────────────────────────────────────
@@ -37,7 +51,7 @@ class TestCreate(BaseModel):
class TestClassificationUpdate(BaseModel):
"""Admin-only payload for changing data classification."""
"""Payload for changing a test's data classification (any test participant or admin)."""
# data_classification: DataClassification
data_classification: DataClassification
@@ -79,12 +93,14 @@ class TestRedUpdate(BaseModel):
# Assign tool_used = None
tool_used: str | None = None
# Assign attack_success = None
attack_success: bool | None = None
attack_success: AttackSuccessResult | None = None
# Assign red_summary = None
red_summary: str | None = None
execution_start_time: datetime | None = None
execution_end_time: datetime | None = None
_normalize_datetimes = field_validator("execution_start_time", "execution_end_time")(_to_naive_utc)
# ── Blue Team update ───────────────────────────────────────────────
@@ -99,6 +115,40 @@ class TestBlueUpdate(BaseModel):
containment_time: datetime | None = None
# Assign blue_summary = None
blue_summary: str | None = None
detect_procedure: str | None = None
_normalize_datetimes = field_validator("detection_time", "containment_time")(_to_naive_utc)
# ── Round history (archived on reopen) ─────────────────────────────
class TestRoundHistoryOut(BaseModel):
"""One archived round snapshot, taken right before a reopen resets the live fields."""
id: uuid.UUID
team: str
round_number: int
started_at: datetime | None = None
ended_at: datetime | None = None
paused_seconds: int = 0
procedure_text: str | None = None
tool_used: str | None = None
attack_success: AttackSuccessResult | None = None
red_summary: str | None = None
execution_start_time: datetime | None = None
execution_end_time: datetime | None = None
detection_result: TestResult | None = None
containment_result: ContainmentResult | None = None
detection_time: datetime | None = None
containment_time: datetime | None = None
blue_summary: str | None = None
detect_procedure: str | None = None
review_notes: str | None = None
reviewed_by: uuid.UUID | None = None
archived_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
# ── Red Lead validation ────────────────────────────────────────────
@@ -135,6 +185,13 @@ class TestResolveDispute(BaseModel):
notes: str | None = None
class TestManagerResolveDispute(BaseModel):
"""Payload sent by a manager making the final call on a disputed test."""
outcome: str # "validated" | "rejected"
notes: str | None = None
# ── Red Lead review gate (pre-Blue-Team) ────────────────────────────
@@ -171,10 +228,12 @@ class TestRemediationUpdate(BaseModel):
class TestAssign(BaseModel):
"""Payload for assigning operators to a test."""
"""Payload for assigning operators or reviewers to a test."""
red_tech_assignee: uuid.UUID | None = None
blue_tech_assignee: uuid.UUID | None = None
red_reviewer_assignee: uuid.UUID | None = None
blue_reviewer_assignee: uuid.UUID | None = None
class TestHold(BaseModel):
@@ -219,6 +278,7 @@ class TestOut(BaseModel):
execution_date: datetime | None = None
# Assign created_by = None
created_by: uuid.UUID | None = None
source_template_id: uuid.UUID | None = None
# Assign result = None
result: TestResult | None = None
# Assign state = TestState.draft
@@ -229,7 +289,7 @@ class TestOut(BaseModel):
# Red Team fields
red_summary: str | None = None
# Assign attack_success = None
attack_success: bool | None = None
attack_success: AttackSuccessResult | None = None
execution_start_time: datetime | None = None
execution_end_time: datetime | None = None
# Assign red_validated_by = None
@@ -243,6 +303,7 @@ class TestOut(BaseModel):
# Blue Team fields
blue_summary: str | None = None
detect_procedure: str | None = None
# Assign detection_result = None
detection_result: TestResult | None = None
containment_result: ContainmentResult | None = None
@@ -267,6 +328,8 @@ class TestOut(BaseModel):
red_paused_seconds: int = 0
# Assign blue_paused_seconds = 0
blue_paused_seconds: int = 0
red_round_number: int = 1
blue_round_number: int = 1
# Remediation fields
remediation_steps: str | None = None
@@ -278,6 +341,12 @@ class TestOut(BaseModel):
# Assignment fields
red_tech_assignee: uuid.UUID | None = None
blue_tech_assignee: uuid.UUID | None = None
# Resolved usernames — the operator (or lead) viewing a test they're not
# a lead/manager on can't call GET /users/operators (403), so they have
# no other way to resolve who an assignee ID actually is. Populated
# from the ORM relationship, same pattern as technique_name below.
red_tech_assignee_username: str | None = None
blue_tech_assignee_username: str | None = None
# Review assignment fields
red_reviewer_assignee: uuid.UUID | None = None
@@ -288,6 +357,8 @@ class TestOut(BaseModel):
blue_review_by: uuid.UUID | None = None
blue_review_at: datetime | None = None
blue_review_notes: str | None = None
red_reviewer_assignee_username: str | None = None
blue_reviewer_assignee_username: str | None = None
system_gaps: str | None = None
# On-hold fields
@@ -299,8 +370,8 @@ class TestOut(BaseModel):
retest_of: uuid.UUID | None = None
# Assign retest_count = 0
retest_count: int = 0
# Assign data_classification = "internal"
data_classification: str = "internal"
# Assign data_classification = "internal_use_only"
data_classification: str = "internal_use_only"
# Technique info (populated when joined)
technique_mitre_id: str | None = None
@@ -311,6 +382,9 @@ class TestOut(BaseModel):
red_evidences: list[EvidenceOut] = []
blue_evidences: list[EvidenceOut] = []
# Archived rounds — full history, oldest first (populated from the ORM relationship)
round_history: list[TestRoundHistoryOut] = []
model_config = ConfigDict(from_attributes=True)
@model_validator(mode="before")
@@ -343,6 +417,23 @@ class TestOut(BaseModel):
except Exception: # nosec B110
pass # DetachedInstanceError or similar — leave technique fields None
# Resolved assignee usernames (lazy-load, same as technique above).
# A plain operator/tech viewing their own test can't call
# GET /users/operators (lead/manager-only) to resolve an assignee ID
# into a name, so the API needs to hand it over pre-resolved.
for attr, field in (
("red_tech_assigned_user", "red_tech_assignee_username"),
("blue_tech_assigned_user", "blue_tech_assignee_username"),
("red_reviewer", "red_reviewer_assignee_username"),
("blue_reviewer", "blue_reviewer_assignee_username"),
):
try:
user = getattr(obj, attr, None)
if user is not None:
obj.__dict__[field] = user.username
except Exception: # nosec B110
pass # DetachedInstanceError or similar — leave username None
# Only split evidences when they are already in memory (loaded via joinedload)
raw_evs = obj.__dict__.get("evidences")
if raw_evs is not None:
@@ -367,4 +458,11 @@ class TestOut(BaseModel):
obj.__dict__["red_evidences"] = red_evs
obj.__dict__["blue_evidences"] = blue_evs
# Only populate round history when already loaded (via joinedload)
raw_rounds = obj.__dict__.get("round_history")
if raw_rounds is not None:
obj.__dict__["round_history"] = [
TestRoundHistoryOut.model_validate(r) for r in raw_rounds
]
return obj
+4
View File
@@ -102,6 +102,9 @@ class TestTemplateSummary(BaseModel):
severity: str | None = None
# Assign is_active = True
is_active: bool = True
# Number of existing Test rows for this template's technique — lets the
# catalog UI warn before creating a likely-duplicate test.
existing_test_count: int = 0
# Assign model_config = ConfigDict(from_attributes=True)
model_config = ConfigDict(from_attributes=True)
@@ -127,3 +130,4 @@ class TestTemplateInstantiate(BaseModel):
platform: str | None = None
procedure_text: str | None = None
tool_used: str | None = None
detect_procedure: str | None = None
+95 -33
View File
@@ -112,49 +112,57 @@ def _validate_password_strength(password: str) -> str:
# ── Create ──────────────────────────────────────────────────────────
class UserCreate(BaseModel):
"""Payload for creating a new user."""
"""Payload for creating a new user.
# username: str
username: str
# Assign email = None
email: str | None = None
# password: str
password: str
No password is set here — the admin creates the user with just a
name/email/role, then uses "Send Email" to let the user set their own
password via a one-time link. ``username`` is no longer admin-supplied;
the service layer derives it from ``email`` since it's still needed
internally as the login identifier, but it is never shown in the UI.
"""
full_name: str
email: str
# Assign role = "viewer"
role: str = "viewer"
# Apply the @field_validator decorator
@field_validator("username")
# Apply the @classmethod decorator
@field_validator("full_name")
@classmethod
def full_name_not_blank(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("Full name is required")
return v
@field_validator("email")
@classmethod
def email_format(cls, v: str) -> str:
v = v.strip()
if "@" not in v or v.startswith("@") or v.endswith("@"):
raise ValueError("A valid email address is required")
return v
# ── Legacy direct-password creation (hidden from the UI, kept for a
# possible future revert — see UserCreate above for the active path) ──
class LegacyUserCreateWithPassword(BaseModel):
"""The old admin-sets-the-password creation payload. Not wired to any
active endpoint; retained only so this path can be restored quickly."""
username: str
email: str | None = None
password: str
role: str = "viewer"
@field_validator("username")
@classmethod
# Define function username_format
def username_format(cls, v: str) -> str:
"""Validate the username field against the platform policy.
Args:
v (str): Raw username value from the request body.
Returns:
str: The validated username.
"""
# Return _validate_username(v)
return _validate_username(v)
# Apply the @field_validator decorator
@field_validator("password")
# Apply the @classmethod decorator
@classmethod
# Define function password_strength
def password_strength(cls, v: str) -> str:
"""Validate the password field against the complexity policy.
Args:
v (str): Raw password value from the request body.
Returns:
str: The validated password.
"""
# Return _validate_password_strength(v)
return _validate_password_strength(v)
@@ -163,13 +171,19 @@ class UserCreate(BaseModel):
class UserUpdate(BaseModel):
"""Payload for partially updating an existing user.
Every field is optional so callers send only what changed.
Every field is optional so callers send only what changed. ``password``
is intentionally still supported server-side (see
LegacyUserCreateWithPassword) but the current UI never sends it —
password changes go through the email-a-set-password-link flow instead.
"""
# Assign email = None
email: str | None = None
full_name: str | None = None
# Assign role = None
role: str | None = None
# Other roles this user can switch into via the top-bar switcher.
extra_roles: list[str] | None = None
# Assign is_active = None
is_active: bool | None = None
# Assign password = None
@@ -248,10 +262,12 @@ class UserOut(BaseModel):
id: uuid.UUID
# username: str
username: str
full_name: str | None = None
# Assign email = None
email: str | None = None
# role: str
role: str
extra_roles: list[str] = Field(default_factory=list)
# is_active: bool
is_active: bool
# Assign must_change_password = True
@@ -284,3 +300,49 @@ class UserOut(BaseModel):
self.jira_token_set = bool(self.jira_api_token)
self.tempo_token_set = bool(self.tempo_api_token)
return self
class UserOperatorOut(BaseModel):
"""Minimal user representation for lead/admin operator-picker dropdowns."""
id: uuid.UUID
username: str
full_name: str | None = None
role: str
model_config = ConfigDict(from_attributes=True)
# ── Password setup / reset (via emailed one-time token) ─────────────
class SendPasswordEmailOut(BaseModel):
"""Response after an admin triggers a set-password email for a user."""
detail: str
class SetPasswordTokenValidateOut(BaseModel):
"""Response for checking a set-password token before rendering the form."""
valid: bool
full_name: str | None = None
class SetPasswordRequest(BaseModel):
"""Payload for completing a password setup/reset via a one-time token."""
token: str
new_password: str
@field_validator("new_password")
@classmethod
def new_password_strength(cls, v: str) -> str:
return _validate_password_strength(v)
# ── Multi-role switching ─────────────────────────────────────────────
class SwitchRoleRequest(BaseModel):
"""Payload for switching the caller's currently-active role."""
role: str
+27 -6
View File
@@ -1,7 +1,10 @@
"""Seed script — creates the initial admin user if it does not already exist.
On first run the admin credentials are generated securely:
- Username is read from ``ADMIN_USERNAME`` env var (default: ``admin``).
- Email (the unique login identifier) is read from ``ADMIN_EMAIL`` env var.
Falls back to ``ADMIN_USERNAME`` if it's already email-shaped, otherwise
to a placeholder that MUST be changed before this account can receive
any webhook email (password reset, notifications, etc).
- Password is read from ``ADMIN_PASSWORD`` env var. When the variable is
**not set**, a cryptographically random 16-character password is generated
automatically and printed to the startup logs so the operator can copy it.
@@ -54,12 +57,25 @@ def seed_admin() -> None:
# Assign admin_username = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin"
admin_username = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin"
# Assign existing = db.query(User).filter(User.username == admin_username).first()
existing = db.query(User).filter(User.username == admin_username).first()
# Email is the unique login identifier — prefer ADMIN_EMAIL, then an
# already email-shaped ADMIN_USERNAME, then a placeholder that needs
# fixing later (Users page) before this account can receive email.
admin_email = os.environ.get("ADMIN_EMAIL", "").strip()
if not admin_email:
admin_email = admin_username if "@" in admin_username else f"{admin_username}@localhost"
# Skip if ANY admin already exists — not just one matching this
# specific username/email. Matching only on the computed identifier
# is fragile: an install predating ADMIN_EMAIL has a user whose
# email was migration-backfilled to its bare username (e.g.
# "administrator"), which won't match a freshly-computed
# "...@localhost" placeholder, so every restart would otherwise
# create a new duplicate admin account.
existing = db.query(User).filter(User.role == "admin").first()
# Check: existing
if existing:
# Call print()
print(f"Admin user '{admin_username}' already exists — skipping.")
print(f"An admin user already exists ('{existing.email}') — skipping.")
# Return control to caller
return
@@ -78,7 +94,9 @@ def seed_admin() -> None:
# Assign admin = User(
admin = User(
# Keyword argument: username
username=admin_username,
username=admin_email,
# Keyword argument: email
email=admin_email,
# Keyword argument: hashed_password
hashed_password=hash_password(admin_password),
# Keyword argument: role
@@ -98,7 +116,10 @@ def seed_admin() -> None:
# Call print()
print("=" * 60)
# Call print()
print(f" Username : {admin_username}")
print(f" Login (email) : {admin_email}")
if "@localhost" in admin_email:
print(" ** No ADMIN_EMAIL was set — using a placeholder. **")
print(" ** Update it in Users before relying on emailed links. **")
# Check: password_was_generated
if password_was_generated:
# Call print()
+10 -3
View File
@@ -35,7 +35,7 @@ from app.database import SessionLocal
from app.models.audit import AuditLog
# Import TeamSide, TechniqueStatus, TestResult, TestState from app.models.enums
from app.models.enums import TeamSide, TechniqueStatus, TestResult, TestState
from app.models.enums import AttackSuccessResult, TeamSide, TechniqueStatus, TestResult, TestState
# Import Evidence from app.models.evidence
from app.models.evidence import Evidence
@@ -54,6 +54,7 @@ from app.models.test_template import TestTemplate
# Import User from app.models.user
from app.models.user import User
from app.services.test_crud_service import determine_initial_classification
# Assign logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
@@ -348,14 +349,20 @@ def _seed_tests(db: Session, users: list[User], techniques: list[Technique], cou
state=state,
# Keyword argument: created_at
created_at=datetime.utcnow() - timedelta(days=random.randint(0, 90)),
data_classification=determine_initial_classification(technique),
)
# Populate team fields based on state
if state in (TestState.blue_evaluating, TestState.in_review, TestState.validated, TestState.rejected):
# Assign test.red_summary = f"Attack executed successfully using {test.tool_used}."
test.red_summary = f"Attack executed successfully using {test.tool_used}."
# Assign test.attack_success = random.choice([True, True, True, False])
test.attack_success = random.choice([True, True, True, False])
test.attack_success = random.choice([
AttackSuccessResult.successful,
AttackSuccessResult.successful,
AttackSuccessResult.successful,
AttackSuccessResult.partially_successful,
AttackSuccessResult.not_successful,
])
# Check: state in (TestState.in_review, TestState.validated, TestState.rejec...
if state in (TestState.in_review, TestState.validated, TestState.rejected):
+1 -1
View File
@@ -105,7 +105,7 @@ def get_tests_analytics(
# Literal argument value
"tool_used": t.tool_used,
# Literal argument value
"attack_success": t.attack_success,
"attack_success": t.attack_success.value if t.attack_success else None,
# Literal argument value
"remediation_status": t.remediation_status,
}
@@ -35,13 +35,14 @@ from typing import Any
import requests
from sqlalchemy.orm import Session
from app.models.enums import TestState, TestResult
from app.models.enums import AttackSuccessResult, TestState, TestResult
from app.models.evaluation_import import EvaluationImport
from app.models.technique import Technique
from app.models.test import Test
from app.models.user import User
from app.services.audit_service import log_action
from app.services.status_service import recalculate_technique_status
from app.services.test_crud_service import determine_initial_classification
logger = logging.getLogger(__name__)
@@ -625,12 +626,13 @@ def import_evaluation_round(
procedure_text=procedure_text,
created_by=current_user.id,
state=TestState.in_review,
attack_success=True,
attack_success=AttackSuccessResult.successful,
red_summary=red_summary,
red_validation_status="approved",
red_validated_by=current_user.id,
red_validated_at=datetime.utcnow(),
detection_result=detection_result,
data_classification=determine_initial_classification(technique),
blue_validation_status=None,
execution_date=datetime.utcnow(),
created_at=datetime.utcnow(),
@@ -92,6 +92,7 @@ def list_logs(
"user_id": log.user_id,
# Literal argument value
"username": log.user.username if log.user else None,
"full_name": log.user.full_name if log.user else None,
# Literal argument value
"action": log.action,
# Literal argument value
+4 -4
View File
@@ -19,15 +19,15 @@ _DUMMY_HASH = "$2b$12$LJ3m4ys3Lg3dMO/NpNmOaeVwFpWJMxlB2FLmEAo9fZr.S8H1vC4Wy"
# Define function authenticate_user
def authenticate_user(db: Session, *, username: str, password: str) -> User:
def authenticate_user(db: Session, *, email: str, password: str) -> User:
"""Validate credentials and return the User.
Raises BusinessRuleViolation for invalid credentials.
Raises PermissionViolation for disabled account.
Uses constant-time comparison to prevent timing attacks.
"""
# Assign user = db.query(User).filter(User.username == username).first()
user = db.query(User).filter(User.username == username).first()
# Assign user = db.query(User).filter(User.email == email).first()
user = db.query(User).filter(User.email == email).first()
# Assign hashed = user.hashed_password if user else _DUMMY_HASH
hashed = user.hashed_password if user else _DUMMY_HASH
# Assign password_valid = verify_password(password, hashed)
@@ -36,7 +36,7 @@ def authenticate_user(db: Session, *, username: str, password: str) -> User:
# Check: user is None or not password_valid
if user is None or not password_valid:
# Raise BusinessRuleViolation
raise BusinessRuleViolation("Incorrect username or password")
raise BusinessRuleViolation("Incorrect email or password")
# Check: not user.is_active
if not user.is_active:
# Raise PermissionViolation
+45 -7
View File
@@ -281,8 +281,20 @@ def create_campaign(
tags: Optional[list[str]] = None,
# Entry: scheduled_at
scheduled_at: Optional[str] = None,
# A manager's own campaign is auto-approved on creation instead of
# going through the draft -> submit -> pending_approval queue (a
# manager is the same role that would otherwise approve it).
auto_approve: bool = False,
start_date: Optional[str] = None,
approver_id: Optional[uuid.UUID] = None,
) -> dict:
"""Create a new campaign. Does not commit; caller commits."""
"""Create a new campaign. Does not commit; caller commits.
Raises BusinessRuleViolation if auto_approve is set without a start_date.
"""
if auto_approve and not start_date:
raise BusinessRuleViolation("start_date is required to auto-approve a campaign")
# Assign campaign = Campaign(
campaign = Campaign(
# Keyword argument: name
@@ -302,6 +314,11 @@ def create_campaign(
# Keyword argument: scheduled_at
scheduled_at=datetime.fromisoformat(scheduled_at) if scheduled_at else None,
)
if auto_approve:
campaign.start_date = datetime.fromisoformat(start_date)
campaign.status = "active"
campaign.approved_by = approver_id
campaign.approved_at = datetime.utcnow()
# Stage new record(s) for database insertion
db.add(campaign)
# Flush changes to DB without committing the transaction
@@ -350,6 +367,11 @@ def approve_campaign(
) -> Campaign:
"""Approve a pending campaign: fix its start date and activate it.
Also allows approving a previously-rejected campaign directly (status
``draft`` with a ``rejection_reason`` set) — a manager can edit it via
``update_campaign()`` and then approve it themselves, without needing
the original lead to resubmit it through the normal queue.
Raises EntityNotFoundError, BusinessRuleViolation.
Does not commit; caller commits.
"""
@@ -357,8 +379,9 @@ def approve_campaign(
if not campaign:
raise EntityNotFoundError("Campaign", campaign_id)
if campaign.status != "pending_approval":
raise BusinessRuleViolation("Only campaigns pending approval can be approved")
is_previously_rejected_draft = campaign.status == "draft" and campaign.rejection_reason
if campaign.status != "pending_approval" and not is_previously_rejected_draft:
raise BusinessRuleViolation("Only campaigns pending approval (or previously rejected) can be approved")
if not start_date:
raise BusinessRuleViolation("start_date is required to approve a campaign")
@@ -437,7 +460,7 @@ def update_campaign(
Does not commit; caller commits.
"""
# Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
# Check: not campaign
if not campaign:
# Raise EntityNotFoundError
@@ -448,10 +471,16 @@ def update_campaign(
# Raise BusinessRuleViolation
raise BusinessRuleViolation("Can only update draft or active campaigns")
# Check: str(campaign.created_by) != str(updater_id) and updater_role != "ad...
if str(campaign.created_by) != str(updater_id) and updater_role != "admin":
# A manager may edit a campaign they (or another manager) previously
# rejected — this is what lets them fix it up and self-approve it,
# instead of bouncing it back to the original lead to resubmit.
is_manager_editing_own_rejection = (
updater_role == "manager" and campaign.status == "draft" and campaign.rejection_reason
)
is_owner_or_admin = str(campaign.created_by) == str(updater_id) or updater_role == "admin"
if not is_owner_or_admin and not is_manager_editing_own_rejection:
# Raise PermissionViolation
raise PermissionViolation("Only the creator or admin can update this campaign")
raise PermissionViolation("Only the creator, admin, or a manager reviewing a rejected campaign can update this campaign")
# Check: "scheduled_at" in fields and fields["scheduled_at"]
if "scheduled_at" in fields and fields["scheduled_at"]:
@@ -726,6 +755,15 @@ def complete_campaign(db: Session, campaign_id: str) -> Campaign:
campaign.completed_at = datetime.utcnow()
# Flush changes to DB without committing the transaction
db.flush()
from app.services.notification_service import notify_user_by_email
notify_user_by_email(
db, campaign.created_by,
preference_key="email_on_campaign_completed",
subject=f"Campaign Completed: {campaign.name}",
message=f'Your campaign "{campaign.name}" has been completed.',
)
# Return campaign
return campaign
@@ -72,32 +72,24 @@ def _clone_campaign(db: Session, original: Campaign) -> Campaign:
1. Clone the campaign with a date-stamped name.
2. For each ``CampaignTest`` in the original, create a new ``Test``
with the same base data (in ``draft`` state) and link it.
3. Activate the new campaign.
3. Queue the new campaign for manager approval recurrence only
automates *creating* the run, not skipping the same approval gate
every other campaign goes through. A manager still picks the real
start date (and that's what triggers Jira ticket creation) via the
normal /approve endpoint.
"""
# Assign now = datetime.utcnow()
now = datetime.utcnow()
# Assign run_label = now.strftime("%Y-%m-%d")
run_label = now.strftime("%Y-%m-%d")
# Assign child = Campaign(
child = Campaign(
# Keyword argument: name
name=f"{original.name} (Run {run_label})",
# Keyword argument: description
description=original.description,
# Keyword argument: type
type=original.type,
# Keyword argument: threat_actor_id
threat_actor_id=original.threat_actor_id,
# Keyword argument: status
status="active",
# Keyword argument: created_by
status="pending_approval",
created_by=original.created_by,
# Keyword argument: target_platform
target_platform=original.target_platform,
# Keyword argument: tags
tags=original.tags or [],
# Keyword argument: parent_campaign_id
parent_campaign_id=original.id,
)
# Stage new record(s) for database insertion
@@ -239,46 +231,34 @@ def check_and_run_recurring_campaigns(db: Session) -> int:
# Commit all pending changes to the database
db.commit()
# Notify
# Notify the creator — the run happened, but it still needs a
# manager's approval before it goes anywhere.
if campaign.created_by:
# Call create_notification()
create_notification(
db,
# Keyword argument: user_id
user_id=campaign.created_by,
# Keyword argument: type
type="recurring_campaign_run",
# Keyword argument: title
title="Recurring campaign executed",
# Keyword argument: message
title="Recurring campaign created — awaiting approval",
message=(
f'Campaign "{child.name}" was automatically created '
f'from recurring template "{campaign.name}".'
f'from recurring template "{campaign.name}" and is now '
f'queued for manager approval.'
),
# Keyword argument: entity_type
entity_type="campaign",
# Keyword argument: entity_id
entity_id=child.id,
)
# Notify red_tech users
red_techs = db.query(User).filter(User.role == "red_tech", User.is_active == True).all() # noqa: E712
# Iterate over red_techs
for user in red_techs:
# Call create_notification()
# Notify managers — same approval gate as any other campaign,
# recurrence only automates spawning the run, not skipping review.
managers = db.query(User).filter(User.role == "manager", User.is_active == True).all() # noqa: E712
for user in managers:
create_notification(
db,
# Keyword argument: user_id
user_id=user.id,
# Keyword argument: type
type="campaign_activated",
# Keyword argument: title
title="New recurring campaign active",
# Keyword argument: message
message=f'Campaign "{child.name}" is now active and ready for execution.',
# Keyword argument: entity_type
type="campaign_pending_approval",
title="Recurring campaign needs approval",
message=f'Campaign "{child.name}" was auto-created from a recurring template and needs your approval.',
entity_type="campaign",
# Keyword argument: entity_id
entity_id=child.id,
)
@@ -296,3 +276,61 @@ def check_and_run_recurring_campaigns(db: Session) -> int:
# Return spawned
return spawned
# ---------------------------------------------------------------------------
# Catch up on due campaigns' Jira tickets (periodic job)
# ---------------------------------------------------------------------------
def sync_due_campaign_jira_tickets(db: Session) -> int:
"""Create Jira tickets for active campaigns whose start_date has arrived.
A campaign approved with a future ``start_date`` intentionally skips
Jira ticket creation at approval time (see
``app.routers.campaigns._create_jira_tickets_for_campaign``). This job
is what actually creates those tickets once that date arrives it
finds active campaigns lacking a Jira link and, for any whose
start_date is now due, calls the same idempotent creation helper.
Returns the number of campaigns processed.
"""
from app.models.jira_link import JiraLink, JiraLinkEntityType
from app.services.jira_service import ensure_campaign_jira_tickets
now = datetime.utcnow()
linked_campaign_ids = {
row[0]
for row in db.query(JiraLink.entity_id).filter(
JiraLink.entity_type == JiraLinkEntityType.campaign
).all()
}
due_campaigns = (
db.query(Campaign)
.filter(
Campaign.status == "active",
Campaign.start_date.isnot(None),
Campaign.start_date <= now,
)
.all()
)
processed = 0
for campaign in due_campaigns:
if campaign.id in linked_campaign_ids:
continue
actor = db.query(User).filter(User.id == campaign.approved_by).first()
if not actor:
actor = db.query(User).filter(User.id == campaign.created_by).first()
if not actor:
logger.warning(
"Cannot sync Jira tickets for campaign %s: no valid actor found",
campaign.id,
)
continue
ensure_campaign_jira_tickets(db, campaign, actor)
processed += 1
return processed
+4
View File
@@ -178,6 +178,8 @@ def generate_campaign_from_threat_actor(
actor_id: uuid.UUID,
# Entry: user
user: User,
# Entry: start_date
start_date: datetime | None = None,
) -> Campaign:
"""Auto-generate a campaign from a threat actor's uncovered techniques.
@@ -235,6 +237,8 @@ def generate_campaign_from_threat_actor(
created_by=user.id,
# Keyword argument: tags
tags=[actor.name, "auto-generated"],
# Keyword argument: start_date
start_date=start_date,
)
# Stage new record(s) for database insertion
db.add(campaign)
@@ -305,7 +305,10 @@ def build_test_results_report(
# Literal argument value
"platform": t.platform,
# Literal argument value
"attack_success": t.attack_success,
"attack_success": (
t.attack_success.value if t.attack_success and hasattr(t.attack_success, "value")
else str(t.attack_success) if t.attack_success else None
),
# Literal argument value
"detection_result": (
t.detection_result.value if t.detection_result and hasattr(t.detection_result, "value")
@@ -583,6 +583,8 @@ def import_d3fend_mappings(db: Session) -> dict[str, int]:
created = 0
# Assign skipped = 0
skipped = 0
# Assign new_technique_ids = set()
new_technique_ids: set[str] = set()
# Get all ATT&CK techniques from the DB
attack_techniques = db.query(Technique).all()
@@ -649,6 +651,12 @@ def import_d3fend_mappings(db: Session) -> dict[str, int]:
db.add(mapping)
# Assign created = 1
created += 1
new_technique_ids.add(mitre_id)
if new_technique_ids:
db.query(Technique).filter(
Technique.mitre_id.in_(new_technique_ids)
).update({"review_required": True}, synchronize_session=False)
# Commit all pending changes to the database
db.commit()
+2
View File
@@ -57,6 +57,8 @@ ALLOWED_EXTENSIONS: frozenset[str] = frozenset({
".zip", ".tar", ".gz", ".7z",
# Literal argument value
".har", ".eml", ".msg",
# Red team artifacts shared with Blue for detection analysis.
".exe",
})
+18
View File
@@ -353,6 +353,17 @@ def build_coverage_layer(
layer["techniques"].append({
# Literal argument value
"techniqueID": tech.mitre_id,
# Technique display name — not part of the official ATT&CK
# Navigator schema, but harmless extra data for Navigator
# imports and lets Aegis's own heatmap UI show names inline
# instead of only ID + hover tooltip.
"name": tech.name,
# Only the coverage layer has a real TechniqueStatus concept —
# exposed so the frontend can color cells by the exact same
# discrete status palette the Techniques page uses, instead of
# a continuous score-derived hex. The other 3 layer types don't
# set this (their score doesn't correspond to a TechniqueStatus).
"status": tech.status_global.value,
# Literal argument value
"tactic": _format_tactic(tech.tactic),
# Literal argument value
@@ -491,6 +502,11 @@ def build_threat_actor_layer(
)
layer["techniques"].append({
"techniqueID": tech.mitre_id,
"name": tech.name,
# Every appended row here is an actor-used technique (non-actor
# ones are skipped above), so status_global is meaningful here
# too — see the coverage layer's "status" field for why.
"status": tech.status_global.value,
"tactic": _format_tactic(tech.tactic),
"color": _score_to_color(score),
"score": score,
@@ -586,6 +602,7 @@ def build_detection_rules_layer(
layer["techniques"].append({
# Literal argument value
"techniqueID": tech.mitre_id,
"name": tech.name,
# Literal argument value
"tactic": _format_tactic(tech.tactic),
# Literal argument value
@@ -754,6 +771,7 @@ def build_campaign_layer(
layer["techniques"].append({
# Literal argument value
"techniqueID": mitre_id,
"name": tech.name,
# Literal argument value
"tactic": _format_tactic(tech.tactic),
# Literal argument value
+612 -28
View File
@@ -31,6 +31,9 @@ from __future__ import annotations
# Import logging
import logging
# Import re
import re
# Import datetime from datetime
from datetime import datetime
@@ -65,6 +68,50 @@ from app.models.technique import Technique
from app.models.test import Test
from app.models.user import User
# ---------------------------------------------------------------------------
# Custom field IDs (project-specific — see System Settings → Jira Configuration)
# ---------------------------------------------------------------------------
JIRA_FIELD_RT_START_DATE = "customfield_11803"
JIRA_FIELD_RT_END_DATE = "customfield_11804"
JIRA_FIELD_BT_START_DATE = "customfield_11805"
JIRA_FIELD_BT_END_DATE = "customfield_11806"
JIRA_FIELD_TTP = "customfield_11807"
JIRA_FIELD_SEVERITY = "customfield_10098"
JIRA_FIELD_ATTACK_SUCCESS = "customfield_11815"
JIRA_FIELD_ATTACK_DETECTED = "customfield_11809"
JIRA_FIELD_TIME_TO_DETECT = "customfield_11810"
JIRA_FIELD_TIME_TO_CONTAIN = "customfield_11811"
JIRA_FIELD_DATA_SENSITIVITY = "customfield_11814"
JIRA_FIELD_ATTACK_CONTAINED = "customfield_11885"
_ATTACK_SUCCESS_TO_JIRA = {
"successful": "Yes",
"not_successful": "No",
"partially_successful": "Partial",
}
_DETECTION_TO_JIRA = {
"detected": "Yes",
"not_detected": "No",
"partially_detected": "Partial",
}
_CONTAINMENT_TO_JIRA = {
"contained": "Yes",
"not_contained": "No",
"partially_contained": "Partial",
}
_DATA_CLASSIFICATION_TO_JIRA = {
"public": "Public",
"general_use": "General Use",
"internal_use_only": "Internal Use Only",
"trusted_people": "Trusted People",
"customer_content": "Customer Content",
"pii": "PII",
}
# Assign logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
@@ -248,6 +295,9 @@ def get_admin_jira_client(db: Session):
def lookup_user_jira_account_id(db: Session, user: User) -> bool:
"""Lookup *user*'s Atlassian account ID by email using the admin Jira client.
Uses ``user.jira_email`` when set (for users whose corporate Atlassian
email differs from their Aegis login email), falling back to
``user.email`` same resolution order as the rest of this module.
Updates ``user.jira_account_id`` in-place when found or changed.
Returns ``True`` when the value was updated, ``False`` otherwise.
Non-fatal all errors are logged at DEBUG level and swallowed.
@@ -255,13 +305,13 @@ def lookup_user_jira_account_id(db: Session, user: User) -> bool:
if not has_admin_jira_configured(db):
return False
email = getattr(user, "email", None)
email = getattr(user, "jira_email", None) or getattr(user, "email", None)
if not email:
return False
try:
jira = get_admin_jira_client(db)
results = jira.user_find_by_user_string(query=email, maxResults=10)
results = jira.user_find_by_user_string(query=email, limit=10)
account_id: Optional[str] = None
for u in results or []:
@@ -292,6 +342,28 @@ def lookup_user_jira_account_id(db: Session, user: User) -> bool:
return False
def _resolve_jira_account_id(db: Session, user: Optional[User]) -> Optional[str]:
"""Return *user*'s Atlassian account ID, trying a fresh lookup if it
isn't cached yet.
``jira_account_id`` is normally populated on login, but a user assigned
or reviewing before their first login (or before Jira was configured)
would otherwise leave every Jira assignment silently no-op'd forever —
exactly the gap that caused a blue_lead's reviews to never reassign in
Jira while red_lead's did, since only push_assignee_update had this
fallback. Every Jira-assignment call site should use this, not read
``jira_account_id`` directly.
"""
if user is None:
return None
jira_account_id = getattr(user, "jira_account_id", None)
if jira_account_id:
return jira_account_id
lookup_user_jira_account_id(db, user)
db.flush()
return getattr(user, "jira_account_id", None)
# ---------------------------------------------------------------------------
# Ticket content builders (inspired by the pentest-to-Jira script)
# ---------------------------------------------------------------------------
@@ -313,6 +385,31 @@ _STATE_EMOJI: dict[str, str] = {
"in_review": "📋 In Review",
"validated": "✅ Validated",
"rejected": "❌ Rejected",
"disputed": "⚠️ Disputed",
}
# TestState -> Jira workflow status. These status names must already exist
# as valid transition targets in the Purple Team project's Jira workflow
# scheme (configured by a Jira admin) — set_issue_status() is a no-op
# (logged warning, non-fatal) if the named status isn't a legal transition
# from the issue's current status.
#
# "blue_evaluating" maps to "Queued Blue Team" here because that's the
# state's *first-entry* meaning (approved by Red Lead, not yet claimed by
# Blue, or re-routed to Blue Team during dispute resolution). When Blue
# Lead sends work back to the *same* operator for rework
# (reopen_blue_review), that's pushed as "In Progress" directly instead of
# through this table — see push_bt_review_reopened().
_STATE_TO_JIRA_STATUS: dict[str, str] = {
"draft": "To Do",
"red_executing": "In Progress",
"red_review": "Red Team test review",
"blue_evaluating": "Queued Blue Team",
"blue_review": "Blue Team test review",
"in_review": "Validation",
"validated": "Done",
"rejected": "Rejected",
"disputed": "Dispute",
}
@@ -374,21 +471,33 @@ def _build_state_comment(
elif new_state == "red_review":
lines += [
"Red Team has submitted evidence and the test is awaiting Red Lead review "
"before it queues for Blue Team.",
f"Red Team has submitted evidence for Round {test.red_round_number or 1} and the "
"test is awaiting Red Lead review before it queues for Blue Team.",
"",
f"*Procedure:* {test.procedure_text or 'N/A'}",
f"*Tool used:* {test.tool_used or 'N/A'}",
f"*Attack Success:* {_enum_value(test.attack_success) or 'N/A'}",
]
if test.red_summary:
lines += ["", "h4. Red Team Summary", test.red_summary]
elif new_state == "blue_review":
lines += [
"Blue Team has submitted evidence and the test is awaiting Blue Lead review "
"before cross-validation.",
f"Blue Team has submitted evidence for Round {test.blue_round_number or 1} and the "
"test is awaiting Blue Lead review before cross-validation.",
"",
f"*Detect Procedure:* {test.detect_procedure or 'N/A'}",
f"*Detection Result:* {_enum_value(test.detection_result) or 'N/A'}",
f"*Containment Result:* {_enum_value(test.containment_result) or 'N/A'}",
]
if test.blue_summary:
lines += ["", "h4. Blue Team Summary", test.blue_summary]
elif new_state == "blue_evaluating":
lines += [
"Red Team has finished execution and submitted evidence for Blue Team evaluation.",
"",
f"*Attack Success:* {test.attack_success if test.attack_success is not None else 'N/A'}",
f"*Attack Success:* {_enum_value(test.attack_success) or 'N/A'}",
]
if test.red_summary:
lines += ["", "h4. Red Team Summary", test.red_summary]
@@ -397,16 +506,27 @@ def _build_state_comment(
lines += [
"Blue Team has completed evaluation. Test is awaiting lead validation.",
"",
f"*Detection Result:* {test.detection_result or 'N/A'}",
f"*Detection Result:* {_enum_value(test.detection_result) or 'N/A'}",
]
if test.system_gaps:
lines += ["", "h4. ⚠️ System Gap Flagged", test.system_gaps]
if test.blue_summary:
lines += ["", "h4. Blue Team Summary", test.blue_summary]
if test.remediation_steps:
lines += ["", "h4. Remediation Steps", test.remediation_steps]
elif new_state == "validated":
# A disputed test resolved by a manager keeps the leads' original,
# disagreeing votes (one approved, one rejected) — that combination
# is otherwise unreachable, since normal dual-validation requires
# both leads to agree. Use it to tell the two paths apart.
manager_resolved = (
test.red_validation_status and test.blue_validation_status
and test.red_validation_status != test.blue_validation_status
)
lines += [
"Test has been *validated* by both leads.",
"Test has been *validated* by a manager's decision on a disputed test."
if manager_resolved else "Test has been *validated* by both leads.",
"",
f"*Red Lead Status:* {test.red_validation_status or 'N/A'}",
f"*Blue Lead Status:* {test.blue_validation_status or 'N/A'}",
@@ -417,8 +537,13 @@ def _build_state_comment(
lines += ["", f"*Blue Lead Notes:* {test.blue_validation_notes}"]
elif new_state == "rejected":
manager_resolved = (
test.red_validation_status and test.blue_validation_status
and test.red_validation_status != test.blue_validation_status
)
lines += [
"Test has been *rejected* and must be reworked.",
"Test has been *rejected* by a manager's decision on a disputed test."
if manager_resolved else "Test has been *rejected* and must be reworked.",
"",
f"*Red Lead Status:* {test.red_validation_status or 'N/A'}",
f"*Blue Lead Status:* {test.blue_validation_status or 'N/A'}",
@@ -580,6 +705,37 @@ def auto_create_campaign_issue(
return None
def ensure_campaign_jira_tickets(db: Session, campaign, user: User) -> None:
"""Create Jira tickets for a campaign and its already-linked tests, if missing.
Called from two places: immediately at manager-approval time when the
campaign's ``start_date`` is now/past, and from the periodic
``sync_due_campaign_jira_tickets`` job for campaigns approved with a
future ``start_date`` once that date actually arrives. Idempotent
checks for existing links before creating anything so it is safe to
call repeatedly. Best-effort: failures are logged, not raised, since a
Jira outage must not block campaign activation.
"""
try:
campaign_jira_key = get_campaign_jira_key(db, campaign.id)
if not campaign_jira_key:
campaign_jira_key = auto_create_campaign_issue(db, campaign, user)
if campaign_jira_key:
for ct in campaign.campaign_tests:
if ct.test and not get_test_jira_key(db, ct.test.id):
auto_create_test_issue(
db, ct.test, user,
parent_ticket_override=campaign_jira_key,
campaign_start_date=campaign.start_date,
)
db.commit()
except Exception:
logger.exception(
"Jira ticket creation failed for campaign %s",
campaign.id,
)
def auto_create_test_issue(
db: Session,
test: Test,
@@ -627,16 +783,32 @@ def auto_create_test_issue(
issue_type = settings.JIRA_ISSUE_TYPE_TEST # always Task
poc = test.procedure_text or "N/A"
severity = _technique_severity(technique).capitalize()
labels = ["aegis", mitre_id.replace(".", "-")]
if test.platform:
# Jira labels can't contain whitespace — normalize to kebab-case.
labels.append(re.sub(r"[^a-z0-9_-]+", "-", test.platform.lower()).strip("-"))
# No parent_ticket_override means this test isn't nested under a
# campaign ticket — i.e. it's standalone. Tag it so standalone tests
# are identifiable in Jira without cross-referencing Aegis.
if parent_ticket_override is None:
labels.append("standalone-test")
fields: dict = {
"project": {"key": project_key},
"summary": f"[Aegis] {mitre_id}{test.name}",
"description": _build_test_description(test, technique),
"issuetype": {"name": issue_type},
"labels": ["aegis", "security-test", mitre_id.replace(".", "-")],
"labels": labels,
# customfield_10309 = Proof of Concept field (required by team's Jira config)
"customfield_10309": f"{{code}}{poc}{{code}}",
JIRA_FIELD_TTP: mitre_id,
JIRA_FIELD_SEVERITY: _select_field(severity),
}
data_sensitivity = _DATA_CLASSIFICATION_TO_JIRA.get(_enum_value(test.data_classification))
if data_sensitivity:
fields[JIRA_FIELD_DATA_SENSITIVITY] = _select_field(data_sensitivity)
# Inherit campaign start date if available, otherwise use today
from datetime import date as _date
effective_start = campaign_start_date or _date.today()
@@ -646,7 +818,23 @@ def auto_create_test_issue(
if parent:
fields["parent"] = {"key": parent}
result = jira.issue_create(fields=fields)
try:
result = jira.issue_create(fields=fields)
except Exception as exc:
# Jira rejects the whole issue when a custom field isn't on the
# project's create screen ("cannot be set ... not on the
# appropriate screen"). Don't let a screen-config gap on an
# optional field (data sensitivity) block ticket creation
# entirely — drop it and retry once.
if JIRA_FIELD_DATA_SENSITIVITY in fields and JIRA_FIELD_DATA_SENSITIVITY in str(exc):
logger.warning(
"Jira rejected %s (not on create screen); retrying without it for test %s",
JIRA_FIELD_DATA_SENSITIVITY, test.id,
)
fields.pop(JIRA_FIELD_DATA_SENSITIVITY)
result = jira.issue_create(fields=fields)
else:
raise
issue_key = result["key"]
issue_id = result.get("id", "")
@@ -708,20 +896,30 @@ def push_test_event(
comment = _build_state_comment(test, new_state, actor, extra)
jira.issue_add_comment(link.jira_issue_key, comment)
# When the operator starts execution: transition to "In Progress"
# and assign the ticket to that operator.
if new_state == "red_executing":
# Transition the Jira issue's status to match the new Aegis state,
# per _STATE_TO_JIRA_STATUS. Covers the full Purple Team workflow:
# To-Do -> In Progress -> RT Test Review -> Queued Blue Team ->
# In Progress -> Blue Team Test Review -> Validation -> Done /
# Rejected / Dispute.
target_status = _STATE_TO_JIRA_STATUS.get(new_state)
if target_status:
try:
jira.set_issue_status(link.jira_issue_key, "In Progress")
jira.set_issue_status(link.jira_issue_key, target_status)
logger.info(
"Transitioned Jira ticket %s to In Progress", link.jira_issue_key
"Transitioned Jira ticket %s to %s", link.jira_issue_key, target_status
)
except Exception as exc_t:
logger.warning(
"Could not transition %s to In Progress: %s",
link.jira_issue_key, exc_t,
"Could not transition %s to %s: %s",
link.jira_issue_key, target_status, exc_t,
)
jira_account_id = getattr(actor, "jira_account_id", None)
# When the operator starts execution: assign the ticket to that
# operator. On reopen (rework), *actor* is the lead who reopened
# it, not the operator who should keep working it — the caller
# passes the original red_tech_assignee as *assignee* in that case.
if new_state == "red_executing":
jira_account_id = _resolve_jira_account_id(db, assignee or actor)
if jira_account_id:
try:
jira.assign_issue(link.jira_issue_key, account_id=jira_account_id)
@@ -736,7 +934,7 @@ def push_test_event(
)
if new_state in ("red_review", "blue_review") and assignee:
jira_account_id = getattr(assignee, "jira_account_id", None)
jira_account_id = _resolve_jira_account_id(db, assignee)
if jira_account_id:
try:
jira.assign_issue(link.jira_issue_key, account_id=jira_account_id)
@@ -750,6 +948,41 @@ def push_test_event(
link.jira_issue_key, jira_account_id, exc_a,
)
# blue_evaluating is reached two different ways: a fresh hand-off
# from Red (no blue_tech_assignee yet — queued, no owner) or a
# reopen/rework of a test some blue_tech already had (assignee is
# still set) — that operator should get the ticket back, not have
# it clear. in_review is always dual-validation by both leads at
# once, so it never has a single owner.
if new_state == "blue_evaluating":
if test.blue_tech_assignee:
reassignee = db.query(User).filter(User.id == test.blue_tech_assignee).first()
jira_account_id = _resolve_jira_account_id(db, reassignee)
else:
jira_account_id = None
try:
jira.assign_issue(link.jira_issue_key, account_id=jira_account_id)
logger.info(
"%s Jira ticket %s (state=%s)",
"Reassigned" if jira_account_id else "Unassigned", link.jira_issue_key, new_state,
)
except Exception as exc_a:
logger.warning(
"Could not update assignee on %s for state %s: %s",
link.jira_issue_key, new_state, exc_a,
)
elif new_state == "in_review":
try:
jira.assign_issue(link.jira_issue_key, account_id=None)
logger.info("Unassigned Jira ticket %s (state=%s)", link.jira_issue_key, new_state)
except Exception as exc_a:
logger.warning(
"Could not unassign %s for state %s: %s",
link.jira_issue_key, new_state, exc_a,
)
if test.system_gaps:
_add_jira_label(jira, link.jira_issue_key, "system-gap")
link.last_synced_at = datetime.utcnow()
db.flush()
logger.info(
@@ -763,6 +996,47 @@ def push_test_event(
)
def push_assignee_update(db: Session, test: Test, assignee: User) -> None:
"""Sync a lead's manual operator assignment to the linked Jira ticket.
Called from ``POST /tests/{id}/assign`` so Jira reflects the assignment
immediately, instead of waiting for the operator to start execution
(the only other point that pushes a Jira assignee see
``push_test_event``'s ``red_executing`` handling above).
"""
if not has_admin_jira_configured(db):
return
link = (
db.query(JiraLink)
.filter(
JiraLink.entity_type == JiraLinkEntityType.test,
JiraLink.entity_id == test.id,
)
.first()
)
if not link:
return
jira_account_id = _resolve_jira_account_id(db, assignee)
if not jira_account_id:
return
try:
jira = get_admin_jira_client(db)
jira.assign_issue(link.jira_issue_key, account_id=jira_account_id)
link.last_synced_at = datetime.utcnow()
db.flush()
logger.info(
"Assigned Jira ticket %s to account %s (manual assignment)",
link.jira_issue_key, jira_account_id,
)
except Exception as exc:
logger.warning(
"Could not assign %s to %s: %s", link.jira_issue_key, jira_account_id, exc,
)
# ---------------------------------------------------------------------------
# On-hold Jira notification
# ---------------------------------------------------------------------------
@@ -803,24 +1077,24 @@ def push_hold_event(
f"h3. ▶ Test Resumed\n\n"
f"*Resumed by:* {actor.username}\n"
f"*At:* {ts}\n\n"
f"_The test has been taken off hold and is active again._"
f"_The test has been unblocked and is active again._"
)
try:
jira.set_issue_status(link.jira_issue_key, "In Progress")
except Exception as exc_t:
logger.warning("Could not transition %s off hold: %s", link.jira_issue_key, exc_t)
logger.warning("Could not transition %s off blocked: %s", link.jira_issue_key, exc_t)
else:
comment = (
f"h3. Test Placed On Hold\n\n"
f"*Placed on hold by:* {actor.username}\n"
f"h3. 🚫 Test Blocked\n\n"
f"*Blocked by:* {actor.username}\n"
f"*At:* {ts}\n"
f"*Reason:* {reason or 'No reason provided'}\n\n"
f"_This test has been paused. No action required until it is resumed._"
f"_This test is blocked. No action required until it is resumed._"
)
try:
jira.set_issue_status(link.jira_issue_key, "On Hold")
jira.set_issue_status(link.jira_issue_key, "Blocked")
except Exception as exc_t:
logger.warning("Could not transition %s to On Hold: %s", link.jira_issue_key, exc_t)
logger.warning("Could not transition %s to Blocked: %s", link.jira_issue_key, exc_t)
jira.issue_add_comment(link.jira_issue_key, comment)
link.last_synced_at = datetime.utcnow()
@@ -830,6 +1104,316 @@ def push_hold_event(
logger.warning("Failed to push hold event for test %s: %s", test.id, exc, exc_info=True)
def push_pause_event(db: Session, test, actor, *, resuming: bool = False) -> None:
"""Post a timer pause/resume comment to Jira — status is left untouched.
Distinct from :func:`push_hold_event` a short timer pause (the timer
sitting idle between rework rounds, or the operator briefly stepping
away) is not the same thing as the test actually being blocked, so it
must not flip the Jira issue to "On Hold". Only a genuine Hold action
(:func:`push_hold_event`) does that. This just leaves a record.
Non-fatal any Jira error is logged and swallowed.
"""
if not has_admin_jira_configured(db):
return
link = (
db.query(JiraLink)
.filter(
JiraLink.entity_type == JiraLinkEntityType.test,
JiraLink.entity_id == test.id,
)
.first()
)
if not link:
return
try:
jira = get_admin_jira_client(db)
ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
if resuming:
comment = f"h3. ▶ Timer Resumed\n\n*Resumed by:* {actor.username}\n*At:* {ts}"
else:
comment = f"h3. ⏸ Timer Paused\n\n*Paused by:* {actor.username}\n*At:* {ts}"
jira.issue_add_comment(link.jira_issue_key, comment)
link.last_synced_at = datetime.utcnow()
db.flush()
logger.info("Posted pause event to Jira %s (resuming=%s)", link.jira_issue_key, resuming)
except Exception as exc:
logger.warning("Failed to push pause event for test %s: %s", test.id, exc, exc_info=True)
def push_round_archived(db: Session, test, actor, *, round_data) -> None:
"""Post a permanent Jira comment summarizing a round right before it's
reset for rework.
``round_data`` custom fields (attack success, detection result, etc.)
only ever show Jira's *latest* value — each new round's submission
overwrites the field. This comment is what preserves every prior
round's results in Jira, since comments are never overwritten.
Non-fatal any Jira error is logged and swallowed.
"""
if not has_admin_jira_configured(db):
return
link = (
db.query(JiraLink)
.filter(
JiraLink.entity_type == JiraLinkEntityType.test,
JiraLink.entity_id == test.id,
)
.first()
)
if not link:
return
try:
jira = get_admin_jira_client(db)
team = round_data.team
lines = [f"h3. \U0001F4CB Round {round_data.round_number} archived ({team} team)\n"]
if team == "red":
lines.append(f"*Procedure:* {round_data.procedure_text or '_none recorded_'}")
lines.append(f"*Tool used:* {round_data.tool_used or '-'}")
lines.append(f"*Attack success:* {round_data.attack_success.value if round_data.attack_success else '-'}")
lines.append(f"*Summary:* {round_data.red_summary or '-'}")
else:
lines.append(f"*Detect procedure:* {round_data.detect_procedure or '_none recorded_'}")
lines.append(f"*Detection result:* {round_data.detection_result.value if round_data.detection_result else '-'}")
lines.append(f"*Containment result:* {round_data.containment_result.value if round_data.containment_result else '-'}")
lines.append(f"*Summary:* {round_data.blue_summary or '-'}")
lines.append(f"*Sent back by:* {actor.username}")
lines.append(f"*Reopen notes:* {round_data.review_notes or '-'}")
comment = "\n".join(lines)
jira.issue_add_comment(link.jira_issue_key, comment)
_add_jira_label(jira, link.jira_issue_key, "reopened")
link.last_synced_at = datetime.utcnow()
db.flush()
logger.info(
"Posted round-archived comment to Jira %s (team=%s, round=%s)",
link.jira_issue_key, team, round_data.round_number,
)
except Exception as exc:
logger.warning("Failed to push round-archived comment for test %s: %s", test.id, exc, exc_info=True)
def _add_jira_label(jira, issue_key: str, label: str) -> None:
"""Add *label* to an issue's labels without clobbering the existing ones.
Jira's update API takes the whole ``labels`` array, so adding one means
read-modify-write: fetch the current list, append if missing, write it
back. Failures here are logged and swallowed by the caller.
"""
issue = jira.issue(issue_key, fields="labels")
current = (issue.get("fields") or {}).get("labels") or []
if label not in current:
jira.update_issue_field(issue_key, fields={"labels": current + [label]})
def push_bt_work_started(db: Session, test, actor) -> None:
"""Transition the Jira issue to "In Progress" when a blue tech picks up
a queued test to start evaluating.
The test's TestState stays "blue_evaluating" throughout (queued AND
actively worked are the same Aegis state), so this can't be driven by
the generic push_test_event()/_STATE_TO_JIRA_STATUS dispatch that
would incorrectly re-push "Queued Blue Team". Non-fatal.
"""
if not has_admin_jira_configured(db):
return
link = (
db.query(JiraLink)
.filter(
JiraLink.entity_type == JiraLinkEntityType.test,
JiraLink.entity_id == test.id,
)
.first()
)
if not link:
return
try:
jira = get_admin_jira_client(db)
ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
comment = f"h3. ▶ Blue Team Started\n\n*Picked up by:* {actor.username}\n*At:* {ts}"
try:
jira.set_issue_status(link.jira_issue_key, "In Progress")
except Exception as exc_t:
logger.warning("Could not transition %s to In Progress: %s", link.jira_issue_key, exc_t)
jira.issue_add_comment(link.jira_issue_key, comment)
jira_account_id = getattr(actor, "jira_account_id", None)
if jira_account_id:
try:
jira.assign_issue(link.jira_issue_key, account_id=jira_account_id)
except Exception as exc_a:
logger.warning(
"Could not assign %s to %s: %s", link.jira_issue_key, jira_account_id, exc_a,
)
link.last_synced_at = datetime.utcnow()
db.flush()
logger.info("Posted blue-team-started event to Jira %s", link.jira_issue_key)
except Exception as exc:
logger.warning("Failed to push blue-team-started event for test %s: %s", test.id, exc, exc_info=True)
# ---------------------------------------------------------------------------
# RT/BT date + result custom-field sync
# ---------------------------------------------------------------------------
def _enum_value(v) -> Optional[str]:
"""Return the plain string value of an enum member, or the value itself."""
if v is None:
return None
return v.value if hasattr(v, "value") else str(v)
def _select_field(value: str) -> dict:
"""Wrap a value for a Jira single-select custom field.
Jira's REST API rejects a bare string for select-type custom fields
("Specify a valid 'id' or 'name' for <field>") it must be posted as
``{"value": ...}``.
"""
return {"value": value}
def _jira_datetime(dt: datetime) -> str:
"""Format a naive-UTC datetime for a Jira ``datetime``-type custom field.
RT/BT Start/End Date are genuine Jira datetime fields (confirmed via
issue_editmeta schema type "datetime"), not plain dates. Sending a
bare "YYYY-MM-DD" string makes Jira default the time-of-day to
midnight, so RT Start Date and RT End Date ended up showing the same
meaningless midnight timestamp regardless of when the operator
actually started/finished. Jira's REST API expects
``yyyy-MM-dd'T'HH:mm:ss.SSSZ`` for this field type.
"""
return dt.strftime("%Y-%m-%dT%H:%M:%S.000+0000")
def _update_test_fields(db: Session, test: Test, fields: dict) -> None:
"""Best-effort update of custom fields on the Jira issue linked to *test*.
Non-fatal any Jira error is logged and swallowed so it never blocks
the test workflow.
"""
if not fields or not has_admin_jira_configured(db):
return
link = (
db.query(JiraLink)
.filter(
JiraLink.entity_type == JiraLinkEntityType.test,
JiraLink.entity_id == test.id,
)
.first()
)
if not link:
return
try:
jira = get_admin_jira_client(db)
jira.update_issue_field(link.jira_issue_key, fields=fields)
link.last_synced_at = datetime.utcnow()
db.flush()
logger.info("Updated Jira fields %s on %s", list(fields.keys()), link.jira_issue_key)
except Exception as exc:
logger.warning(
"Failed to update Jira fields for test %s: %s", test.id, exc, exc_info=True
)
def push_rt_submitted(db: Session, test: Test) -> None:
"""Set RT Start Date, RT End Date, and Attack Success — called when Red submits for review.
RT Start Date always reflects the *first* round's execution start (from
round_number=1 in round_history if this test has been reopened, else the
live field this is round 1 in that case). RT End Date and Attack
Success always reflect the *current* round, since they're read live off
the test each time this runs and get pushed again on every resubmit.
Previously this pushed ``datetime.utcnow()`` (click time) instead of the
operator-entered execution window fixed to read the real fields.
"""
from app.models.test_round_history import TestRoundHistory
fields: dict = {}
first_round = (
db.query(TestRoundHistory)
.filter(
TestRoundHistory.test_id == test.id,
TestRoundHistory.team == "red",
TestRoundHistory.round_number == 1,
)
.first()
)
first_start = (
first_round.execution_start_time
if first_round and first_round.execution_start_time
else test.execution_start_time
)
if first_start:
fields[JIRA_FIELD_RT_START_DATE] = _jira_datetime(first_start)
if test.execution_end_time:
fields[JIRA_FIELD_RT_END_DATE] = _jira_datetime(test.execution_end_time)
attack_success = _enum_value(test.attack_success)
if attack_success in _ATTACK_SUCCESS_TO_JIRA:
fields[JIRA_FIELD_ATTACK_SUCCESS] = _select_field(_ATTACK_SUCCESS_TO_JIRA[attack_success])
_update_test_fields(db, test, fields)
def push_bt_started(db: Session, test: Test) -> None:
"""Set the BT Start Date field — called when Blue picks up the test to evaluate.
Only pushed on round 1. On later rounds (after a reopen), the field
already holds round 1's real pickup date and must not be overwritten —
same "first round survives every reopen" rule as RT Start Date.
"""
if (test.blue_round_number or 1) > 1:
return
_update_test_fields(db, test, {
JIRA_FIELD_BT_START_DATE: _jira_datetime(datetime.utcnow()),
})
def push_bt_submitted(db: Session, test: Test) -> None:
"""Set BT End Date, Attack Detected/Contained, and Time to Detect/Contain — Blue submits for review.
Time to Detect / Time to Contain are Jira **datetime** fields (confirmed
via issue_editmeta), not numeric duration fields despite the name.
This used to push a computed hour count, which Jira rejected outright;
since Jira validates the whole fields payload atomically, that one bad
field silently sank BT End Date and Attack Detected too. Now pushes the
actual detection_time/containment_time timestamps.
"""
fields: dict = {JIRA_FIELD_BT_END_DATE: _jira_datetime(datetime.utcnow())}
detection_result = _enum_value(test.detection_result)
if detection_result in _DETECTION_TO_JIRA:
fields[JIRA_FIELD_ATTACK_DETECTED] = _select_field(_DETECTION_TO_JIRA[detection_result])
containment_result = _enum_value(test.containment_result)
if containment_result in _CONTAINMENT_TO_JIRA:
fields[JIRA_FIELD_ATTACK_CONTAINED] = _select_field(_CONTAINMENT_TO_JIRA[containment_result])
if test.detection_time:
fields[JIRA_FIELD_TIME_TO_DETECT] = _jira_datetime(test.detection_time)
if test.containment_time:
fields[JIRA_FIELD_TIME_TO_CONTAIN] = _jira_datetime(test.containment_time)
_update_test_fields(db, test, fields)
# ---------------------------------------------------------------------------
# Legacy / generic helpers (kept for existing routes)
# ---------------------------------------------------------------------------
+34 -1
View File
@@ -38,6 +38,26 @@ from app.schemas.metrics import (
ValidationRate,
)
# Canonical MITRE ATT&CK kill-chain order — used to sort "coverage by
# tactic" consistently everywhere it's rendered (dashboard table, executive
# bar chart), instead of each consumer inventing its own order/subset.
MITRE_TACTIC_ORDER: list[str] = [
"reconnaissance",
"resource-development",
"initial-access",
"execution",
"persistence",
"privilege-escalation",
"defense-evasion",
"credential-access",
"discovery",
"lateral-movement",
"collection",
"command-and-control",
"exfiltration",
"impact",
]
# Define function get_coverage_summary
def get_coverage_summary(db: Session) -> CoverageSummary:
@@ -112,6 +132,12 @@ def get_coverage_by_tactic(db: Session) -> list[TacticCoverage]:
lambda: {s.value: 0 for s in TechniqueStatus}
)
# Materialize every canonical tactic up front so the response always
# includes all 14, zero-filled, rather than only whichever tactics
# happen to have techniques seeded.
for tactic in MITRE_TACTIC_ORDER:
tactic_data[tactic] # noqa: B018 — touch to create the defaultdict entry
# Iterate over techniques
for tactic_str, status in techniques:
# Check: not tactic_str
@@ -128,10 +154,17 @@ def get_coverage_by_tactic(db: Session) -> list[TacticCoverage]:
# Assign tactic_data[tactic][status.value] = 1
tactic_data[tactic][status.value] += 1
def _tactic_sort_key(tactic: str) -> tuple[int, str]:
try:
return (MITRE_TACTIC_ORDER.index(tactic), tactic)
except ValueError:
# Anything outside the canonical 14 (e.g. "unknown") sorts last.
return (len(MITRE_TACTIC_ORDER), tactic)
# Assign result = []
result = []
# Iterate over sorted(tactic_data)
for tactic in sorted(tactic_data):
for tactic in sorted(tactic_data, key=_tactic_sort_key):
# Assign counts = tactic_data[tactic]
counts = tactic_data[tactic]
# Assign total = sum(counts.values())
@@ -391,5 +391,18 @@ def sync_mitre(db: Session) -> dict:
# Commit all pending changes to the database
db.commit()
if created > 0:
from app.services.notification_service import notify_all_users_by_email
notify_all_users_by_email(
db,
preference_key="email_on_new_mitre_techniques",
subject=f"MITRE ATT&CK Updated: {created} new techniques",
message=(
f"{created} new techniques were added and {updated} were "
"updated in the MITRE ATT&CK catalog."
),
)
# Return summary
return summary
@@ -245,6 +245,76 @@ def cleanup_old_notifications(db: Session, days: int = 90) -> int:
# ---------------------------------------------------------------------------
def _preference_allows(user: User | None, preference_key: str) -> bool:
"""True unless the user has explicitly turned *preference_key* off.
``notification_preferences`` may be ``None`` (older rows predating the
column) or missing individual keys (newer preference toggles added
after the DB default was set) both cases default to "on".
"""
if user is None or not user.email:
return False
prefs = user.notification_preferences or {}
return prefs.get(preference_key, True) is not False
def notify_user_by_email(db: Session, user_id, *, preference_key: str, subject: str, message: str) -> None:
"""Email a single user (by id) if their preferences allow it.
Best-effort swallows lookup/send failures so a notification email
never breaks whatever workflow step triggered it.
"""
if not user_id:
return
try:
user = db.query(User).filter(User.id == user_id).first()
if not _preference_allows(user, preference_key):
return
from app.services.webhook_email_service import send_webhook_email
send_webhook_email(db, to=user.email, subject=subject, message=message, full_name=user.full_name)
except Exception:
pass # nosec B110 — email failures never crash the caller
def notify_all_users_by_email(db: Session, *, preference_key: str, subject: str, message: str) -> None:
"""Email every active, opted-in user — for platform-wide events
(e.g. a MITRE ATT&CK sync) rather than a single actor's own action.
"""
users = db.query(User).filter(User.is_active == True, User.email.isnot(None)).all() # noqa: E712
for user in users:
if not _preference_allows(user, preference_key):
continue
try:
from app.services.webhook_email_service import send_webhook_email
send_webhook_email(db, to=user.email, subject=subject, message=message, full_name=user.full_name)
except Exception:
pass # nosec B110 — one user's failure must not skip the rest
def notify_roles_by_email(
db: Session, *, roles: list[str], preference_key: str, subject: str, message: str,
exclude_user_id=None,
) -> None:
"""Email every active, opted-in user holding any of *roles*.
For workflow-wide callouts to a team (e.g. every lead vote) rather than
a single actor's own action — no in-app notification is created here,
only the email; pair with ``create_notification``/``notify_role_with_email``
if an in-app entry is also needed.
"""
users = db.query(User).filter(User.role.in_(roles), User.is_active == True).all() # noqa: E712
for user in users:
if exclude_user_id and user.id == exclude_user_id:
continue
if not _preference_allows(user, preference_key):
continue
try:
from app.services.webhook_email_service import send_webhook_email
send_webhook_email(db, to=user.email, subject=subject, message=message, full_name=user.full_name)
except Exception:
pass # nosec B110 — one user's failure must not skip the rest
def notify_role_with_email(
db: Session,
*,
@@ -316,6 +386,12 @@ def notify_test_state_change(db: Session, test, new_state: str) -> None:
# Keyword argument: entity_id
entity_id=test_id,
)
notify_user_by_email(
db, creator_id,
preference_key="email_on_test_state_change",
subject=f"Test Execution Started: {test_name}",
message=f'Your test "{test_name}" has moved to the execution phase.',
)
# Alternative: new_state == "blue_evaluating"
elif new_state == "blue_evaluating":
@@ -339,6 +415,12 @@ def notify_test_state_change(db: Session, test, new_state: str) -> None:
# Keyword argument: entity_id
entity_id=test_id,
)
notify_user_by_email(
db, user.id,
preference_key="email_on_test_state_change",
subject=f"Test Ready for Blue Evaluation: {test_name}",
message=f'Test "{test_name}" needs blue team evaluation.',
)
# Alternative: new_state == "in_review"
elif new_state == "in_review":
@@ -368,6 +450,12 @@ def notify_test_state_change(db: Session, test, new_state: str) -> None:
# Keyword argument: entity_id
entity_id=test_id,
)
notify_user_by_email(
db, user.id,
preference_key="email_on_test_state_change",
subject=f"Test Ready for Validation: {test_name}",
message=f'Test "{test_name}" is awaiting your review.',
)
# Alternative: new_state == "rejected" and creator_id
elif new_state == "rejected" and creator_id:
@@ -387,6 +475,12 @@ def notify_test_state_change(db: Session, test, new_state: str) -> None:
# Keyword argument: entity_id
entity_id=test_id,
)
notify_user_by_email(
db, creator_id,
preference_key="email_on_test_rejected",
subject=f"Test Rejected: {test_name}",
message=f'Your test "{test_name}" has been rejected. Please review and resubmit.',
)
# Alternative: new_state == "validated" and creator_id
elif new_state == "validated" and creator_id:
@@ -406,3 +500,9 @@ def notify_test_state_change(db: Session, test, new_state: str) -> None:
# Keyword argument: entity_id
entity_id=test_id,
)
notify_user_by_email(
db, creator_id,
preference_key="email_on_test_validated",
subject=f"Test Validated: {test_name}",
message=f'Your test "{test_name}" has been validated successfully.',
)
@@ -29,7 +29,7 @@ log = logging.getLogger(__name__)
def _dispatch_inapp_notifications(db: Session, rule: AlertRule, instance: AlertInstance) -> None:
"""Create in-app Notification rows for all admins and leads."""
from app.services.notification_service import create_notification
from app.services.notification_service import create_notification, notify_roles_by_email
admin_roles = {"admin", "red_lead", "blue_lead"}
users = db.query(User).filter(
@@ -47,6 +47,14 @@ def _dispatch_inapp_notifications(db: Session, rule: AlertRule, instance: AlertI
entity_id = instance.id,
)
if rule.rule_type == AlertRuleType.stale_technique.value:
notify_roles_by_email(
db, roles=list(admin_roles),
preference_key="email_on_stale_coverage",
subject=instance.title,
message=instance.message,
)
def _dispatch_webhooks(rule: AlertRule, instance: AlertInstance) -> None:
"""Fire webhook(s) for a triggered alert (all exceptions caught)."""
@@ -0,0 +1,105 @@
"""Passwordless-user onboarding — one-time set-password links sent by webhook.
An admin creates a user with just a name/email/role (see
``user_service.create_user_without_password``). To let that user in, the
admin clicks "Send Email": this service issues a one-time token and sends
it via the shared ``webhook_email_service`` (POSTs to an admin-configured
Power Automate webhook). The same mechanism, and the same button, is
reused for password resets on existing users.
"""
from __future__ import annotations
import secrets
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from app.config import settings
from app.domain.errors import BusinessRuleViolation, EntityNotFoundError
from app.models.password_setup_token import PasswordSetupToken
from app.models.user import User
from app.services.webhook_email_service import get_webhook_url, send_webhook_email
_TOKEN_TTL = timedelta(hours=24)
def create_setup_token(db: Session, user: User) -> PasswordSetupToken:
"""Issue a fresh one-time token for *user*, invalidating any unused ones.
Does not commit; caller commits.
"""
db.query(PasswordSetupToken).filter(
PasswordSetupToken.user_id == user.id,
PasswordSetupToken.used_at.is_(None),
).delete()
token = PasswordSetupToken(
user_id=user.id,
token=secrets.token_urlsafe(32),
expires_at=datetime.utcnow() + _TOKEN_TTL,
)
db.add(token)
db.flush()
return token
def send_password_setup_email(db: Session, user: User) -> None:
"""Issue a token and email it via the configured webhook.
Raises BusinessRuleViolation if no webhook is configured yet.
Does not commit; caller commits (the token must be persisted even if
the webhook call itself fails, so a fresh "Send Email" retry works).
"""
if not get_webhook_url(db):
raise BusinessRuleViolation(
"No email webhook is configured yet — set one in Settings first."
)
token = create_setup_token(db, user)
set_password_url = f"{settings.PLATFORM_URL}/set-password?token={token.token}"
is_reset = not user.must_change_password
subject = "Reset Your Password" if is_reset else "Set Your Password"
message = (
f'Click the link below to {"reset" if is_reset else "set"} your Aegis password:\n\n'
f"{set_password_url}\n\n"
"This link expires in 24 hours and can only be used once."
)
sent = send_webhook_email(db, to=user.email, subject=subject, message=message, full_name=user.full_name)
if not sent:
raise BusinessRuleViolation(
"Failed to send the email — check the webhook URL/API key in Settings and the server logs."
)
def _get_valid_token_or_raise(db: Session, token: str) -> PasswordSetupToken:
row = db.query(PasswordSetupToken).filter(PasswordSetupToken.token == token).first()
if row is None:
raise EntityNotFoundError("Password setup token", token)
if row.used_at is not None:
raise BusinessRuleViolation("This link has already been used")
if row.expires_at < datetime.utcnow():
raise BusinessRuleViolation("This link has expired — ask an admin to send a new one")
return row
def validate_token(db: Session, token: str) -> User:
"""Return the token's owning user if the token is valid, else raise."""
row = _get_valid_token_or_raise(db, token)
return row.user
def consume_token_and_set_password(db: Session, token: str, new_password: str) -> User:
"""Set *new_password* for the token's user and mark the token used.
Does not commit; caller commits.
"""
from app.auth import hash_password
row = _get_valid_token_or_raise(db, token)
user = row.user
user.hashed_password = hash_password(new_password)
user.must_change_password = False
row.used_at = datetime.utcnow()
db.flush()
return user
@@ -0,0 +1,86 @@
"""Heuristic extraction of the actual command(s)/query out of a free-text
procedure field (``procedure_text`` for Red, ``detect_procedure`` for
Blue), for proposing as a template's suggested procedure.
Operators write these fields as free text mixing narrative ("I ran this
against the DC and it worked"), the actual command, and sometimes pasted
output. We only want the command not the story around it, not the
output. There's no reliable general way to do this with NLP-level
accuracy, so this uses a deterministic, conservative regex approach:
1. Fenced code blocks (```...``` or ~~~...~~~) are the strongest signal
an operator who bothered to fence something almost always fenced the
command, not the narrative. If any exist, their contents are returned
verbatim (narrative outside the fence is ignored).
2. Otherwise, each line is checked against a curated set of patterns that
look like real commands or detection queries (shell prompts, known
binaries/cmdlets, SQL/KQL/SPL-style query syntax). Only matching lines
survive; narrative sentences and plain command output are discarded.
This is deliberately conservative false negatives (missing a real
command written in an unrecognized style) are far less harmful than false
positives (proposing narrative or output as "the command"), especially
since a lead reviews every suggestion before it reaches a template.
"""
from __future__ import annotations
import re
_FENCE_RE = re.compile(r"```(?:\w*\n)?(.*?)```|~~~(?:\w*\n)?(.*?)~~~", re.DOTALL)
# Leading shell/PowerShell prompt markers — stripped, not treated as
# evidence by themselves (the remainder still has to look like a command).
_PROMPT_RE = re.compile(r"^\s*(?:PS(?:\s+\S+)?>|[$#>])\s+")
# Signatures that indicate "this line is a command or query", not prose.
_COMMAND_LINE_RE = re.compile(
r"""
^\s*(?:PS(?:\s+\S+)?>|[$#>])\s*\S # explicit shell/PowerShell prompt with something after it
|
(?-i:\b[A-Z][a-zA-Z]+-[A-Z][a-zA-Z]+\b) # PowerShell Verb-Noun cmdlet, e.g. Get-Process
# (case-sensitive even though the rest of this
# pattern is IGNORECASE — otherwise lowercase
# hyphenated prose like "well-known" false-matches)
|
^\s*(?:sudo|chmod|chown|curl|wget|nmap|ssh|scp|python3?|bash|sh|zsh|
powershell(?:\.exe)?|cmd(?:\.exe)?|reg(?:\.exe)?|net(?:\.exe)?|
netsh|wmic|schtasks|sc|rundll32|regsvr32|msiexec|certutil|
mimikatz\S*|cscript|wscript|osascript|openssl|systemctl|service|
reg\s+query|whoami|nslookup|dig|ping|tasklist|ps\s|kill|
Invoke-\S+|iex|dsquery|nltest|
sysmon\S*|wevtutil|auditpol|tcpdump|tshark|procmon\S*|autorunsc\S*|
volatility\S*)\b
|
\bSELECT\s+.+\s+FROM\b # SQL
|
\bsearch\s+index\s*= # SPL (explicit "search" keyword)
|
\bindex\s*=\S # SPL (bare "index=value")
|
\|\s*(?:where|project|summarize|extend|join|render)\b # KQL pipe syntax
""",
re.IGNORECASE | re.VERBOSE,
)
def extract_commands(text: str | None) -> str | None:
"""Return the command/query lines found in *text*, or None if none found."""
if not text or not text.strip():
return None
fence_matches = _FENCE_RE.findall(text)
if fence_matches:
blocks = [(a or b).strip() for a, b in fence_matches if (a or b).strip()]
if blocks:
return "\n\n".join(blocks)
kept: list[str] = []
for line in text.splitlines():
stripped = line.strip()
if not stripped:
continue
if _COMMAND_LINE_RE.search(stripped):
kept.append(_PROMPT_RE.sub("", stripped))
return "\n".join(kept) if kept else None
@@ -0,0 +1,180 @@
"""Procedure-improvement suggestion workflow.
When an operator submits a round with a filled-in procedure field, the
command(s) in it are extracted heuristically (see
``procedure_extraction_service``) and proposed as an update to the
originating template's suggested-procedure field. A suggestion is only
ever created when there's a real, novel improvement to propose — nothing
is written to the template until a lead reviews and approves it, and
approving one only ever appends new commands, never overwrites or drops
whatever earlier approved rounds already contributed.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy.orm import Session
from app.domain.errors import EntityNotFoundError, InvalidOperationError
from app.models.procedure_suggestion import ProcedureSuggestion
from app.models.test import Test
from app.models.test_template import TestTemplate
from app.models.user import User
from app.services.notification_service import notify_role
from app.services.procedure_extraction_service import extract_commands
# Which Test field holds the operator's free-text procedure, and which
# TestTemplate field it's proposed as an improvement to, per team.
_SOURCE_FIELD = {"red": "procedure_text", "blue": "detect_procedure"}
_TEMPLATE_FIELD = {"red": "attack_procedure", "blue": "expected_detection"}
_LEAD_ROLE = {"red": "red_lead", "blue": "blue_lead"}
def _new_lines(current: str | None, suggested: str) -> list[str]:
"""Lines in *suggested* not already present verbatim in *current*.
Line-level, not whole-text: a later round's extraction is usually a
different subset/superset of commands than an earlier one, so an exact
string comparison would treat almost every resubmission as "novel" and
then, on approval, blow away whatever an earlier approved round already
contributed.
"""
existing = {line.strip() for line in (current or "").splitlines() if line.strip()}
seen: set[str] = set()
new: list[str] = []
for line in suggested.splitlines():
stripped = line.strip()
if not stripped or stripped in existing or stripped in seen:
continue
new.append(stripped)
seen.add(stripped)
return new
def _merge_procedure_text(current: str | None, suggested: str) -> str:
"""Append genuinely new lines from *suggested* onto *current*.
Approving a suggestion must only ever grow a template's procedure,
never replace it otherwise a narrower extraction from a later round
would silently erase commands an earlier approved round already added.
"""
current_stripped = (current or "").strip()
new = _new_lines(current, suggested)
if not new:
return current_stripped
if not current_stripped:
return "\n".join(new)
return current_stripped + "\n" + "\n".join(new)
def create_suggestion_from_submission(db: Session, test: Test, team: str) -> ProcedureSuggestion | None:
"""Propose a template procedure improvement from *test*'s just-submitted round.
Returns the created (unflushed-to-caller-commit) suggestion, or ``None``
when there's nothing worth proposing: the test wasn't created from a
template, no command could be extracted, the extraction matches what
the template already suggests, or an identical suggestion is already
pending review. Does not commit; caller uses UnitOfWork.
"""
if not test.source_template_id:
return None
procedure_text = getattr(test, _SOURCE_FIELD[team])
extracted = extract_commands(procedure_text)
if not extracted:
return None
template = db.query(TestTemplate).filter(TestTemplate.id == test.source_template_id).first()
if template is None:
return None
current = getattr(template, _TEMPLATE_FIELD[team])
if not _new_lines(current, extracted):
return None
duplicate = (
db.query(ProcedureSuggestion)
.filter(
ProcedureSuggestion.template_id == template.id,
ProcedureSuggestion.team == team,
ProcedureSuggestion.suggested_text == extracted,
ProcedureSuggestion.status == "pending",
)
.first()
)
if duplicate is not None:
return None
submitter_id = test.red_tech_assignee if team == "red" else test.blue_tech_assignee
suggestion = ProcedureSuggestion(
template_id=template.id,
team=team,
suggested_text=extracted,
source_test_id=test.id,
submitted_by=submitter_id,
)
db.add(suggestion)
db.flush()
notify_role(
db,
role=_LEAD_ROLE[team],
type="procedure_suggestion",
title="New procedure suggestion for review",
message=f'A {team} procedure improvement was proposed for template "{template.name}".',
entity_type="procedure_suggestion",
entity_id=suggestion.id,
)
return suggestion
def list_pending_suggestions(
db: Session, *, team: str | None = None, source_test_id: uuid.UUID | None = None,
) -> list[ProcedureSuggestion]:
"""List pending suggestions, optionally filtered to one team and/or the
specific test that originated them (used to block a lead's test-detail
view on a suggestion awaiting their review)."""
query = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.status == "pending")
if team is not None:
query = query.filter(ProcedureSuggestion.team == team)
if source_test_id is not None:
query = query.filter(ProcedureSuggestion.source_test_id == source_test_id)
return query.order_by(ProcedureSuggestion.created_at.asc()).all()
def _get_pending_suggestion_or_raise(db: Session, suggestion_id: uuid.UUID) -> ProcedureSuggestion:
suggestion = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.id == suggestion_id).first()
if suggestion is None:
raise EntityNotFoundError("ProcedureSuggestion", str(suggestion_id))
if suggestion.status != "pending":
raise InvalidOperationError("This suggestion has already been reviewed.")
return suggestion
def approve_suggestion(db: Session, suggestion_id: uuid.UUID, user: User) -> ProcedureSuggestion:
"""Approve a suggestion: write it into the template and mark reviewed. Does not commit."""
suggestion = _get_pending_suggestion_or_raise(db, suggestion_id)
template = db.query(TestTemplate).filter(TestTemplate.id == suggestion.template_id).first()
if template is None:
raise EntityNotFoundError("TestTemplate", str(suggestion.template_id))
current_text = getattr(template, _TEMPLATE_FIELD[suggestion.team])
setattr(template, _TEMPLATE_FIELD[suggestion.team], _merge_procedure_text(current_text, suggestion.suggested_text))
suggestion.status = "approved"
suggestion.reviewed_by = user.id
suggestion.reviewed_at = datetime.utcnow()
db.flush()
return suggestion
def reject_suggestion(db: Session, suggestion_id: uuid.UUID, user: User) -> ProcedureSuggestion:
"""Reject a suggestion without touching the template. Does not commit."""
suggestion = _get_pending_suggestion_or_raise(db, suggestion_id)
suggestion.status = "rejected"
suggestion.reviewed_by = user.id
suggestion.reviewed_at = datetime.utcnow()
db.flush()
return suggestion
+6 -5
View File
@@ -177,8 +177,9 @@ def process_callback(db: Session, request_data: dict) -> User:
name_id = auth.get_nameid()
# Attribute claim URIs — defaults support both plain names and Azure AD full URIs
# (attr_username is no longer read: email is the sole login identifier
# platform-wide, username always mirrors it — see below.)
email_attr = cfg.attr_email or "email"
username_attr = cfg.attr_username or "email" # Azure AD: use email as username
role_attr = cfg.attr_role or "http://schemas.microsoft.com/ws/2008/06/identity/claims/role"
# Resolve email: try configured attr → Azure email claim URI → NameID
@@ -189,9 +190,9 @@ def process_callback(db: Session, request_data: dict) -> User:
or ""
)
# Resolve username: keep full email (e.g. user@company.com) to avoid collisions with local accounts
raw_username = _first_attr(attrs, username_attr) or email or name_id or ""
username = raw_username.strip() or email.split("@")[0] or name_id
# Email is the unique login identifier platform-wide — username always
# mirrors it (see User model), never a separately-provisioned IdP value.
username = email
# Resolve role: try configured attr → Azure role claim URI → default
role = (
@@ -202,7 +203,7 @@ def process_callback(db: Session, request_data: dict) -> User:
)
# Validate role
valid_roles = {"admin", "red_lead", "blue_lead", "red_tech", "blue_tech", "viewer"}
valid_roles = {"admin", "red_lead", "blue_lead", "red_tech", "blue_tech", "manager", "viewer"}
if role not in valid_roles:
log.warning("SSO: unknown role '%s' for user '%s', falling back to default", role, username)
role = cfg.default_role or "viewer"
@@ -0,0 +1,119 @@
"""Operator template-proposal workflow.
Leads create test templates directly (see test_template_service.create_template,
gated to red_lead/blue_lead in the router). An operator (red_tech/blue_tech)
gets the same "propose a template" action, but nothing is written to the
live catalog until a lead on their team reviews it the lead can approve
as-is, approve with edits, or discard it outright.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy.orm import Session
from app.domain.errors import EntityNotFoundError, InvalidOperationError
from app.models.template_suggestion import TemplateSuggestion
from app.models.test_template import TestTemplate
from app.models.user import User
from app.services.notification_service import notify_role
from app.services.test_template_service import create_template, validate_mitre_technique_id
_TEAM_BY_ROLE = {"red_tech": "red", "blue_tech": "blue"}
_LEAD_ROLE = {"red": "red_lead", "blue": "blue_lead"}
_TEMPLATE_FIELDS = (
"mitre_technique_id", "name", "description", "source", "source_url",
"attack_procedure", "expected_detection", "platform", "tool_suggested",
"severity", "atomic_test_id", "suggested_remediation",
)
def team_for_submitter(user: User) -> str:
"""Which team a submission belongs to, based on the submitter's role."""
team = _TEAM_BY_ROLE.get(user.role)
if team is None:
raise InvalidOperationError("Only red_tech/blue_tech operators can propose templates")
return team
def create_template_suggestion(db: Session, submitter: User, **fields: object) -> TemplateSuggestion:
"""Create a pending template suggestion and notify the submitter's lead.
Does not commit; caller uses UnitOfWork.
"""
team = team_for_submitter(submitter)
validate_mitre_technique_id(db, fields["mitre_technique_id"])
suggestion = TemplateSuggestion(team=team, submitted_by=submitter.id, **fields)
db.add(suggestion)
db.flush()
notify_role(
db,
role=_LEAD_ROLE[team],
type="template_suggestion",
title="New test template proposed for review",
message=f'A new template, "{suggestion.name}", was proposed and needs your review.',
entity_type="template_suggestion",
entity_id=suggestion.id,
)
return suggestion
def list_pending_template_suggestions(db: Session, *, team: str | None = None) -> list[TemplateSuggestion]:
"""List pending template suggestions, optionally filtered to one team."""
query = db.query(TemplateSuggestion).filter(TemplateSuggestion.status == "pending")
if team is not None:
query = query.filter(TemplateSuggestion.team == team)
return query.order_by(TemplateSuggestion.created_at.asc()).all()
def _get_pending_suggestion_or_raise(db: Session, suggestion_id: uuid.UUID) -> TemplateSuggestion:
suggestion = db.query(TemplateSuggestion).filter(TemplateSuggestion.id == suggestion_id).first()
if suggestion is None:
raise EntityNotFoundError("TemplateSuggestion", str(suggestion_id))
if suggestion.status != "pending":
raise InvalidOperationError("This template suggestion has already been reviewed.")
return suggestion
def approve_template_suggestion(
db: Session,
suggestion_id: uuid.UUID,
user: User,
overrides: dict | None = None,
) -> tuple[TemplateSuggestion, TestTemplate]:
"""Approve a suggestion: create the real TestTemplate and mark it reviewed.
*overrides* lets the reviewing lead adjust any field before it goes
live unset/None fields fall back to what the operator submitted.
Does not commit; caller uses UnitOfWork.
"""
suggestion = _get_pending_suggestion_or_raise(db, suggestion_id)
fields = {name: getattr(suggestion, name) for name in _TEMPLATE_FIELDS}
for key, value in (overrides or {}).items():
if value is not None and key in fields:
fields[key] = value
template = create_template(db, **fields)
db.flush()
suggestion.status = "approved"
suggestion.reviewed_by = user.id
suggestion.reviewed_at = datetime.utcnow()
suggestion.created_template_id = template.id
db.flush()
return suggestion, template
def reject_template_suggestion(db: Session, suggestion_id: uuid.UUID, user: User) -> TemplateSuggestion:
"""Discard a suggestion without creating a template. Does not commit."""
suggestion = _get_pending_suggestion_or_raise(db, suggestion_id)
suggestion.status = "rejected"
suggestion.reviewed_by = user.id
suggestion.reviewed_at = datetime.utcnow()
db.flush()
return suggestion
+11 -4
View File
@@ -6,8 +6,11 @@ Each user authenticates to Tempo with their own personal Tempo API token,
stored in ``user.tempo_api_token``. This is different from the Jira API token.
Obtain a Tempo token at: Jira Apps Tempo Settings API Integration.
The global ``settings.TEMPO_ENABLED`` flag acts as a kill-switch. When False,
all Tempo calls are silently skipped regardless of whether users have tokens.
The global ``settings.TEMPO_ENABLED`` env var is an optional hard kill-switch
for ops (e.g. disable entirely in a demo environment). It defaults to False,
but that default is bypassed automatically once an admin or personal Tempo
token is actually configured configuring a token via Settings is enough
to turn Tempo sync on, same as Jira's DB-backed "enabled" toggle.
What goes to Tempo
------------------
@@ -223,8 +226,12 @@ def auto_log_test_worklog(
logger.debug("Skipping Tempo sync for activity_type=%s", activity_type)
return None
# Global kill-switch
if not settings.TEMPO_ENABLED:
# Global kill-switch — bypassed once an admin or personal Tempo token
# is actually configured, mirroring how Jira's DB-backed "enabled"
# toggle takes priority over its env-var default. Without this, an
# admin who configures a Tempo token via Settings still gets silent
# no-ops because TEMPO_ENABLED defaults to False and has no UI toggle.
if not settings.TEMPO_ENABLED and not has_admin_tempo_configured(db) and not has_tempo_configured(user):
return None
# Compute duration from test timestamps when not supplied by the caller
+196 -18
View File
@@ -10,7 +10,8 @@ from datetime import datetime
from typing import Any
# Import Session, joinedload from sqlalchemy.orm
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func, or_
from sqlalchemy.orm import Query, Session, joinedload
# Import from app.domain.errors
from app.domain.errors import (
@@ -18,7 +19,7 @@ from app.domain.errors import (
EntityNotFoundError,
PermissionViolation,
)
from app.models.enums import TestState
from app.models.enums import DataClassification, TestState
from app.models.technique import Technique
from app.models.test import Test
from app.models.test_template import TestTemplate
@@ -26,29 +27,53 @@ from app.models.campaign import CampaignTest
from app.models.audit import AuditLog
from app.utils import escape_like
# Tactics whose test data is more likely to touch customer content, PII, or
# high-impact operational detail — these get an initial classification of
# 'pii' rather than the 'internal_use_only' baseline. This is a starting
# point only; the assigned operator or lead can always correct it.
_RESTRICTED_TACTICS = frozenset({
"exfiltration", "collection", "credential-access", "impact",
})
# Define function list_tests
def list_tests(
# Entry: db
def determine_initial_classification(technique: Technique | None) -> str:
"""Best-effort initial data classification for a new test.
Every security test reveals internal attack/defense posture, so the
baseline is 'internal_use_only' never public or general use by
default. Escalated to 'pii' when the technique's tactic implies
customer data, PII, or destructive impact.
"""
if technique and technique.tactic and technique.tactic.lower() in _RESTRICTED_TACTICS:
return DataClassification.pii.value
return DataClassification.internal_use_only.value
def _build_test_query(
db: Session,
*,
# Entry: state
state: str | None = None,
# Entry: technique_id
technique_id: uuid.UUID | None = None,
# Entry: platform
technique_search: str | None = None,
platform: str | None = None,
# Entry: created_by
created_by: uuid.UUID | None = None,
# Entry: pending_validation_side
pending_validation_side: str | None = None,
reviewer_id: uuid.UUID | None = None,
not_in_any_campaign: bool = False,
offset: int = 0,
# Entry: limit
limit: int = 50,
) -> list[Test]:
"""Return a paginated list of tests with optional filters."""
query = db.query(Test).options(joinedload(Test.technique))
attack_success: str | None = None,
detection_result: str | None = None,
validated_from: datetime | None = None,
validated_to: datetime | None = None,
) -> Query:
"""Build the filtered Test query shared by list_tests() and count_tests().
Kept as a single source of truth so the displayed page of results and
the total count it's paginated against can never drift apart.
"""
query = db.query(Test).options(
joinedload(Test.technique),
joinedload(Test.red_tech_assigned_user), joinedload(Test.blue_tech_assigned_user),
)
# Check: state
if state:
@@ -58,6 +83,16 @@ def list_tests(
if technique_id:
# Assign query = query.filter(Test.technique_id == technique_id)
query = query.filter(Test.technique_id == technique_id)
# Free-text technique filter (MITRE ID or name) — doesn't require the
# caller to already know the technique's UUID like technique_id does.
if technique_search:
pattern = f"%{escape_like(technique_search)}%"
query = query.join(Technique, Test.technique_id == Technique.id).filter(
or_(
Technique.mitre_id.ilike(pattern),
Technique.name.ilike(pattern),
)
)
# Check: platform
if platform:
# Assign query = query.filter(Test.platform.ilike(f"%{escape_like(platform)}%"))
@@ -80,14 +115,123 @@ def list_tests(
Test.state == TestState.in_review,
Test.blue_validation_status.in_(["pending", None]),
)
# "My reviews" — tests currently assigned to *reviewer_id* at the
# red_review/blue_review lead-review gate (distinct from
# pending_validation_side, which covers the later in_review stage).
if reviewer_id:
if state == TestState.red_review.value:
query = query.filter(Test.red_reviewer_assignee == reviewer_id)
elif state == TestState.blue_review.value:
query = query.filter(Test.blue_reviewer_assignee == reviewer_id)
else:
query = query.filter(
Test.state.in_([TestState.red_review, TestState.blue_review]),
or_(
Test.red_reviewer_assignee == reviewer_id,
Test.blue_reviewer_assignee == reviewer_id,
),
)
if not_in_any_campaign:
linked = db.query(CampaignTest.test_id).distinct().subquery()
query = query.filter(~Test.id.in_(linked))
# Return query.order_by(Test.created_at.desc()).offset(offset).limit(limit)....
if attack_success:
query = query.filter(Test.attack_success == attack_success)
if detection_result:
query = query.filter(Test.detection_result == detection_result)
# Validated-date range: whichever lead validated last (mirrors the
# "Validated" column shown on the Validated Tests page).
if validated_from or validated_to:
validated_at = func.coalesce(Test.blue_validated_at, Test.red_validated_at)
if validated_from:
query = query.filter(validated_at >= validated_from)
if validated_to:
query = query.filter(validated_at <= validated_to)
return query
# Define function list_tests
def list_tests(
db: Session,
*,
state: str | None = None,
technique_id: uuid.UUID | None = None,
technique_search: str | None = None,
platform: str | None = None,
created_by: uuid.UUID | None = None,
pending_validation_side: str | None = None,
# Entry: reviewer_id — "my reviews" queue for the red_review/blue_review gate stage
reviewer_id: uuid.UUID | None = None,
not_in_any_campaign: bool = False,
attack_success: str | None = None,
detection_result: str | None = None,
validated_from: datetime | None = None,
validated_to: datetime | None = None,
offset: int = 0,
# Entry: limit
limit: int = 50,
) -> list[Test]:
"""Return a paginated list of tests with optional filters."""
query = _build_test_query(
db,
state=state,
technique_id=technique_id,
technique_search=technique_search,
platform=platform,
created_by=created_by,
pending_validation_side=pending_validation_side,
reviewer_id=reviewer_id,
not_in_any_campaign=not_in_any_campaign,
attack_success=attack_success,
detection_result=detection_result,
validated_from=validated_from,
validated_to=validated_to,
)
return query.order_by(Test.created_at.desc()).offset(offset).limit(limit).all()
def count_tests(
db: Session,
*,
state: str | None = None,
technique_id: uuid.UUID | None = None,
technique_search: str | None = None,
platform: str | None = None,
created_by: uuid.UUID | None = None,
pending_validation_side: str | None = None,
reviewer_id: uuid.UUID | None = None,
not_in_any_campaign: bool = False,
attack_success: str | None = None,
detection_result: str | None = None,
validated_from: datetime | None = None,
validated_to: datetime | None = None,
) -> int:
"""Return the total count of tests matching the same filters as list_tests().
Lets the frontend show a true total even when the page size caps the
number of rows actually returned (e.g. GET /tests's limit<=200).
"""
query = _build_test_query(
db,
state=state,
technique_id=technique_id,
technique_search=technique_search,
platform=platform,
created_by=created_by,
pending_validation_side=pending_validation_side,
reviewer_id=reviewer_id,
not_in_any_campaign=not_in_any_campaign,
attack_success=attack_success,
detection_result=detection_result,
validated_from=validated_from,
validated_to=validated_to,
)
return query.count()
# Define function create_test
def create_test(
# Entry: db
@@ -121,6 +265,8 @@ def create_test(
# Raise EntityNotFoundError
raise EntityNotFoundError("Technique", str(technique_id))
data_classification = fields.pop("data_classification", None) or determine_initial_classification(technique)
# Assign test = Test(
test = Test(
# Keyword argument: technique_id
@@ -130,6 +276,7 @@ def create_test(
# Keyword argument: state
state=TestState.draft,
created_at=datetime.utcnow(), # explicit — DB column has no server default
data_classification=data_classification,
**fields,
)
# Stage new record(s) for database insertion
@@ -157,6 +304,7 @@ def create_test_from_template(
platform_override: str | None = None,
procedure_text_override: str | None = None,
tool_used_override: str | None = None,
detect_procedure_override: str | None = None,
) -> Test:
"""Instantiate a Test from a TestTemplate.
@@ -217,12 +365,15 @@ def create_test_from_template(
platform=platform_override if platform_override is not None else template.platform,
procedure_text=procedure_text_override if procedure_text_override is not None else template.attack_procedure,
tool_used=tool_used_override if tool_used_override is not None else template.tool_suggested,
detect_procedure=detect_procedure_override if detect_procedure_override is not None else template.expected_detection,
remediation_steps=template.suggested_remediation,
# Keyword argument: created_by
created_by=creator_id,
source_template_id=template.id,
# Keyword argument: state
state=TestState.draft,
created_at=datetime.utcnow(), # explicit — DB column has no server default
data_classification=determine_initial_classification(technique),
)
# Stage new record(s) for database insertion
db.add(test)
@@ -248,7 +399,12 @@ def get_test_detail(db: Session, test_id: uuid.UUID) -> Test:
# Assign test = (
test = (
db.query(Test)
.options(joinedload(Test.evidences), joinedload(Test.technique))
.options(
joinedload(Test.evidences), joinedload(Test.technique),
joinedload(Test.round_history),
joinedload(Test.red_tech_assigned_user), joinedload(Test.blue_tech_assigned_user),
joinedload(Test.red_reviewer), joinedload(Test.blue_reviewer),
)
.filter(Test.id == test_id)
# Chain .first() call
.first()
@@ -282,6 +438,28 @@ def get_test_or_raise(db: Session, test_id: uuid.UUID) -> Test:
return test
def delete_test(db: Session, test_id: uuid.UUID) -> None:
"""Delete a standalone, not-yet-started test from the queue.
Only a test still in ``draft`` (execution hasn't started) and not
linked to any campaign may be deleted this way a test already being
worked on, or one that's part of a campaign's plan, must be handled
through the normal workflow/campaign-modification paths instead.
Raises EntityNotFoundError, BusinessRuleViolation. Does not commit;
caller commits.
"""
test = get_test_or_raise(db, test_id)
if test.state != TestState.draft:
raise BusinessRuleViolation("Only tests that haven't started yet can be deleted")
in_campaign = db.query(CampaignTest).filter(CampaignTest.test_id == test.id).first()
if in_campaign:
raise BusinessRuleViolation("Cannot delete a test that belongs to a campaign")
db.delete(test)
# Define function get_test_with_technique
def get_test_with_technique(db: Session, test_id: uuid.UUID) -> Test:
"""Fetch a test with technique joined. Raises EntityNotFoundError if not found.
+93 -41
View File
@@ -12,15 +12,53 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import Session
# Import EntityNotFoundError from app.domain.errors
from app.domain.errors import EntityNotFoundError
from app.domain.errors import BusinessRuleViolation, EntityNotFoundError
# Import TestTemplate from app.models.test_template
from app.models.test_template import TestTemplate
from app.models.technique import Technique
from app.models.test import Test
# Import escape_like from app.utils
from app.utils import escape_like
def _build_template_query(
db: Session,
*,
source: str | None = None,
platform: str | None = None,
severity: str | None = None,
mitre_technique_id: str | None = None,
search: str | None = None,
is_active: bool | None = None,
):
"""Build the filtered TestTemplate query shared by list_templates() and
count_templates() a single source of truth so a paginated page of
results and the total count it's paginated against can never drift
apart (same pattern used for tests in test_crud_service.py)."""
query = db.query(TestTemplate)
if is_active is not None:
query = query.filter(TestTemplate.is_active == is_active)
if source:
query = query.filter(TestTemplate.source == source)
if platform:
query = query.filter(TestTemplate.platform.ilike(f"%{escape_like(platform)}%"))
if severity:
query = query.filter(TestTemplate.severity == severity)
if mitre_technique_id:
query = query.filter(TestTemplate.mitre_technique_id == mitre_technique_id)
if search:
pattern = f"%{escape_like(search)}%"
query = query.filter(
or_(
TestTemplate.name.ilike(pattern),
TestTemplate.description.ilike(pattern),
)
)
return query
# Define function list_templates
def list_templates(
# Entry: db
@@ -44,57 +82,56 @@ def list_templates(
limit: int = 50,
) -> list:
"""Return paginated, filterable list of test templates."""
# Assign query = db.query(TestTemplate)
query = db.query(TestTemplate)
# Check: is_active is not None
if is_active is not None:
# Assign query = query.filter(TestTemplate.is_active == is_active)
query = query.filter(TestTemplate.is_active == is_active)
query = _build_template_query(
db, source=source, platform=platform, severity=severity,
mitre_technique_id=mitre_technique_id, search=search, is_active=is_active,
)
# Check: source
if source:
# Assign query = query.filter(TestTemplate.source == source)
query = query.filter(TestTemplate.source == source)
# Check: platform
if platform:
# Assign query = query.filter(TestTemplate.platform.ilike(f"%{escape_like(platform)}...
query = query.filter(TestTemplate.platform.ilike(f"%{escape_like(platform)}%"))
# Check: severity
if severity:
# Assign query = query.filter(TestTemplate.severity == severity)
query = query.filter(TestTemplate.severity == severity)
# Check: mitre_technique_id
if mitre_technique_id:
# Assign query = query.filter(TestTemplate.mitre_technique_id == mitre_technique_id)
query = query.filter(TestTemplate.mitre_technique_id == mitre_technique_id)
# Check: search
if search:
# Assign pattern = f"%{escape_like(search)}%"
pattern = f"%{escape_like(search)}%"
# Assign query = query.filter(
query = query.filter(
or_(
TestTemplate.name.ilike(pattern),
TestTemplate.description.ilike(pattern),
)
)
# Assign templates = (
templates = (
query
# Chain .order_by() call
.order_by(TestTemplate.mitre_technique_id, TestTemplate.name)
# Chain .offset() call
.offset(offset)
# Chain .limit() call
.limit(limit)
# Chain .all() call
.all()
)
# Return templates
# Attach existing_test_count per template — lets the catalog warn before
# creating a likely-duplicate test for a technique that already has one.
if templates:
mitre_ids = {t.mitre_technique_id for t in templates}
counts = dict(
db.query(Technique.mitre_id, func.count(Test.id))
.join(Test, Test.technique_id == Technique.id)
.filter(Technique.mitre_id.in_(mitre_ids))
.group_by(Technique.mitre_id)
.all()
)
for t in templates:
t.existing_test_count = counts.get(t.mitre_technique_id, 0)
return templates
def count_templates(
db: Session,
*,
source: str | None = None,
platform: str | None = None,
severity: str | None = None,
mitre_technique_id: str | None = None,
search: str | None = None,
is_active: bool | None = None,
) -> int:
"""Return the total count of templates matching the same filters as
list_templates() lets the catalog UI show a true page count even
though the list endpoint caps how many rows it returns per request."""
query = _build_template_query(
db, source=source, platform=platform, severity=severity,
mitre_technique_id=mitre_technique_id, search=search, is_active=is_active,
)
return query.count()
# Define function get_template_stats
def get_template_stats(db: Session) -> dict:
"""Return catalog statistics: totals by source, platform, active/inactive."""
@@ -198,9 +235,22 @@ def get_template_or_raise(db: Session, template_id: uuid.UUID) -> TestTemplate:
return template
def validate_mitre_technique_id(db: Session, mitre_technique_id: str) -> None:
"""Raise BusinessRuleViolation unless *mitre_technique_id* matches a real,
already-synced MITRE ATT&CK technique free text like "made up" or a
typo'd ID would otherwise silently create an orphaned template that can
never be found via technique lookups or coverage reporting."""
exists = db.query(Technique).filter(Technique.mitre_id == mitre_technique_id).first()
if not exists:
raise BusinessRuleViolation(
f"'{mitre_technique_id}' is not a known MITRE ATT&CK technique ID"
)
# Define function create_template
def create_template(db: Session, **fields: object) -> TestTemplate:
"""Create a test template from keyword args (e.g. payload.model_dump()). Does NOT commit."""
validate_mitre_technique_id(db, fields["mitre_technique_id"])
# Assign template = TestTemplate(**fields)
template = TestTemplate(**fields)
# Stage new record(s) for database insertion
@@ -212,6 +262,8 @@ def create_template(db: Session, **fields: object) -> TestTemplate:
# Define function update_template
def update_template(db: Session, template_id: uuid.UUID, **fields: object) -> TestTemplate:
"""Update an existing template. Raises EntityNotFoundError if not found. Does NOT commit."""
if fields.get("mitre_technique_id"):
validate_mitre_technique_id(db, fields["mitre_technique_id"])
# Assign template = get_template_or_raise(db, template_id)
template = get_template_or_raise(db, template_id)
# Iterate over fields.items()
+221 -13
View File
@@ -28,15 +28,18 @@ from sqlalchemy.orm import Session
from app.config import settings
from app.domain.exceptions import BusinessRuleViolation, InvalidOperationError
from app.domain.test_entity import TestEntity
from app.models.campaign import Campaign, CampaignTest
from app.models.enums import TestState, TeamSide
from app.models.evidence import Evidence
from app.models.test import Test
from app.models.test_round_history import TestRoundHistory
from app.models.user import User
from app.services.audit_service import log_action
from app.services.notification_service import (
notify_test_state_change,
create_notification,
notify_role_with_email,
notify_roles_by_email,
)
# Assign logger = logging.getLogger(__name__)
@@ -174,8 +177,26 @@ def transition_state(
# ---------------------------------------------------------------------------
def _get_campaign_start_date(db: Session, test_id) -> datetime | None:
"""Return the scheduled start_date of the campaign *test_id* belongs to, if any."""
campaign = (
db.query(Campaign)
.join(CampaignTest, CampaignTest.campaign_id == Campaign.id)
.filter(CampaignTest.test_id == test_id)
.first()
)
return campaign.start_date if campaign else None
def start_execution(db: Session, test: Test, user: User) -> Test:
"""Move from ``draft`` → ``red_executing``."""
campaign_start_date = _get_campaign_start_date(db, test.id)
if campaign_start_date and campaign_start_date > datetime.utcnow():
raise BusinessRuleViolation(
"This test's campaign is scheduled to start on "
f"{campaign_start_date.date().isoformat()} — it cannot be started before then."
)
entity = TestEntity.from_orm(test)
# Call entity.start_execution()
entity.start_execution()
@@ -314,6 +335,18 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
except Exception as e:
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
try:
from app.services.jira_service import push_rt_submitted
push_rt_submitted(db, test)
except Exception as e:
logger.warning("Jira RT end date push failed for test %s: %s", test.id, e, exc_info=True)
try:
from app.services.procedure_suggestion_service import create_suggestion_from_submission
create_suggestion_from_submission(db, test, "red")
except Exception as e:
logger.warning("Procedure suggestion extraction failed for test %s: %s", test.id, e, exc_info=True)
return test
@@ -352,18 +385,43 @@ def reopen_red_review(db: Session, test: Test, user: User, notes: str) -> Test:
if not notes or not notes.strip():
raise InvalidOperationError("A comment is required when reopening a test for rework")
# Archive this round's data before resetting the live fields — reopening
# is for a fresh attempt, not for erasing what Blue Team already saw
# about the first one.
archived_round = TestRoundHistory(
test_id=test.id, team="red", round_number=test.red_round_number or 1,
started_at=test.red_started_at, ended_at=datetime.utcnow(),
paused_seconds=test.red_paused_seconds or 0,
procedure_text=test.procedure_text, tool_used=test.tool_used,
attack_success=test.attack_success, red_summary=test.red_summary,
execution_start_time=test.execution_start_time, execution_end_time=test.execution_end_time,
review_notes=notes.strip(), reviewed_by=user.id,
)
db.add(archived_round)
entity = TestEntity.from_orm(test)
entity.reopen_red_review()
entity.apply_to(test)
test.red_review_by = user.id
test.red_review_at = datetime.utcnow()
test.red_review_notes = notes.strip()
test.red_round_number = (test.red_round_number or 1) + 1
# Only the per-round verdict/timing fields get a blank slate — the
# operator must explicitly answer "was THIS attempt successful" and
# "when did THIS attempt run" rather than silently inheriting round 1's
# answer. The old values aren't lost: they're in round_history and
# shown on the archived Phase Timeline card. Free-text fields
# (procedure, tool, summary) are NOT cleared — the operator edits them
# in place for the new round instead of retyping from scratch.
test.attack_success = None
test.execution_start_time = None
test.execution_end_time = None
db.flush()
log_action(
db, user_id=user.id, action="reopen_red_review",
entity_type="test", entity_id=test.id,
details={"notes": notes, "test_name": test.name},
details={"notes": notes, "test_name": test.name, "round_number": archived_round.round_number},
)
if test.red_tech_assignee:
@@ -377,6 +435,20 @@ def reopen_red_review(db: Session, test: Test, user: User, notes: str) -> Test:
except Exception as e:
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
try:
from app.services.jira_service import push_test_event, push_round_archived
original_operator = (
db.query(User).filter(User.id == test.red_tech_assignee).first()
if test.red_tech_assignee else None
)
push_round_archived(db, test, user, round_data=archived_round)
push_test_event(
db, test, user, "red_executing",
extra={"notes": notes.strip()}, assignee=original_operator,
)
except Exception as e:
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
return test
@@ -410,6 +482,18 @@ def start_blue_work(db: Session, test: Test, user: User) -> Test:
except Exception as e:
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
try:
from app.services.jira_service import push_bt_started
push_bt_started(db, test)
except Exception as e:
logger.warning("Jira BT start date push failed for test %s: %s", test.id, e, exc_info=True)
try:
from app.services.jira_service import push_bt_work_started
push_bt_work_started(db, test, user)
except Exception as e:
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
return test
@@ -494,6 +578,18 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
except Exception as e:
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
try:
from app.services.jira_service import push_bt_submitted
push_bt_submitted(db, test)
except Exception as e:
logger.warning("Jira BT end date push failed for test %s: %s", test.id, e, exc_info=True)
try:
from app.services.procedure_suggestion_service import create_suggestion_from_submission
create_suggestion_from_submission(db, test, "blue")
except Exception as e:
logger.warning("Procedure suggestion extraction failed for test %s: %s", test.id, e, exc_info=True)
return test
@@ -532,6 +628,19 @@ def reopen_blue_review(db: Session, test: Test, user: User, notes: str) -> Test:
if not notes or not notes.strip():
raise InvalidOperationError("A comment is required when reopening a test for rework")
# Archive this round's data before resetting the live fields — same
# reasoning as the red side: don't lose what was already evaluated.
archived_round = TestRoundHistory(
test_id=test.id, team="blue", round_number=test.blue_round_number or 1,
started_at=test.blue_work_started_at, ended_at=datetime.utcnow(),
paused_seconds=test.blue_paused_seconds or 0,
detection_result=test.detection_result, containment_result=test.containment_result,
detection_time=test.detection_time, containment_time=test.containment_time,
blue_summary=test.blue_summary, detect_procedure=test.detect_procedure,
review_notes=notes.strip(), reviewed_by=user.id,
)
db.add(archived_round)
entity = TestEntity.from_orm(test)
entity.reopen_blue_review()
entity.apply_to(test)
@@ -539,12 +648,21 @@ def reopen_blue_review(db: Session, test: Test, user: User, notes: str) -> Test:
test.blue_review_by = user.id
test.blue_review_at = datetime.utcnow()
test.blue_review_notes = notes.strip()
test.blue_round_number = (test.blue_round_number or 1) + 1
# Same split as the red side: verdict/timing fields get a blank slate
# for this round (old values live on in round_history + the archived
# Phase Timeline card), but blue_summary is free text the operator
# edits in place rather than retyping.
test.detection_result = None
test.containment_result = None
test.detection_time = None
test.containment_time = None
db.flush()
log_action(
db, user_id=user.id, action="reopen_blue_review",
entity_type="test", entity_id=test.id,
details={"notes": notes, "test_name": test.name},
details={"notes": notes, "test_name": test.name, "round_number": archived_round.round_number},
)
if test.blue_tech_assignee:
@@ -558,6 +676,13 @@ def reopen_blue_review(db: Session, test: Test, user: User, notes: str) -> Test:
except Exception as e:
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
try:
from app.services.jira_service import push_test_event, push_round_archived
push_round_archived(db, test, user, round_data=archived_round)
push_test_event(db, test, user, "blue_evaluating", extra={"notes": notes.strip()})
except Exception as e:
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
return test
@@ -637,6 +762,13 @@ def pause_timer(db: Session, test: Test, user: User) -> Test:
# Keyword argument: details
details={"state": test.state.value},
)
try:
from app.services.jira_service import push_pause_event
push_pause_event(db, test, user, resuming=False)
except Exception as e:
logger.warning("Jira pause push failed for test %s: %s", test.id, e, exc_info=True)
# Return test
return test
@@ -692,6 +824,13 @@ def resume_timer(db: Session, test: Test, user: User) -> Test:
# Keyword argument: details
details={"paused_seconds": paused_seconds, "state": test.state.value},
)
try:
from app.services.jira_service import push_pause_event
push_pause_event(db, test, user, resuming=True)
except Exception as e:
logger.warning("Jira resume push failed for test %s: %s", test.id, e, exc_info=True)
# Return test
return test
@@ -910,6 +1049,13 @@ def validate_as_red_lead(
},
)
notify_roles_by_email(
db, roles=["red_lead", "blue_lead"],
preference_key="email_on_all_team_validations",
subject=f"Red Lead Vote: {test.name}",
message=f'{user.full_name or user.username} cast a "{validation_status}" vote as Red Lead on test "{test.name}".',
exclude_user_id=user.id,
)
_dispatch_dual_validation_effects(db, test, entity, actor=user)
return test
@@ -975,6 +1121,13 @@ def validate_as_blue_lead(
},
)
notify_roles_by_email(
db, roles=["red_lead", "blue_lead"],
preference_key="email_on_all_team_validations",
subject=f"Blue Lead Vote: {test.name}",
message=f'{user.full_name or user.username} cast a "{validation_status}" vote as Blue Lead on test "{test.name}".',
exclude_user_id=user.id,
)
_dispatch_dual_validation_effects(db, test, entity, actor=user)
return test
@@ -1009,9 +1162,14 @@ def check_dual_validation(db: Session, test: Test) -> Test:
# Define function _dispatch_dual_validation_effects
def _dispatch_dual_validation_effects(
db: Session, test: Test, entity: TestEntity, actor: User | None = None
db: Session, test: Test, entity: TestEntity, actor: User | None = None,
extra: dict | None = None,
) -> None:
"""Dispatch side effects (notifications, cache, Jira) based on domain events."""
"""Dispatch side effects (notifications, cache, Jira) based on domain events.
``extra`` is forwarded onto the Jira comment (e.g. a manager's decision
notes) see ``push_test_event``'s ``extra`` kwarg.
"""
for event in entity.events:
# Check: event.name == "dual_validation_approved"
if event.name == "dual_validation_approved":
@@ -1040,7 +1198,7 @@ def _dispatch_dual_validation_effects(
if actor:
try:
from app.services.jira_service import push_test_event
push_test_event(db, test, actor, "validated")
push_test_event(db, test, actor, "validated", extra=extra)
except Exception as e:
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
@@ -1060,11 +1218,17 @@ def _dispatch_dual_validation_effects(
if actor:
try:
from app.services.jira_service import push_test_event
push_test_event(db, test, actor, "rejected")
push_test_event(db, test, actor, "rejected", extra=extra)
except Exception as e:
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
elif event.name == "dual_validation_disputed":
if actor:
try:
from app.services.jira_service import push_test_event
push_test_event(db, test, actor, "disputed")
except Exception as e:
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
# Notify the lead who APPROVED asking them to review the rejection
_notify_validation_conflict(db, test, actor)
# Notify managers too, in case the dispute stalls and needs escalation
@@ -1147,13 +1311,12 @@ def resolve_dispute(db: Session, test: Test, user: User, target_team: str, notes
if target_team not in ("red", "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_blue_approver = user.role == "blue_lead" and test.blue_validation_status == "approved"
if not (is_red_approver or is_blue_approver):
raise InvalidOperationError(
"Only the lead who approved can flip their vote to resolve this dispute"
)
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"
if not (is_red_approver or is_blue_approver):
raise InvalidOperationError(
"Only the lead who approved can flip their vote to resolve this dispute"
)
entity = TestEntity.from_orm(test)
entity.resolve_dispute_reject(target_team)
@@ -1198,6 +1361,51 @@ def resolve_dispute(db: Session, test: Test, user: User, target_team: str, notes
return test
def resolve_dispute_by_manager(db: Session, test: Test, user: User, outcome: str, notes: str | None = None) -> Test:
"""A manager makes the final call on a disputed test — validated or rejected.
Unlike ``resolve_dispute`` (only the approving lead flipping their own
vote), this is a manager override that ends the standoff directly,
without either lead having to concede. Reuses the same dual-validation
event dispatch as a normal in_review resolution so Jira/notifications
behave identically to an ordinary validated/rejected outcome.
"""
if test.state != TestState.disputed:
raise InvalidOperationError("Test is not in disputed state")
entity = TestEntity.from_orm(test)
entity.resolve_dispute_by_manager(outcome)
entity.apply_to(test)
db.flush()
log_action(
db, user_id=user.id, action="manager_resolve_dispute",
entity_type="test", entity_id=test.id,
details={"outcome": outcome, "notes": notes, "test_name": test.name},
)
jira_extra = {"Manager Decision": f"{user.username}" + (f"{notes}" if notes else "")}
_dispatch_dual_validation_effects(db, test, entity, actor=user, extra=jira_extra)
# Let both leads know the manager made the final call, not just whichever
# side "won" — the losing side needs to know just as much.
for lead_id in filter(None, {test.red_validated_by, test.blue_validated_by}):
try:
create_notification(
db, user_id=lead_id, type="manager_dispute_resolution",
title=f"Manager resolved disputed test as {outcome}",
message=(
f'{user.username} resolved the dispute on "{test.name}" as {outcome}.'
+ (f" Notes: {notes}" if notes else "")
),
entity_type="test", entity_id=test.id,
)
except Exception as e:
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
return test
# Define function handle_remediation_completed
def handle_remediation_completed(db: Session, test: Test, user: User) -> Test | None:
"""Create a re-test when remediation is completed.
+153 -11
View File
@@ -10,6 +10,9 @@ from __future__ import annotations
# Import uuid
import uuid
# Import secrets
import secrets
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
@@ -26,8 +29,8 @@ from app.domain.errors import (
# Import User from app.models.user
from app.models.user import User
# Assign VALID_ROLES = {"admin", "red_tech", "blue_tech", "red_lead", "blue_lead", "viewer"}
VALID_ROLES = {"admin", "red_tech", "blue_tech", "red_lead", "blue_lead", "viewer"}
# Assign VALID_ROLES = {"admin", "red_tech", "blue_tech", "red_lead", "blue_lead", "manager", "viewer"}
VALID_ROLES = {"admin", "red_tech", "blue_tech", "red_lead", "blue_lead", "manager", "viewer"}
# Define function list_users
@@ -42,27 +45,28 @@ def create_user(
# Entry: db
db: Session,
*,
# Entry: username
username: str,
# Entry: email
email: str | None,
email: str,
# Entry: password
password: str,
# Entry: role
role: str,
) -> User:
"""Create a new user.
"""Create a new user. Email is the unique login identifier.
Raises DuplicateEntityError if username already exists.
``username`` is derived from ``email`` it's kept internally (JWT,
audit logs) but always mirrors email, never a separate value.
Raises DuplicateEntityError if a user with this email already exists.
Raises BusinessRuleViolation if role is invalid.
Does not commit; the router handles that.
"""
# Assign existing = db.query(User).filter(User.username == username).first()
existing = db.query(User).filter(User.username == username).first()
# Assign existing = db.query(User).filter(User.email == email).first()
existing = db.query(User).filter(User.email == email).first()
# Check: existing
if existing:
# Raise DuplicateEntityError
raise DuplicateEntityError("User", "username", username)
raise DuplicateEntityError("User", "email", email)
# Check: role not in VALID_ROLES
if role not in VALID_ROLES:
@@ -74,7 +78,7 @@ def create_user(
# Assign user = User(
user = User(
# Keyword argument: username
username=username,
username=email,
# Keyword argument: email
email=email,
# Keyword argument: hashed_password
@@ -88,6 +92,40 @@ def create_user(
return user
def create_user_without_password(db: Session, *, full_name: str, email: str, role: str) -> User:
"""Create a new user without a password — they set their own via an
admin-triggered, emailed one-time link (see password_setup_service).
``username`` is derived from ``email`` since it's still needed
internally as the login identifier, but it's never surfaced in the UI.
Raises DuplicateEntityError if a user with this email/username already
exists. Raises BusinessRuleViolation if role is invalid. Does not
commit; the router handles that.
"""
existing = db.query(User).filter(User.email == email).first()
if existing:
raise DuplicateEntityError("User", "email", email)
if role not in VALID_ROLES:
raise BusinessRuleViolation(
f"Invalid role '{role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}"
)
user = User(
username=email,
email=email,
full_name=full_name,
# Unusable random password — the user can only ever get in via the
# emailed set-password link, never by guessing/being told a value.
hashed_password=hash_password(secrets.token_urlsafe(32)),
role=role,
must_change_password=True,
)
db.add(user)
return user
# Define function get_user_or_raise
def get_user_or_raise(db: Session, user_id: uuid.UUID) -> User:
"""Return a user by ID or raise EntityNotFoundError."""
@@ -122,11 +160,26 @@ def update_user(db: Session, user_id: uuid.UUID, **fields: object) -> User:
f"Invalid role '{update_data['role']}'. Must be one of: {', '.join(sorted(VALID_ROLES))}"
)
if update_data.get("extra_roles") is not None:
invalid = [r for r in update_data["extra_roles"] if r not in VALID_ROLES]
if invalid:
raise BusinessRuleViolation(
f"Invalid role(s) {', '.join(invalid)}. Must be one of: {', '.join(sorted(VALID_ROLES))}"
)
# Check: "password" in update_data
if "password" in update_data:
# Assign update_data["hashed_password"] = hash_password(str(update_data.pop("password")))
update_data["hashed_password"] = hash_password(str(update_data.pop("password")))
# Email is the unique login identifier — enforce uniqueness on change,
# and keep username (the internal id) mirrored to it.
if update_data.get("email") is not None and update_data["email"] != user.email:
existing = db.query(User).filter(User.email == update_data["email"], User.id != user_id).first()
if existing:
raise DuplicateEntityError("User", "email", update_data["email"])
update_data["username"] = update_data["email"]
# Iterate over update_data.items()
for field, value in update_data.items():
# Call setattr()
@@ -134,3 +187,92 @@ def update_user(db: Session, user_id: uuid.UUID, **fields: object) -> User:
# Return user
return user
def delete_user(db: Session, user_id: uuid.UUID, actor_id: uuid.UUID) -> None:
"""Permanently delete a user — only if they have no activity footprint.
Raises EntityNotFoundError if the user doesn't exist. Raises
BusinessRuleViolation if the caller is trying to delete themselves, or
if the user has any historical footprint (tests, evidence, worklogs,
audit entries, Jira links, procedure/template suggestions, reviewed
imports) deleting those users would either violate FK constraints or
silently erase audit trail we're required to keep; deactivate them
instead. Notifications and pending password-setup tokens are personal-
only records and are cleaned up as part of the deletion.
Does not commit; caller commits.
"""
user = get_user_or_raise(db, user_id)
if user_id == actor_id:
raise BusinessRuleViolation("You cannot delete your own account.")
from app.models.audit import AuditLog
from app.models.evaluation_import import EvaluationImport
from app.models.evidence import Evidence
from app.models.jira_link import JiraLink
from app.models.notification import Notification
from app.models.password_setup_token import PasswordSetupToken
from app.models.procedure_suggestion import ProcedureSuggestion
from app.models.template_suggestion import TemplateSuggestion
from app.models.test import Test
from app.models.test_round_history import TestRoundHistory
from app.models.worklog import Worklog
has_footprint = any(
db.query(model).filter(condition).first() is not None
for model, condition in [
(
Test,
(Test.created_by == user_id)
| (Test.red_validated_by == user_id)
| (Test.blue_validated_by == user_id)
| (Test.remediation_assignee == user_id)
| (Test.red_tech_assignee == user_id)
| (Test.blue_tech_assignee == user_id)
| (Test.red_reviewer_assignee == user_id)
| (Test.blue_reviewer_assignee == user_id),
),
(Evidence, Evidence.uploaded_by == user_id),
(AuditLog, AuditLog.user_id == user_id),
(JiraLink, JiraLink.created_by == user_id),
(Worklog, Worklog.user_id == user_id),
(ProcedureSuggestion, (ProcedureSuggestion.submitted_by == user_id) | (ProcedureSuggestion.reviewed_by == user_id)),
(TemplateSuggestion, (TemplateSuggestion.submitted_by == user_id) | (TemplateSuggestion.reviewed_by == user_id)),
(TestRoundHistory, TestRoundHistory.reviewed_by == user_id),
(EvaluationImport, EvaluationImport.imported_by == user_id),
]
)
if has_footprint:
raise BusinessRuleViolation(
"This user has activity history (tests, evidence, worklogs, audit "
"entries, or similar) and can't be permanently deleted — "
"deactivate them instead to keep the audit trail intact."
)
db.query(Notification).filter(Notification.user_id == user_id).delete()
db.query(PasswordSetupToken).filter(PasswordSetupToken.user_id == user_id).delete()
db.delete(user)
def switch_active_role(db: Session, user: User, new_role: str) -> User:
"""Swap *user*'s currently-active role with one from their extra_roles.
Roles never mix the user acts under exactly one role at a time, this
just changes which one. Raises BusinessRuleViolation if new_role isn't
one this user has been granted. Does not commit; caller commits.
"""
available = {user.role, *(user.extra_roles or [])}
if new_role not in available:
raise BusinessRuleViolation(f"Role '{new_role}' is not available to this user")
if new_role == user.role:
return user
old_role = user.role
remaining = [r for r in (user.extra_roles or []) if r != new_role]
remaining.append(old_role)
user.role = new_role
user.extra_roles = remaining
return user
@@ -0,0 +1,193 @@
"""Generic outbound-email sender — POSTs to an admin-configured webhook.
Replaces the old SMTP-based email system. Every notification email in the
platform (password setup/reset, test validated, campaign completed, new
MITRE techniques synced, etc.) goes through ``send_webhook_email`` here,
which POSTs a ``{to, subject, body}`` JSON payload to a single configured
webhook URL intended for a Power Automate flow that actually delivers
the email (the ``body`` is full HTML; the flow's "send email" step must
have its "Is HTML" option enabled). An optional API key, if configured,
is sent as an ``x-api-key`` header.
SMTP (``app.services.email_service``) is intentionally left in place but
unused and unreachable from the UI see that module's docstring.
"""
from __future__ import annotations
import base64
import html as html_lib
import logging
import re
from pathlib import Path
import requests
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
_WEBHOOK_URL_CONFIG_KEY = "email_webhook.url"
_WEBHOOK_API_KEY_CONFIG_KEY = "email_webhook.api_key"
_LOGO_PATH = Path(__file__).resolve().parent.parent / "assets" / "email_logo.png"
_URL_RE = re.compile(r"^https?://\S+$")
_logo_b64_cache: str | None = None
def _get_logo_base64() -> str:
"""Read + cache the inline email logo as a base64 string (empty if missing)."""
global _logo_b64_cache
if _logo_b64_cache is None:
try:
_logo_b64_cache = base64.b64encode(_LOGO_PATH.read_bytes()).decode("ascii")
except OSError:
logger.warning("Email logo asset missing at %s", _LOGO_PATH)
_logo_b64_cache = ""
return _logo_b64_cache
def _message_to_html(message: str) -> str:
"""Turn a plain-text message (paragraphs separated by blank lines) into
escaped HTML, rendering any lone-URL paragraph as a call-to-action button.
"""
parts: list[str] = []
for para in message.strip().split("\n\n"):
para = para.strip()
if not para:
continue
if _URL_RE.match(para):
url = html_lib.escape(para, quote=True)
parts.append(
f'<p style="margin:0 0 20px;">'
f'<a href="{url}" style="display:inline-block;background-color:#0891b2;'
f'color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:6px;'
f'font-weight:600;font-size:14px;">Open Link &rarr;</a>'
f'</p>'
)
else:
escaped = html_lib.escape(para).replace("\n", "<br>")
parts.append(f'<p style="margin:0 0 16px;">{escaped}</p>')
return "".join(parts)
def build_email_body(full_name: str | None, message: str) -> str:
"""Wrap *message* in Aegis's branded HTML email template (inline logo)."""
greeting = html_lib.escape(full_name) if full_name else "there"
message_html = _message_to_html(message)
logo_b64 = _get_logo_base64()
logo_tag = (
f'<img src="data:image/png;base64,{logo_b64}" width="56" height="57" alt="Aegis" '
f'style="display:block;margin:0 auto 10px;border:0;" />'
if logo_b64
else ""
)
return f"""<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background-color:#0b0f19;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#0b0f19;padding:32px 0;">
<tr>
<td align="center">
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;background-color:#111827;border-radius:12px;overflow:hidden;border:1px solid #1f2937;">
<tr>
<td style="background-color:#0e7490;background-image:linear-gradient(135deg,#0e7490,#0891b2);padding:28px 32px;text-align:center;">
{logo_tag}
<div style="color:#ffffff;font-size:20px;font-weight:600;letter-spacing:0.5px;">AEGIS SECURITY PLATFORM</div>
<div style="color:#cffafe;font-size:11px;letter-spacing:2px;text-transform:uppercase;margin-top:4px;">Purple Team Engineering</div>
</td>
</tr>
<tr>
<td style="padding:32px;">
<p style="color:#e5e7eb;font-size:16px;margin:0 0 16px;">Hi {greeting},</p>
<div style="color:#d1d5db;font-size:14px;line-height:1.6;">{message_html}</div>
</td>
</tr>
<tr>
<td style="padding:20px 32px;background-color:#0b0f19;border-top:1px solid #1f2937;">
<p style="color:#67e8f9;font-size:12px;margin:0 0 8px;font-style:italic;">Assume breach. Validate controls. Improve continuously.</p>
<p style="color:#4b5563;font-size:11px;margin:0;">Owned and operated by Enterprise Corp. This is an automated notification &mdash; please do not reply.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
def _read_system_config(db: Session, key: str) -> str | None:
from app.models.system_config import SystemConfig # avoid circular at import time
row = db.query(SystemConfig).filter(SystemConfig.key == key).first()
return row.value if row else None
def _write_system_config(db: Session, key: str, value: str) -> None:
from app.models.system_config import SystemConfig
row = db.query(SystemConfig).filter(SystemConfig.key == key).first()
if row:
row.value = value
else:
db.add(SystemConfig(key=key, value=value))
def get_webhook_url(db: Session) -> str | None:
"""Return the configured Power-Automate-style webhook URL, or None."""
return _read_system_config(db, _WEBHOOK_URL_CONFIG_KEY) or None
def set_webhook_url(db: Session, url: str) -> None:
"""Persist the webhook URL. Does not commit; caller commits."""
_write_system_config(db, _WEBHOOK_URL_CONFIG_KEY, url)
def get_webhook_api_key(db: Session) -> str | None:
"""Return the configured webhook API key, or None."""
return _read_system_config(db, _WEBHOOK_API_KEY_CONFIG_KEY) or None
def set_webhook_api_key(db: Session, api_key: str) -> None:
"""Persist the webhook API key. Does not commit; caller commits."""
_write_system_config(db, _WEBHOOK_API_KEY_CONFIG_KEY, api_key)
def send_webhook_email(
db: Session, *, to: str | None, subject: str, message: str, full_name: str | None = None,
) -> bool:
"""POST a notification email to the configured webhook.
Best-effort: returns False (and logs a warning) if no webhook is
configured, *to* is empty, the request fails, or the webhook responds
with a non-2xx status never raises, since a webhook outage must not
break whatever triggered the notification.
"""
if not to:
return False
webhook_url = get_webhook_url(db)
if not webhook_url:
logger.warning("Email webhook is not configured — dropping email to %s", to)
return False
api_key = get_webhook_api_key(db)
headers = {"x-api-key": api_key} if api_key else {}
try:
response = requests.post(
webhook_url,
json={"to": to, "subject": subject, "body": build_email_body(full_name, message)},
headers=headers,
timeout=10,
)
if not response.ok:
logger.warning(
"Email webhook rejected the request for %s: HTTP %d%s",
to, response.status_code, response.text[:500],
)
return False
return True
except requests.RequestException:
logger.warning("Email webhook call failed for %s", to, exc_info=True)
return False
+13
View File
@@ -184,6 +184,19 @@ def _send_webhook(db, wh: WebhookConfig, event_type: str, payload: dict) -> None
"Webhook '%s' (%s) failed for event=%s: %s (failure_count=%d)",
wh.name, wh.url, event_type, exc, wh.failure_count,
)
if wh.failure_count == 3:
# Email once on the 3rd consecutive failure — signals "this looks
# broken", without alerting on every transient single failure.
from app.services.notification_service import notify_roles_by_email
notify_roles_by_email(
db, roles=["admin"],
preference_key="email_on_webhook_failures",
subject=f"Webhook Delivery Failing: {wh.name}",
message=(
f'The webhook "{wh.name}" ({wh.url}) has failed {wh.failure_count} times '
f'in a row for event "{event_type}". Last error: {exc}'
),
)
# ---------------------------------------------------------------------------
+2 -2
View File
@@ -41,7 +41,7 @@ pillow==12.2.0
psycopg2-binary==2.9.12
pycparser==3.0
pydantic==2.13.4
pydantic-settings==2.14.1
pydantic-settings==2.14.2
pydantic_core==2.46.4
pydyf==0.12.1
PyJWT==2.13.0
@@ -62,7 +62,7 @@ slowapi==0.1.9
sortedcontainers==2.4.0
soupsieve==2.8.4
SQLAlchemy==2.0.50
starlette==1.3.0
starlette==1.3.1
taxii2-client==2.3.0
tempo-api-python-client==0.12.0
tinycss2==1.5.1
+2 -1
View File
@@ -15,7 +15,8 @@ pyyaml>=6.0.3
toml>=0.10.2
taxii2-client>=2.3.0
python-multipart>=0.0.32
pydantic-settings>=2.14.0
starlette>=1.3.1
pydantic-settings>=2.14.2
slowapi>=0.1.9
defusedxml>=0.7.1
redis>=8.0.0
+6 -6
View File
@@ -239,7 +239,7 @@ def admin_token(client, admin_user):
"""Get an auth token for the admin user."""
response = client.post(
"/api/v1/auth/login",
data={"username": "admin", "password": "admin123"},
data={"username": "admin@test.com", "password": "admin123"},
)
return response.json()["access_token"]
@@ -249,7 +249,7 @@ def red_tech_token(client, red_tech_user):
"""Get an auth token for the red_tech user."""
response = client.post(
"/api/v1/auth/login",
data={"username": "redtech", "password": "redtech123"},
data={"username": "redtech@test.com", "password": "redtech123"},
)
return response.json()["access_token"]
@@ -271,7 +271,7 @@ def blue_tech_token(client, blue_tech_user):
"""Get an auth token for the blue_tech user."""
response = client.post(
"/api/v1/auth/login",
data={"username": "bluetech", "password": "bluetech123"},
data={"username": "bluetech@test.com", "password": "bluetech123"},
)
return response.json()["access_token"]
@@ -287,7 +287,7 @@ def red_lead_token(client, red_lead_user):
"""Get an auth token for the red_lead user."""
response = client.post(
"/api/v1/auth/login",
data={"username": "redlead", "password": "redlead123"},
data={"username": "redlead@test.com", "password": "redlead123"},
)
return response.json()["access_token"]
@@ -303,7 +303,7 @@ def blue_lead_token(client, blue_lead_user):
"""Get an auth token for the blue_lead user."""
response = client.post(
"/api/v1/auth/login",
data={"username": "bluelead", "password": "bluelead123"},
data={"username": "bluelead@test.com", "password": "bluelead123"},
)
return response.json()["access_token"]
@@ -319,7 +319,7 @@ def manager_token(client, manager_user):
"""Get an auth token for the manager user."""
response = client.post(
"/api/v1/auth/login",
data={"username": "manager", "password": "manager123"},
data={"username": "manager@test.com", "password": "manager123"},
)
return response.json()["access_token"]
@@ -0,0 +1,198 @@
"""Tests for the admin configuration export/import bundle (Block 4).
The feature was already implemented (GET/POST /admin/export-config,
/admin/import-config) but had zero test coverage for an admin-sensitive
data-migration endpoint. Covers: admin-only gating, secret redaction,
custom-template-only scoping, and import idempotency/user-provisioning
safety (forced password reset, no secret restoration from redacted values).
"""
from app.models.scoring_config import ScoringConfig
from app.models.sso_config import SsoConfig
from app.models.system_config import SystemConfig
from app.models.test_template import TestTemplate
from app.models.user import User
from app.models.webhook_config import WebhookConfig
def test_export_config_requires_admin(client, red_tech_headers):
resp = client.get("/api/v1/admin/export-config", headers=red_tech_headers)
assert resp.status_code == 403
def test_import_config_requires_admin(client, red_tech_headers):
resp = client.post("/api/v1/admin/import-config", json={}, headers=red_tech_headers)
assert resp.status_code == 403
def test_export_config_returns_expected_sections(client, auth_headers):
resp = client.get("/api/v1/admin/export-config", headers=auth_headers)
assert resp.status_code == 200
body = resp.json()
for key in ("_meta", "system_configs", "webhooks", "sso", "scoring", "custom_templates", "users"):
assert key in body
assert resp.headers["content-disposition"].startswith("attachment;")
def test_export_config_redacts_smtp_password(client, db, auth_headers):
db.add(SystemConfig(key="smtp.password", value="hunter2"))
db.add(SystemConfig(key="smtp.host", value="smtp.example.com"))
db.commit()
body = client.get("/api/v1/admin/export-config", headers=auth_headers).json()
values = {r["key"]: r["value"] for r in body["system_configs"]}
assert values["smtp.password"] == "[REDACTED]"
assert values["smtp.host"] == "smtp.example.com"
def test_export_config_redacts_live_jira_and_tempo_admin_tokens(client, db, auth_headers):
"""Regression test: the redaction key list previously named
jira.api_token/jira.password/tempo.api_token, which don't match the
actual keys routers/system.py writes (jira.admin_api_token,
tempo.admin_token) so the live admin Jira/Tempo credentials were
exported in plaintext instead of being redacted."""
db.add(SystemConfig(key="jira.admin_api_token", value="ATATT-real-jira-token"))
db.add(SystemConfig(key="tempo.admin_token", value="real-tempo-token"))
db.commit()
body = client.get("/api/v1/admin/export-config", headers=auth_headers).json()
values = {r["key"]: r["value"] for r in body["system_configs"]}
assert values["jira.admin_api_token"] == "[REDACTED]"
assert values["tempo.admin_token"] == "[REDACTED]"
def test_export_config_never_exports_sso_private_key(client, db, auth_headers):
db.add(SsoConfig(is_enabled=True, provider_name="Okta", sp_private_key="super-secret-key"))
db.commit()
body = client.get("/api/v1/admin/export-config", headers=auth_headers).json()
assert body["sso"]["sp_private_key"] == "[REDACTED]"
def test_export_config_only_includes_custom_templates(client, db, auth_headers):
db.add(TestTemplate(mitre_technique_id="T1059", name="Atomic one", source="atomic_red_team"))
db.add(TestTemplate(mitre_technique_id="T1059", name="My custom one", source="custom"))
db.commit()
body = client.get("/api/v1/admin/export-config", headers=auth_headers).json()
names = {t["name"] for t in body["custom_templates"]}
assert "My custom one" in names
assert "Atomic one" not in names
def test_export_config_users_have_no_secrets(client, db, auth_headers, red_tech_user):
body = client.get("/api/v1/admin/export-config", headers=auth_headers).json()
exported = next(u for u in body["users"] if u["username"] == red_tech_user.username)
assert "hashed_password" not in exported
assert "jira_api_token" not in exported
assert exported["must_change_password"] is True
def test_import_config_rejects_invalid_json(client, auth_headers):
resp = client.post(
"/api/v1/admin/import-config",
data=b"not json",
headers={**auth_headers, "Content-Type": "application/json"},
)
assert resp.status_code == 400
def test_import_config_creates_new_user_with_forced_reset(client, db, auth_headers):
resp = client.post(
"/api/v1/admin/import-config",
json={"users": [{"username": "imported_user", "email": "imported_user@test.com", "role": "red_tech", "is_active": True}]},
headers=auth_headers,
)
assert resp.status_code == 200
assert resp.json()["summary"]["users_created"] == 1
user = db.query(User).filter(User.email == "imported_user@test.com").first()
assert user is not None
assert user.must_change_password is True
assert user.role == "red_tech"
def test_import_config_skips_user_with_no_email(client, db, auth_headers):
resp = client.post(
"/api/v1/admin/import-config",
json={"users": [{"username": "no_email_user", "role": "red_tech", "is_active": True}]},
headers=auth_headers,
)
assert resp.status_code == 200
assert resp.json()["summary"]["users_skipped_no_email"] == 1
assert db.query(User).filter(User.username == "no_email_user").first() is None
def test_import_config_updates_existing_user_role_only(client, db, auth_headers, red_tech_user):
original_hash = red_tech_user.hashed_password
resp = client.post(
"/api/v1/admin/import-config",
json={"users": [{"username": red_tech_user.username, "email": red_tech_user.email, "role": "red_lead", "is_active": True}]},
headers=auth_headers,
)
assert resp.status_code == 200
assert resp.json()["summary"]["users_updated"] == 1
db.refresh(red_tech_user)
assert red_tech_user.role == "red_lead"
assert red_tech_user.hashed_password == original_hash
def test_import_config_skips_redacted_system_config_value(client, db, auth_headers):
db.add(SystemConfig(key="smtp.password", value="original-real-value"))
db.commit()
client.post(
"/api/v1/admin/import-config",
json={"system_configs": [{"key": "smtp.password", "value": "[REDACTED]"}]},
headers=auth_headers,
)
row = db.query(SystemConfig).filter(SystemConfig.key == "smtp.password").first()
assert row.value == "original-real-value"
def test_import_config_never_restores_sso_private_key(client, db, auth_headers):
resp = client.post(
"/api/v1/admin/import-config",
json={"sso": {"is_enabled": True, "provider_name": "Okta", "sp_private_key": "[REDACTED]"}},
headers=auth_headers,
)
assert resp.status_code == 200
row = db.query(SsoConfig).first()
assert row is not None
assert row.sp_private_key is None
def test_import_config_upserts_scoring_weights(client, db, auth_headers):
db.add(ScoringConfig(weight_tests=40.0, weight_detection_rules=25.0, weight_d3fend=15.0, weight_recency=10.0, weight_severity=10.0))
db.commit()
client.post(
"/api/v1/admin/import-config",
json={"scoring": {"weight_tests": 55.0, "weight_detection_rules": 20.0, "weight_d3fend": 10.0, "weight_recency": 10.0, "weight_severity": 5.0}},
headers=auth_headers,
)
row = db.query(ScoringConfig).first()
assert row.weight_tests == 55.0
def test_import_config_round_trip_is_idempotent(client, db, auth_headers):
db.add(WebhookConfig(name="alert-hook", url="https://example.com/hook", events=["test_validated"], is_active=True))
db.commit()
exported = client.get("/api/v1/admin/export-config", headers=auth_headers).json()
resp = client.post("/api/v1/admin/import-config", json=exported, headers=auth_headers)
assert resp.status_code == 200
hooks = db.query(WebhookConfig).filter(WebhookConfig.name == "alert-hook").all()
assert len(hooks) == 1
@@ -0,0 +1,66 @@
"""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"}),
("post", f"/api/v1/tests/{DUMMY_ID}/start-execution", None),
("post", f"/api/v1/tests/{DUMMY_ID}/submit-red", None),
("post", f"/api/v1/tests/{DUMMY_ID}/start-blue-work", None),
("post", f"/api/v1/tests/{DUMMY_ID}/submit-blue", None),
("post", f"/api/v1/tests/{DUMMY_ID}/pause-timer", None),
("post", f"/api/v1/tests/{DUMMY_ID}/resume-timer", None),
("patch", f"/api/v1/tests/{DUMMY_ID}/red", {"procedure_text": "x"}),
("patch", f"/api/v1/tests/{DUMMY_ID}/blue", {"detection_result": "detected"}),
("patch", f"/api/v1/tests/{DUMMY_ID}", {"name": "x"}),
("patch", f"/api/v1/tests/{DUMMY_ID}/remediation", {"remediation_status": "completed"}),
("post", "/api/v1/tests", {"technique_id": DUMMY_ID, "name": "x"}),
("post", "/api/v1/tests/from-template", {"template_id": DUMMY_ID, "technique_id": DUMMY_ID}),
("post", "/api/v1/tests/import-rt", {"name": "x", "techniques": []}),
("post", "/api/v1/campaigns", {"name": "x"}),
("post", f"/api/v1/campaigns/from-threat-actor/{DUMMY_ID}", None),
],
)
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}"
@pytest.mark.parametrize(
"method,path,payload",
[
("post", "/api/v1/test-templates", {"name": "x", "mitre_technique_id": "T1059"}),
("patch", f"/api/v1/test-templates/{DUMMY_ID}", {"name": "x"}),
("patch", f"/api/v1/test-templates/{DUMMY_ID}/toggle-active", None),
("delete", f"/api/v1/test-templates/{DUMMY_ID}", None),
("patch", "/api/v1/test-templates/bulk-activate", {"template_ids": [DUMMY_ID], "is_active": True}),
],
)
def test_admin_forbidden_templates(client, auth_headers, method, path, payload):
kwargs = {"headers": auth_headers}
if payload is not None:
kwargs["json"] = payload
resp = getattr(client, method)(path, **kwargs)
assert resp.status_code == 403, f"{method.upper()} {path} -> {resp.status_code}: {resp.text}"
+224
View File
@@ -0,0 +1,224 @@
"""Tests for POST /tests/{id}/assign — lead/manager manual operator assignment."""
from unittest.mock import MagicMock, patch
from app.models.test import Test
from app.models.technique import Technique
def _seed_technique(db, tactic="execution") -> Technique:
technique = Technique(
mitre_id="T9999",
name="Test Technique",
tactic=tactic,
platforms=["linux"],
)
db.add(technique)
db.commit()
db.refresh(technique)
return technique
def _seed_test(db, technique, created_by) -> Test:
test = Test(technique_id=technique.id, name="Assignable test", created_by=created_by)
db.add(test)
db.commit()
db.refresh(test)
return test
def test_red_lead_can_assign_red_tech(client, db, red_lead_headers, red_lead_user, red_tech_user):
technique = _seed_technique(db)
test = _seed_test(db, technique, red_lead_user.id)
resp = client.post(
f"/api/v1/tests/{test.id}/assign",
json={"red_tech_assignee": str(red_tech_user.id)},
headers=red_lead_headers,
)
assert resp.status_code == 200, resp.text
assert resp.json()["red_tech_assignee"] == str(red_tech_user.id)
db.refresh(test)
assert test.red_tech_assignee == red_tech_user.id
def test_assign_rejects_wrong_role_for_side(client, db, red_lead_headers, red_lead_user, blue_tech_user):
technique = _seed_technique(db)
test = _seed_test(db, technique, red_lead_user.id)
resp = client.post(
f"/api/v1/tests/{test.id}/assign",
json={"red_tech_assignee": str(blue_tech_user.id)},
headers=red_lead_headers,
)
assert resp.status_code == 400
def test_admin_cannot_be_assigned_as_operator(client, db, red_lead_headers, red_lead_user, admin_user):
"""Admin administers the site and can't be assigned as an operator either."""
technique = _seed_technique(db)
test = _seed_test(db, technique, red_lead_user.id)
resp = client.post(
f"/api/v1/tests/{test.id}/assign",
json={"red_tech_assignee": str(admin_user.id)},
headers=red_lead_headers,
)
assert resp.status_code == 400
def test_red_tech_cannot_assign(client, db, red_tech_headers, red_tech_user):
technique = _seed_technique(db)
test = _seed_test(db, technique, red_tech_user.id)
resp = client.post(
f"/api/v1/tests/{test.id}/assign",
json={"red_tech_assignee": str(red_tech_user.id)},
headers=red_tech_headers,
)
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)
test = _seed_test(db, technique, red_lead_user.id)
test.red_tech_assignee = red_tech_user.id
db.commit()
with patch("app.services.jira_service.push_assignee_update") as mock_push:
resp = client.post(
f"/api/v1/tests/{test.id}/assign",
json={"red_tech_assignee": None},
headers=red_lead_headers,
)
assert resp.status_code == 200
assert resp.json()["red_tech_assignee"] is None
mock_push.assert_not_called()
def test_assigning_operator_syncs_to_jira(client, db, red_lead_headers, red_lead_user, red_tech_user):
technique = _seed_technique(db)
test = _seed_test(db, technique, red_lead_user.id)
# Capture IDs *during* the call, while the endpoint's request-scoped
# session is still open — reading them afterward hits a
# DetachedInstanceError once that session closes.
captured = {}
def _capture(_db, test_arg, assignee_arg):
captured["test_id"] = test_arg.id
captured["assignee_id"] = assignee_arg.id
with patch("app.services.jira_service.push_assignee_update", side_effect=_capture) as mock_push:
resp = client.post(
f"/api/v1/tests/{test.id}/assign",
json={"red_tech_assignee": str(red_tech_user.id)},
headers=red_lead_headers,
)
assert resp.status_code == 200
mock_push.assert_called_once()
assert captured["test_id"] == test.id
assert captured["assignee_id"] == red_tech_user.id
def _make_second_red_lead(db):
from app.auth import hash_password
from app.models.user import User
u = User(
username="redlead2", email="redlead2@test.com",
hashed_password=hash_password("x"), role="red_lead", is_active=True,
must_change_password=False,
)
db.add(u)
db.commit()
db.refresh(u)
return u
def test_lead_can_reassign_red_reviewer_to_peer(client, db, red_lead_headers, red_lead_user):
"""A lead can hand a review off to a peer lead — e.g. if the currently
assigned reviewer can't get to it."""
technique = _seed_technique(db)
test = _seed_test(db, technique, red_lead_user.id)
peer = _make_second_red_lead(db)
test.red_reviewer_assignee = red_lead_user.id
db.commit()
resp = client.post(
f"/api/v1/tests/{test.id}/assign",
json={"red_reviewer_assignee": str(peer.id)},
headers=red_lead_headers,
)
assert resp.status_code == 200, resp.text
assert resp.json()["red_reviewer_assignee"] == str(peer.id)
db.refresh(test)
assert test.red_reviewer_assignee == peer.id
def test_reviewer_reassign_rejects_non_lead(client, db, red_lead_headers, red_lead_user, red_tech_user):
"""Only a red_lead can be the red reviewer — a red_tech is not eligible."""
technique = _seed_technique(db)
test = _seed_test(db, technique, red_lead_user.id)
resp = client.post(
f"/api/v1/tests/{test.id}/assign",
json={"red_reviewer_assignee": str(red_tech_user.id)},
headers=red_lead_headers,
)
assert resp.status_code == 400
def test_reviewer_reassign_rejects_wrong_side(client, db, red_lead_headers, red_lead_user, blue_lead_user):
"""A blue_lead can't be set as the red reviewer."""
technique = _seed_technique(db)
test = _seed_test(db, technique, red_lead_user.id)
resp = client.post(
f"/api/v1/tests/{test.id}/assign",
json={"red_reviewer_assignee": str(blue_lead_user.id)},
headers=red_lead_headers,
)
assert resp.status_code == 400
def test_manager_can_reassign_blue_reviewer(client, db, manager_headers, manager_user, blue_lead_user):
technique = _seed_technique(db)
test = _seed_test(db, technique, manager_user.id)
resp = client.post(
f"/api/v1/tests/{test.id}/assign",
json={"blue_reviewer_assignee": str(blue_lead_user.id)},
headers=manager_headers,
)
assert resp.status_code == 200, resp.text
assert resp.json()["blue_reviewer_assignee"] == str(blue_lead_user.id)
+67 -4
View File
@@ -1,13 +1,29 @@
"""Tests for authentication endpoints."""
import uuid
from datetime import datetime, timedelta, timezone
import jwt
import pytest
from app.config import settings
def _make_token(username: str, *, expired_seconds_ago: int | None = None) -> str:
"""Build a raw JWT mirroring ``create_access_token`` with a controllable exp."""
if expired_seconds_ago is None:
expire = datetime.now(timezone.utc) + timedelta(minutes=30)
else:
expire = datetime.now(timezone.utc) - timedelta(seconds=expired_seconds_ago)
payload = {"sub": username, "exp": expire, "jti": str(uuid.uuid4())}
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def test_login_success(client, admin_user):
"""Test successful login returns a token."""
response = client.post(
"/api/v1/auth/login",
data={"username": "admin", "password": "admin123"},
data={"username": "admin@test.com", "password": "admin123"},
)
assert response.status_code == 200
data = response.json()
@@ -19,7 +35,7 @@ def test_login_wrong_password(client, admin_user):
"""Test login with wrong password returns 400."""
response = client.post(
"/api/v1/auth/login",
data={"username": "admin", "password": "wrongpassword"},
data={"username": "admin@test.com", "password": "wrongpassword"},
)
assert response.status_code == 400
@@ -40,6 +56,7 @@ def test_login_inactive_user(client, db):
user = User(
username="inactive",
email="inactive@test.com",
hashed_password=hash_password("password"),
role="viewer",
is_active=False,
@@ -49,7 +66,7 @@ def test_login_inactive_user(client, db):
response = client.post(
"/api/v1/auth/login",
data={"username": "inactive", "password": "password"},
data={"username": "inactive@test.com", "password": "password"},
)
assert response.status_code == 403
@@ -90,7 +107,7 @@ def test_logout_revokes_token(client, admin_user):
"""
login = client.post(
"/api/v1/auth/login",
data={"username": "admin", "password": "admin123"},
data={"username": "admin@test.com", "password": "admin123"},
)
assert login.status_code == 200
token = login.json()["access_token"]
@@ -122,3 +139,49 @@ def test_logout_revokes_token(client, admin_user):
)
assert me.status_code == 401
assert me.json()["detail"] == "Token has been revoked"
def test_refresh_without_cookie_fails(client):
response = client.post("/api/v1/auth/refresh")
assert response.status_code == 403
def test_refresh_valid_token_succeeds(client, admin_user):
token = _make_token("admin")
client.cookies.set("aegis_token", token)
response = client.post("/api/v1/auth/refresh")
assert response.status_code == 200
assert "access_token" in response.json()
def test_refresh_recently_expired_token_succeeds_within_grace(client, admin_user):
"""A token that expired moments ago must still be refreshable.
This is the core of the refresh-token bug fix: without a grace window,
`/auth/refresh` decodes with the same strict expiry check as every
other endpoint, so a 401-triggered refresh attempt always also fails.
"""
token = _make_token("admin", expired_seconds_ago=60)
client.cookies.set("aegis_token", token)
response = client.post("/api/v1/auth/refresh")
assert response.status_code == 200
assert "access_token" in response.json()
def test_refresh_long_expired_token_fails(client, admin_user):
token = _make_token("admin", expired_seconds_ago=60 * 60)
client.cookies.set("aegis_token", token)
response = client.post("/api/v1/auth/refresh")
assert response.status_code == 403
def test_refresh_blacklisted_token_fails_even_if_not_expired(client, admin_user):
from app.auth import blacklist_token
token = _make_token("admin")
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
blacklist_token(payload["jti"], float(payload["exp"]))
client.cookies.set("aegis_token", token)
response = client.post("/api/v1/auth/refresh")
assert response.status_code == 403
+3 -3
View File
@@ -6,7 +6,7 @@ from app.models.audit import AuditLog
def test_login_failed_creates_audit_entry(client, admin_user, db):
response = client.post(
"/api/v1/auth/login",
data={"username": "admin", "password": "wrong"},
data={"username": "admin@test.com", "password": "wrong"},
headers={"X-Forwarded-For": "198.51.100.10", "User-Agent": "LoginAuditTest/1.0"},
)
assert response.status_code == 400
@@ -19,7 +19,7 @@ def test_login_failed_creates_audit_entry(client, admin_user, db):
)
assert log is not None
assert log.entity_type == "auth"
assert log.details["username"] == "admin"
assert log.details["email"] == "admin@test.com"
assert log.details["reason"] == "invalid_credentials"
assert log.ip_address == "198.51.100.10"
assert log.user_agent == "LoginAuditTest/1.0"
@@ -30,7 +30,7 @@ def test_login_success_creates_audit_entry(client, admin_user, db):
client.cookies.clear()
response = client.post(
"/api/v1/auth/login",
data={"username": "admin", "password": "admin123"},
data={"username": "admin@test.com", "password": "admin123"},
headers={"X-Forwarded-For": "198.51.100.20"},
)
assert response.status_code == 200
+19 -29
View File
@@ -1,9 +1,10 @@
"""Tests for red/blue blind visibility on GET /tests/{id}.
"""Regression: Red and Blue must see each other's fields at every stage.
Neither team should see the other team's data while a test is in
draft/red_executing/red_review/blue_evaluating/blue_review. Both sides
see everything once the test reaches in_review (and beyond). admin and
viewer are never blinded.
Blind visibility (hiding the other team's fields until both reviews pass)
was a deliberate design in an earlier phase, but the org decided both teams
should see each other's work throughout — detection testing isn't meant to
be blind here. This confirms neither the frontend nor backend hides
anything based on role/state anymore.
"""
import uuid
@@ -46,11 +47,11 @@ def technique(api, auth_headers):
@pytest.fixture
def blind_test(client, db, api, auth_headers, red_tech_headers, technique):
def in_progress_test(client, db, api, red_lead_headers, red_lead_user, red_tech_headers, technique):
"""A test in red_executing with red-side content already filled in."""
resp = api(
"post", "/api/v1/tests", auth_headers,
json={"technique_id": technique, "name": "Blind visibility test"},
"post", "/api/v1/tests", red_lead_headers,
json={"technique_id": technique, "name": "Visibility test"},
)
test_id = resp.json()["id"]
@@ -62,32 +63,21 @@ def blind_test(client, db, api, auth_headers, red_tech_headers, technique):
return test_id
def test_blue_viewer_cannot_see_red_fields_during_red_executing(
client, db, api, blind_test, blue_tech_headers
def test_blue_viewer_sees_red_fields_during_red_executing(
client, db, api, in_progress_test, blue_tech_headers
):
resp = api("get", f"/api/v1/tests/{blind_test}", blue_tech_headers)
resp = api("get", f"/api/v1/tests/{in_progress_test}", blue_tech_headers)
assert resp.status_code == 200
body = resp.json()
for field in _RED_FIELDS:
assert body[field] is None, f"{field} should be hidden from blue viewer"
def test_red_viewer_cannot_see_blue_fields_during_red_executing(
client, db, api, blind_test, red_tech_headers
):
resp = api("get", f"/api/v1/tests/{blind_test}", red_tech_headers)
assert resp.status_code == 200
body = resp.json()
for field in _BLUE_FIELDS:
assert body[field] is None, f"{field} should be hidden from red viewer"
# Red's own fields ARE visible to red
assert body["procedure_text"] == "run mimikatz"
assert body["tool_used"] == "mimikatz"
assert body["red_summary"] == "dumped creds"
def test_admin_sees_everything_during_blind_states(
client, db, api, blind_test, auth_headers
def test_red_viewer_sees_own_fields_during_red_executing(
client, db, api, in_progress_test, red_tech_headers
):
resp = api("get", f"/api/v1/tests/{blind_test}", auth_headers)
resp = api("get", f"/api/v1/tests/{in_progress_test}", red_tech_headers)
assert resp.status_code == 200
body = resp.json()
assert body["procedure_text"] == "run mimikatz"
@@ -95,9 +85,9 @@ def test_admin_sees_everything_during_blind_states(
def test_both_sides_see_everything_once_in_review(
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
blue_tech_headers, blue_lead_headers, red_lead_user, blue_lead_user, blind_test,
blue_tech_headers, blue_lead_headers, red_lead_user, blue_lead_user, in_progress_test,
):
test_id = blind_test
test_id = in_progress_test
_add_evidence(db, test_id, TeamSide.red)
submit_red = api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
assert submit_red.status_code == 200, submit_red.text
+176 -2
View File
@@ -1,5 +1,7 @@
"""Router-level tests for the campaign manager-approval workflow."""
from unittest.mock import patch
from app.models.campaign import Campaign, CampaignTest
from app.models.technique import Technique
from app.models.test import Test
@@ -43,7 +45,7 @@ def test_manager_can_approve_and_sets_start_date(api, db, red_lead_user, red_lea
"post",
f"/api/v1/campaigns/{campaign.id}/approve",
manager_headers,
json={"start_date": "2026-09-01T00:00:00"},
json={"start_date": "2020-01-01T00:00:00"},
)
assert resp.status_code == 200
body = resp.json()
@@ -51,6 +53,56 @@ def test_manager_can_approve_and_sets_start_date(api, db, red_lead_user, red_lea
assert body["start_date"] is not None
def test_manager_approval_creates_jira_tickets_when_start_date_is_due(
api, db, red_lead_user, red_lead_headers, manager_headers
):
"""The normal manager-approval path must create Jira tickets too, not just
the admin-only emergency /activate override this was the Block 1 gap:
campaigns approved through the standard flow never got a Jira ticket.
Uses a past start_date so ticket creation is due immediately."""
campaign = _make_draft_campaign(db, red_lead_user.id)
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
with patch("app.services.jira_service.get_campaign_jira_key", return_value=None) as mock_get_key, \
patch("app.services.jira_service.auto_create_campaign_issue", return_value="PT-100") as mock_create_campaign, \
patch("app.services.jira_service.get_test_jira_key", return_value=None), \
patch("app.services.jira_service.auto_create_test_issue") as mock_create_test:
resp = api(
"post",
f"/api/v1/campaigns/{campaign.id}/approve",
manager_headers,
json={"start_date": "2020-01-01T00:00:00"},
)
assert resp.status_code == 200
mock_get_key.assert_called_once()
mock_create_campaign.assert_called_once()
mock_create_test.assert_called_once()
def test_manager_approval_skips_jira_tickets_when_start_date_is_future(
api, db, red_lead_user, red_lead_headers, manager_headers
):
"""A campaign scheduled to start in the future must not get its Jira
tickets created at approval time that must wait for the periodic
due-campaign sync job, once the scheduled start_date actually arrives."""
campaign = _make_draft_campaign(db, red_lead_user.id)
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
with patch("app.services.jira_service.get_campaign_jira_key", return_value=None) as mock_get_key, \
patch("app.services.jira_service.auto_create_campaign_issue", return_value="PT-100") as mock_create_campaign:
resp = api(
"post",
f"/api/v1/campaigns/{campaign.id}/approve",
manager_headers,
json={"start_date": "2099-01-01T00:00:00"},
)
assert resp.status_code == 200
mock_get_key.assert_not_called()
mock_create_campaign.assert_not_called()
def test_lead_cannot_approve(api, db, red_lead_user, red_lead_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
@@ -294,5 +346,127 @@ def test_create_campaign_payload_has_no_start_date_field(api, db, red_lead_heade
json={"name": "No date campaign", "start_date": "2026-01-01"},
)
assert resp.status_code == 201
# start_date silently ignored — only the manager can ever set it, via /approve
# start_date silently ignored for a lead — only a manager's own
# creation (or a later /approve) can ever set it.
assert resp.json()["start_date"] is None
def test_manager_can_create_campaign_auto_approved(api, db, manager_headers):
"""A manager is the same role that would otherwise approve a campaign,
so their own campaign skips the draft -> submit -> pending_approval
queue and goes straight to active with the start_date they provide."""
resp = api(
"post",
"/api/v1/campaigns",
manager_headers,
json={"name": "Manager-created campaign", "start_date": "2020-01-01T00:00:00"},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["status"] == "active"
assert body["start_date"] is not None
def test_manager_campaign_creation_requires_start_date(api, db, manager_headers):
resp = api(
"post",
"/api/v1/campaigns",
manager_headers,
json={"name": "Manager-created campaign, no date"},
)
assert resp.status_code == 400
def test_manager_creation_creates_jira_tickets_when_start_date_is_due(api, db, manager_headers):
with patch(
"app.services.jira_service.get_campaign_jira_key", return_value=None
) as mock_get_key, patch(
"app.services.jira_service.auto_create_campaign_issue", return_value="PT-300"
) as mock_create_campaign:
resp = api(
"post",
"/api/v1/campaigns",
manager_headers,
json={"name": "Manager-created, due now", "start_date": "2020-01-01T00:00:00"},
)
assert resp.status_code == 201, resp.text
mock_get_key.assert_called_once()
mock_create_campaign.assert_called_once()
def test_manager_creation_skips_jira_tickets_when_start_date_is_future(api, db, manager_headers):
with patch(
"app.services.jira_service.get_campaign_jira_key", return_value=None
) as mock_get_key:
resp = api(
"post",
"/api/v1/campaigns",
manager_headers,
json={"name": "Manager-created, future", "start_date": "2099-01-01T00:00:00"},
)
assert resp.status_code == 201, resp.text
mock_get_key.assert_not_called()
def test_lead_created_campaign_is_still_draft_not_auto_approved(api, db, red_lead_headers):
resp = api(
"post",
"/api/v1/campaigns",
red_lead_headers,
json={"name": "Lead-created campaign"},
)
assert resp.status_code == 201, resp.text
assert resp.json()["status"] == "draft"
def test_manager_can_edit_a_campaign_they_rejected(api, db, red_lead_user, red_lead_headers, manager_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
reject = api(
"post", f"/api/v1/campaigns/{campaign.id}/reject", manager_headers,
json={"reason": "Needs a clearer scope"},
)
assert reject.status_code == 200, reject.text
assert reject.json()["status"] == "draft"
resp = api(
"patch", f"/api/v1/campaigns/{campaign.id}", manager_headers,
json={"name": "Edited by manager after rejection"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["name"] == "Edited by manager after rejection"
def test_manager_cannot_edit_a_draft_campaign_that_was_never_rejected(
api, db, red_lead_user, red_lead_headers, manager_headers,
):
campaign = _make_draft_campaign(db, red_lead_user.id)
resp = api(
"patch", f"/api/v1/campaigns/{campaign.id}", manager_headers,
json={"name": "Manager should not be able to do this"},
)
assert resp.status_code == 403
def test_manager_can_approve_a_campaign_they_previously_rejected(
api, db, red_lead_user, red_lead_headers, manager_headers,
):
campaign = _make_draft_campaign(db, red_lead_user.id)
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
api(
"post", f"/api/v1/campaigns/{campaign.id}/reject", manager_headers,
json={"reason": "Needs a clearer scope"},
)
api(
"patch", f"/api/v1/campaigns/{campaign.id}", manager_headers,
json={"description": "Scope clarified after rejection"},
)
resp = api(
"post", f"/api/v1/campaigns/{campaign.id}/approve", manager_headers,
json={"start_date": "2020-01-01T00:00:00"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["status"] == "active"
assert body["rejection_reason"] is None
@@ -0,0 +1,72 @@
"""Tests for generating a campaign from a threat actor's uncovered techniques.
Covers the Block 1 fix: the frontend sends a `start_date` when generating a
campaign from a Threat Actor, but `GenerateFromActorPayload` used to be an
empty schema, so the date was silently discarded and the campaign was
created with `start_date = NULL`.
"""
from datetime import datetime
from app.models.enums import TechniqueStatus
from app.models.technique import Technique
from app.models.test_template import TestTemplate
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
from app.services.campaign_service import generate_campaign_from_threat_actor
def _seed_actor_with_gap_technique(db):
tech = Technique(
mitre_id="T1059.001",
name="PowerShell",
tactic="execution",
platforms=["windows"],
status_global=TechniqueStatus.not_evaluated,
)
db.add(tech)
db.flush()
template = TestTemplate(
mitre_technique_id=tech.mitre_id,
name="PowerShell template",
source="custom",
severity="high",
is_active=True,
)
db.add(template)
actor = ThreatActor(name="APT-Test", mitre_id="G9999")
db.add(actor)
db.flush()
db.add(ThreatActorTechnique(threat_actor_id=actor.id, technique_id=tech.id))
db.commit()
db.refresh(actor)
return actor
def test_generate_from_actor_without_start_date_leaves_it_null(db, red_lead_user):
actor = _seed_actor_with_gap_technique(db)
campaign = generate_campaign_from_threat_actor(db, actor.id, red_lead_user)
assert campaign.start_date is None
def test_generate_from_actor_persists_start_date(db, red_lead_user):
actor = _seed_actor_with_gap_technique(db)
start_date = datetime(2026, 9, 1)
campaign = generate_campaign_from_threat_actor(
db, actor.id, red_lead_user, start_date=start_date,
)
assert campaign.start_date == start_date
def test_generate_from_actor_router_forwards_start_date(api, db, red_lead_user, red_lead_headers):
actor = _seed_actor_with_gap_technique(db)
resp = api(
"post",
f"/api/v1/campaigns/from-threat-actor/{actor.id}",
red_lead_headers,
json={"start_date": "2026-09-01T00:00:00"},
)
assert resp.status_code == 201, resp.text
assert resp.json()["start_date"] is not None
@@ -0,0 +1,76 @@
"""Tests for the campaign start_date scheduling gate on test execution.
Block 3 fix: `Campaign.start_date` was persisted and displayed but never
actually enforced a test belonging to a campaign scheduled for the future
could still be started immediately. `start_execution` now blocks this.
"""
from datetime import datetime, timedelta
import pytest
from app.domain.errors import BusinessRuleViolation
from app.models.campaign import Campaign, CampaignTest
from app.models.enums import TestState
from app.models.technique import Technique
from app.models.test import Test
from app.services.test_workflow_service import start_execution
def _seed_test_in_campaign(db, owner_id, start_date):
tech = Technique(mitre_id="T1059.003", name="Windows Command Shell", tactic="execution", platforms=["windows"])
db.add(tech)
db.flush()
campaign = Campaign(name="Scheduled Campaign", type="custom", status="active", created_by=owner_id, start_date=start_date)
db.add(campaign)
db.flush()
test = Test(technique_id=tech.id, name="Scheduled test", state=TestState.draft, created_by=owner_id)
db.add(test)
db.flush()
db.add(CampaignTest(campaign_id=campaign.id, test_id=test.id, order_index=0))
db.commit()
db.refresh(test)
return test
def test_start_execution_blocked_before_campaign_start_date(db, red_tech_user):
future = datetime.utcnow() + timedelta(days=7)
test = _seed_test_in_campaign(db, red_tech_user.id, future)
with pytest.raises(BusinessRuleViolation):
start_execution(db, test, red_tech_user)
def test_start_execution_allowed_after_campaign_start_date(db, red_tech_user):
past = datetime.utcnow() - timedelta(days=1)
test = _seed_test_in_campaign(db, red_tech_user.id, past)
result = start_execution(db, test, red_tech_user)
assert result.state == TestState.red_executing
def test_start_execution_allowed_for_standalone_test(db, red_tech_user):
"""A test with no campaign at all is never gated by this check."""
tech = Technique(mitre_id="T1059.004", name="Unix Shell", tactic="execution", platforms=["linux"])
db.add(tech)
db.flush()
test = Test(technique_id=tech.id, name="Standalone test", state=TestState.draft, created_by=red_tech_user.id)
db.add(test)
db.commit()
db.refresh(test)
result = start_execution(db, test, red_tech_user)
assert result.state == TestState.red_executing
def test_start_execution_allowed_when_campaign_has_no_start_date(db, red_tech_user):
test = _seed_test_in_campaign(db, red_tech_user.id, None)
result = start_execution(db, test, red_tech_user)
assert result.state == TestState.red_executing
+85 -1
View File
@@ -21,6 +21,7 @@ from app.services.campaign_service import (
from app.services.campaign_scheduler_service import (
calculate_next_run,
check_and_run_recurring_campaigns,
sync_due_campaign_jira_tickets,
)
from app.services.snapshot_service import (
create_snapshot,
@@ -199,7 +200,10 @@ class TestCampaigns:
)
assert child is not None
assert "Run" in child.name
assert child.status == "active"
# Recurrence only automates spawning the run — it still needs a
# manager's approval like any other campaign, same as the manual
# draft -> submit -> approve path.
assert child.status == "pending_approval"
# Check child tests are fresh copies (new IDs, draft state)
child_cts = (
@@ -217,12 +221,92 @@ class TestCampaigns:
test = db.query(Test).filter(Test.id == ct.test_id).first()
assert test.state == TestState.draft
def test_campaign_cloning_notifies_managers(self, db, campaign_with_tests, manager_user):
"""Managers must hear about a spawned recurring run — it's sitting in
their approval queue just like a manually-submitted campaign."""
from app.models.notification import Notification
campaign = campaign_with_tests["campaign"]
campaign.is_recurring = True
campaign.recurrence_pattern = "monthly"
campaign.next_run_at = datetime.utcnow() - timedelta(hours=1)
db.commit()
check_and_run_recurring_campaigns(db)
notif = (
db.query(Notification)
.filter(Notification.user_id == manager_user.id, Notification.type == "campaign_pending_approval")
.first()
)
assert notif is not None
# Check parent was updated
db.refresh(campaign)
assert campaign.last_run_at is not None
assert campaign.next_run_at > datetime.utcnow()
class TestDueCampaignJiraSync:
"""sync_due_campaign_jira_tickets — the periodic catch-up job for
campaigns approved with a future start_date whose Jira tickets were
deliberately skipped at approval time."""
def test_creates_tickets_for_due_campaign_without_jira_link(self, db, campaign_with_tests, admin_user):
from unittest.mock import patch
campaign = campaign_with_tests["campaign"]
campaign.status = "active"
campaign.approved_by = admin_user.id
campaign.start_date = datetime.utcnow() - timedelta(hours=1) # now due
db.commit()
with patch(
"app.services.jira_service.get_campaign_jira_key", return_value=None
) as mock_get_key, patch(
"app.services.jira_service.auto_create_campaign_issue", return_value="PT-200"
) as mock_create_campaign, patch(
"app.services.jira_service.get_test_jira_key", return_value=None
), patch(
"app.services.jira_service.auto_create_test_issue"
) as mock_create_test:
processed = sync_due_campaign_jira_tickets(db)
assert processed == 1
mock_get_key.assert_called_once()
mock_create_campaign.assert_called_once()
assert mock_create_test.call_count == len(campaign_with_tests["tests"])
def test_skips_campaign_with_future_start_date(self, db, campaign_with_tests, admin_user):
campaign = campaign_with_tests["campaign"]
campaign.status = "active"
campaign.approved_by = admin_user.id
campaign.start_date = datetime.utcnow() + timedelta(days=30)
db.commit()
processed = sync_due_campaign_jira_tickets(db)
assert processed == 0
def test_skips_campaign_already_linked_to_jira(self, db, campaign_with_tests, admin_user):
from app.models.jira_link import JiraLink, JiraLinkEntityType
campaign = campaign_with_tests["campaign"]
campaign.status = "active"
campaign.approved_by = admin_user.id
campaign.start_date = datetime.utcnow() - timedelta(hours=1)
db.add(JiraLink(
entity_type=JiraLinkEntityType.campaign,
entity_id=campaign.id,
jira_issue_key="PT-1",
jira_project_key="PT",
created_by=admin_user.id,
))
db.commit()
processed = sync_due_campaign_jira_tickets(db)
assert processed == 0
# ═══════════════════════════════════════════════════════════════════════
# Snapshot Tests
# ═══════════════════════════════════════════════════════════════════════
@@ -0,0 +1,48 @@
"""Tests for canonical MITRE kill-chain ordering in coverage-by-tactic.
Block 3 fix: TacticCoverageChart.tsx (dashboard) rendered whatever order
the backend happened to return (alphabetical, and only tactics with
seeded techniques), while ExecutiveDashboardPage.tsx re-sorted client-side
into the correct kill-chain order. get_coverage_by_tactic now returns the
canonical order and all 14 tactics, zero-filled so both consumers get
consistent results without needing their own reordering logic.
"""
from app.models.technique import Technique
from app.services.metrics_query_service import MITRE_TACTIC_ORDER, get_coverage_by_tactic
def test_coverage_by_tactic_returns_canonical_kill_chain_order(db):
# Seed a few tactics out of order to prove the response isn't alphabetical.
db.add(Technique(mitre_id="T3001", name="A", tactic="impact"))
db.add(Technique(mitre_id="T3002", name="B", tactic="execution"))
db.add(Technique(mitre_id="T3003", name="C", tactic="reconnaissance"))
db.commit()
rows = get_coverage_by_tactic(db)
tactics = [r.tactic for r in rows]
canonical_present = [t for t in tactics if t in MITRE_TACTIC_ORDER]
assert canonical_present == MITRE_TACTIC_ORDER
def test_coverage_by_tactic_zero_fills_tactics_with_no_techniques(db):
db.add(Technique(mitre_id="T3004", name="D", tactic="execution"))
db.commit()
rows = get_coverage_by_tactic(db)
by_tactic = {r.tactic: r for r in rows}
assert "persistence" in by_tactic
assert by_tactic["persistence"].total == 0
assert by_tactic["execution"].total == 1
def test_coverage_by_tactic_unknown_tactic_sorts_last(db):
db.add(Technique(mitre_id="T3005", name="E", tactic=None))
db.commit()
rows = get_coverage_by_tactic(db)
tactics = [r.tactic for r in rows]
assert tactics[-1] == "unknown"
@@ -0,0 +1,42 @@
"""Tests for D3FEND mapping import — review_required trigger parity.
Every other import source (Atomic Red Team, Caldera, Elastic, Sigma, LOLBAS)
flags a technique with review_required=True when it gains new content, so a
lead knows to look at it. D3FEND mapping import was missing this this test
locks in the fix.
"""
from app.models.defensive_technique import DefensiveTechnique
from app.models.technique import Technique
from app.services.d3fend_import_service import import_d3fend_mappings
def test_import_d3fend_mappings_sets_review_required(db):
technique = Technique(mitre_id="T1590", name="Gather Victim Network Information", review_required=False)
db.add(technique)
db.add(DefensiveTechnique(d3fend_id="D3-NTA", name="Network Traffic Analysis"))
db.commit()
result = import_d3fend_mappings(db)
assert result["created"] >= 1
db.refresh(technique)
assert technique.review_required is True
def test_import_d3fend_mappings_skips_existing_without_reflagging(db):
technique = Technique(mitre_id="T1590", name="Gather Victim Network Information", review_required=False)
db.add(technique)
db.add(DefensiveTechnique(d3fend_id="D3-NTA", name="Network Traffic Analysis"))
db.commit()
import_d3fend_mappings(db)
db.refresh(technique)
technique.review_required = False
db.commit()
result = import_d3fend_mappings(db)
assert result["created"] == 0
db.refresh(technique)
assert technique.review_required is False
+117 -12
View File
@@ -1,15 +1,31 @@
"""Tests for data classification fields and admin updates."""
"""Tests for data classification fields and edit permissions."""
from unittest.mock import MagicMock
import pytest
from app.models.enums import TestState
from app.models.test import Test
from app.models.technique import Technique
from app.services.test_crud_service import determine_initial_classification
def _seed_technique(db) -> Technique:
@pytest.fixture
def technique(client, auth_headers):
"""Create a technique for test association (mirrors test_tests.py)."""
response = client.post(
"/api/v1/techniques",
json={"mitre_id": "T9998", "name": "Classification Fixture Technique"},
headers=auth_headers,
)
return response.json()
def _seed_technique(db, tactic="execution") -> Technique:
technique = Technique(
mitre_id="T9999",
name="Test Technique",
tactic="test",
tactic=tactic,
platforms=["linux"],
)
db.add(technique)
@@ -18,7 +34,9 @@ def _seed_technique(db) -> Technique:
return technique
def test_new_test_defaults_to_internal(db, red_lead_user):
def test_new_test_defaults_to_internal_use_only_via_db_default(db, red_lead_user):
"""A Test() constructed without going through the create_test service
still gets a safe classification via the column's server_default."""
technique = _seed_technique(db)
test = Test(
technique_id=technique.id,
@@ -28,10 +46,24 @@ def test_new_test_defaults_to_internal(db, red_lead_user):
db.add(test)
db.commit()
db.refresh(test)
assert test.data_classification == "internal"
assert test.data_classification == "internal_use_only"
def test_admin_can_update_classification(client, db, admin_user, admin_token, red_lead_user):
def test_create_test_endpoint_uses_tactic_heuristic(client, db, api, red_lead_headers, red_lead_user, technique):
"""Going through the real create-test flow applies the tactic-based heuristic."""
resp = api(
"post", "/api/v1/tests", red_lead_headers,
json={"technique_id": technique["id"], "name": "Heuristic test"},
)
assert resp.status_code == 201, resp.text
# The shared `technique` fixture (see conftest/test_tests.py) doesn't set
# a pii-tier tactic, so this should land on the baseline.
assert resp.json()["data_classification"] == "internal_use_only"
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,
@@ -44,17 +76,70 @@ def test_admin_can_update_classification(client, db, admin_user, admin_token, re
response = client.patch(
f"/api/v1/tests/{test.id}/classification",
json={"data_classification": "sensitive"},
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"] == "sensitive"
assert response.json()["data_classification"] == "pii"
db.refresh(test)
assert test.data_classification == "sensitive"
assert test.data_classification == "pii"
def test_non_admin_cannot_update_classification(client, db, admin_user, red_lead_token, red_lead_user):
def test_operator_can_update_classification(client, db, admin_user, red_lead_token, red_lead_user):
"""Any test participant (not just admin) can correct the initial classification."""
technique = _seed_technique(db)
test = Test(
technique_id=technique.id,
name="Operator-editable",
created_by=red_lead_user.id,
)
db.add(test)
db.commit()
response = client.patch(
f"/api/v1/tests/{test.id}/classification",
json={"data_classification": "general_use"},
headers={"Authorization": f"Bearer {red_lead_token}"},
)
assert response.status_code == 200
assert response.json()["data_classification"] == "general_use"
def test_viewer_cannot_update_classification(client, db, admin_user, red_lead_user):
"""Viewer is not a test participant and should still be forbidden."""
from app.auth import hash_password
from app.models.user import User
viewer = User(
username="viewer_classif", email="viewer_classif@test.com",
hashed_password=hash_password("x"), role="viewer", is_active=True,
must_change_password=False,
)
db.add(viewer)
db.commit()
login = client.post("/api/v1/auth/login", data={"username": "viewer_classif@test.com", "password": "x"})
viewer_token = login.json()["access_token"]
technique = _seed_technique(db)
test = Test(
technique_id=technique.id,
@@ -66,7 +151,27 @@ def test_non_admin_cannot_update_classification(client, db, admin_user, red_lead
response = client.patch(
f"/api/v1/tests/{test.id}/classification",
json={"data_classification": "restricted"},
headers={"Authorization": f"Bearer {red_lead_token}"},
json={"data_classification": "pii"},
headers={"Authorization": f"Bearer {viewer_token}"},
)
assert response.status_code == 403
def _technique_stub(tactic):
t = MagicMock()
t.tactic = tactic
return t
def test_determine_initial_classification_defaults_to_internal_use_only():
assert determine_initial_classification(_technique_stub("execution")) == "internal_use_only"
assert determine_initial_classification(_technique_stub(None)) == "internal_use_only"
assert determine_initial_classification(None) == "internal_use_only"
def test_determine_initial_classification_escalates_for_sensitive_tactics():
assert determine_initial_classification(_technique_stub("exfiltration")) == "pii"
assert determine_initial_classification(_technique_stub("collection")) == "pii"
assert determine_initial_classification(_technique_stub("credential-access")) == "pii"
assert determine_initial_classification(_technique_stub("impact")) == "pii"
assert determine_initial_classification(_technique_stub("Exfiltration")) == "pii"
@@ -0,0 +1,115 @@
"""Coverage for the generic email webhook: the admin test endpoint, and
the new notification-dispatch helpers that route test/campaign/MITRE
events through it (see webhook_email_service.py, notification_service.py).
"""
import uuid
from unittest.mock import patch
import pytest
from app.services.notification_service import (
_preference_allows,
notify_all_users_by_email,
notify_user_by_email,
)
def test_email_webhook_test_endpoint_requires_admin(api, red_lead_headers):
resp = api(
"post", "/api/v1/system/email-webhook-test", red_lead_headers,
json={"to": "someone@test.com"},
)
assert resp.status_code == 403
def test_email_webhook_test_endpoint_fails_without_config(api, auth_headers):
resp = api(
"post", "/api/v1/system/email-webhook-test", auth_headers,
json={"to": "someone@test.com"},
)
assert resp.status_code == 502
def test_email_webhook_test_endpoint_sends_via_configured_webhook(api, auth_headers):
api(
"patch", "/api/v1/system/email-webhook-config", auth_headers,
json={"url": "https://example.com/power-automate-hook", "api_key": "super-secret-key"},
)
with patch("app.services.webhook_email_service.requests.post") as mock_post:
resp = api(
"post", "/api/v1/system/email-webhook-test", auth_headers,
json={"to": "marcos.luna@kaseya.com"},
)
assert resp.status_code == 200, resp.text
mock_post.assert_called_once()
call_kwargs = mock_post.call_args
assert call_kwargs.args[0] == "https://example.com/power-automate-hook"
assert call_kwargs.kwargs["headers"] == {"x-api-key": "super-secret-key"}
payload = call_kwargs.kwargs["json"]
assert payload["to"] == "marcos.luna@kaseya.com"
assert "Purple Team Engineering" in payload["body"]
class _FakeUser:
def __init__(self, email="user@test.com", full_name="A User", prefs=None):
self.email = email
self.full_name = full_name
self.notification_preferences = prefs
def test_preference_allows_defaults_to_true_when_missing():
assert _preference_allows(_FakeUser(prefs=None), "email_on_test_validated") is True
assert _preference_allows(_FakeUser(prefs={}), "email_on_test_validated") is True
def test_preference_allows_respects_explicit_false():
user = _FakeUser(prefs={"email_on_test_validated": False})
assert _preference_allows(user, "email_on_test_validated") is False
def test_preference_allows_false_without_email():
assert _preference_allows(_FakeUser(email=None), "email_on_test_validated") is False
assert _preference_allows(None, "email_on_test_validated") is False
def test_notify_user_by_email_sends_when_allowed(db, auth_headers, api):
resp = api(
"post", "/api/v1/users", auth_headers,
json={"full_name": "Notify Me", "email": "notifyme@test.com", "role": "viewer"},
)
assert resp.status_code == 201, resp.text
user_id = resp.json()["id"]
with patch("app.services.webhook_email_service.send_webhook_email") as mock_send:
notify_user_by_email(
db, uuid.UUID(user_id),
preference_key="email_on_test_validated",
subject="Test Validated: Some Test",
message='Your test "Some Test" has been validated successfully.',
)
mock_send.assert_called_once()
assert mock_send.call_args.kwargs["to"] == "notifyme@test.com"
def test_notify_all_users_by_email_skips_opted_out(db):
allowed = _FakeUser(email="allowed@test.com", prefs={"email_on_new_mitre_techniques": True})
blocked = _FakeUser(email="blocked@test.com", prefs={"email_on_new_mitre_techniques": False})
class _FakeQuery:
def filter(self, *args, **kwargs):
return self
def all(self):
return [allowed, blocked]
with patch.object(db, "query", return_value=_FakeQuery()), \
patch("app.services.webhook_email_service.send_webhook_email") as mock_send:
notify_all_users_by_email(
db,
preference_key="email_on_new_mitre_techniques",
subject="MITRE ATT&CK Updated: 3 new techniques",
message="3 new techniques were added and 1 were updated.",
)
mock_send.assert_called_once()
assert mock_send.call_args.kwargs["to"] == "allowed@test.com"
@@ -0,0 +1,28 @@
"""Evidence upload file-type validation — Red team artifacts (e.g. .exe
payloads) must be shareable with Blue for detection analysis."""
import pytest
from app.domain.errors import BusinessRuleViolation
from app.services.evidence_service import validate_file
def test_exe_files_are_allowed():
validate_file("payload.exe", 1024)
def test_allowed_extensions_still_pass():
for name in ("screenshot.png", "notes.pdf", "capture.pcap", "archive.zip"):
validate_file(name, 1024)
def test_disallowed_extension_still_rejected():
with pytest.raises(BusinessRuleViolation):
validate_file("script.sh", 1024)
def test_oversized_file_still_rejected():
from app.services.evidence_service import MAX_UPLOAD_SIZE
with pytest.raises(BusinessRuleViolation):
validate_file("payload.exe", MAX_UPLOAD_SIZE + 1)
@@ -0,0 +1,85 @@
"""Every heatmap layer must include the technique's display name.
The frontend heatmap previously showed only the MITRE ID on each cell
(name only available via hover tooltip), unlike the Techniques page which
always showed both. Unifying the two pages' cell style requires the name
to be available on every layer's technique entries, not just derived
client-side (HeatmapTechnique has no name field to derive it from).
"""
from app.models.campaign import Campaign, CampaignTest
from app.models.detection_rule import DetectionRule
from app.models.enums import TestState
from app.models.technique import Technique
from app.models.test import Test
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
from app.services.heatmap_service import (
build_campaign_layer,
build_coverage_layer,
build_detection_rules_layer,
build_threat_actor_layer,
)
def _seed_technique(db, mitre_id="T1059", name="Command and Scripting Interpreter"):
tech = Technique(mitre_id=mitre_id, name=name, tactic="execution", platforms=["windows"])
db.add(tech)
db.flush()
return tech
def test_coverage_layer_includes_technique_name_and_status(db):
tech = _seed_technique(db)
db.commit()
layer = build_coverage_layer(db)
entry = next(t for t in layer["techniques"] if t["techniqueID"] == tech.mitre_id)
assert entry["name"] == tech.name
assert entry["status"] == "not_evaluated"
def test_threat_actor_layer_includes_technique_name_and_status(db):
tech = _seed_technique(db)
actor = ThreatActor(name="APT-Test")
db.add(actor)
db.flush()
db.add(ThreatActorTechnique(threat_actor_id=actor.id, technique_id=tech.id))
db.commit()
layer = build_threat_actor_layer(db, actor.id)
entry = next(t for t in layer["techniques"] if t["techniqueID"] == tech.mitre_id)
assert entry["name"] == tech.name
assert entry["status"] == "not_evaluated"
def test_detection_rules_layer_includes_technique_name(db):
tech = _seed_technique(db)
db.add(DetectionRule(
mitre_technique_id=tech.mitre_id, title="Rule 1", source="sigma",
rule_content="title: Rule 1", rule_format="sigma",
))
db.commit()
layer = build_detection_rules_layer(db)
entry = next(t for t in layer["techniques"] if t["techniqueID"] == tech.mitre_id)
assert entry["name"] == tech.name
def test_campaign_layer_includes_technique_name(db, red_lead_user):
tech = _seed_technique(db)
campaign = Campaign(name="Test Campaign", type="custom", status="active", created_by=red_lead_user.id)
db.add(campaign)
db.flush()
test = Test(technique_id=tech.id, name="A test", state=TestState.validated, created_by=red_lead_user.id)
db.add(test)
db.flush()
db.add(CampaignTest(campaign_id=campaign.id, test_id=test.id, order_index=0))
db.commit()
layer = build_campaign_layer(db, campaign.id)
entry = next(t for t in layer["techniques"] if t["techniqueID"] == tech.mitre_id)
assert entry["name"] == tech.name
+150
View File
@@ -0,0 +1,150 @@
"""Tests for POST /tests/{id}/hold and /resume — must pause/resume the phase timer."""
from datetime import datetime, timedelta
from app.models.test import Test
from app.models.technique import Technique
from app.models.enums import TestState
def _seed_technique(db) -> Technique:
technique = Technique(
mitre_id="T9999", name="Test Technique", tactic="execution", platforms=["linux"],
)
db.add(technique)
db.commit()
db.refresh(technique)
return technique
def _seed_executing_test(db, technique, created_by) -> Test:
test = Test(
technique_id=technique.id,
name="Holdable test",
created_by=created_by,
state=TestState.red_executing,
red_started_at=datetime.utcnow() - timedelta(minutes=10),
)
db.add(test)
db.commit()
db.refresh(test)
return test
def _seed_blue_evaluating_test(db, technique, created_by) -> Test:
test = Test(
technique_id=technique.id,
name="Holdable test (blue)",
created_by=created_by,
state=TestState.blue_evaluating,
blue_started_at=datetime.utcnow() - timedelta(minutes=10),
)
db.add(test)
db.commit()
db.refresh(test)
return test
def test_hold_pauses_the_running_timer(client, db, red_tech_headers, red_tech_user):
technique = _seed_technique(db)
test = _seed_executing_test(db, technique, red_tech_user.id)
assert test.paused_at is None
resp = client.post(
f"/api/v1/tests/{test.id}/hold",
json={"reason": "waiting on lab access"},
headers=red_tech_headers,
)
assert resp.status_code == 200, resp.text
assert resp.json()["paused_at"] is not None
db.refresh(test)
assert test.paused_at is not None
def test_resume_clears_pause_and_accumulates_seconds(client, db, red_tech_headers, red_tech_user):
technique = _seed_technique(db)
test = _seed_executing_test(db, technique, red_tech_user.id)
resp = client.post(
f"/api/v1/tests/{test.id}/hold",
json={"reason": "waiting on lab access"},
headers=red_tech_headers,
)
assert resp.status_code == 200
resp = client.post(f"/api/v1/tests/{test.id}/resume", headers=red_tech_headers)
assert resp.status_code == 200, resp.text
assert resp.json()["paused_at"] is None
db.refresh(test)
assert test.paused_at is None
assert test.red_paused_seconds >= 0
def test_hold_does_not_double_pause_an_already_paused_timer(client, db, red_tech_headers, red_tech_user):
"""If the operator already paused the timer manually, holding must not
overwrite paused_at (which would reset the elapsed-pause calculation)."""
technique = _seed_technique(db)
test = _seed_executing_test(db, technique, red_tech_user.id)
resp = client.post(f"/api/v1/tests/{test.id}/pause-timer", headers=red_tech_headers)
assert resp.status_code == 200
db.refresh(test)
manual_pause_time = test.paused_at
resp = client.post(
f"/api/v1/tests/{test.id}/hold",
json={"reason": "waiting on lab access"},
headers=red_tech_headers,
)
assert resp.status_code == 200
db.refresh(test)
assert test.paused_at == manual_pause_time
def test_red_tech_cannot_hold_a_test_in_blue_evaluating(
client, db, red_tech_headers, red_tech_user,
):
"""Once a test has moved into Blue's queue, only blue_tech may hold it —
a red_tech is no longer involved at this stage."""
technique = _seed_technique(db)
test = _seed_blue_evaluating_test(db, technique, red_tech_user.id)
resp = client.post(
f"/api/v1/tests/{test.id}/hold",
json={"reason": "waiting on lab access"},
headers=red_tech_headers,
)
assert resp.status_code == 403
def test_blue_tech_can_hold_a_test_in_blue_evaluating(
client, db, blue_tech_headers, red_tech_user,
):
technique = _seed_technique(db)
test = _seed_blue_evaluating_test(db, technique, red_tech_user.id)
resp = client.post(
f"/api/v1/tests/{test.id}/hold",
json={"reason": "waiting on EDR access"},
headers=blue_tech_headers,
)
assert resp.status_code == 200, resp.text
def test_blue_tech_cannot_resume_a_test_that_was_held_during_red_executing(
client, db, api, red_tech_headers, blue_tech_headers, red_tech_user,
):
technique = _seed_technique(db)
test = _seed_executing_test(db, technique, red_tech_user.id)
resp = api(
"post", f"/api/v1/tests/{test.id}/hold", red_tech_headers,
json={"reason": "waiting on lab access"},
)
assert resp.status_code == 200, resp.text
resp = api("post", f"/api/v1/tests/{test.id}/resume", blue_tech_headers)
assert resp.status_code == 403
+6
View File
@@ -97,11 +97,17 @@ if "apscheduler.schedulers.background" not in sys.modules:
_apsched = ModuleType("apscheduler.schedulers.background")
class _FakeBGScheduler:
def add_job(self, *a, **kw): pass
def add_listener(self, *a, **kw): pass
def start(self): pass
def shutdown(self, **kw): pass
_apsched.BackgroundScheduler = _FakeBGScheduler
sys.modules["apscheduler.schedulers.background"] = _apsched
if "apscheduler.events" not in sys.modules:
_apsched_events = ModuleType("apscheduler.events")
_apsched_events.EVENT_JOB_ERROR = 1
sys.modules["apscheduler.events"] = _apsched_events
if "taxii2client" not in sys.modules:
sys.modules["taxii2client"] = ModuleType("taxii2client")
if "taxii2client.v20" not in sys.modules:
@@ -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

Some files were not shown because too many files have changed in this diff Show More