Compare commits

61 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
162 changed files with 9664 additions and 963 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,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."
)
+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",
]
@@ -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])
+17
View File
@@ -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)
@@ -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 ...
@@ -126,6 +139,10 @@ class Test(Base):
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)
+11 -6
View File
@@ -41,6 +41,7 @@ _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",
@@ -221,6 +222,7 @@ async def import_config(
"custom_templates": 0,
"users_created": 0,
"users_updated": 0,
"users_skipped_no_email": 0,
}
# ── 1. system_configs ────────────────────────────────────────────
@@ -308,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)
@@ -322,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
+48 -6
View File
@@ -57,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
@@ -72,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"])
@@ -108,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)
@@ -134,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
@@ -146,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:
@@ -168,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,
)
@@ -381,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"}
+62 -37
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__)
@@ -135,39 +135,21 @@ 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 a campaign and its already-linked tests, if missing.
"""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.
Tests may already be linked to the campaign from when it was still a
draft, i.e. before any Jira ticket existed for the campaign — so they
need their own tickets created here too. Best-effort: failures are
logged, not raised, since a Jira outage must not block activation.
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.
"""
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, 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 while activating campaign %s",
campaign_id,
)
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 ─────────────────────────────────────────────────
@@ -189,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
@@ -332,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(
@@ -365,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(
@@ -372,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,
@@ -381,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
@@ -427,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.
@@ -532,6 +543,12 @@ 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)
@@ -857,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,
@@ -979,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.
@@ -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)
+89
View File
@@ -344,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),
+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``.
+208 -77
View File
@@ -42,7 +42,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, require_any_role_strict
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
@@ -65,6 +65,7 @@ from app.schemas.test import (
TestClassificationUpdate,
TestCreate,
TestHold,
TestManagerResolveDispute,
TestOut,
TestRedReview,
TestRedUpdate,
@@ -93,6 +94,11 @@ 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,
@@ -152,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,
@@ -163,46 +170,6 @@ 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
# ---------------------------------------------------------------------------
@@ -331,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.
@@ -411,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.
@@ -442,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(
@@ -504,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)
# ---------------------------------------------------------------------------
@@ -527,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:
@@ -580,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
# ---------------------------------------------------------------------------
@@ -658,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``).
@@ -671,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")
@@ -724,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``).
@@ -737,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")
@@ -788,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``.
@@ -839,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``.
@@ -887,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``.
@@ -931,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)
@@ -1029,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).
@@ -1068,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.
@@ -1229,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
# ---------------------------------------------------------------------------
@@ -1280,44 +1354,65 @@ def assign_test_operators(
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("manager", "red_lead", "blue_lead")),
):
"""Assign red_tech and/or blue_tech operators to a test. Leads/managers only — not admin, who administers the site rather than coordinating operators."""
"""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: User | None = None
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 = u
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 = u
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 is not None:
try:
from app.services.jira_service import push_assignee_update
push_assignee_update(db, test, newly_assigned)
db.commit()
except Exception: # nosec B110
pass # jira_service already logs warnings internally
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
@@ -1327,6 +1422,21 @@ 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,
@@ -1347,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")
@@ -1354,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)
@@ -1375,6 +1491,7 @@ def resume_test(
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)
@@ -1382,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)
@@ -1410,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.
@@ -1685,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
@@ -1723,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,
}
@@ -1764,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:
+157 -14
View File
@@ -1,5 +1,8 @@
"""User management router (admin only)."""
# Import logging
import logging
# Import uuid
import uuid
@@ -17,24 +20,38 @@ 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, UserOperatorOut
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,13 +185,36 @@ 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
@@ -161,13 +232,14 @@ def list_operators_route(
"""Return active red/blue operators and leads, for lead/manager assignment pickers.
Not reachable by admin — admin administers the site, leads/managers
coordinate operators. Returns only id/username/role — no emails or
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", "admin"]),
User.role.in_(["red_tech", "red_lead", "blue_tech", "blue_lead"]),
User.is_active.is_(True),
)
.order_by(User.username)
@@ -215,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)
@@ -240,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)
+101 -3
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 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 ──────────────────────────────────────────────────────────
@@ -85,6 +99,8 @@ class TestRedUpdate(BaseModel):
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
@@ -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
@@ -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
+1
View File
@@ -130,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
+85 -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
@@ -291,6 +307,42 @@ class UserOperatorOut(BaseModel):
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()
@@ -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
+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",
})
+295 -52
View File
@@ -295,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.
@@ -302,7 +305,7 @@ 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
@@ -339,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)
# ---------------------------------------------------------------------------
@@ -376,11 +401,11 @@ _STATE_EMOJI: dict[str, str] = {
# (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",
"draft": "To Do",
"red_executing": "In Progress",
"red_review": "RT Test Review",
"red_review": "Red Team test review",
"blue_evaluating": "Queued Blue Team",
"blue_review": "Blue Team Test Review",
"blue_review": "Blue Team test review",
"in_review": "Validation",
"validated": "Done",
"rejected": "Rejected",
@@ -446,15 +471,27 @@ 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 += [
@@ -469,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'}",
@@ -489,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'}",
@@ -652,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,
@@ -704,6 +788,11 @@ def auto_create_test_issue(
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}",
@@ -825,9 +914,12 @@ def push_test_event(
link.jira_issue_key, target_status, exc_t,
)
# When the operator starts execution: assign the ticket to that operator.
# 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 = getattr(actor, "jira_account_id", None)
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)
@@ -842,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)
@@ -856,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(
@@ -891,7 +1018,7 @@ def push_assignee_update(db: Session, test: Test, assignee: User) -> None:
if not link:
return
jira_account_id = getattr(assignee, "jira_account_id", None)
jira_account_id = _resolve_jira_account_id(db, assignee)
if not jira_account_id:
return
@@ -978,11 +1105,13 @@ def push_hold_event(
def push_pause_event(db: Session, test, actor, *, resuming: bool = False) -> None:
"""Post a timer pause/resume comment to Jira and toggle 'On Hold' / 'In Progress'.
"""Post a timer pause/resume comment to Jira — status is left untouched.
Distinct from :func:`push_hold_event` a short timer pause (the
operator stepping away briefly) maps to "On Hold", while an explicit
hold (something external blocking progress) maps to "Blocked".
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):
@@ -1005,16 +1134,8 @@ def push_pause_event(db: Session, test, actor, *, resuming: bool = False) -> Non
if resuming:
comment = f"h3. ▶ Timer Resumed\n\n*Resumed 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 off On Hold: %s", link.jira_issue_key, exc_t)
else:
comment = f"h3. ⏸ Timer Paused\n\n*Paused by:* {actor.username}\n*At:* {ts}"
try:
jira.set_issue_status(link.jira_issue_key, "On Hold")
except Exception as exc_t:
logger.warning("Could not transition %s to On Hold: %s", link.jira_issue_key, exc_t)
jira.issue_add_comment(link.jira_issue_key, comment)
link.last_synced_at = datetime.utcnow()
@@ -1024,6 +1145,73 @@ def push_pause_event(db: Session, test, actor, *, resuming: bool = False) -> Non
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.
@@ -1056,6 +1244,16 @@ def push_bt_work_started(db: Session, test, actor) -> None:
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)
@@ -1085,12 +1283,18 @@ def _select_field(value: str) -> dict:
return {"value": value}
def _compute_hours(start: Optional[datetime], end: Optional[datetime]) -> Optional[float]:
"""Return the elapsed time between two timestamps in hours, or None if unavailable."""
if not start or not end:
return None
delta_hours = (end - start).total_seconds() / 3600
return round(delta_hours, 2) if delta_hours >= 0 else None
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:
@@ -1125,32 +1329,73 @@ def _update_test_fields(db: Session, test: Test, fields: dict) -> None:
)
def push_rt_started(db: Session, test: Test) -> None:
"""Set the RT Start Date field — called when the operator hits Start Execution."""
_update_test_fields(db, test, {
JIRA_FIELD_RT_START_DATE: datetime.utcnow().strftime("%Y-%m-%d"),
})
def push_rt_submitted(db: Session, test: Test) -> None:
"""Set the RT End Date + Attack Success fields — called when Red submits for review."""
fields: dict = {JIRA_FIELD_RT_END_DATE: datetime.utcnow().strftime("%Y-%m-%d")}
"""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."""
"""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: datetime.utcnow().strftime("%Y-%m-%d"),
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."""
fields: dict = {JIRA_FIELD_BT_END_DATE: datetime.utcnow().strftime("%Y-%m-%d")}
"""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:
@@ -1160,13 +1405,11 @@ def push_bt_submitted(db: Session, test: Test) -> None:
if containment_result in _CONTAINMENT_TO_JIRA:
fields[JIRA_FIELD_ATTACK_CONTAINED] = _select_field(_CONTAINMENT_TO_JIRA[containment_result])
time_to_detect = _compute_hours(test.execution_end_time, test.detection_time)
if time_to_detect is not None:
fields[JIRA_FIELD_TIME_TO_DETECT] = time_to_detect
if test.detection_time:
fields[JIRA_FIELD_TIME_TO_DETECT] = _jira_datetime(test.detection_time)
time_to_contain = _compute_hours(test.detection_time, test.containment_time)
if time_to_contain is not None:
fields[JIRA_FIELD_TIME_TO_CONTAIN] = time_to_contain
if test.containment_time:
fields[JIRA_FIELD_TIME_TO_CONTAIN] = _jira_datetime(test.containment_time)
_update_test_fields(db, test, fields)
@@ -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
+35 -2
View File
@@ -70,7 +70,10 @@ def _build_test_query(
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))
query = db.query(Test).options(
joinedload(Test.technique),
joinedload(Test.red_tech_assigned_user), joinedload(Test.blue_tech_assigned_user),
)
# Check: state
if state:
@@ -301,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.
@@ -361,9 +365,11 @@ 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
@@ -393,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()
@@ -427,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.
+76 -41
View File
@@ -12,7 +12,7 @@ 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
@@ -23,6 +23,42 @@ from app.models.test import Test
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
@@ -46,51 +82,16 @@ 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()
)
@@ -108,10 +109,29 @@ def list_templates(
for t in templates:
t.existing_test_count = counts.get(t.mitre_technique_id, 0)
# Return templates
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."""
@@ -215,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
@@ -229,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()
+143 -15
View File
@@ -32,12 +32,14 @@ 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__)
@@ -242,12 +244,6 @@ def start_execution(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_started
push_rt_started(db, test)
except Exception as e:
logger.warning("Jira RT start date push failed for test %s: %s", test.id, e, exc_info=True)
return test
@@ -345,6 +341,12 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> 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
@@ -383,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:
@@ -409,8 +436,16 @@ def reopen_red_review(db: Session, test: Test, user: User, notes: str) -> Test:
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_test_event(db, test, user, "red_executing", extra={"notes": notes.strip()})
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)
@@ -549,6 +584,12 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> 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
@@ -587,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)
@@ -594,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:
@@ -614,7 +677,8 @@ def reopen_blue_review(db: Session, test: Test, user: User, notes: str) -> Test:
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
try:
from app.services.jira_service import push_test_event
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)
@@ -985,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
@@ -1050,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
@@ -1084,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":
@@ -1115,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)
@@ -1135,7 +1218,7 @@ 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)
@@ -1278,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}'
),
)
# ---------------------------------------------------------------------------
+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"]
@@ -105,24 +105,35 @@ def test_import_config_rejects_invalid_json(client, auth_headers):
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", "role": "red_tech", "is_active": True}]},
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.username == "imported_user").first()
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, "role": "red_lead", "is_active": True}]},
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
@@ -26,8 +26,41 @@ DUMMY_ID = str(uuid.uuid4())
("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}"
+87
View File
@@ -55,6 +55,19 @@ def test_assign_rejects_wrong_role_for_side(client, db, red_lead_headers, red_le
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)
@@ -135,3 +148,77 @@ def test_assigning_operator_syncs_to_jira(client, db, red_lead_headers, red_lead
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)
+5 -4
View File
@@ -23,7 +23,7 @@ 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()
@@ -35,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
@@ -56,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,
@@ -65,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
@@ -106,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"]
+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
+153 -5
View File
@@ -45,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()
@@ -53,10 +53,13 @@ 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(api, db, red_lead_user, red_lead_headers, manager_headers):
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."""
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)
@@ -68,7 +71,7 @@ def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_
"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
@@ -77,6 +80,29 @@ def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_
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)
@@ -320,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
+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
# ═══════════════════════════════════════════════════════════════════════
+3 -3
View File
@@ -49,10 +49,10 @@ def test_new_test_defaults_to_internal_use_only_via_db_default(db, red_lead_user
assert test.data_classification == "internal_use_only"
def test_create_test_endpoint_uses_tactic_heuristic(client, db, api, auth_headers, technique):
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", auth_headers,
"post", "/api/v1/tests", red_lead_headers,
json={"technique_id": technique["id"], "name": "Heuristic test"},
)
assert resp.status_code == 201, resp.text
@@ -137,7 +137,7 @@ def test_viewer_cannot_update_classification(client, db, admin_user, red_lead_us
)
db.add(viewer)
db.commit()
login = client.post("/api/v1/auth/login", data={"username": "viewer_classif", "password": "x"})
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)
@@ -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)
+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:
+592 -21
View File
@@ -53,9 +53,14 @@ def _make_test(**overrides):
t.attack_success = None
t.detection_result = None
t.containment_result = None
t.execution_start_time = None
t.execution_end_time = None
t.detection_time = None
t.containment_time = None
t.red_round_number = 1
t.blue_round_number = 1
t.procedure_text = None
t.tool_used = None
# _build_state_comment() appends these raw (not via an f-string) when
# truthy, so an un-nulled MagicMock attribute breaks "\n".join(lines).
t.red_summary = None
@@ -65,6 +70,9 @@ def _make_test(**overrides):
t.blue_validation_status = None
t.red_validation_notes = None
t.blue_validation_notes = None
t.red_tech_assignee = None
t.blue_tech_assignee = None
t.system_gaps = None
for k, v in overrides.items():
setattr(t, k, v)
return t
@@ -72,7 +80,10 @@ def _make_test(**overrides):
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_pause_event_sets_on_hold(mock_get_client, mock_configured, db):
def test_push_pause_event_does_not_change_jira_status(mock_get_client, mock_configured, db):
"""A timer pause is not the same as a real Hold — it must never flip the
Jira issue to 'On Hold'. Only push_hold_event (a genuine Hold action) is
allowed to change status. Regression: pausing used to set 'On Hold'."""
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test()
@@ -82,13 +93,13 @@ def test_push_pause_event_sets_on_hold(mock_get_client, mock_configured, db):
jira_service.push_pause_event(db, test, MagicMock(username="alice"), resuming=False)
mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, "On Hold")
mock_jira.set_issue_status.assert_not_called()
mock_jira.issue_add_comment.assert_called_once()
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_pause_event_resume_sets_in_progress(mock_get_client, mock_configured, db):
def test_push_pause_event_resume_does_not_change_jira_status(mock_get_client, mock_configured, db):
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test()
@@ -98,7 +109,8 @@ def test_push_pause_event_resume_sets_in_progress(mock_get_client, mock_configur
jira_service.push_pause_event(db, test, MagicMock(username="alice"), resuming=True)
mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, "In Progress")
mock_jira.set_issue_status.assert_not_called()
mock_jira.issue_add_comment.assert_called_once()
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@@ -116,14 +128,86 @@ def test_push_hold_event_sets_blocked(mock_get_client, mock_configured, db):
mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, "Blocked")
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_round_archived_posts_comment_without_changing_status(mock_get_client, mock_configured, db):
"""Archiving a round before reopen must leave a permanent Jira comment
(custom fields get overwritten by the next round, comments don't) but
must not touch the issue's status — that's push_test_event's job."""
from app.domain.enums import AttackSuccessResult
from app.models.test_round_history import TestRoundHistory
test = _make_test()
link = _make_link(test.id)
db.add(link)
db.commit()
round_data = TestRoundHistory(
test_id=test.id, team="red", round_number=1,
attack_success=AttackSuccessResult.successful, red_summary="Got a shell.",
procedure_text="ran mimikatz", tool_used="mimikatz",
review_notes="Evidence was incomplete, redo with screenshots",
)
mock_jira = MagicMock()
mock_jira.issue.return_value = {"fields": {"labels": []}}
mock_get_client.return_value = mock_jira
jira_service.push_round_archived(db, test, MagicMock(username="redlead"), round_data=round_data)
mock_jira.set_issue_status.assert_not_called()
mock_jira.issue_add_comment.assert_called_once()
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_round_archived_adds_reopened_label_without_clobbering_others(mock_get_client, mock_configured, db):
from app.models.test_round_history import TestRoundHistory
test = _make_test()
link = _make_link(test.id)
db.add(link)
db.commit()
round_data = TestRoundHistory(test_id=test.id, team="blue", round_number=1)
mock_jira = MagicMock()
mock_jira.issue.return_value = {"fields": {"labels": ["security-review"]}}
mock_get_client.return_value = mock_jira
jira_service.push_round_archived(db, test, MagicMock(username="bluelead"), round_data=round_data)
mock_jira.update_issue_field.assert_called_once_with(
link.jira_issue_key, fields={"labels": ["security-review", "reopened"]},
)
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_round_archived_does_not_duplicate_reopened_label(mock_get_client, mock_configured, db):
from app.models.test_round_history import TestRoundHistory
test = _make_test()
link = _make_link(test.id)
db.add(link)
db.commit()
round_data = TestRoundHistory(test_id=test.id, team="red", round_number=2)
mock_jira = MagicMock()
mock_jira.issue.return_value = {"fields": {"labels": ["reopened"]}}
mock_get_client.return_value = mock_jira
jira_service.push_round_archived(db, test, MagicMock(username="redlead"), round_data=round_data)
mock_jira.update_issue_field.assert_not_called()
@pytest.mark.parametrize(
"new_state,expected_status",
[
("draft", "To-Do"),
("draft", "To Do"),
("red_executing", "In Progress"),
("red_review", "RT Test Review"),
("red_review", "Red Team test review"),
("blue_evaluating", "Queued Blue Team"),
("blue_review", "Blue Team Test Review"),
("blue_review", "Blue Team test review"),
("in_review", "Validation"),
("validated", "Done"),
("rejected", "Rejected"),
@@ -149,6 +233,302 @@ def test_push_test_event_maps_every_state_to_jira_status(
mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, expected_status)
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_test_event_validated_says_both_leads_when_they_agree(mock_get_client, mock_configured, db):
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test(red_validation_status="approved", blue_validation_status="approved")
link = _make_link(test.id)
db.add(link)
db.commit()
jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "validated")
comment = mock_jira.issue_add_comment.call_args[0][1]
assert "validated* by both leads" in comment
assert "manager" not in comment.lower()
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_test_event_validated_flags_manager_override_when_leads_disagree(mock_get_client, mock_configured, db):
"""Regression: a disputed test resolved by a manager keeps the leads'
original disagreeing votes the comment must not claim 'both leads'
agreed, and must carry the manager's own decision/notes."""
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test(red_validation_status="approved", blue_validation_status="rejected")
link = _make_link(test.id)
db.add(link)
db.commit()
jira_service.push_test_event(
db, test, MagicMock(username="sergio", jira_account_id=None), "validated",
extra={"Manager Decision": "sergio — test rejected"},
)
comment = mock_jira.issue_add_comment.call_args[0][1]
assert "manager's decision" in comment.lower()
assert "both leads" not in comment
assert "Manager Decision:* sergio — test rejected" in comment
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_test_event_rejected_flags_manager_override_when_leads_disagree(mock_get_client, mock_configured, db):
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test(red_validation_status="rejected", blue_validation_status="approved")
link = _make_link(test.id)
db.add(link)
db.commit()
jira_service.push_test_event(
db, test, MagicMock(username="sergio", jira_account_id=None), "rejected",
extra={"Manager Decision": "sergio — needs full rework"},
)
comment = mock_jira.issue_add_comment.call_args[0][1]
assert "manager's decision" in comment.lower()
assert "must be reworked" not in comment
assert "Manager Decision:* sergio — needs full rework" in comment
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_test_event_red_review_comment_includes_round_data(mock_get_client, mock_configured, db):
"""Every submission — not just ones that later get reopened — must leave
a data-bearing comment in Jira. Previously this comment was a generic
one-liner with no procedure/tool/result, so a round that got approved
(never archived via reopen) left no record of what was actually done."""
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test(
red_round_number=2,
procedure_text="ran mimikatz again", tool_used="mimikatz",
attack_success=MagicMock(value="successful"), red_summary="Got a shell this time.",
)
link = _make_link(test.id)
db.add(link)
db.commit()
jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "red_review")
comment = mock_jira.issue_add_comment.call_args[0][1]
assert "Round 2" in comment
assert "ran mimikatz again" in comment
assert "mimikatz" in comment
assert "successful" in comment
assert "Got a shell this time." in comment
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_test_event_blue_review_comment_includes_round_data(mock_get_client, mock_configured, db):
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test(
blue_round_number=1,
detection_result=MagicMock(value="detected"),
containment_result=MagicMock(value="contained"),
blue_summary="Caught it via EDR.",
)
link = _make_link(test.id)
db.add(link)
db.commit()
jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "blue_review")
comment = mock_jira.issue_add_comment.call_args[0][1]
assert "Round 1" in comment
assert "detected" in comment
assert "contained" in comment
assert "Caught it via EDR." in comment
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_test_event_clears_assignee_on_in_review(mock_get_client, mock_configured, db):
"""Regression: the previous reviewer's assignment must not linger on the
ticket once dual-validation starts both leads act independently, so
there's no single owner."""
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test()
link = _make_link(test.id)
db.add(link)
db.commit()
jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "in_review")
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id=None)
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_test_event_includes_system_gap_and_labels_it(mock_get_client, mock_configured, db):
"""A Blue Lead flagging a capability gap must surface it in Jira — both
in the comment (so the reason is visible) and as a label (so gap-flagged
tickets are filterable without opening each one)."""
mock_jira = MagicMock()
mock_jira.issue.return_value = {"fields": {"labels": []}}
mock_get_client.return_value = mock_jira
test = _make_test(system_gaps="Missing EDR agent on host X")
link = _make_link(test.id)
db.add(link)
db.commit()
jira_service.push_test_event(db, test, MagicMock(username="bluelead", jira_account_id=None), "in_review")
comment = mock_jira.issue_add_comment.call_args[0][1]
assert "Missing EDR agent on host X" in comment
mock_jira.update_issue_field.assert_called_once_with(
link.jira_issue_key, fields={"labels": ["system-gap"]},
)
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_test_event_in_review_without_gap_does_not_add_label(mock_get_client, mock_configured, db):
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test()
link = _make_link(test.id)
db.add(link)
db.commit()
jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "in_review")
mock_jira.issue.assert_not_called()
mock_jira.update_issue_field.assert_not_called()
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_test_event_clears_assignee_on_fresh_blue_queue_entry(mock_get_client, mock_configured, db):
"""A test reaching blue_evaluating for the first time (red just approved)
has no blue_tech_assignee yet queued, no owner, must not stay stuck on
the red_lead who approved it."""
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test() # blue_tech_assignee defaults to None
link = _make_link(test.id)
db.add(link)
db.commit()
jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "blue_evaluating")
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id=None)
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_test_event_reassigns_blue_operator_on_reopen(mock_get_client, mock_configured, db):
"""Regression: reopening blue_review (rework) sends the test back to
blue_evaluating, but the SAME operator is still assigned in Aegis
(blue_tech_assignee isn't cleared, only blue_work_started_at is) — Jira
must give them the ticket back, not leave it on the blue_lead who
reopened it or clear it as if nobody owned it."""
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
from app.models.user import User
blue_tech = User(
username="bluetech-reopen", email="bluetech-reopen@test.com",
hashed_password="x", role="blue_tech", jira_account_id="blue-tech-account",
)
db.add(blue_tech)
db.commit()
test = _make_test(blue_tech_assignee=blue_tech.id)
link = _make_link(test.id)
db.add(link)
db.commit()
jira_service.push_test_event(
db, test, MagicMock(username="bluelead", jira_account_id=None), "blue_evaluating",
)
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="blue-tech-account")
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_test_event_red_executing_assigns_actor_by_default(mock_get_client, mock_configured, db):
"""The normal start-execution path: no explicit assignee param, so the
acting operator (actor) is who gets the ticket."""
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test()
link = _make_link(test.id)
db.add(link)
db.commit()
jira_service.push_test_event(
db, test, MagicMock(username="redtech", jira_account_id="red-tech-account"), "red_executing",
)
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="red-tech-account")
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_test_event_red_executing_reassigns_original_operator_on_reopen(mock_get_client, mock_configured, db):
"""Regression: reopening red_review sends the test back to
red_executing, but *actor* here is the red_lead who reopened it, not
the operator who should keep working it. The caller passes the
original operator as the explicit assignee, which must win over actor."""
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test()
link = _make_link(test.id)
db.add(link)
db.commit()
original_operator = MagicMock(username="redtech", jira_account_id="red-tech-account")
reopening_lead = MagicMock(username="redlead", jira_account_id="red-lead-account")
jira_service.push_test_event(
db, test, reopening_lead, "red_executing", assignee=original_operator,
)
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="red-tech-account")
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_test_event_reviewer_assignment_resolves_account_id_on_the_fly(
mock_get_client, mock_configured, db,
):
"""Regression: a blue_lead assigned as reviewer had never logged in
(jira_account_id was still NULL), so the Jira reassignment silently
no-op'd and the ticket stayed on the operator forever — while red_lead
worked purely by coincidence of already having logged in once. Every
assignment call site must attempt a live lookup, not just
push_assignee_update."""
from app.models.user import User
mock_jira = MagicMock()
mock_jira.user_find_by_user_string.return_value = [
{"accountId": "blue-lead-account", "emailAddress": "bluelead@test.com"},
]
mock_get_client.return_value = mock_jira
test = _make_test()
link = _make_link(test.id)
db.add(link)
db.commit()
blue_lead = User(
username="bluelead-never-logged-in", email="bluelead@test.com",
hashed_password="x", role="blue_lead", jira_account_id=None,
)
db.add(blue_lead)
db.commit()
jira_service.push_test_event(db, test, MagicMock(username="bluetech"), "blue_review", assignee=blue_lead)
assert blue_lead.jira_account_id == "blue-lead-account"
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="blue-lead-account")
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_bt_work_started_sets_in_progress(mock_get_client, mock_configured, db):
@@ -163,27 +543,46 @@ def test_push_bt_work_started_sets_in_progress(mock_get_client, mock_configured,
db.add(link)
db.commit()
jira_service.push_bt_work_started(db, test, MagicMock(username="bob"))
actor = MagicMock(username="bob", jira_account_id="bob-account-id")
jira_service.push_bt_work_started(db, test, actor)
mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, "In Progress")
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="bob-account-id")
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_rt_started_sets_date_field(mock_get_client, mock_configured, db):
def test_push_bt_started_sets_date_field_on_round_one(mock_get_client, mock_configured, db):
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test()
test = _make_test(blue_round_number=1)
link = _make_link(test.id)
db.add(link)
db.commit()
jira_service.push_rt_started(db, test)
jira_service.push_bt_started(db, test)
mock_jira.update_issue_field.assert_called_once()
args, kwargs = mock_jira.update_issue_field.call_args
assert args[0] == link.jira_issue_key
assert jira_service.JIRA_FIELD_RT_START_DATE in kwargs["fields"]
fields = mock_jira.update_issue_field.call_args[1]["fields"]
assert jira_service.JIRA_FIELD_BT_START_DATE in fields
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_bt_started_preserves_round_one_date_on_reopen(mock_get_client, mock_configured, db):
"""Regression: mirrors the RT Start Date bug — BT Start Date must keep
showing round 1's real pickup date even after the operator re-claims
the test via 'Start Evaluation' on a later round."""
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test(blue_round_number=2)
link = _make_link(test.id)
db.add(link)
db.commit()
jira_service.push_bt_started(db, test)
mock_jira.update_issue_field.assert_not_called()
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@@ -191,7 +590,11 @@ def test_push_rt_started_sets_date_field(mock_get_client, mock_configured, db):
def test_push_rt_submitted_includes_attack_success(mock_get_client, mock_configured, db):
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test(attack_success=MagicMock(value="successful"))
test = _make_test(
attack_success=MagicMock(value="successful"),
execution_start_time=datetime(2026, 2, 1, 8, 0, 0),
execution_end_time=datetime(2026, 2, 1, 9, 0, 0),
)
link = _make_link(test.id)
db.add(link)
db.commit()
@@ -205,15 +608,82 @@ def test_push_rt_submitted_includes_attack_success(mock_get_client, mock_configu
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_bt_submitted_includes_detection_and_hours(mock_get_client, mock_configured, db):
def test_push_rt_submitted_uses_execution_times_not_click_time(mock_get_client, mock_configured, db):
"""Regression: this used to push datetime.utcnow() (whenever the submit
button was clicked) instead of the operator-entered execution window."""
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test(
execution_start_time=datetime(2026, 3, 1, 9, 0, 0),
execution_end_time=datetime(2026, 3, 1, 11, 0, 0),
)
link = _make_link(test.id)
db.add(link)
db.commit()
jira_service.push_rt_submitted(db, test)
fields = mock_jira.update_issue_field.call_args[1]["fields"]
assert fields[jira_service.JIRA_FIELD_RT_START_DATE] == "2026-03-01T09:00:00.000+0000"
assert fields[jira_service.JIRA_FIELD_RT_END_DATE] == "2026-03-01T11:00:00.000+0000"
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_rt_submitted_rt_start_date_survives_reopen(mock_get_client, mock_configured, db):
"""RT Start Date must keep showing round 1's execution start even after
a reopen resets the live execution_start_time for round 2 otherwise
Jira loses track of when the test was originally attempted."""
from app.models.technique import Technique
from app.models.test import Test as TestModel
from app.models.test_round_history import TestRoundHistory
# A real, committed Test row is required here (unlike the other tests in
# this file) because test_round_history.test_id has a real FK to tests.id.
technique = Technique(mitre_id="T9997", name="RT Date Test Technique", tactic="execution", platforms=["linux"])
db.add(technique)
db.commit()
real_test = TestModel(technique_id=technique.id, name="RT date fixture")
db.add(real_test)
db.commit()
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test(
id=real_test.id,
red_round_number=2,
execution_start_time=datetime(2026, 3, 5, 8, 0, 0), # round 2's start
execution_end_time=datetime(2026, 3, 5, 10, 0, 0),
)
link = _make_link(test.id)
db.add(link)
db.add(TestRoundHistory(
test_id=test.id, team="red", round_number=1,
execution_start_time=datetime(2026, 3, 1, 9, 0, 0), # round 1's start
))
db.commit()
jira_service.push_rt_submitted(db, test)
fields = mock_jira.update_issue_field.call_args[1]["fields"]
assert fields[jira_service.JIRA_FIELD_RT_START_DATE] == "2026-03-01T09:00:00.000+0000"
assert fields[jira_service.JIRA_FIELD_RT_END_DATE] == "2026-03-05T10:00:00.000+0000"
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_bt_submitted_includes_detection_and_timestamps(mock_get_client, mock_configured, db):
"""Regression: Time to Detect / Time to Contain are Jira *datetime*
fields, not numeric hour counts pushing a computed duration made Jira
reject the whole fields payload (atomic validation), silently sinking
BT End Date and Attack Detected too even though only these two were
actually wrong."""
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
exec_end = datetime(2026, 1, 1, 10, 0, 0)
detect = datetime(2026, 1, 1, 12, 0, 0)
contain = datetime(2026, 1, 1, 13, 30, 0)
test = _make_test(
detection_result=MagicMock(value="detected"),
execution_end_time=exec_end,
detection_time=detect,
containment_time=contain,
)
@@ -225,8 +695,8 @@ def test_push_bt_submitted_includes_detection_and_hours(mock_get_client, mock_co
fields = mock_jira.update_issue_field.call_args[1]["fields"]
assert fields[jira_service.JIRA_FIELD_ATTACK_DETECTED] == {"value": "Yes"}
assert fields[jira_service.JIRA_FIELD_TIME_TO_DETECT] == 2.0
assert fields[jira_service.JIRA_FIELD_TIME_TO_CONTAIN] == 1.5
assert fields[jira_service.JIRA_FIELD_TIME_TO_DETECT] == "2026-01-01T12:00:00.000+0000"
assert fields[jira_service.JIRA_FIELD_TIME_TO_CONTAIN] == "2026-01-01T13:30:00.000+0000"
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@@ -418,7 +888,61 @@ def test_auto_create_test_issue_adds_platform_label(
if expected_label:
assert expected_label in labels
else:
assert len(labels) == 2
# Just "aegis", the technique ID, and "standalone-test" (no
# parent_ticket_override passed here, same as any non-campaign test).
assert len(labels) == 3
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_jira_project_key", return_value="SEC")
@patch("app.services.jira_service.get_jira_parent_ticket_standalone", return_value=None)
@patch("app.services.jira_service.get_admin_jira_client")
def test_auto_create_test_issue_labels_standalone_tests(
mock_get_client, mock_parent, mock_project_key, mock_configured, db,
):
"""A test created with no campaign parent ticket is standalone — tag it
so standalone tests are identifiable in Jira without cross-referencing
Aegis."""
mock_jira = MagicMock()
mock_jira.issue_create.return_value = {"key": "SEC-1", "id": "1"}
mock_get_client.return_value = mock_jira
from app.models.user import User
test = _make_test_for_issue()
technique = _make_technique()
actor = User(username="gerardo-ruiz", email="gerardo.ruiz@kaseya.com", hashed_password="x")
db.add(actor)
db.commit()
jira_service.auto_create_test_issue(db, test, actor, technique=technique)
labels = mock_jira.issue_create.call_args.kwargs["fields"]["labels"]
assert "standalone-test" in labels
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_jira_project_key", return_value="SEC")
@patch("app.services.jira_service.get_admin_jira_client")
def test_auto_create_test_issue_does_not_label_campaign_tests_as_standalone(
mock_get_client, mock_project_key, mock_configured, db,
):
"""A campaign test passes parent_ticket_override — it must NOT get
"standalone-test", since it's nested under the campaign's ticket."""
mock_jira = MagicMock()
mock_jira.issue_create.return_value = {"key": "SEC-1", "id": "1"}
mock_get_client.return_value = mock_jira
from app.models.user import User
test = _make_test_for_issue()
technique = _make_technique()
actor = User(username="gerardo-ruiz", email="gerardo.ruiz@kaseya.com", hashed_password="x")
db.add(actor)
db.commit()
jira_service.auto_create_test_issue(
db, test, actor, technique=technique, parent_ticket_override="CAMP-1",
)
labels = mock_jira.issue_create.call_args.kwargs["fields"]["labels"]
assert "standalone-test" not in labels
def _make_user(**overrides):
@@ -427,6 +951,7 @@ def _make_user(**overrides):
u.username = "gerardo-ruiz"
u.email = "gerardo.ruiz@kaseya.com"
u.jira_account_id = None
u.jira_email = None
for k, v in overrides.items():
setattr(u, k, v)
return u
@@ -455,6 +980,28 @@ def test_lookup_user_jira_account_id_calls_with_valid_kwargs(mock_get_client, mo
assert user.jira_account_id == "abc123"
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_lookup_user_jira_account_id_prefers_jira_email_override(mock_get_client, mock_configured, db):
"""Regression: a user's real Atlassian email can differ from their Aegis
login email (e.g. jesus-huertas@kaseya.com in Aegis vs
jesus.rodenas@kaseya.com in Jira) jira_email must take priority."""
mock_jira = MagicMock()
mock_jira.user_find_by_user_string.return_value = [
{"emailAddress": "jesus.rodenas@kaseya.com", "accountId": "jesus-account-id"}
]
mock_get_client.return_value = mock_jira
user = _make_user(email="jesus.huertas@kaseya.com", jira_email="jesus.rodenas@kaseya.com")
updated = jira_service.lookup_user_jira_account_id(db, user)
mock_jira.user_find_by_user_string.assert_called_once_with(
query="jesus.rodenas@kaseya.com", limit=10
)
assert updated is True
assert user.jira_account_id == "jesus-account-id"
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_lookup_user_jira_account_id_no_match(mock_get_client, mock_configured, db):
@@ -488,6 +1035,7 @@ def test_push_assignee_update_assigns_jira_issue(mock_get_client, mock_configure
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_assignee_update_noop_without_jira_account_id(mock_get_client, mock_configured, db):
mock_jira = MagicMock()
mock_jira.user_find_by_user_string.return_value = [] # fallback lookup finds nobody either
mock_get_client.return_value = mock_jira
test = _make_test()
link = _make_link(test.id)
@@ -500,6 +1048,29 @@ def test_push_assignee_update_noop_without_jira_account_id(mock_get_client, mock
mock_jira.assign_issue.assert_not_called()
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_assignee_update_looks_up_missing_account_id(mock_get_client, mock_configured, db):
"""Regression: a lead can assign someone who has never logged in, so
jira_account_id isn't populated yet. Must try a fresh lookup instead of
silently giving up, so the assignment still syncs immediately."""
mock_jira = MagicMock()
mock_jira.user_find_by_user_string.return_value = [
{"emailAddress": "gerardo.ruiz@kaseya.com", "accountId": "freshly-found-id"}
]
mock_get_client.return_value = mock_jira
test = _make_test()
link = _make_link(test.id)
db.add(link)
db.commit()
assignee = _make_user(jira_account_id=None)
jira_service.push_assignee_update(db, test, assignee)
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="freshly-found-id")
assert assignee.jira_account_id == "freshly-found-id"
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_assignee_update_noop_without_link(mock_get_client, mock_configured, db):
+8
View File
@@ -44,3 +44,11 @@ def test_manager_can_list_operators(client, manager_headers, red_tech_user):
assert resp.status_code == 200, resp.text
usernames = {u["username"] for u in resp.json()}
assert "redtech" in usernames
def test_operators_list_excludes_admin(client, manager_headers, admin_user):
"""Admin can't be assigned as an operator, so it shouldn't appear in the picker."""
resp = client.get("/api/v1/users/operators", headers=manager_headers)
assert resp.status_code == 200
usernames = {u["username"] for u in resp.json()}
assert admin_user.username not in usernames
@@ -0,0 +1,56 @@
"""Manager role — creating tests from templates.
A manager organizes and validates work rather than executing it, but they
should still be able to seed a test from the catalog directly (same as a
red_lead/blue_lead), not just approve/reject what leads submit.
"""
def test_manager_can_create_test_from_template(api, auth_headers, red_lead_headers, manager_headers):
technique = api(
"post", "/api/v1/techniques", auth_headers,
json={"mitre_id": "T1059.400", "name": "Command Line Manager Test"},
)
assert technique.status_code == 201, technique.text
technique_id = technique.json()["id"]
template = api(
"post", "/api/v1/test-templates", red_lead_headers,
json={
"mitre_technique_id": "T1059.400",
"name": "Manager-usable template",
"attack_procedure": "Run a discovery command.",
},
)
assert template.status_code == 201, template.text
template_id = template.json()["id"]
resp = api(
"post", "/api/v1/tests/from-template", manager_headers,
json={"template_id": template_id, "technique_id": technique_id},
)
assert resp.status_code == 201, resp.text
assert resp.json()["name"] == "Manager-usable template"
def test_admin_still_cannot_create_test_from_template(api, red_lead_headers, auth_headers):
"""require_any_role_strict keeps site-admin separate from workflow
actions adding manager to the allow-list must not accidentally
reintroduce an admin bypass here."""
technique = api(
"post", "/api/v1/techniques", auth_headers,
json={"mitre_id": "T1059.401", "name": "Command Line Admin Test"},
)
technique_id = technique.json()["id"]
template = api(
"post", "/api/v1/test-templates", red_lead_headers,
json={"mitre_technique_id": "T1059.401", "name": "Admin-blocked template"},
)
template_id = template.json()["id"]
resp = api(
"post", "/api/v1/tests/from-template", auth_headers,
json={"template_id": template_id, "technique_id": technique_id},
)
assert resp.status_code == 403
+73
View File
@@ -0,0 +1,73 @@
"""A manager (and only a manager) can delete a standalone test from the
queue, but only while it hasn't started yet (draft) and isn't linked to
any campaign a test already in progress, or one that's part of a
campaign's plan, must go through the normal workflow instead.
"""
import pytest
@pytest.fixture
def technique(api, auth_headers):
resp = api(
"post", "/api/v1/techniques", auth_headers,
json={"mitre_id": "T1059.600", "name": "Command Line Delete Test"},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
@pytest.fixture
def draft_test(api, red_lead_headers, technique):
resp = api(
"post", "/api/v1/tests", red_lead_headers,
json={"technique_id": technique, "name": "Standalone draft test"},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
def test_manager_can_delete_draft_standalone_test(api, manager_headers, draft_test):
resp = api("delete", f"/api/v1/tests/{draft_test}", manager_headers)
assert resp.status_code == 204, resp.text
reloaded = api("get", f"/api/v1/tests/{draft_test}", manager_headers)
assert reloaded.status_code == 404
def test_red_lead_cannot_delete_test(api, red_lead_headers, draft_test):
resp = api("delete", f"/api/v1/tests/{draft_test}", red_lead_headers)
assert resp.status_code == 403
def test_admin_cannot_delete_test(api, auth_headers, draft_test):
resp = api("delete", f"/api/v1/tests/{draft_test}", auth_headers)
assert resp.status_code == 403
def test_manager_cannot_delete_started_test(api, manager_headers, red_tech_headers, draft_test):
started = api("post", f"/api/v1/tests/{draft_test}/start-execution", red_tech_headers)
assert started.status_code == 200, started.text
resp = api("delete", f"/api/v1/tests/{draft_test}", manager_headers)
assert resp.status_code == 400
def test_manager_cannot_delete_test_linked_to_a_campaign(
api, db, manager_headers, red_lead_headers, draft_test,
):
campaign = api(
"post", "/api/v1/campaigns", red_lead_headers,
json={"name": "Holds the test"},
)
assert campaign.status_code == 201, campaign.text
campaign_id = campaign.json()["id"]
add = api(
"post", f"/api/v1/campaigns/{campaign_id}/tests", red_lead_headers,
json={"test_id": draft_test},
)
assert add.status_code in (200, 201), add.text
resp = api("delete", f"/api/v1/tests/{draft_test}", manager_headers)
assert resp.status_code == 400
+2
View File
@@ -76,6 +76,7 @@ for _mod in [
"apscheduler", "apscheduler.schedulers",
"apscheduler.schedulers.background",
"apscheduler.triggers", "apscheduler.triggers.cron",
"apscheduler.events",
]:
if _mod not in sys.modules:
m = ModuleType(_mod)
@@ -85,6 +86,7 @@ for _mod in [
elif _mod == "botocore.exceptions": m.ClientError = Exception
elif _mod == "apscheduler.schedulers.background": m.BackgroundScheduler = MagicMock
elif _mod == "apscheduler.triggers.cron": m.CronTrigger = MagicMock
elif _mod == "apscheduler.events": m.EVENT_JOB_ERROR = 1
sys.modules[_mod] = m
# ---------------------------------------------------------------------------
@@ -0,0 +1,78 @@
"""A TestTemplate/TemplateSuggestion's mitre_technique_id must correspond
to a real, already-synced MITRE ATT&CK technique free text or a typo'd
ID would otherwise create an orphaned template invisible to technique
lookups and coverage reporting.
"""
import pytest
@pytest.fixture
def technique(api, auth_headers):
resp = api(
"post", "/api/v1/techniques", auth_headers,
json={"mitre_id": "T1059.500", "name": "Command Line MITRE Validation"},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
class TestTemplateCreateValidation:
def test_create_template_rejects_unknown_mitre_id(self, api, red_lead_headers, technique):
resp = api(
"post", "/api/v1/test-templates", red_lead_headers,
json={"mitre_technique_id": "T9999.999", "name": "Bogus template"},
)
assert resp.status_code == 400
assert "T9999.999" in resp.text
def test_create_template_accepts_known_mitre_id(self, api, red_lead_headers, technique):
resp = api(
"post", "/api/v1/test-templates", red_lead_headers,
json={"mitre_technique_id": "T1059.500", "name": "Valid template"},
)
assert resp.status_code == 201, resp.text
def test_update_template_rejects_unknown_mitre_id(self, api, red_lead_headers, technique):
created = api(
"post", "/api/v1/test-templates", red_lead_headers,
json={"mitre_technique_id": "T1059.500", "name": "Will be edited"},
)
template_id = created.json()["id"]
resp = api(
"patch", f"/api/v1/test-templates/{template_id}", red_lead_headers,
json={"mitre_technique_id": "T0000.000", "name": "Will be edited"},
)
assert resp.status_code == 400
class TestTemplateSuggestionValidation:
def test_propose_template_rejects_unknown_mitre_id(self, api, red_tech_headers, technique):
resp = api(
"post", "/api/v1/template-suggestions", red_tech_headers,
json={"mitre_technique_id": "T8888.888", "name": "Bogus proposal"},
)
assert resp.status_code == 400
def test_propose_template_accepts_known_mitre_id(self, api, red_tech_headers, technique):
resp = api(
"post", "/api/v1/template-suggestions", red_tech_headers,
json={"mitre_technique_id": "T1059.500", "name": "Valid proposal"},
)
assert resp.status_code == 201, resp.text
def test_approve_suggestion_with_edited_unknown_mitre_id_rejected(
self, api, red_tech_headers, red_lead_headers, technique,
):
created = api(
"post", "/api/v1/template-suggestions", red_tech_headers,
json={"mitre_technique_id": "T1059.500", "name": "Valid proposal"},
)
suggestion_id = created.json()["id"]
resp = api(
"post", f"/api/v1/template-suggestions/{suggestion_id}/approve", red_lead_headers,
json={"mitre_technique_id": "T7777.777"},
)
assert resp.status_code == 400
+132
View File
@@ -0,0 +1,132 @@
"""A user can be granted more than one role but only ever acts under a
single active one at a time switching swaps which role is active
instead of ever mixing permissions from more than one.
"""
def test_admin_cannot_remove_own_admin_role(api, auth_headers, admin_user):
resp = api(
"patch", f"/api/v1/users/{admin_user.id}", auth_headers,
json={"role": "viewer"},
)
assert resp.status_code == 400
assert "admin" in resp.text.lower()
def test_admin_cannot_drop_admin_via_extra_roles_either(api, auth_headers, admin_user):
# Give themselves a second role first, then try to swap the primary
# role away from admin without admin anywhere in extra_roles.
resp = api(
"patch", f"/api/v1/users/{admin_user.id}", auth_headers,
json={"role": "red_lead", "extra_roles": ["viewer"]},
)
assert resp.status_code == 400
def test_admin_can_switch_primary_role_if_admin_kept_in_extra_roles(api, auth_headers, admin_user):
resp = api(
"patch", f"/api/v1/users/{admin_user.id}", auth_headers,
json={"role": "red_lead", "extra_roles": ["admin"]},
)
assert resp.status_code == 200, resp.text
def test_admin_can_edit_own_other_fields_without_touching_role(api, auth_headers, admin_user):
resp = api(
"patch", f"/api/v1/users/{admin_user.id}", auth_headers,
json={"full_name": "Renamed Admin"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["role"] == "admin"
def test_admin_can_grant_extra_roles(api, auth_headers):
created = api(
"post", "/api/v1/users", auth_headers,
json={"full_name": "Multi Role User", "email": "multirole@test.com", "role": "red_tech"},
)
assert created.status_code == 201, created.text
user_id = created.json()["id"]
resp = api(
"patch", f"/api/v1/users/{user_id}", auth_headers,
json={"extra_roles": ["blue_tech", "red_lead"]},
)
assert resp.status_code == 200, resp.text
assert resp.json()["role"] == "red_tech"
assert set(resp.json()["extra_roles"]) == {"blue_tech", "red_lead"}
def test_admin_cannot_grant_invalid_extra_role(api, auth_headers):
created = api(
"post", "/api/v1/users", auth_headers,
json={"full_name": "Bad Extra Role", "email": "badextrarole@test.com", "role": "viewer"},
)
user_id = created.json()["id"]
resp = api(
"patch", f"/api/v1/users/{user_id}", auth_headers,
json={"extra_roles": ["superuser"]},
)
assert resp.status_code == 400
def test_user_can_switch_to_a_granted_extra_role(api, db, auth_headers):
from app.auth import hash_password
from app.models.user import User
user = User(
username="switcher@test.com",
email="switcher@test.com",
full_name="Switcher",
hashed_password=hash_password("SwitcherPass123!@#"),
role="red_tech",
extra_roles=["blue_tech"],
must_change_password=False,
)
db.add(user)
db.commit()
login = api(
"post", "/api/v1/auth/login", {},
data={"username": "switcher@test.com", "password": "SwitcherPass123!@#"},
)
assert login.status_code == 200, login.text
token = login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
resp = api("post", "/api/v1/users/me/switch-role", headers, json={"role": "blue_tech"})
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["role"] == "blue_tech"
assert body["extra_roles"] == ["red_tech"]
me = api("get", "/api/v1/auth/me", headers)
assert me.json()["role"] == "blue_tech"
def test_user_cannot_switch_to_an_ungranted_role(api, db, auth_headers):
from app.auth import hash_password
from app.models.user import User
user = User(
username="switcher2@test.com",
email="switcher2@test.com",
full_name="Switcher Two",
hashed_password=hash_password("SwitcherPass123!@#"),
role="red_tech",
extra_roles=["blue_tech"],
must_change_password=False,
)
db.add(user)
db.commit()
login = api(
"post", "/api/v1/auth/login", {},
data={"username": "switcher2@test.com", "password": "SwitcherPass123!@#"},
)
token = login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
resp = api("post", "/api/v1/users/me/switch-role", headers, json={"role": "admin"})
assert resp.status_code == 400
@@ -0,0 +1,161 @@
"""Coverage for the remaining notification-email hooks wired this session:
stale coverage alerts, campaign assignment, generic test-state-change,
all-team validations, webhook delivery failures, new user registration,
and background-job system errors (see notification_service.notify_roles_by_email
and its call sites).
"""
import uuid
from types import SimpleNamespace
from unittest.mock import patch
from app.services.notification_service import notify_roles_by_email
class _FakeUser:
def __init__(self, user_id=None, email="user@test.com", full_name="A User", role="admin", prefs=None):
self.id = user_id or uuid.uuid4()
self.email = email
self.full_name = full_name
self.role = role
self.notification_preferences = prefs
class _FakeQuery:
def __init__(self, rows):
self._rows = rows
def filter(self, *args, **kwargs):
return self
def all(self):
return self._rows
def test_notify_roles_by_email_filters_by_role_and_preference(db):
lead = _FakeUser(role="red_lead", email="lead@test.com")
other_role = _FakeUser(role="viewer", email="viewer@test.com")
opted_out = _FakeUser(role="blue_lead", email="opted-out@test.com", prefs={"email_on_all_team_validations": False})
with patch.object(db, "query", return_value=_FakeQuery([lead, other_role, opted_out])), \
patch("app.services.webhook_email_service.send_webhook_email") as mock_send:
notify_roles_by_email(
db, roles=["red_lead", "blue_lead"],
preference_key="email_on_all_team_validations",
subject="Vote cast",
message="Someone voted.",
)
# Only `lead` matches role filter (query already scopes it) *and* is opted in;
# `opted_out` matched the fake query's role filter (a no-op in the stub) but
# is excluded by preference; `other_role` is excluded by role in a real query
# (the stub returns all rows regardless, so assert on what was actually sent).
sent_to = {c.kwargs["to"] for c in mock_send.call_args_list}
assert "lead@test.com" in sent_to
assert "opted-out@test.com" not in sent_to
def test_notify_roles_by_email_excludes_actor(db):
actor = _FakeUser(email="actor@test.com")
other = _FakeUser(email="other@test.com")
with patch.object(db, "query", return_value=_FakeQuery([actor, other])), \
patch("app.services.webhook_email_service.send_webhook_email") as mock_send:
notify_roles_by_email(
db, roles=["admin"],
preference_key="email_on_all_team_validations",
subject="x", message="y",
exclude_user_id=actor.id,
)
sent_to = {c.kwargs["to"] for c in mock_send.call_args_list}
assert sent_to == {"other@test.com"}
def test_stale_technique_alert_dispatches_email_but_other_rule_types_dont():
from app.services.operational_alert_service import _dispatch_inapp_notifications
class _FakeAlertQuery:
def filter(self, *a, **kw):
return self
def all(self):
return []
rule = SimpleNamespace(rule_type="stale_technique")
instance = SimpleNamespace(id=uuid.uuid4(), title="Stale coverage", message="5 techniques are stale.")
fake_db = SimpleNamespace(query=lambda *a, **kw: _FakeAlertQuery())
with patch("app.services.notification_service.notify_roles_by_email") as mock_notify:
_dispatch_inapp_notifications(fake_db, rule, instance)
mock_notify.assert_called_once()
assert mock_notify.call_args.kwargs["preference_key"] == "email_on_stale_coverage"
rule.rule_type = "high_risk"
with patch("app.services.notification_service.notify_roles_by_email") as mock_notify:
_dispatch_inapp_notifications(fake_db, rule, instance)
mock_notify.assert_not_called()
def test_webhook_failure_emails_admins_on_third_consecutive_failure():
from app.services.webhook_service import _send_webhook
wh = SimpleNamespace(name="my-hook", url="https://example.com/hook", secret=None, failure_count=2,
last_triggered_at=None)
class _FakeDb:
def commit(self):
pass
def rollback(self):
pass
with patch("app.services.webhook_service.requests.post", side_effect=Exception("boom")), \
patch("app.services.notification_service.notify_roles_by_email") as mock_notify:
_send_webhook(_FakeDb(), wh, "test_validated", {"foo": "bar"})
assert wh.failure_count == 3
mock_notify.assert_called_once()
assert mock_notify.call_args.kwargs["preference_key"] == "email_on_webhook_failures"
def test_webhook_failure_does_not_email_before_threshold():
from app.services.webhook_service import _send_webhook
wh = SimpleNamespace(name="my-hook", url="https://example.com/hook", secret=None, failure_count=0,
last_triggered_at=None)
class _FakeDb:
def commit(self):
pass
def rollback(self):
pass
with patch("app.services.webhook_service.requests.post", side_effect=Exception("boom")), \
patch("app.services.notification_service.notify_roles_by_email") as mock_notify:
_send_webhook(_FakeDb(), wh, "test_validated", {"foo": "bar"})
assert wh.failure_count == 1
mock_notify.assert_not_called()
def test_create_user_notifies_other_admins_but_not_the_actor(api, auth_headers, db):
from app.models.user import User
from app.auth import hash_password
other_admin = User(
username="secondadmin@test.com", email="secondadmin@test.com",
hashed_password=hash_password("Whatever123!@#"), role="admin",
)
db.add(other_admin)
db.commit()
with patch("app.services.webhook_email_service.send_webhook_email") as mock_send:
resp = api(
"post", "/api/v1/users", auth_headers,
json={"full_name": "Fresh User", "email": "freshuser@test.com", "role": "viewer"},
)
assert resp.status_code == 201, resp.text
sent_to = {c.kwargs["to"] for c in mock_send.call_args_list}
assert "secondadmin@test.com" in sent_to
assert mock_send.call_args_list[0].kwargs["subject"].startswith("New User Created")
+245
View File
@@ -0,0 +1,245 @@
"""End-to-end coverage for the passwordless user creation + emailed
set-password-link flow (also reused for password resets).
"""
import uuid
from unittest.mock import patch
import pytest
@pytest.fixture
def new_user_id(api, auth_headers):
resp = api(
"post", "/api/v1/users", auth_headers,
json={"full_name": "Set Password User", "email": "setpw@test.com", "role": "viewer"},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
def test_create_user_automatically_sends_set_password_email(api, db, auth_headers):
api(
"patch", "/api/v1/system/email-webhook-config", auth_headers,
json={"url": "https://example.com/power-automate-hook"},
)
with patch("app.services.webhook_email_service.requests.post") as mock_post:
resp = api(
"post", "/api/v1/users", auth_headers,
json={"full_name": "Auto Send User", "email": "autosend@test.com", "role": "viewer"},
)
assert resp.status_code == 201, resp.text
mock_post.assert_called_once()
payload = mock_post.call_args.kwargs["json"]
assert payload["to"] == "autosend@test.com"
assert payload["subject"] == "Set Your Password"
assert "token=" in payload["body"]
from app.models.password_setup_token import PasswordSetupToken
token_row = db.query(PasswordSetupToken).filter(
PasswordSetupToken.user_id == uuid.UUID(resp.json()["id"]),
).first()
assert token_row is not None
def test_create_user_succeeds_even_if_auto_send_fails(api, auth_headers):
"""No webhook configured yet — the automatic first-email attempt fails,
but user creation itself must still succeed."""
resp = api(
"post", "/api/v1/users", auth_headers,
json={"full_name": "No Webhook User", "email": "nowebhook@test.com", "role": "viewer"},
)
assert resp.status_code == 201, resp.text
def test_send_password_email_requires_webhook_configured(api, auth_headers, new_user_id):
resp = api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
assert resp.status_code == 400
assert "webhook" in resp.text.lower()
def test_admin_can_configure_password_webhook(api, auth_headers):
resp = api(
"patch", "/api/v1/system/email-webhook-config", auth_headers,
json={"url": "https://example.com/power-automate-hook"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["configured"] is True
get_resp = api("get", "/api/v1/system/email-webhook-config", auth_headers)
assert get_resp.json()["url"] == "https://example.com/power-automate-hook"
def test_admin_can_configure_webhook_api_key(api, auth_headers):
resp = api(
"patch", "/api/v1/system/email-webhook-config", auth_headers,
json={"url": "https://example.com/power-automate-hook", "api_key": "super-secret-key"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["api_key_set"] is True
# The key itself is never echoed back.
assert "api_key" not in resp.json()
assert "super-secret-key" not in resp.text
def test_send_password_email_sends_api_key_header(api, db, auth_headers, new_user_id):
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:
api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
call_kwargs = mock_post.call_args
assert call_kwargs.kwargs["headers"] == {"x-api-key": "super-secret-key"}
def test_send_password_email_posts_to_webhook_and_issues_token(api, db, auth_headers, new_user_id):
api(
"patch", "/api/v1/system/email-webhook-config", auth_headers,
json={"url": "https://example.com/power-automate-hook"},
)
with patch("app.services.webhook_email_service.requests.post") as mock_post:
resp = api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
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"
payload = call_kwargs.kwargs["json"]
assert payload["to"] == "setpw@test.com"
assert payload["subject"] == "Set Your Password"
assert "Hi Set Password User" in payload["body"]
assert "token=" in payload["body"]
assert "Purple Team Engineering" in payload["body"]
from app.models.password_setup_token import PasswordSetupToken
token_row = db.query(PasswordSetupToken).filter(PasswordSetupToken.user_id == uuid.UUID(new_user_id)).first()
assert token_row is not None
assert token_row.used_at is None
def test_send_password_email_fails_loudly_when_webhook_rejects(api, auth_headers, new_user_id):
"""A webhook that's reachable but rejects the request (bad URL/API key,
wrong Power Automate config, etc.) must not be reported as a success
previously the HTTP response status was never checked."""
api(
"patch", "/api/v1/system/email-webhook-config", auth_headers,
json={"url": "https://example.com/power-automate-hook"},
)
from unittest.mock import MagicMock
fake_response = MagicMock(ok=False, status_code=404, text="not found")
with patch("app.services.webhook_email_service.requests.post", return_value=fake_response):
resp = api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
assert resp.status_code == 400
assert "failed to send" in resp.text.lower()
def test_send_password_email_resend_after_password_set_is_a_reset_email(api, db, auth_headers, new_user_id):
"""Once a user has already set their password, hitting 'Send Email'
again must still actually send as a password-reset email not
silently no-op."""
api(
"patch", "/api/v1/system/email-webhook-config", auth_headers,
json={"url": "https://example.com/power-automate-hook"},
)
with patch("app.services.webhook_email_service.requests.post") as mock_post:
resp = api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
assert resp.status_code == 200, resp.text
first_payload = mock_post.call_args.kwargs["json"]
assert first_payload["subject"] == "Set Your Password"
from app.models.password_setup_token import PasswordSetupToken
token_row = db.query(PasswordSetupToken).filter(
PasswordSetupToken.user_id == uuid.UUID(new_user_id),
).first()
api(
"post", "/api/v1/auth/set-password", {},
json={"token": token_row.token, "new_password": "BrandNewPass123!@#"},
)
# Now the user has must_change_password=False — resending must still
# go through, this time as a reset email.
with patch("app.services.webhook_email_service.requests.post") as mock_post_2:
resp2 = api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
assert resp2.status_code == 200, resp2.text
mock_post_2.assert_called_once()
second_payload = mock_post_2.call_args.kwargs["json"]
assert second_payload["subject"] == "Reset Your Password"
def test_set_password_with_valid_token_succeeds(api, db, auth_headers, new_user_id):
api(
"patch", "/api/v1/system/email-webhook-config", auth_headers,
json={"url": "https://example.com/power-automate-hook"},
)
with patch("app.services.webhook_email_service.requests.post"):
api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
from app.models.password_setup_token import PasswordSetupToken
token_row = db.query(PasswordSetupToken).filter(PasswordSetupToken.user_id == uuid.UUID(new_user_id)).first()
validate = api("get", f"/api/v1/auth/set-password/validate?token={token_row.token}", {})
assert validate.status_code == 200
assert validate.json()["valid"] is True
assert validate.json()["full_name"] == "Set Password User"
resp = api(
"post", "/api/v1/auth/set-password", {},
json={"token": token_row.token, "new_password": "BrandNewPass123!@#"},
)
assert resp.status_code == 200, resp.text
login = api(
"post", "/api/v1/auth/login", {},
data={"username": "setpw@test.com", "password": "BrandNewPass123!@#"},
)
assert login.status_code == 200, login.text
def test_set_password_token_cannot_be_reused(api, db, auth_headers, new_user_id):
api(
"patch", "/api/v1/system/email-webhook-config", auth_headers,
json={"url": "https://example.com/power-automate-hook"},
)
with patch("app.services.webhook_email_service.requests.post"):
api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
from app.models.password_setup_token import PasswordSetupToken
token_row = db.query(PasswordSetupToken).filter(PasswordSetupToken.user_id == uuid.UUID(new_user_id)).first()
first = api(
"post", "/api/v1/auth/set-password", {},
json={"token": token_row.token, "new_password": "BrandNewPass123!@#"},
)
assert first.status_code == 200
second = api(
"post", "/api/v1/auth/set-password", {},
json={"token": token_row.token, "new_password": "AnotherPass456!@#"},
)
assert second.status_code == 400
def test_set_password_invalid_token_rejected(api):
validate = api("get", "/api/v1/auth/set-password/validate?token=not-a-real-token", {})
assert validate.status_code == 200
assert validate.json()["valid"] is False
resp = api(
"post", "/api/v1/auth/set-password", {},
json={"token": "not-a-real-token", "new_password": "BrandNewPass123!@#"},
)
assert resp.status_code == 404
def test_password_webhook_config_requires_admin(api, red_lead_headers):
resp = api("get", "/api/v1/system/email-webhook-config", red_lead_headers)
assert resp.status_code == 403
def test_send_password_email_requires_admin(api, red_lead_headers, new_user_id):
resp = api("post", f"/api/v1/users/{new_user_id}/send-password-email", red_lead_headers)
assert resp.status_code == 403
@@ -0,0 +1,109 @@
"""Tests for the regex-based command/query extraction heuristic used to
build procedure-improvement suggestions from free-text procedure fields."""
import pytest
from app.services.procedure_extraction_service import extract_commands
@pytest.mark.parametrize("value", [None, "", " ", "\n\n"])
def test_returns_none_for_empty_input(value):
assert extract_commands(value) is None
def test_returns_none_for_pure_narrative():
text = (
"I logged into the domain controller and tried a well-known credential "
"dumping tool. It worked well and I documented everything in the summary."
)
assert extract_commands(text) is None
def test_fenced_code_block_takes_priority_over_surrounding_narrative():
text = (
"Ran the following from an elevated prompt:\n"
"```\n"
"Invoke-Mimikatz -DumpCreds\n"
"```\n"
"It successfully extracted plaintext passwords from LSASS memory."
)
assert extract_commands(text) == "Invoke-Mimikatz -DumpCreds"
def test_tilde_fence_supported():
text = "~~~\nGet-Process lsass\n~~~"
assert extract_commands(text) == "Get-Process lsass"
def test_multiple_fenced_blocks_are_joined():
text = "```\nwhoami /priv\n```\nthen\n```\nGet-Process lsass\n```"
assert extract_commands(text) == "whoami /priv\n\nGet-Process lsass"
def test_extracts_command_lines_from_mixed_narrative_no_fence():
text = (
"First I checked what privileges I had.\n"
"whoami /priv\n"
"Then I dumped credentials from LSASS.\n"
"Invoke-Mimikatz -DumpCreds\n"
"This successfully retrieved plaintext passwords, as shown below.\n"
"Handles NPM(K) PM(K) WS(K) CPU(s) Id SI ProcessName\n"
" 542 27 23012 41564 0.45 1234 1 explorer\n"
)
result = extract_commands(text)
assert result == "whoami /priv\nInvoke-Mimikatz -DumpCreds"
def test_strips_shell_prompt_markers():
text = (
"Ran this against the target host:\n"
"$ curl -X POST http://target/api/exfil -d @creds.txt\n"
"Got a 200 OK back.\n"
)
assert extract_commands(text) == "curl -X POST http://target/api/exfil -d @creds.txt"
def test_strips_powershell_prompt_marker():
text = "PS C:\\Users\\op> Get-Process lsass"
assert extract_commands(text) == "Get-Process lsass"
def test_extracts_kql_detection_query_mixed_with_narrative():
text = (
"I built a Sentinel query to catch this technique going forward.\n"
"DeviceProcessEvents\n"
"| where FileName == \"mimikatz.exe\"\n"
"That caught it within a minute of execution in testing.\n"
)
result = extract_commands(text)
assert 'where FileName == "mimikatz.exe"' in result
assert "That caught it" not in result
def test_extracts_splunk_query():
text = (
"Search used for detection:\n"
"index=main sourcetype=WinEventLog:Security EventCode=4688\n"
"This surfaced the process creation event immediately.\n"
)
result = extract_commands(text)
assert "index=main sourcetype=WinEventLog:Security EventCode=4688" in result
assert "immediately" not in result
def test_known_binary_at_start_of_line_is_recognized():
text = "Notes: used the following.\nsudo tcpdump -i eth0 -w capture.pcap\nCaptured 500 packets."
result = extract_commands(text)
assert result == "sudo tcpdump -i eth0 -w capture.pcap"
def test_recognizes_sysmon_as_a_detection_command():
text = "launch sysmon to detect\nsysmon.exe\nthen search this query\n\\? mimikatz \\n"
result = extract_commands(text)
assert result == "sysmon.exe"
def test_recognizes_wevtutil_and_auditpol():
text = "Pulled the security log.\nwevtutil qe Security /c:5\nThen checked audit policy.\nauditpol /get /category:*"
result = extract_commands(text)
assert result == "wevtutil qe Security /c:5\nauditpol /get /category:*"
+372
View File
@@ -0,0 +1,372 @@
"""End-to-end coverage for the procedure-suggestion review workflow.
An operator submitting a round with a filled-in procedure field that
contains an extractable command should produce a pending
ProcedureSuggestion against the originating template; a lead can then
approve it (writing it into the template) or reject it (leaving the
template untouched). Nothing is ever written to a template automatically.
"""
import uuid
import pytest
from app.models.enums import TeamSide
from app.models.evidence import Evidence
def _add_evidence(db, test_id, team: TeamSide):
ev = Evidence(
test_id=uuid.UUID(test_id),
file_name="proof.txt",
file_path="s3://bucket/proof.txt",
sha256_hash="a" * 64,
team=team,
)
db.add(ev)
db.commit()
@pytest.fixture
def technique(api, auth_headers):
resp = api(
"post", "/api/v1/techniques", auth_headers,
json={"mitre_id": "T1059.200", "name": "Command Line Suggestions"},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
@pytest.fixture
def template(api, red_lead_headers, technique):
resp = api(
"post", "/api/v1/test-templates", red_lead_headers,
json={
"mitre_technique_id": "T1059.200",
"name": "Suggestion source template",
"attack_procedure": "Run a discovery command on the target.",
"expected_detection": "Check process creation logs.",
},
)
assert resp.status_code == 201, resp.text
return resp.json()
@pytest.fixture
def test_from_template(api, red_lead_headers, technique, template):
resp = api(
"post", "/api/v1/tests/from-template", red_lead_headers,
json={"template_id": template["id"], "technique_id": technique},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
def test_red_submission_with_command_creates_pending_suggestion(
client, db, api, test_from_template, red_tech_headers, red_lead_headers,
):
test_id = test_from_template
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
api(
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
json={"procedure_text": "Ran discovery.\nwhoami /priv\nGot back SeDebugPrivilege."},
)
_add_evidence(db, test_id, TeamSide.red)
resp = api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
assert resp.status_code == 200, resp.text
listed = api("get", "/api/v1/procedure-suggestions", red_lead_headers)
assert listed.status_code == 200, listed.text
suggestions = listed.json()
assert len(suggestions) == 1
assert suggestions[0]["team"] == "red"
assert suggestions[0]["suggested_text"] == "whoami /priv"
assert suggestions[0]["status"] == "pending"
def test_blue_submission_with_command_creates_pending_suggestion(
client, db, api, test_from_template, red_tech_headers, red_lead_headers,
blue_tech_headers, blue_lead_headers,
):
test_id = test_from_template
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
_add_evidence(db, test_id, TeamSide.red)
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
api("post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers, json={"decision": "approve"})
api("post", f"/api/v1/tests/{test_id}/start-blue-work", blue_tech_headers)
api(
"patch", f"/api/v1/tests/{test_id}/blue", blue_tech_headers,
json={
"detection_result": "detected",
"detect_procedure": "Checked logs.\nGet-WinEvent -LogName Security | where Id -eq 4688\nFound it.",
},
)
_add_evidence(db, test_id, TeamSide.blue)
resp = api("post", f"/api/v1/tests/{test_id}/submit-blue", blue_tech_headers)
assert resp.status_code == 200, resp.text
listed = api("get", "/api/v1/procedure-suggestions", blue_lead_headers)
assert listed.status_code == 200, listed.text
suggestions = listed.json()
assert len(suggestions) == 1
assert suggestions[0]["team"] == "blue"
assert "Get-WinEvent" in suggestions[0]["suggested_text"]
def test_submission_without_extractable_command_creates_no_suggestion(
client, db, api, test_from_template, red_tech_headers, red_lead_headers,
):
test_id = test_from_template
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
api(
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
json={"procedure_text": "Just talked to the target and it agreed to be compromised."},
)
_add_evidence(db, test_id, TeamSide.red)
resp = api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
assert resp.status_code == 200, resp.text
listed = api("get", "/api/v1/procedure-suggestions", red_lead_headers)
assert listed.json() == []
def test_approving_a_suggestion_writes_it_into_the_template(
client, db, api, test_from_template, template, red_tech_headers, red_lead_headers,
):
test_id = test_from_template
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
api(
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."},
)
_add_evidence(db, test_id, TeamSide.red)
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
suggestion = api("get", "/api/v1/procedure-suggestions", red_lead_headers).json()[0]
approve = api(
"post", f"/api/v1/procedure-suggestions/{suggestion['id']}/approve", red_lead_headers,
)
assert approve.status_code == 200, approve.text
assert approve.json()["status"] == "approved"
reloaded = api("get", f"/api/v1/test-templates/{template['id']}", red_lead_headers)
assert reloaded.json()["attack_procedure"] == "Run a discovery command on the target.\nwhoami /priv"
listed = api("get", "/api/v1/procedure-suggestions", red_lead_headers)
assert listed.json() == []
def test_rejecting_a_suggestion_leaves_the_template_untouched(
client, db, api, test_from_template, template, red_tech_headers, red_lead_headers,
):
test_id = test_from_template
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
api(
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."},
)
_add_evidence(db, test_id, TeamSide.red)
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
suggestion = api("get", "/api/v1/procedure-suggestions", red_lead_headers).json()[0]
reject = api(
"post", f"/api/v1/procedure-suggestions/{suggestion['id']}/reject", red_lead_headers,
)
assert reject.status_code == 200, reject.text
assert reject.json()["status"] == "rejected"
reloaded = api("get", f"/api/v1/test-templates/{template['id']}", red_lead_headers)
assert reloaded.json()["attack_procedure"] == "Run a discovery command on the target."
def test_approving_a_later_suggestion_does_not_erase_an_earlier_approved_one(
client, db, api, test_from_template, template, red_tech_headers, red_lead_headers,
):
"""Two separate rounds, each contributing a different command, must
both survive in the template once their suggestions are approved
approving round 2's suggestion must not wipe out what round 1 added."""
test_id = test_from_template
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
api(
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."},
)
_add_evidence(db, test_id, TeamSide.red)
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
first_suggestion = api("get", "/api/v1/procedure-suggestions", red_lead_headers).json()[0]
api("post", f"/api/v1/procedure-suggestions/{first_suggestion['id']}/approve", red_lead_headers)
api(
"post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers,
json={"decision": "reopen", "notes": "one more pass"},
)
api(
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
json={"procedure_text": "Ran a follow-up check.\nnltest /dsgetdc:corp\nDone."},
)
_add_evidence(db, test_id, TeamSide.red)
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
second_suggestion = api("get", "/api/v1/procedure-suggestions", red_lead_headers).json()[0]
assert second_suggestion["suggested_text"] == "nltest /dsgetdc:corp"
approve_second = api(
"post", f"/api/v1/procedure-suggestions/{second_suggestion['id']}/approve", red_lead_headers,
)
assert approve_second.status_code == 200, approve_second.text
reloaded = api("get", f"/api/v1/test-templates/{template['id']}", red_lead_headers)
assert reloaded.json()["attack_procedure"] == (
"Run a discovery command on the target.\nwhoami /priv\nnltest /dsgetdc:corp"
)
def test_blue_lead_cannot_review_a_red_suggestion(
client, db, api, test_from_template, red_tech_headers, red_lead_headers, blue_lead_headers,
):
test_id = test_from_template
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
api(
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."},
)
_add_evidence(db, test_id, TeamSide.red)
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
suggestion = api("get", "/api/v1/procedure-suggestions", red_lead_headers).json()[0]
# A blue_lead sees no red suggestions in their own queue...
blue_view = api("get", "/api/v1/procedure-suggestions", blue_lead_headers)
assert blue_view.json() == []
# ...and is forbidden from acting on one directly by id.
forbidden = api(
"post", f"/api/v1/procedure-suggestions/{suggestion['id']}/approve", blue_lead_headers,
)
assert forbidden.status_code == 403
def test_duplicate_submission_does_not_create_a_second_pending_suggestion(
client, db, api, test_from_template, red_tech_headers, red_lead_headers,
):
test_id = test_from_template
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
api(
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."},
)
_add_evidence(db, test_id, TeamSide.red)
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
api(
"post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers,
json={"decision": "reopen", "notes": "please redo this round"},
)
api(
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
json={"procedure_text": "Ran discovery again.\nwhoami /priv\nStill works."},
)
_add_evidence(db, test_id, TeamSide.red)
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
listed = api("get", "/api/v1/procedure-suggestions", red_lead_headers).json()
assert len(listed) == 1
def test_lead_can_override_detect_procedure_when_creating_from_template(
client, db, api, red_lead_headers, technique, template,
):
"""A lead editing Expected Detection in the create-from-template form
seeds the new test's detect_procedure with their edit — same mechanism
as procedure_text_override on the red side without touching the
template itself. The template only updates later, through the normal
procedure-suggestion approval flow once a round is actually submitted."""
resp = api(
"post", "/api/v1/tests/from-template", red_lead_headers,
json={
"template_id": template["id"],
"technique_id": technique,
"detect_procedure": "Check Sysmon Event ID 1 for mimikatz.exe process creation.",
},
)
assert resp.status_code == 201, resp.text
assert resp.json()["detect_procedure"] == "Check Sysmon Event ID 1 for mimikatz.exe process creation."
reloaded_template = api("get", f"/api/v1/test-templates/{template['id']}", red_lead_headers)
assert reloaded_template.json()["expected_detection"] == "Check process creation logs."
def test_detect_procedure_defaults_to_template_expected_detection(
client, db, api, red_lead_headers, technique, template,
):
resp = api(
"post", "/api/v1/tests/from-template", red_lead_headers,
json={"template_id": template["id"], "technique_id": technique},
)
assert resp.status_code == 201, resp.text
assert resp.json()["detect_procedure"] == "Check process creation logs."
class TestSuggestionsForTest:
"""GET /procedure-suggestions/for-test/{test_id} — powers the blocking
review popup a lead sees when opening a test with a pending suggestion."""
def test_returns_pending_suggestion_for_the_right_test(
self, client, db, api, test_from_template, red_tech_headers, red_lead_headers,
):
test_id = test_from_template
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
api(
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."},
)
_add_evidence(db, test_id, TeamSide.red)
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
resp = api("get", f"/api/v1/procedure-suggestions/for-test/{test_id}", red_lead_headers)
assert resp.status_code == 200, resp.text
suggestions = resp.json()
assert len(suggestions) == 1
assert suggestions[0]["suggested_text"] == "whoami /priv"
def test_returns_empty_for_a_test_with_no_suggestion(
self, client, db, api, test_from_template, red_lead_headers,
):
resp = api("get", f"/api/v1/procedure-suggestions/for-test/{test_from_template}", red_lead_headers)
assert resp.status_code == 200, resp.text
assert resp.json() == []
def test_blue_lead_does_not_see_a_red_suggestion_for_the_test(
self, client, db, api, test_from_template, red_tech_headers, blue_lead_headers,
):
test_id = test_from_template
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
api(
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."},
)
_add_evidence(db, test_id, TeamSide.red)
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
resp = api("get", f"/api/v1/procedure-suggestions/for-test/{test_id}", blue_lead_headers)
assert resp.status_code == 200, resp.text
assert resp.json() == []
def test_suggestion_disappears_from_for_test_once_approved(
self, client, db, api, test_from_template, red_tech_headers, red_lead_headers,
):
test_id = test_from_template
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
api(
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."},
)
_add_evidence(db, test_id, TeamSide.red)
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
suggestion = api("get", f"/api/v1/procedure-suggestions/for-test/{test_id}", red_lead_headers).json()[0]
api("post", f"/api/v1/procedure-suggestions/{suggestion['id']}/approve", red_lead_headers)
resp = api("get", f"/api/v1/procedure-suggestions/for-test/{test_id}", red_lead_headers)
assert resp.json() == []
@@ -16,7 +16,8 @@ def test_purple_campaign_pdf_download(mock_gen, client, auth_headers, db):
f.write(b"%PDF-1.4 fake")
mock_gen.return_value = fake_pdf
with patch.object(settings, "REPORT_OUTPUT_DIR", tmpdir):
with patch.object(settings, "REPORT_OUTPUT_DIR", tmpdir), \
patch.object(settings, "REPORTS_ENABLED", True):
campaign = Campaign(name="Export Camp", status="active")
db.add(campaign)
db.commit()
@@ -38,7 +39,8 @@ def test_coverage_summary_html(mock_gen, client, auth_headers):
f.write("<html><body>ok</body></html>")
mock_gen.return_value = fake_html
with patch.object(settings, "REPORT_OUTPUT_DIR", tmpdir):
with patch.object(settings, "REPORT_OUTPUT_DIR", tmpdir), \
patch.object(settings, "REPORTS_ENABLED", True):
r = client.get(
"/api/v1/reports/generate/coverage-summary",
params={"format": "html"},
@@ -46,3 +48,15 @@ def test_coverage_summary_html(mock_gen, client, auth_headers):
)
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
def test_reports_disabled_by_default_returns_503(client, auth_headers):
"""Reports are unfinished — the frontend hides the UI entirely, and the
backend must refuse cleanly rather than let a half-working render
pipeline (e.g. missing weasyprint system deps) surface a raw 500."""
r = client.get(
"/api/v1/reports/generate/coverage-summary",
params={"format": "pdf"},
headers=auth_headers,
)
assert r.status_code == 503
+94 -1
View File
@@ -36,7 +36,7 @@ def _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_he
blue_tech_headers, blue_lead_headers, technique):
"""Drive a fresh test all the way to disputed: red approves, blue rejects."""
resp = api(
"post", "/api/v1/tests", auth_headers,
"post", "/api/v1/tests", red_lead_headers,
json={"technique_id": technique, "name": "Dispute test"},
)
test_id = resp.json()["id"]
@@ -112,3 +112,96 @@ def test_resolve_dispute_routes_to_red_queue(
assert body["state"] == "red_executing"
assert body["red_validation_status"] is None
assert body["blue_validation_status"] is None
def test_escalate_to_manager_notifies_managers(
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
blue_tech_headers, blue_lead_headers, manager_headers, manager_user, technique,
):
test_id = _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers,
blue_tech_headers, blue_lead_headers, technique)
resp = api("post", f"/api/v1/tests/{test_id}/escalate-to-manager", red_lead_headers)
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "escalated"
notifications = api("get", "/api/v1/notifications", manager_headers).json()
assert any("escalated" in (n.get("title") or "").lower() for n in notifications)
def test_escalate_to_manager_requires_disputed_state(
client, db, api, auth_headers, red_tech_headers, red_lead_headers, technique,
):
resp = api(
"post", "/api/v1/tests", red_lead_headers,
json={"technique_id": technique, "name": "Not disputed"},
)
test_id = resp.json()["id"]
resp = api("post", f"/api/v1/tests/{test_id}/escalate-to-manager", red_lead_headers)
assert resp.status_code == 400
def test_manager_resolve_dispute_as_validated(
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
blue_tech_headers, blue_lead_headers, manager_headers, technique,
):
test_id = _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers,
blue_tech_headers, blue_lead_headers, technique)
resp = api(
"post", f"/api/v1/tests/{test_id}/manager-resolve-dispute", manager_headers,
json={"outcome": "validated", "notes": "Detection was sufficient after all"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["state"] == "validated"
red_notifs = api("get", "/api/v1/notifications", red_lead_headers).json()
assert any(n["type"] == "manager_dispute_resolution" for n in red_notifs)
blue_notifs = api("get", "/api/v1/notifications", blue_lead_headers).json()
assert any(n["type"] == "manager_dispute_resolution" for n in blue_notifs)
def test_manager_resolve_dispute_as_rejected(
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
blue_tech_headers, blue_lead_headers, manager_headers, technique,
):
test_id = _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers,
blue_tech_headers, blue_lead_headers, technique)
resp = api(
"post", f"/api/v1/tests/{test_id}/manager-resolve-dispute", manager_headers,
json={"outcome": "rejected"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["state"] == "rejected"
def test_manager_resolve_dispute_forbidden_for_lead(
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
blue_tech_headers, blue_lead_headers, technique,
):
test_id = _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers,
blue_tech_headers, blue_lead_headers, technique)
resp = api(
"post", f"/api/v1/tests/{test_id}/manager-resolve-dispute", red_lead_headers,
json={"outcome": "validated"},
)
assert resp.status_code == 403
def test_manager_resolve_dispute_requires_disputed_state(
client, db, api, red_lead_headers, manager_headers, technique,
):
resp = api(
"post", "/api/v1/tests", red_lead_headers,
json={"technique_id": technique, "name": "Not disputed either"},
)
test_id = resp.json()["id"]
resp = api(
"post", f"/api/v1/tests/{test_id}/manager-resolve-dispute", manager_headers,
json={"outcome": "validated"},
)
assert resp.status_code == 400
+14 -14
View File
@@ -86,7 +86,7 @@ def test_review_red_forbidden_for_non_assigned_lead(
client, db, api, auth_headers, red_tech_headers, red_lead_headers, red_lead_user, technique
):
"""A red_lead who isn't the assigned reviewer gets 403."""
test_id = _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique)
test_id = _reach_red_review(client, db, api, red_lead_headers, red_tech_headers, technique)
# red_lead_user IS the only red_lead fixture, so it WILL be auto-assigned
# as reviewer (single-candidate load balancing). To exercise the 403
# path we need a second, non-assigned red_lead.
@@ -101,7 +101,7 @@ def test_review_red_forbidden_for_non_assigned_lead(
db.add(other_lead)
db.commit()
login = client.post("/api/v1/auth/login", data={"username": "otherredlead", "password": "x"})
login = client.post("/api/v1/auth/login", data={"username": "otherredlead@test.com", "password": "x"})
assert login.status_code == 200
other_headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
@@ -112,7 +112,7 @@ def test_review_red_forbidden_for_non_assigned_lead(
def test_review_red_approve_moves_to_blue_evaluating(
client, db, api, auth_headers, red_tech_headers, red_lead_headers, red_lead_user, technique
):
test_id = _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique)
test_id = _reach_red_review(client, db, api, red_lead_headers, red_tech_headers, technique)
resp = api("post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers, json={"decision": "approve", "notes": "LGTM"})
assert resp.status_code == 200, resp.text
@@ -125,7 +125,7 @@ def test_review_red_approve_moves_to_blue_evaluating(
def test_review_red_reopen_requires_notes(
client, db, api, auth_headers, red_tech_headers, red_lead_headers, red_lead_user, technique
):
test_id = _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique)
test_id = _reach_red_review(client, db, api, red_lead_headers, red_tech_headers, technique)
resp = api("post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers, json={"decision": "reopen"})
assert resp.status_code == 400
@@ -134,7 +134,7 @@ def test_review_red_reopen_requires_notes(
def test_review_red_reopen_moves_to_red_executing(
client, db, api, auth_headers, red_tech_headers, red_lead_headers, red_lead_user, technique
):
test_id = _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique)
test_id = _reach_red_review(client, db, api, red_lead_headers, red_tech_headers, technique)
resp = api("post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers, json={"decision": "reopen", "notes": "add more detail"})
assert resp.status_code == 200, resp.text
@@ -144,11 +144,11 @@ def test_review_red_reopen_moves_to_red_executing(
def test_review_red_forbidden_for_admin(
client, db, api, auth_headers, red_tech_headers, red_lead_user, technique
client, db, api, auth_headers, red_tech_headers, red_lead_headers, red_lead_user, technique
):
"""Admin administers the site, not the test workflow — reviewing is a
red_lead-only action now, with no admin override."""
test_id = _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique)
test_id = _reach_red_review(client, db, api, red_lead_headers, red_tech_headers, technique)
resp = api("post", f"/api/v1/tests/{test_id}/review-red", auth_headers, json={"decision": "approve"})
assert resp.status_code == 403
@@ -163,7 +163,7 @@ def test_review_blue_forbidden_for_non_assigned_lead(
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
red_lead_user, blue_lead_user, technique,
):
test_id = _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
test_id = _reach_blue_review(client, db, api, red_lead_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
from app.auth import hash_password
from app.models.user import User
@@ -176,7 +176,7 @@ def test_review_blue_forbidden_for_non_assigned_lead(
db.add(other_lead)
db.commit()
login = client.post("/api/v1/auth/login", data={"username": "otherbluelead", "password": "x"})
login = client.post("/api/v1/auth/login", data={"username": "otherbluelead@test.com", "password": "x"})
other_headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", other_headers, json={"decision": "approve"})
@@ -187,7 +187,7 @@ def test_review_blue_approve_moves_to_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, technique,
):
test_id = _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
test_id = _reach_blue_review(client, db, api, red_lead_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers, json={"decision": "approve"})
assert resp.status_code == 200, resp.text
@@ -198,7 +198,7 @@ def test_review_blue_reopen_requires_notes(
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
red_lead_user, blue_lead_user, technique,
):
test_id = _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
test_id = _reach_blue_review(client, db, api, red_lead_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers, json={"decision": "reopen"})
assert resp.status_code == 400
@@ -208,7 +208,7 @@ def test_review_blue_reopen_moves_to_blue_evaluating(
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
red_lead_user, blue_lead_user, technique,
):
test_id = _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
test_id = _reach_blue_review(client, db, api, red_lead_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers, json={"decision": "reopen", "notes": "redo detection"})
assert resp.status_code == 200, resp.text
@@ -221,7 +221,7 @@ def test_review_blue_gap_requires_system_gaps(
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
red_lead_user, blue_lead_user, technique,
):
test_id = _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
test_id = _reach_blue_review(client, db, api, red_lead_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers, json={"decision": "gap"})
assert resp.status_code == 400
@@ -231,7 +231,7 @@ def test_review_blue_gap_moves_to_in_review_with_system_gaps(
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
red_lead_user, blue_lead_user, technique,
):
test_id = _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
test_id = _reach_blue_review(client, db, api, red_lead_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
resp = api(
"post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers,
@@ -0,0 +1,25 @@
"""Manager must have the same Review Queue access as red_lead/blue_lead —
mark_reviewed was previously red_lead/blue_lead (and admin) only."""
def test_manager_can_mark_technique_reviewed(client, db, api, auth_headers, manager_headers):
resp = api(
"post", "/api/v1/techniques", auth_headers,
json={"mitre_id": "T1059.400", "name": "Manager Review Access Technique"},
)
assert resp.status_code == 201, resp.text
resp = api("patch", "/api/v1/techniques/T1059.400/review", manager_headers)
assert resp.status_code == 200, resp.text
assert resp.json()["review_required"] is False
def test_red_tech_cannot_mark_technique_reviewed(client, db, api, auth_headers, red_tech_headers):
resp = api(
"post", "/api/v1/techniques", auth_headers,
json={"mitre_id": "T1059.401", "name": "Red Tech No Access Technique"},
)
assert resp.status_code == 201, resp.text
resp = api("patch", "/api/v1/techniques/T1059.401/review", red_tech_headers)
assert resp.status_code == 403
+19
View File
@@ -0,0 +1,19 @@
"""GET /scores/history returns a list of weekly data points — regression
test for a response-model mismatch (endpoint annotated -> dict while the
service actually returns a list, causing a raw 500 on serialization)."""
def test_scores_history_returns_a_list(client, auth_headers):
resp = client.get("/api/v1/scores/history", headers=auth_headers)
assert resp.status_code == 200, resp.text
body = resp.json()
assert isinstance(body, list)
def test_scores_history_accepts_all_periods(client, auth_headers):
for period in ("30d", "90d", "1y"):
resp = client.get(
"/api/v1/scores/history", params={"period": period}, headers=auth_headers,
)
assert resp.status_code == 200, resp.text
assert isinstance(resp.json(), list)
+1 -1
View File
@@ -10,7 +10,7 @@ if backend_dir not in sys.path:
import pytest
from pydantic import ValidationError
from app.schemas.user import UserCreate
from app.schemas.user import LegacyUserCreateWithPassword as UserCreate
# ── Username validation ──────────────────────────────────────────────
@@ -103,6 +103,8 @@ if "apscheduler" not in sys.modules:
sys.modules["apscheduler.triggers"] = ModuleType("apscheduler.triggers")
sys.modules["apscheduler.triggers.cron"] = ModuleType("apscheduler.triggers.cron")
sys.modules["apscheduler.triggers.cron"].CronTrigger = MagicMock
sys.modules["apscheduler.events"] = ModuleType("apscheduler.events")
sys.modules["apscheduler.events"].EVENT_JOB_ERROR = 1
# ---------------------------------------------------------------------------
# Imports
+2
View File
@@ -100,6 +100,8 @@ if "apscheduler" not in sys.modules:
sys.modules["apscheduler.triggers"] = ModuleType("apscheduler.triggers")
sys.modules["apscheduler.triggers.cron"] = ModuleType("apscheduler.triggers.cron")
sys.modules["apscheduler.triggers.cron"].CronTrigger = MagicMock
sys.modules["apscheduler.events"] = ModuleType("apscheduler.events")
sys.modules["apscheduler.events"].EVENT_JOB_ERROR = 1
# ---------------------------------------------------------------------------
# Imports
+3
View File
@@ -76,6 +76,7 @@ for mod_name in [
"apscheduler", "apscheduler.schedulers",
"apscheduler.schedulers.background",
"apscheduler.triggers", "apscheduler.triggers.cron",
"apscheduler.events",
]:
if mod_name not in sys.modules:
m = ModuleType(mod_name)
@@ -92,6 +93,8 @@ for mod_name in [
m.BackgroundScheduler = MagicMock
elif mod_name == "apscheduler.triggers.cron":
m.CronTrigger = MagicMock
elif mod_name == "apscheduler.events":
m.EVENT_JOB_ERROR = 1
sys.modules[mod_name] = m
# ---------------------------------------------------------------------------
@@ -74,6 +74,7 @@ for mod_name in [
"apscheduler", "apscheduler.schedulers",
"apscheduler.schedulers.background",
"apscheduler.triggers", "apscheduler.triggers.cron",
"apscheduler.events",
]:
if mod_name not in sys.modules:
m = ModuleType(mod_name)
@@ -83,6 +84,7 @@ for mod_name in [
elif mod_name == "botocore.exceptions": m.ClientError = Exception
elif mod_name == "apscheduler.schedulers.background": m.BackgroundScheduler = MagicMock
elif mod_name == "apscheduler.triggers.cron": m.CronTrigger = MagicMock
elif mod_name == "apscheduler.events": m.EVENT_JOB_ERROR = 1
sys.modules[mod_name] = m
# ---------------------------------------------------------------------------
@@ -74,6 +74,7 @@ for mod_name in [
"apscheduler", "apscheduler.schedulers",
"apscheduler.schedulers.background",
"apscheduler.triggers", "apscheduler.triggers.cron",
"apscheduler.events",
]:
if mod_name not in sys.modules:
m = ModuleType(mod_name)
@@ -83,6 +84,7 @@ for mod_name in [
elif mod_name == "botocore.exceptions": m.ClientError = Exception
elif mod_name == "apscheduler.schedulers.background": m.BackgroundScheduler = MagicMock
elif mod_name == "apscheduler.triggers.cron": m.CronTrigger = MagicMock
elif mod_name == "apscheduler.events": m.EVENT_JOB_ERROR = 1
sys.modules[mod_name] = m
# ---------------------------------------------------------------------------
@@ -182,11 +184,14 @@ def test_soft_delete():
def test_search_filter():
from app.routers.test_templates import list_templates
source = inspect.getsource(list_templates)
from app.services.test_template_service import _build_template_query, list_templates
# The actual filter-building (including search) now lives in the shared
# _build_template_query() helper, reused by both list and count.
source = inspect.getsource(_build_template_query)
assert "search" in source
assert "name" in source and "description" in source
assert "ilike" in source
assert "_build_template_query" in inspect.getsource(list_templates)
print(" [PASS] Search filter searches in name and description")
+2
View File
@@ -78,6 +78,7 @@ for mod_name in [
"apscheduler", "apscheduler.schedulers",
"apscheduler.schedulers.background",
"apscheduler.triggers", "apscheduler.triggers.cron",
"apscheduler.events",
]:
if mod_name not in sys.modules:
m = ModuleType(mod_name)
@@ -87,6 +88,7 @@ for mod_name in [
elif mod_name == "botocore.exceptions": m.ClientError = Exception
elif mod_name == "apscheduler.schedulers.background": m.BackgroundScheduler = MagicMock
elif mod_name == "apscheduler.triggers.cron": m.CronTrigger = MagicMock
elif mod_name == "apscheduler.events": m.EVENT_JOB_ERROR = 1
sys.modules[mod_name] = m
# ---------------------------------------------------------------------------
@@ -0,0 +1,68 @@
"""GET /test-templates/count — total matching the same filters as the list
endpoint, so the catalog UI can compute a true page count."""
import pytest
@pytest.fixture
def technique(api, auth_headers):
resp = api(
"post", "/api/v1/techniques", auth_headers,
json={"mitre_id": "T1059.300", "name": "Count Endpoint Technique"},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
def _create_template(api, headers, mitre_technique_id, name):
resp = api(
"post", "/api/v1/test-templates", headers,
json={"mitre_technique_id": mitre_technique_id, "name": name},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
def test_count_matches_number_of_matching_templates(
client, db, api, auth_headers, red_lead_headers, technique,
):
_create_template(api, red_lead_headers, "T1059.300", "Count test template A")
_create_template(api, red_lead_headers, "T1059.300", "Count test template B")
resp = api(
"get", "/api/v1/test-templates/count", auth_headers,
params={"mitre_technique_id": "T1059.300"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["total"] == 2
def test_count_respects_search_filter(client, db, api, auth_headers, red_lead_headers, technique):
_create_template(api, red_lead_headers, "T1059.300", "Unique Zebra Template")
_create_template(api, red_lead_headers, "T1059.300", "Something else entirely")
resp = api("get", "/api/v1/test-templates/count", auth_headers, params={"search": "Zebra"})
assert resp.status_code == 200, resp.text
assert resp.json()["total"] == 1
def test_count_matches_list_length_when_under_page_size(
client, db, api, auth_headers, red_lead_headers, technique,
):
_create_template(api, red_lead_headers, "T1059.300", "Parity check template")
listed = api(
"get", "/api/v1/test-templates", auth_headers,
params={"mitre_technique_id": "T1059.300"},
)
counted = api(
"get", "/api/v1/test-templates/count", auth_headers,
params={"mitre_technique_id": "T1059.300"},
)
assert len(listed.json()) == counted.json()["total"]
def test_count_accessible_to_any_authenticated_role(client, db, api, red_tech_headers):
"""Pagination is a basic UX need, not a lead-only privilege like /stats."""
resp = api("get", "/api/v1/test-templates/count", red_tech_headers)
assert resp.status_code == 200, resp.text

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