Compare commits

37 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
147 changed files with 6443 additions and 646 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,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."
)
+21
View File
@@ -672,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), "
+2
View File
@@ -44,6 +44,7 @@ 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
@@ -215,6 +216,7 @@ 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()
+6
View File
@@ -46,7 +46,10 @@ 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__ = [
@@ -90,4 +93,7 @@ __all__ = [
"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])
+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])
+6 -7
View File
@@ -41,13 +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
# Suggested detection procedure — Blue's counterpart to attack_procedure.
# Only ever filled in via an approved procedure suggestion or a lead
# editing the template directly; external syncs never touch it (those
# only insert brand-new rows, never update existing ones).
detect_suggested_procedure = Column(Text, nullable=True)
# 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.
+20 -1
View File
@@ -52,7 +52,7 @@ def _to_out(suggestion, template_by_id: dict) -> ProcedureSuggestionOut:
if template is not None:
out.template_name = template.name
out.template_current_text = (
template.attack_procedure if suggestion.team == "red" else template.detect_suggested_procedure
template.attack_procedure if suggestion.team == "red" else template.expected_detection
)
return out
@@ -73,6 +73,25 @@ def list_suggestions(
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,
@@ -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")
+30
View File
@@ -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)
# ---------------------------------------------------------------------------
+134 -1
View File
@@ -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,
@@ -371,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_strict("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.
@@ -402,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(
@@ -540,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
# ---------------------------------------------------------------------------
@@ -1191,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
# ---------------------------------------------------------------------------
@@ -1310,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,
@@ -1330,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")
@@ -1370,6 +1499,8 @@ 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
@@ -1683,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
@@ -1721,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,
}
+154 -12
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
@@ -216,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)
@@ -241,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,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)
+7
View File
@@ -185,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) ────────────────────────────
+1 -2
View File
@@ -31,7 +31,6 @@ class TestTemplateOut(BaseModel):
attack_procedure: str | None = None
# Assign expected_detection = None
expected_detection: str | None = None
detect_suggested_procedure: str | None = None
# Assign platform = None
platform: str | None = None
# Assign tool_suggested = None
@@ -71,7 +70,6 @@ class TestTemplateCreate(BaseModel):
attack_procedure: str | None = None
# Assign expected_detection = None
expected_detection: str | None = None
detect_suggested_procedure: str | None = None
# Assign platform = None
platform: str | None = None
# Assign tool_suggested = None
@@ -132,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",
})
+52 -3
View File
@@ -506,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'}",
@@ -526,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'}",
@@ -689,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,
@@ -933,6 +980,8 @@ def push_test_event(
"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()
@@ -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
@@ -48,7 +48,9 @@ _COMMAND_LINE_RE = re.compile(
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)\b
Invoke-\S+|iex|dsquery|nltest|
sysmon\S*|wevtutil|auditpol|tcpdump|tshark|procmon\S*|autorunsc\S*|
volatility\S*)\b
|
\bSELECT\s+.+\s+FROM\b # SQL
|
@@ -28,7 +28,7 @@ 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": "detect_suggested_procedure"}
_TEMPLATE_FIELD = {"red": "attack_procedure", "blue": "expected_detection"}
_LEAD_ROLE = {"red": "red_lead", "blue": "blue_lead"}
@@ -131,11 +131,17 @@ def create_suggestion_from_submission(db: Session, test: Test, team: str) -> Pro
return suggestion
def list_pending_suggestions(db: Session, *, team: str | None = None) -> list[ProcedureSuggestion]:
"""List pending suggestions, optionally filtered to one team."""
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()
+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
+24 -1
View File
@@ -304,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.
@@ -364,7 +365,7 @@ 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=template.detect_suggested_procedure,
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,
@@ -437,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()
+69 -4
View File
@@ -39,6 +39,7 @@ 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__)
@@ -1048,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
@@ -1113,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
@@ -1147,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":
@@ -1178,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)
@@ -1198,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)
@@ -1341,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
@@ -39,6 +39,8 @@ DUMMY_ID = str(uuid.uuid4())
("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):
+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
+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
# ═══════════════════════════════════════════════════════════════════════
+1 -1
View File
@@ -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)
+60
View File
@@ -31,6 +31,20 @@ def _seed_executing_test(db, technique, created_by) -> 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)
@@ -88,3 +102,49 @@ def test_hold_does_not_double_pause_an_already_paused_timer(client, db, red_tech
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:
+102
View File
@@ -72,6 +72,7 @@ def _make_test(**overrides):
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
@@ -232,6 +233,68 @@ 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):
@@ -302,6 +365,45 @@ def test_push_test_event_clears_assignee_on_in_review(mock_get_client, mock_conf
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):
@@ -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
@@ -95,3 +95,15 @@ 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:*"
+99 -1
View File
@@ -45,7 +45,7 @@ def template(api, red_lead_headers, technique):
"mitre_technique_id": "T1059.200",
"name": "Suggestion source template",
"attack_procedure": "Run a discovery command on the target.",
"detect_suggested_procedure": "Check process creation logs.",
"expected_detection": "Check process creation logs.",
},
)
assert resp.status_code == 201, resp.text
@@ -272,3 +272,101 @@ def test_duplicate_submission_does_not_create_a_second_pending_suggestion(
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
@@ -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
+2 -2
View File
@@ -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']}"}
@@ -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"})
@@ -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
+145
View File
@@ -0,0 +1,145 @@
"""End-to-end coverage for the operator template-proposal review workflow.
Leads create test templates directly via POST /test-templates. An operator
(red_tech/blue_tech) gets the same "propose a template" action, but their
submission lands in the review queue instead of the live catalog a lead
on their team approves (optionally editing fields) or discards it.
"""
import pytest
def _payload(**overrides):
base = {
"mitre_technique_id": "T1059.300",
"name": "Operator-proposed template",
"attack_procedure": "Run a discovery command.",
"expected_detection": "Check process creation logs.",
}
base.update(overrides)
return base
@pytest.fixture
def technique(api, auth_headers):
resp = api(
"post", "/api/v1/techniques", auth_headers,
json={"mitre_id": "T1059.300", "name": "Command Line Proposals"},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
def test_red_tech_can_propose_a_template(api, technique, red_tech_headers):
resp = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["status"] == "pending"
assert body["team"] == "red"
assert body["name"] == "Operator-proposed template"
def test_blue_tech_can_propose_a_template(api, technique, blue_tech_headers):
resp = api("post", "/api/v1/template-suggestions", blue_tech_headers, json=_payload())
assert resp.status_code == 201, resp.text
assert resp.json()["team"] == "blue"
def test_lead_cannot_propose_a_template_via_suggestion_endpoint(api, technique, red_lead_headers):
"""Leads use the direct POST /test-templates endpoint instead."""
resp = api("post", "/api/v1/template-suggestions", red_lead_headers, json=_payload())
assert resp.status_code == 403
def test_proposed_template_does_not_appear_in_catalog(api, technique, red_tech_headers, red_lead_headers):
api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
catalog = api("get", "/api/v1/test-templates?search=Operator-proposed", red_lead_headers)
assert catalog.json() == []
def test_red_lead_sees_pending_red_suggestion(api, technique, red_tech_headers, red_lead_headers):
api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
listed = api("get", "/api/v1/template-suggestions", red_lead_headers)
assert listed.status_code == 200, listed.text
assert len(listed.json()) == 1
assert listed.json()[0]["status"] == "pending"
def test_blue_lead_does_not_see_red_suggestion(api, technique, red_tech_headers, blue_lead_headers):
api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
listed = api("get", "/api/v1/template-suggestions", blue_lead_headers)
assert listed.json() == []
def test_blue_lead_cannot_approve_a_red_suggestion(api, technique, red_tech_headers, blue_lead_headers):
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
suggestion_id = created.json()["id"]
resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/approve", blue_lead_headers)
assert resp.status_code == 403
def test_lead_approves_suggestion_as_is_creates_template(api, technique, red_tech_headers, red_lead_headers):
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
suggestion_id = created.json()["id"]
resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/approve", red_lead_headers)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["status"] == "approved"
assert body["created_template_id"] is not None
template = api("get", f"/api/v1/test-templates/{body['created_template_id']}", red_lead_headers)
assert template.status_code == 200
assert template.json()["name"] == "Operator-proposed template"
listed = api("get", "/api/v1/template-suggestions", red_lead_headers)
assert listed.json() == []
def test_lead_approves_suggestion_with_edits(api, technique, red_tech_headers, red_lead_headers):
"""The lead can add/adjust information before it goes live — the created
template reflects the lead's edits, not just the operator's original text."""
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
suggestion_id = created.json()["id"]
resp = api(
"post", f"/api/v1/template-suggestions/{suggestion_id}/approve", red_lead_headers,
json={"attack_procedure": "Run a discovery command, then pivot via WMI.", "severity": "high"},
)
assert resp.status_code == 200, resp.text
template_id = resp.json()["created_template_id"]
template = api("get", f"/api/v1/test-templates/{template_id}", red_lead_headers)
assert template.json()["attack_procedure"] == "Run a discovery command, then pivot via WMI."
assert template.json()["severity"] == "high"
# Untouched fields still come from the operator's original submission.
assert template.json()["expected_detection"] == "Check process creation logs."
def test_lead_discards_suggestion_without_creating_template(api, technique, red_tech_headers, red_lead_headers):
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
suggestion_id = created.json()["id"]
resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/reject", red_lead_headers)
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "rejected"
assert resp.json()["created_template_id"] is None
catalog = api("get", "/api/v1/test-templates?search=Operator-proposed", red_lead_headers)
assert catalog.json() == []
listed = api("get", "/api/v1/template-suggestions", red_lead_headers)
assert listed.json() == []
def test_cannot_review_an_already_reviewed_suggestion_twice(api, technique, red_tech_headers, red_lead_headers):
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
suggestion_id = created.json()["id"]
api("post", f"/api/v1/template-suggestions/{suggestion_id}/approve", red_lead_headers)
resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/reject", red_lead_headers)
assert resp.status_code == 400
+7 -2
View File
@@ -77,6 +77,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)
@@ -86,6 +87,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
# ---------------------------------------------------------------------------
@@ -147,8 +149,11 @@ def test_list_templates_with_filters():
assert "search" in source, "List must accept search filter"
assert "mitre_technique_id" in source, "List must accept mitre_technique_id filter"
# Verify ilike is used for search
assert "ilike" in source, "Search should use ilike for case-insensitive matching"
# Verify ilike is used for search — lives in the shared
# _build_template_query() helper now, reused by list and count.
from app.services.test_template_service import _build_template_query
assert "ilike" in inspect.getsource(_build_template_query), \
"Search should use ilike for case-insensitive matching"
# ===========================================================================
+1 -1
View File
@@ -102,7 +102,7 @@ def test_start_execution_twice_returns_invalid_transition(
rl = client.post(
"/api/v1/auth/login",
data={"username": "redtech", "password": "redtech123"},
data={"username": "redtech@test.com", "password": "redtech123"},
)
assert rl.status_code == 200
red_headers = {"Authorization": f"Bearer {rl.json()['access_token']}"}
+65 -28
View File
@@ -1,28 +1,30 @@
"""API-level validation tests for user creation (SEC-004, SEC-007)."""
"""API-level validation tests for user creation (SEC-004, SEC-007).
Users are created with a name + email no password (see
password_setup_service for the emailed set-password link flow) and no
admin-supplied username (username is auto-derived from email).
"""
def test_create_user_weak_password_rejected(client, admin_user, admin_token):
def test_create_user_missing_full_name_rejected(client, admin_user, admin_token):
response = client.post(
"/api/v1/users",
json={
"username": "newuser",
"password": "123",
"full_name": "",
"email": "new@test.com",
"role": "viewer",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 422
assert "password" in response.text.lower()
def test_create_user_reserved_username(client, admin_user, admin_token):
def test_create_user_invalid_email_rejected(client, admin_user, admin_token):
response = client.post(
"/api/v1/users",
json={
"username": "system",
"password": "SecurePass123!@#",
"email": "sys@test.com",
"full_name": "New User",
"email": "not-an-email",
"role": "viewer",
},
headers={"Authorization": f"Bearer {admin_token}"},
@@ -30,30 +32,65 @@ def test_create_user_reserved_username(client, admin_user, admin_token):
assert response.status_code == 422
def test_create_user_invalid_username_chars(client, admin_user, admin_token):
def test_create_user_valid_request_accepted(client, admin_user, admin_token):
response = client.post(
"/api/v1/users",
json={
"username": "../admin",
"password": "SecurePass123!@#",
"email": "bad@test.com",
"role": "viewer",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 422
def test_create_user_valid_password_accepted(client, admin_user, admin_token):
response = client.post(
"/api/v1/users",
json={
"username": "validuser99",
"password": "ValidPass123!@#",
"full_name": "Valid User",
"email": "valid@test.com",
"role": "viewer",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 201
assert response.json()["username"] == "validuser99"
assert response.status_code == 201, response.text
body = response.json()
assert body["full_name"] == "Valid User"
assert body["username"] == "valid@test.com"
assert body["must_change_password"] is True
def test_create_user_with_manager_role_accepted(client, admin_user, admin_token):
"""Regression: 'manager' is a real, actively-used role (dispute mediation,
operator assignment) but was missing from the creation whitelist."""
response = client.post(
"/api/v1/users",
json={
"full_name": "New Manager",
"email": "newmanager@test.com",
"role": "manager",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 201, response.text
assert response.json()["role"] == "manager"
def test_create_user_invalid_role_rejected(client, admin_user, admin_token):
response = client.post(
"/api/v1/users",
json={
"full_name": "Bad Role User",
"email": "badrole@test.com",
"role": "superuser",
},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 400
assert "Invalid role" in response.text
def test_create_user_duplicate_email_rejected(client, admin_user, admin_token):
payload = {
"full_name": "Dup User",
"email": "dup@test.com",
"role": "viewer",
}
first = client.post(
"/api/v1/users", json=payload, headers={"Authorization": f"Bearer {admin_token}"},
)
assert first.status_code == 201, first.text
second = client.post(
"/api/v1/users", json=payload, headers={"Authorization": f"Bearer {admin_token}"},
)
assert second.status_code == 409
+96
View File
@@ -0,0 +1,96 @@
"""Admins can permanently delete users with no activity footprint —
anyone with tests/evidence/worklogs/etc must be deactivated instead, to
keep the audit trail intact.
"""
import uuid
import pytest
@pytest.fixture
def technique(client, auth_headers):
response = client.post(
"/api/v1/techniques",
json={"mitre_id": "T1059", "name": "Test Technique"},
headers=auth_headers,
)
return response.json()
def test_admin_can_delete_user_with_no_footprint(api, auth_headers):
created = api(
"post", "/api/v1/users", auth_headers,
json={"full_name": "Disposable User", "email": "disposable@test.com", "role": "viewer"},
)
assert created.status_code == 201, created.text
user_id = created.json()["id"]
resp = api("delete", f"/api/v1/users/{user_id}", auth_headers)
assert resp.status_code == 204, resp.text
get_resp = api("get", f"/api/v1/users/{user_id}", auth_headers)
assert get_resp.status_code == 404
def test_admin_cannot_delete_own_account(api, auth_headers, admin_user):
resp = api("delete", f"/api/v1/users/{admin_user.id}", auth_headers)
assert resp.status_code == 400
assert "own account" in resp.text.lower()
def test_admin_cannot_delete_user_with_test_footprint(api, auth_headers, technique, red_lead_headers, red_lead_user, client):
client.cookies.clear()
test_resp = api(
"post", "/api/v1/tests", red_lead_headers,
json={
"technique_id": technique["id"],
"name": "Footprint Test",
"description": "x",
"platform": "windows",
},
)
assert test_resp.status_code == 201, test_resp.text
resp = api("delete", f"/api/v1/users/{red_lead_user.id}", auth_headers)
assert resp.status_code == 400
assert "activity history" in resp.text.lower() or "deactivate" in resp.text.lower()
# The user must still be there — deactivating is the only allowed path.
get_resp = api("get", f"/api/v1/users/{red_lead_user.id}", auth_headers)
assert get_resp.status_code == 200
def test_delete_user_requires_admin(api, red_lead_headers, red_lead_user):
resp = api("delete", f"/api/v1/users/{red_lead_user.id}", red_lead_headers)
assert resp.status_code == 403
def test_delete_user_not_found(api, auth_headers):
resp = api("delete", f"/api/v1/users/{uuid.uuid4()}", auth_headers)
assert resp.status_code == 404
def test_delete_cleans_up_notifications_and_tokens(api, auth_headers, db):
created = api(
"post", "/api/v1/users", auth_headers,
json={"full_name": "Notifiable User", "email": "notifiable@test.com", "role": "viewer"},
)
user_id = uuid.UUID(created.json()["id"])
from app.models.notification import Notification
from app.models.password_setup_token import PasswordSetupToken
from app.services.notification_service import create_notification
create_notification(
db, user_id=user_id, type="test", title="Hi", message="hi",
entity_type="test", entity_id=uuid.uuid4(),
)
db.add(PasswordSetupToken(user_id=user_id, token="tok-abc", expires_at=__import__("datetime").datetime.utcnow()))
db.commit()
resp = api("delete", f"/api/v1/users/{user_id}", auth_headers)
assert resp.status_code == 204, resp.text
assert db.query(Notification).filter(Notification.user_id == user_id).count() == 0
assert db.query(PasswordSetupToken).filter(PasswordSetupToken.user_id == user_id).count() == 0
+3 -1
View File
@@ -83,6 +83,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)
@@ -92,6 +93,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
# ---------------------------------------------------------------------------
@@ -951,7 +953,7 @@ class TestReviewerSelection:
def _make_lead(self, db, username, role="red_lead"):
from app.models.user import User
u = User(username=username, role=role, hashed_password="x", is_active=True)
u = User(username=username, email=f"{username}@test.com", role=role, hashed_password="x", is_active=True)
db.add(u)
db.flush()
return u
+6
View File
@@ -90,9 +90,15 @@ services:
REDIS_TOKEN_BLACKLIST_DB: ${REDIS_TOKEN_BLACKLIST_DB:-1}
REDIS_CACHE_DB: ${REDIS_CACHE_DB:-2}
CORS_ORIGINS: ${CORS_ORIGINS:-}
# Base URL used to build links in outbound emails (set-password, etc).
# No default on purpose — every deployment has a different domain;
# the backend refuses to start in production without this set.
PLATFORM_URL: ${PLATFORM_URL:?Set PLATFORM_URL in your .env file to this deployment's real public frontend URL}
AEGIS_ENV: ${AEGIS_ENV:-production}
SECURE_COOKIES: ${SECURE_COOKIES:-false}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
# Email is the admin account's unique login identifier.
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
# ── Tempo time-tracking (optional) ────────────────────────────────────
TEMPO_ENABLED: ${TEMPO_ENABLED:-false}
+7 -4
View File
@@ -6,6 +6,7 @@ import ProtectedRoute from "./components/ProtectedRoute";
/* ── Eagerly loaded (core pages) ──────────────────────────────────── */
import LoginPage from "./pages/LoginPage";
import SetPasswordPage from "./pages/SetPasswordPage";
import DashboardPage from "./pages/DashboardPage";
/* ── Lazy loaded (V1-V3 pages) ────────────────────────────────────── */
@@ -20,7 +21,9 @@ const TestCatalogPage = React.lazy(() => import("./pages/TestCatalogPage"));
const ValidatedTestsPage = React.lazy(() => import("./pages/ValidatedTestsPage"));
const ReviewQueuePage = React.lazy(() => import("./pages/ReviewQueuePage"));
const ImportRTPage = React.lazy(() => import("./pages/ImportRTPage"));
const ReportsPage = React.lazy(() => import("./pages/ReportsPage"));
// Reports is unfinished — hidden platform-wide (route + nav link removed
// below) until it's ready. Left unimported/unrouted rather than deleted.
// const ReportsPage = React.lazy(() => import("./pages/ReportsPage"));
const SystemPage = React.lazy(() => import("./pages/SystemPage"));
const UsersPage = React.lazy(() => import("./pages/UsersPage"));
const AuditLogPage = React.lazy(() => import("./pages/AuditLogPage"));
@@ -37,6 +40,7 @@ export default function App() {
<Routes>
{/* Public */}
<Route path="/login" element={<LoginPage />} />
<Route path="/set-password" element={<SetPasswordPage />} />
{/* Protected — wrapped in shared Layout */}
<Route
@@ -57,7 +61,7 @@ export default function App() {
<Route
path="/techniques/review-queue"
element={
<ProtectedRoute roles={["admin", "red_lead", "blue_lead"]}>
<ProtectedRoute roles={["admin", "red_lead", "blue_lead", "manager"]}>
<Suspense fallback={<LoadingSpinner text="Loading…" />}><ReviewQueuePage /></Suspense>
</ProtectedRoute>
}
@@ -110,8 +114,7 @@ export default function App() {
}
/>
{/* ── Reports ──────────────────────────────────────────── */}
<Route path="/reports" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><ReportsPage /></Suspense>} />
{/* ── Reports (hidden — unfinished, see import comment above) ── */}
{/* ── Settings (all authenticated users) ───────────────── */}
<Route path="/settings" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><SettingsPage /></Suspense>} />
+1
View File
@@ -4,6 +4,7 @@ export interface AuditLogOut {
id: string;
user_id: string | null;
username: string | null;
full_name: string | null;
action: string;
entity_type: string | null;
entity_id: string | null;
+23 -4
View File
@@ -2,17 +2,20 @@ import client from "./client";
import type { User } from "../types/models";
/**
* Authenticate the user.
* Authenticate the user with their email (the unique login identifier)
* and password.
*
* The backend sets an HttpOnly cookie with the JWT no token is stored
* in JavaScript memory or localStorage.
* in JavaScript memory or localStorage. The form field is still named
* "username" on the wire that's the OAuth2PasswordRequestForm spec's
* fixed field name, not a statement about what value it holds.
*/
export async function login(
username: string,
email: string,
password: string,
): Promise<void> {
const params = new URLSearchParams();
params.append("username", username);
params.append("username", email);
params.append("password", password);
await client.post("/auth/login", params, {
@@ -50,3 +53,19 @@ export async function changePassword(
new_password: newPassword,
});
}
/** Check a set-password token before rendering the form. Public — no auth. */
export async function validateSetPasswordToken(
token: string,
): Promise<{ valid: boolean; full_name: string | null }> {
const { data } = await client.get<{ valid: boolean; full_name: string | null }>(
"/auth/set-password/validate",
{ params: { token } },
);
return data;
}
/** Complete a password setup/reset using a one-time token. Public — no auth. */
export async function setPassword(token: string, newPassword: string): Promise<void> {
await client.post("/auth/set-password", { token, new_password: newPassword });
}

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