diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..62815aa --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "8.0.28", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6b7aee3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +# Build outputs and deps — rebuilt inside the image +**/bin +**/obj +**/node_modules +**/dist +publish/ + +# Local config & secrets — never bake into the image +**/appsettings.Production.json +**/appsettings.*.local.json +.env + +# Source control / IDE / docs +.git +.gitignore +.vs +.vscode +*.md +docs/ + +# Data Protection keys / runtime artifacts +keys/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..12b370e --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Copy to .env and fill in. Do NOT commit .env. + +# SQL Server SA password (8+ chars, must include upper, lower, digit, symbol). +MSSQL_SA_PASSWORD=Change_me_strong_123! + +# From your Entra app registration (run register-app.ps1 or see README). +TENANT_ID=00000000-0000-0000-0000-000000000000 +CLIENT_ID=00000000-0000-0000-0000-000000000000 + +# First user to sign in with this email becomes Admin. +ADMIN_EMAIL=you@yourdomain.com + +# Must match a SPA redirect URI on the app registration. +# For local Docker, http://localhost:8080 works (Entra allows http://localhost). +REDIRECT_URI=http://localhost:8080 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..72a6cc4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,103 @@ +name: CI + +on: + push: + # This repository's default branch is master; listing only main meant + # push-triggered CI never actually ran. + branches: [master, main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + name: Build and test + runs-on: ubuntu-latest + + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: npm + cache-dependency-path: src/m365-security-dashboard-client/package-lock.json + + - name: Check API and client versions match + shell: pwsh + run: ./scripts/check-version.ps1 + + - name: Restore frontend dependencies + working-directory: src/m365-security-dashboard-client + run: npm ci + + - name: Type-check frontend + working-directory: src/m365-security-dashboard-client + run: npx tsc --noEmit + + - name: Run frontend tests + working-directory: src/m365-security-dashboard-client + run: npm test + + - name: Build frontend + working-directory: src/m365-security-dashboard-client + run: npm run build + + # Supply-chain gates. A security product should not ship on top of + # known-vulnerable dependencies; fail the build rather than warn. + - name: Audit npm dependencies + working-directory: src/m365-security-dashboard-client + run: npm audit --audit-level=high + + - name: Restore .NET dependencies + run: dotnet restore M365SecurityAlertDashboard.sln + + # The installer is WPF and only builds on Windows, so it is compiled in the + # separate build-installer job below. Everything shipped to the server — + # the API and its tests — builds here. + - name: Build .NET projects + run: | + dotnet build src/M365SecurityDashboard.Api/M365SecurityDashboard.Api.csproj --configuration Release --no-restore + dotnet build src/M365SecurityDashboard.Api.Tests/M365SecurityDashboard.Api.Tests.csproj --configuration Release --no-restore + + - name: Audit NuGet dependencies + run: | + dotnet list M365SecurityAlertDashboard.sln package --vulnerable --include-transitive 2>&1 | tee audit.txt + if grep -q "has the following vulnerable packages" audit.txt; then + echo "::error::Vulnerable NuGet packages detected"; exit 1 + fi + + # Point at the test project, not the solution: `dotnet test ` evaluates + # every project, and the WPF installer does not resolve on Linux. + - name: Run .NET tests + run: dotnet test src/M365SecurityDashboard.Api.Tests/M365SecurityDashboard.Api.Tests.csproj --configuration Release --no-build --verbosity normal + + build-installer: + name: Build installer (Windows) + runs-on: windows-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + # Compiles the WPF installer so a break here fails CI instead of surfacing + # only when someone builds the release. Does not produce the shipping + # single-file exe (that is scripts/build-installer.ps1, run at release time). + - name: Build the installer project + run: dotnet build src/M365SecurityDashboard.GuiInstaller/M365SecurityDashboard.GuiInstaller.csproj --configuration Release diff --git a/.gitignore b/.gitignore index 98aa36e..0bbea6e 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,20 @@ dotnet *.sqlite *.mdf *.ldf +publish/ +publish-install-test/ +keys/ + +*.pfx +vigil365.pfx +# Local SQL backups and exported key rings are operational secrets. +backups/ + +# Installer payload — 50MB build artifact produced by scripts/build-installer.ps1 +src/M365SecurityDashboard.GuiInstaller/payload.zip + +# ACME account keys and issued certificates — private key material +certs/ + +# Installer build output — superseded by dist/, which is also ignored +installer-bin/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e1a27bd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,162 @@ +# Changelog + +All notable changes to Vigil365 are recorded here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and +versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The +version lives in exactly two places — the API's `` and the client's +`package.json` — kept in step by `scripts/set-version.ps1` and enforced in CI by +`scripts/check-version.ps1`. + +## [1.0.0] — 2026-08-07 + +First public release of Vigil365: a self-hosted, **read-only** Microsoft 365 +security monitoring dashboard. It collects from Microsoft Graph on a schedule, +evaluates metric / activity / anomaly alert policies, notifies over +Teams / email / webhook, and reports through trends, compliance assessment and an +executive digest — with in-app RBAC over a tamper-evident audit trail. It reports +and recommends, and never changes anything in the tenant. + +Distributed as a single self-contained Windows installer (`Vigil365-Setup.exe`) +that carries the application, the web UI and the .NET runtime — the target server +needs no source tree, Node.js or .NET. + +### Installation + +- **Self-contained setup wizard** — one `Vigil365-Setup.exe` (~120 MB), built at + release time by `scripts/build-installer.ps1`. It checks prerequisites, + registers the Entra application, prepares the database, sets up HTTPS and + installs an auto-starting Windows service. The only external tool is Azure CLI, + used solely for the Entra registration, and installed automatically if missing. +- **Deployment scope choice** — "Just this computer" binds loopback with no + certificate (Entra permits `http://localhost` redirect URIs), or "Other people + on our network" takes a certificate from the Windows store, a `.pfx`, or a + generated self-signed one. `scripts/request-cert.ps1` obtains a real Let's + Encrypt certificate for an internet-reachable host. +- **Automatic Entra provisioning** — the wizard registers the app in the + administrator's own tenant (resolved from their email via OpenID discovery, + not whatever the CLI happened to be signed into), grants the fourteen required + Graph permissions, grants admin consent, and creates the collector's client + secret — so collection works on first run with nothing to configure in the + portal. + +### Security + +- CSV exports are guarded against spreadsheet formula injection. Alert titles, + display names and audit actors are tenant-controlled, so a value beginning + `=`, `+`, `-` or `@` would execute on open in Excel or Sheets. Applied to all + three exporters. +- Idle (30 min) and absolute (12 h) session timeouts. Idle counts real user + input only — the app's own polling is not evidence anyone is present — and the + session start is held in `sessionStorage` so a refresh cannot reset the cap. +- API tokens for SIEM access: 32 CSPRNG bytes, stored only as a SHA-256 hash + with a short display prefix, plus scopes, expiry, revocation and last-used. + The raw token is shown exactly once, at creation. +- Outbound webhooks are signed Stripe-style — HMAC-SHA256 over + `{timestamp}.{body}`, with the timestamp sent alongside so receivers can + reject replays. The signing secret is encrypted at rest. +- Unknown `/api/*` paths now return `404` JSON instead of `200` HTML from the + SPA fallback, which previously masked broken clients and confused scanners. +- Removed `react-router-dom`. It carried a high-severity advisory + (GHSA-qwww-vcr4-c8h2) and was never imported — the app has its own hash + router. With a `postcss` fix this took the project from three high-severity + advisories to zero. +- CI now fails on vulnerable NuGet or npm packages, and the push trigger was + corrected — it listed only `main`, so push-triggered CI had never run. +- Patched four High-severity transitive advisories surfaced once the full + solution audit ran — `System.Security.Cryptography.Xml`, `System.Formats.Asn1`, + `System.Net.Http` and `System.Text.RegularExpressions` — pinned to fixed + versions across the API, tests and installer. +- CI gained a Windows job that compiles the WPF installer, so a break there fails + the build instead of surfacing only at release time. + +### Added + +- **Standing suppression rules** — silence known-noisy alert classes at source + rather than acknowledging them repeatedly. Mutations are Admin-only and + audited, because suppressing an alert class is a security decision. +- **Policy dry-run** — replay a policy against stored history before saving it + ("would have fired 3 times in 30 days"). Counts *episodes*, not evaluation + cycles, because the evaluator keeps one open alert per policy; and reports + honestly when history cannot answer rather than returning a misleading zero. +- **Alert-ops metrics** — MTTA, MTTR, resolution rate and per-analyst workload, + computed from timestamps the workflow already recorded. +- **Policy export/import** as portable JSON packs. Runtime state never travels, + and notification recipients are stripped by default since packs get shared. +- **Executive digest as PDF**, alongside the existing HTML email and CSV. +- Digest entries now carry each alert's **category, status and assignee** in both + the HTML and CSV, so a digest can be triaged without opening the app. +- **SIEM export** — `/api/siem/alerts` and `/api/siem/health`, authenticated by + scoped API token. +- **First-run setup checklist** and a live **Graph permissions reference** + showing granted/missing status per permission, inferred from the last run. +- **Contextual per-page help** describing what each page shows. +- **Entity investigation** is now reachable from an alert, not only from the + Ctrl+K palette. +- **Compact density toggle** and a formal ten-step type scale. +- Frontend test suite (vitest) and a post-deploy smoke test + (`scripts/smoke-test.ps1`) that verifies a running instance end to end. + +### Changed + +- Graph failures are translated into instructions. A denied collector source + used to render as raw JSON; it now names the exact permission to grant and + where. +- `Program.cs` split from 2,545 lines into nine per-domain endpoint modules, + leaving 380 lines of host, DI and middleware. Verified by diffing the full + 90-endpoint route table, including the authorization on every endpoint. +- Every clickable row is keyboard-accessible, with a skip link and a `
` + landmark. Previously the app was mouse-only for its core action — opening an + alert. +- Dashboard panels now distinguish "failed to load this cycle" from "not + configured", instead of telling users to run a collection that had already + succeeded. +- The version shown in the UI is injected from `package.json` at build time + rather than hardcoded, so it cannot claim a version the build is not. +- README corrected against what the app actually does — it had promised a + geographic sign-in map that does not exist, described server-side alerts as + browser storage, claimed every Graph permission was read-only when attack + simulation requires `ReadWrite.All`, and listed several endpoints that had + been renamed or removed. + +### Fixed + +- Tenant Activity rendered twice (duplicate conditional), causing a double fetch. +- "Tampering detected" — the most serious signal the product emits — displayed + as a green success toast. +- Overview's total and the alert queue disagreed once a tenant passed 200 open + alerts; the queue now states what it is showing. +- Dashboard fetch failures were swallowed while the header still stamped a fresh + "Updated" time over stale cards. +- The collection banner's "Details" link opened Microsoft's service advisories, + which cannot explain a Vigil365 collector failure; it now opens Collection + Runs, where the per-source error is readable. +- Error states offered no retry, and relative timestamps froze at render. + +### Fixed — installer and first run + +Each of these previously produced an install that reported success and did not +work: + +- Registered the app in the wrong tenant (whichever the CLI was signed into) + rather than the administrator's own; now resolved and verified. +- Windows service was never created — `sc` was invoked through `cmd.exe`, which + split the quoted binary path; now invoked directly with the exit code checked + and startup confirmed to reach RUNNING. +- The service account had no SQL login (SQL Express grants sysadmin only to local + administrators), so it could never connect; the login and database are now + created during install. +- DataProtection keys were written under `Program Files`, unwritable by the + service, so the keyring never persisted; moved to `ProgramData` with an ACL. +- A fresh database crashed on first start — `NotificationSettings` / `GraphConfig` + are single-row tables with a fixed key, but the migration made those keys + identity columns; corrected with a migration. +- Re-running the wizard failed to reconfigure an existing app registration + (`CannotDeleteOrUpdateEnabledEntitlement`); the exposed scope is now left + untouched when it already exists. +- Certificate-thumbprint auth threw a cryptic error on Linux (the Docker path) + instead of a clear message when a certificate store could not be opened. +- Sign-in dead-ended with `interaction_in_progress` after an abandoned redirect; + the stale MSAL state is now cleared and retried. +- The Setup page threw `EmptyState is not defined` because the component was used + without importing it — shipped because the build does not type-check. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0722d4d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 +# Multi-stage build: compile the React frontend, publish the .NET API, run on Linux. +# Build context is the repo root: docker build -t vigil365 . + +# ---- 1. Build the frontend (vite outputs into the API's wwwroot) ---- +FROM node:20-alpine AS frontend +WORKDIR /src/m365-security-dashboard-client +COPY src/m365-security-dashboard-client/package*.json ./ +RUN npm install --no-audit --no-fund +COPY src/m365-security-dashboard-client/ ./ +RUN npm run build +# vite is configured with outDir ../M365SecurityDashboard.Api/wwwroot, +# so the bundle lands at /src/M365SecurityDashboard.Api/wwwroot + +# ---- 2. Publish the API, including the built wwwroot ---- +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY src/M365SecurityDashboard.Api/ ./M365SecurityDashboard.Api/ +COPY --from=frontend /src/M365SecurityDashboard.Api/wwwroot ./M365SecurityDashboard.Api/wwwroot +RUN dotnet publish M365SecurityDashboard.Api/M365SecurityDashboard.Api.csproj -c Release -o /app + +# ---- 3. Runtime ---- +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app +COPY --from=build /app ./ + +# Container serves HTTP on 8080; TLS is terminated by a reverse proxy (compose/ingress). +ENV ASPNETCORE_URLS=http://+:8080 \ + ASPNETCORE_ENVIRONMENT=Production \ + Security__RequireHttps=false \ + DataProtection__KeyPath=/keys + +# Persist the Data Protection key ring so encrypted secrets survive restarts. +VOLUME ["/keys", "/app/logs"] +EXPOSE 8080 + +ENTRYPOINT ["dotnet", "M365SecurityDashboard.Api.dll"] diff --git a/M365SecurityAlertDashboard.sln b/M365SecurityAlertDashboard.sln index 6dec17b..763bda7 100644 --- a/M365SecurityAlertDashboard.sln +++ b/M365SecurityAlertDashboard.sln @@ -8,6 +8,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{8CEF94CD-3FD EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "M365SecurityDashboard.Api.Tests", "src\M365SecurityDashboard.Api.Tests\M365SecurityDashboard.Api.Tests.csproj", "{B8504228-F65D-4D8B-B972-B6471E615FB9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "M365SecurityDashboard.GuiInstaller", "src\M365SecurityDashboard.GuiInstaller\M365SecurityDashboard.GuiInstaller.csproj", "{E8FA5A64-07B4-4312-A4DB-8427BC184F2E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -22,8 +24,13 @@ Global {B8504228-F65D-4D8B-B972-B6471E615FB9}.Debug|Any CPU.Build.0 = Debug|Any CPU {B8504228-F65D-4D8B-B972-B6471E615FB9}.Release|Any CPU.ActiveCfg = Release|Any CPU {B8504228-F65D-4D8B-B972-B6471E615FB9}.Release|Any CPU.Build.0 = Release|Any CPU + {E8FA5A64-07B4-4312-A4DB-8427BC184F2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E8FA5A64-07B4-4312-A4DB-8427BC184F2E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E8FA5A64-07B4-4312-A4DB-8427BC184F2E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E8FA5A64-07B4-4312-A4DB-8427BC184F2E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {B8504228-F65D-4D8B-B972-B6471E615FB9} = {8CEF94CD-3FDE-42A2-B93C-4D9552702532} + {E8FA5A64-07B4-4312-A4DB-8427BC184F2E} = {8CEF94CD-3FDE-42A2-B93C-4D9552702532} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index d79ed36..e986ebb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # M365 Security Alert Dashboard -A self-hosted, real-time Microsoft 365 security monitoring dashboard that aggregates alerts from Defender XDR, Entra ID Protection, Intune, Exchange Online, Compliance, and more — all in one place. +A self-hosted Microsoft 365 security monitoring dashboard that aggregates alerts from Defender XDR, Entra ID Protection, Intune, Exchange Online, Compliance, and more — all in one place, collected on a schedule (every 15 minutes by default). > **No third-party SaaS required.** Runs entirely on your own Windows host using Microsoft Graph API. @@ -23,22 +23,24 @@ A self-hosted, real-time Microsoft 365 security monitoring dashboard that aggreg | **Service Health** | M365 service advisories and incidents, per-service health status | | **M365 Connectivity** | Sign-in health, connectivity issues | | **Licenses & Users** | License SKU breakdown, inactive users, expiring licenses | -| **Conditional Access** | Policy list, state breakdown (Enabled/Report-only/Disabled), per-policy detail | -| **Audit Log** | Unified audit log with category filter, actor/target detail | -| **Sign-in Locations** | Geographic sign-in map, success/failure breakdown, country drill-down | +| **Conditional Access** | Policy list, state breakdown (Enabled/Report-only/Disabled), per-policy detail, gap analysis | +| **Tenant Activity** | Directory audit events with search, day-range filter, and CSV export — the audit surface behind activity-based alerting | +| **Sign-in Locations** | Success/failure breakdown and country drill-down (tabular; no map) | ### Enterprise Features -- **Alert Policy Engine** — define custom policies (MFA drop, risky user spike, device breach) with thresholds; auto-evaluates against live data and tracks triggered alerts in browser localStorage -- **9 Pre-built Alert Templates** — one-click templates for common security scenarios -- **Detail Modals** — click any alert, user, device, or policy to see all available fields and a direct "View in M365 Portal →" deep link -- **Search, Filter, Sort, Export** — every page has full-text search, dropdown filters, sortable columns, and CSV export -- **Saved Filter Presets** — save and reload custom filter combinations per page (localStorage) -- **Dark Mode** — full dark/light theme toggle, persisted across sessions -- **Collapsible Sidebar** — icon-only collapsed mode with hover tooltips -- **Toast Notifications** — on export, preset save, policy actions -- **Sticky Filter Bars** — filter controls stay visible while scrolling long lists -- **Responsive Layout** — collapses to single-column below 900px +- **Alert Policy Engine** — metric, activity, and anomaly policies (MFA drop, risky-user spike, role assignment, app-consent, PIM changes, and more) with thresholds; auto-evaluates against live data. Triggered alerts are stored server-side in SQL Server with a full acknowledge / snooze / resolve / assign / notes workflow. +- **Activity & Anomaly Alerting** — alerts on tenant *audit activity* (privileged role changes, app credential adds, CA policy edits) and on statistical spikes, not just static thresholds. +- **Notifications** — Microsoft Teams, email (SMTP), and generic webhook delivery, with per-channel digest mode and delivery-failure self-alerting. +- **Reports** — scheduled executive digest (daily/weekly/monthly) over email with a CSV attachment, plus a live preview. +- **Trends** — historical posture tracking (Secure Score, risky users, compliance) from periodic snapshots. +- **Recommendations** — a single findings hub folding in Conditional Access gaps and SharePoint/OneDrive sharing posture. +- **Entity Investigation** — drill into any user or device for a merged timeline of its alerts and audit activity. +- **RBAC & User Management** — in-app Admin / Analyst / Viewer roles, invitations, and a tamper-evident (SHA-256 hash-chained) audit trail of privileged actions. +- **Global Search** — Ctrl+K palette across alerts, users, devices, and pages. +- **Detail Panels** — click any alert, user, device, or policy for all fields and a direct "View in M365 Portal →" deep link. +- **Search, Filter, Export** — full-text search, dropdown filters, and CSV export on every page (sortable columns on the active-alert queue). +- **Dark Mode**, **collapsible sidebar**, **toast notifications**, **saved filter presets**, and a **responsive layout**. --- @@ -48,33 +50,50 @@ A self-hosted, real-time Microsoft 365 security monitoring dashboard that aggreg |-------|-----------| | Backend | ASP.NET Core 8 Minimal API | | Frontend | React 18 + TypeScript + Vite | -| Auth | Microsoft Graph — Client Credentials (app-only) | +| Sign-in | Microsoft Entra sign-in (MSAL) + in-app RBAC | +| Collection | Graph app-only — client secret **or** certificate | | Scheduler | .NET BackgroundService — every 15 minutes | -| Storage | SQL Server Express (alerts + collection runs) | +| Storage | SQL Server Express (EF Core migrations) | | Icons | lucide-react | --- ## Prerequisites -1. Windows host (Windows 10/11 or Windows Server 2019+) -2. [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) or ASP.NET Core 8 Hosting Bundle +**Docker install (Option 1)** — just [Docker](https://docs.docker.com/get-docker/) +(Windows, Linux, or macOS). Everything else runs in containers. + +**Setup wizard (Option 2)** — `Vigil365-Setup.exe` carries the application and +the .NET runtime inside it, so the server needs: +1. Windows 10/11 or Windows Server 2019+, and local administrator rights +2. Nothing else. SQL Server Express and Azure CLI are installed by the wizard if + they are not already there, and an existing SQL instance is detected and reused. + +**Building from source (Option 3):** +1. Windows 10/11 or Windows Server 2019+ +2. [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) (or ASP.NET Core 8 Hosting Bundle) 3. [SQL Server Express](https://www.microsoft.com/en-us/sql-server/sql-server-downloads) (free) 4. [Node.js 20+](https://nodejs.org/) +5. [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) — only if you use `register-app.ps1` + +All paths also need a Microsoft 365 tenant where you can create an app registration. --- ## Microsoft Entra App Registration +> **Tip:** `register-app.ps1` does all of this for you (permissions, redirect URI, +> exposed scope, secret, admin consent). Do it manually only if you prefer. + ### Create the app 1. Go to [Entra admin center](https://entra.microsoft.com) → **App registrations** → **New registration** -2. Name it (e.g. `M365SecurityDashboard`) +2. Name it (e.g. `Vigil365`) 3. Select **Accounts in this organizational directory only** -4. No redirect URI needed -5. Click **Register** -6. Note the **Tenant ID** and **Application (client) ID** -7. Go to **Certificates & secrets** → **New client secret** — note the secret value immediately +4. Under **Redirect URI**, choose **Single-page application (SPA)** and enter the URL you'll serve the app on (e.g. `http://localhost:8080` for Docker, or `https://vigil365.yourco.local:5001`) +5. Click **Register**, then note the **Tenant ID** and **Application (client) ID** +6. **Certificates & secrets** → **New client secret** — copy the value immediately +7. **Expose an API** → set Application ID URI to `api://` → **Add a scope** named `access_as_user` (this is what the browser sign-in requests) ### Required API permissions (Application, not Delegated) @@ -95,12 +114,103 @@ Grant **admin consent** for all of these: | `PrivilegedAccess.Read.AzureAD` | PIM assignments | | `ThreatHunting.Read.All` | Advanced hunting / MDI | | `UserAuthenticationMethod.Read.All` | MFA method details | +| `SharePointTenantSettings.Read.All` | SharePoint/OneDrive sharing posture | +| `AttackSimulation.ReadWrite.All` | Attack-simulation results (read-only in-app; Graph has no read-only variant — optional) | + +The in-app **Graph Permissions** reference (below Collection Runs) shows each of +these with a live granted/missing status inferred from the last collection run. > Some features (IRM, Attack Simulation, Identity Health) require additional Purview/Defender licensing in your tenant. The dashboard gracefully shows a permission error card for unavailable features. --- -## Setup +## Install + +You need **one** thing first: an **Entra app registration** (so Vigil365 can read +your tenant via Graph). You can let the new **Interactive Setup Wizard** create this automatically, or create it manually +(see [Microsoft Entra App Registration](#microsoft-entra-app-registration)). + +### Install (Interactive Setup Wizard) + +Download **`Vigil365-Setup.exe`** from the +[latest release](https://github.com/sameerk27/vigil365/releases/latest) and run +it **as Administrator**. That is the whole install: it is a single self-contained +file that carries the application, the web UI and the .NET runtime, so the server +needs no source tree, no Node.js and no .NET installed. + +The wizard will: +1. Check administrator rights and install Azure CLI if it is missing. +2. Register the Vigil365 application in Microsoft Entra (reusing an existing + registration if you have one). +3. Install SQL Server Express, or detect and reuse an instance you already have, + and create the SQL login the service needs. +4. Set up HTTPS, including the certificate, for a network install. +5. Unpack the application and run it as an auto-starting Windows Service. + +To build that installer from source instead, see +[Building the installer](#building-the-installer). + +#### Choose who needs to reach it + +The wizard's first question decides everything else: + +**Just this computer** — the default, for evaluating Vigil365. It binds +`http://localhost:8080` (loopback only), needs no certificate, and changes no +firewall rules. Entra permits `http` for loopback redirect URIs, so sign-in works +as-is. Nothing else on your network can reach it. + +**Other people on our network** — asks for a hostname and a certificate. Entra +refuses plain `http://` redirect URIs for anything but localhost, so once +Vigil365 is reachable by name, HTTPS is required and the wizard asks where the +certificate comes from: + +| Option | Use when | +| --- | --- | +| **A certificate already on this server** | Your organisation issues certificates from an internal CA (most common). The wizard lists what is in `LocalMachine\My` and marks hostname matches with a ✓. | +| **A `.pfx` file** | You hold a wildcard or externally-issued certificate as a file. | +| **Create one for me** (default) | You have neither and want to get running now. | + +The last option generates a certificate and trusts it **on that server only** — +that machine stops warning, every other browser still warns. Fine for a pilot, +not fine to leave in place: on a security product, a warning you tell people to +click through is training them to ignore the one that matters. Re-run the wizard +and pick one of the first two options to replace it; re-running reuses the +existing Entra app registration rather than creating another. + +If the server is reachable from the internet, `scripts/request-cert.ps1` gets a +free, publicly-trusted certificate from Let's Encrypt instead: + +```bash +pwsh -File scripts/request-cert.ps1 -Hostname vigil365.yourcompany.com -Email you@yourcompany.com +``` + +Everything else is detected or derived: the first administrator is taken from +your Azure CLI sign-in, and an existing SQL Server instance is found and reused +rather than installing a second one. + +After the wizard finishes, open the app and finish **Setup** in the browser to +supply the Graph credentials used for collection. + +#### Building the installer + +For maintainers shipping a release — customers never run this: + +```bash +pwsh -File scripts/build-installer.ps1 +``` + +It builds the SPA from the lockfile, publishes the API self-contained for +`win-x64`, compresses that into a payload embedded in the installer, and emits a +single `dist/Vigil365-Setup.exe` (~120 MB). The build happens here precisely so +it does not happen on the customer's server, where the toolchain is not yours and +the resulting binaries would differ from the ones you tested. + + + +## Build from source (Option 3) + +Only needed if you are not using the setup wizard or Docker — for development, +or to run Vigil365 on a host you build on yourself. ### 1. Clone and configure secrets @@ -208,6 +318,52 @@ sc.exe start M365SecurityDashboard --- +## HTTPS / TLS (required for production) + +Outside Development the app enforces HTTPS (HSTS + redirect). Plain HTTP is only +for local development. Two supported ways to serve TLS: + +### Option A — Reverse proxy (recommended) + +Terminate TLS at IIS / Nginx / Caddy and proxy to the app on localhost. Example +Caddy config: + +``` +vigil365.yourcompany.com { + reverse_proxy localhost:8080 +} +``` + +Run the app bound to localhost only (`--urls http://localhost:8080`) so it is +never directly exposed; the proxy handles certs (e.g. automatic Let's Encrypt). + +### Option B — Kestrel with a certificate + +Let the app terminate TLS directly by configuring a Kestrel HTTPS endpoint in +`appsettings.Production.json` (Kestrel reads this automatically — no code change): + +```json +{ + "Kestrel": { + "Endpoints": { + "Https": { + "Url": "https://0.0.0.0:443", + "Certificate": { "Path": "C:\\certs\\vigil365.pfx", "Password": "YOUR_PFX_PASSWORD" } + } + } + } +} +``` + +Set the Azure App Registration **SPA redirect URI** and `Auth:RedirectUri` to the +HTTPS URL (e.g. `https://vigil365.yourcompany.com`). + +> **Credential hygiene:** prefer **certificate auth** for Graph over a client +> secret, store secrets in a vault or environment variables (never in committed +> files), and rotate any secret that has ever been exposed. + +--- + ## API Reference | Method | Endpoint | Description | @@ -215,23 +371,29 @@ sc.exe start M365SecurityDashboard | `GET` | `/api/dashboard/overview` | Aggregated overview data | | `GET` | `/api/dashboard/identity` | Identity & MFA data | | `GET` | `/api/dashboard/devices` | Intune device compliance | -| `GET` | `/api/dashboard/email` | MDO email alerts | -| `GET` | `/api/dashboard/compliance` | DLP/MCAS/IRM alerts | -| `GET` | `/api/dashboard/incidents` | Defender XDR incidents | +| `GET` | `/api/dashboard/email-protection` | MDO email alerts | +| `GET` | `/api/dashboard/security-incidents` | Defender XDR incidents | +| `GET` | `/api/dashboard/defender-alerts` | Defender XDR alerts | | `GET` | `/api/dashboard/mdi-alerts` | Microsoft Defender for Identity alerts | | `GET` | `/api/dashboard/mcas-alerts` | Defender for Cloud Apps alerts | -| `GET` | `/api/dashboard/insider-risk` | Insider Risk Management alerts | | `GET` | `/api/dashboard/risk-detections` | Entra ID risk detections | -| `GET` | `/api/dashboard/identity-health` | Identity health issues | | `GET` | `/api/dashboard/attack-simulation` | Attack simulation results | -| `GET` | `/api/dashboard/service-health` | M365 service health | +| `GET` | `/api/dashboard/servicehealth` | M365 service health | | `GET` | `/api/dashboard/licenses` | License SKU usage | | `GET` | `/api/dashboard/conditional-access` | CA policies | -| `GET` | `/api/dashboard/audit-log` | Unified audit log | -| `GET` | `/api/dashboard/sign-ins` | Sign-in locations | +| `GET` | `/api/dashboard/ca-gaps` | Conditional Access gap analysis | +| `GET` | `/api/dashboard/sharing-posture` | SharePoint/OneDrive sharing posture | +| `GET` | `/api/dashboard/signin-locations` | Sign-in locations | +| `GET` | `/api/audit-events` | Tenant directory audit events | +| `GET` | `/api/entity/{kind}/{id}` | Entity investigation timeline (user/device) | +| `GET` | `/api/setup/status` | First-run setup progress | +| `GET` | `/api/setup/permissions` | Graph permission reference + status | | `POST` | `/api/collector/run` | Trigger manual data collection | | `GET` | `/api/collector/runs` | Collection run history | +> Unknown `/api/*` paths return `404` JSON. The full surface (alerts workbench, +> notification settings, report schedules, RBAC) is larger than this excerpt. + --- ## Security & Maturity @@ -240,16 +402,16 @@ sc.exe start M365SecurityDashboard ### What is in scope by design -- **Read-only, least privilege.** Every Graph permission requested is `*.Read.All`. The app **cannot modify** users, devices, policies, or tenant settings even if the host is compromised. -- **No remediation automation.** "View in M365 Portal →" links only deep-link you to the correct blade. The app never tells you what to change and never makes changes — remediation stays in Microsoft's tooling where it belongs. -- **No inbound exposure by default.** The API binds to `localhost`. Remote access requires you to deliberately open a firewall port (and you should front it with TLS + auth if you do). -- **App-only client-credentials flow** via MSAL (`Azure.Identity`). Standard Microsoft auth, not a homegrown scheme. All Graph traffic is HTTPS/TLS. +- **Read-only, least privilege.** Nearly every Graph permission requested is `*.Read.All`. The one exception is `AttackSimulation.ReadWrite.All`, which Microsoft Graph offers with no read-only variant — the app only reads with it and never launches simulations. If you don't use the attack-simulation view, don't grant it. The app **cannot modify** users, devices, policies, or tenant settings. +- **Recommends, never remediates.** The Recommendations view and "Fix in M365 Portal →" links tell you what to change and deep-link you to the right blade, but the app makes **no** changes itself — every remediation happens in Microsoft's tooling, by you. +- **No inbound exposure by default.** In development the API binds to `localhost`. A production deployment (`deploy.ps1`) runs behind Kestrel with a TLS certificate; anything beyond localhost is a deliberate choice you make. +- **App-only collection** via MSAL (`Azure.Identity`) using a client secret or certificate; **user sign-in** via Entra with in-app RBAC. Standard Microsoft auth, not a homegrown scheme. All Graph traffic is HTTPS/TLS. ### How credentials and secrets are handled - The Graph client secret is **never** committed to source. Use .NET User Secrets (dev) or `appsettings.Production.json` / environment variables (prod, both gitignored). - Notification secrets stored in the database (SMTP password, Teams/Slack & generic webhook URLs) are **encrypted at rest with the Windows Data Protection API (DPAPI), machine scope** — a leaked database row cannot be decrypted on another machine. Secrets are decrypted only in memory at send time and the SMTP password is never returned by the API. -- **Recommended:** use **certificate-based authentication** instead of a client secret for production (planned/optional). A non-exportable certificate in the Windows cert store removes the plaintext shared secret entirely. _(Not yet wired into the app — track this in Issues.)_ +- **Recommended:** use **certificate-based authentication** instead of a client secret for production. A non-exportable certificate in the Windows cert store removes the plaintext shared secret entirely; Vigil365 supports a certificate thumbprint or PFX path, with a secret only as a fallback. ### Host hardening checklist (your responsibility) @@ -268,6 +430,9 @@ The security of this app is only as good as the box it runs on. Before productio - Rate limiting is handled automatically (429 `Retry-After` respected). - A failed individual Graph source does not stop the whole collection run; each card degrades independently. +- Logs are newline-delimited JSON on stdout and in `logs/vigil365-.json` beside the app. Files roll daily (and at 10 MB) with the newest 14 files retained. Configure `Logging__File__Path`, `Logging__File__RetainedFileCountLimit`, and `Logging__File__FileSizeLimitBytes` for the host policy. Docker persists them in the `vigil365-logs` volume at `/app/logs`. +- Log events include request correlation IDs and structured fields. Do not put access tokens, client secrets, or notification credentials in log messages. +- Follow the [Operations Runbook](docs/OPERATIONS_RUNBOOK.md) for SQL/key-ring backups, restore drills, and upgrades. > Found a security issue? See [SECURITY.md](SECURITY.md) — please report privately, not in a public issue. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..71ec7d8 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,107 @@ +# Vigil365 Roadmap — From Single-User Tool to Org-Ready Product + +> Status note (August 2026): this document is the original delivery sequence. +> Authentication/RBAC, audit identity, certificate auth, migrations, CI, health, +> retention, and structured logging are now implemented. Current work tracking is +> maintained in `docs/MATURITY_PLAN.md`; remaining M4 work is removal of inline +> frontend styles before the CSP can drop `style-src 'unsafe-inline'`. + +Vigil365 is moving from a single-engineer, localhost tool to a product an +organization can install on a server, where engineers sign in with their +Entra ID accounts and get role-based access. + +This document tracks the work in two parallel tracks plus a maturity track. +Each phase is independently shippable and testable. + +--- + +## Track A — Authentication & Access + +### Phase 1 — Secure the API (backend token validation) +- Validate Entra ID Bearer tokens on the backend (not just the UI gate). +- **Root cause of prior 401:** SPA requested scope `api://{clientId}/access_as_user` + so the token `aud` was `api://{clientId}`, but the backend validated `aud == {clientId}`. + Fix: set `AzureAd:Audience` to `api://{clientId}` so they match. +- Add `.RequireAuthorization()` per API endpoint (NOT a global FallbackPolicy — + that broke static file serving). Keep `/api/auth/config` and the SPA fallback + file as `.AllowAnonymous()`. +- **Exit test:** signed-in user loads data (200); request with no token gets 401. + +### Phase 2 — Role-based access (Entra ID App Roles) +- Define App Roles `Admin`, `Analyst`, `Viewer` on the app registration + (chosen over security groups: self-contained, portable, role claim arrives + directly in the token). +- Authorization policies: + - Reads (`GET /api/dashboard/*`, `/api/alerts`, `/api/triggered-alerts`, …) → any authenticated + - Mutations (`acknowledge`/`resolve`/`snooze`/`unsnooze`, alert-policy CRUD) → Analyst+ + - Settings (`PUT /api/notification-settings`, `POST /api/collector/run`, test) → Admin +- `GET /api/auth/me` returns name, email, roles for the frontend. +- Frontend: role context + `useRole()` hook; hide/disable actions for Viewers, + settings for non-Admins. +- **Exit test:** Admin sees all; Viewer's mutating calls return 403 and buttons hide. + +### Phase 3 — Audit trail & real identity +- New `AuditLog` table (who did what, when) via the existing idempotent schema pattern. +- Replace hardcoded `"dashboard"` in `SnoozedBy`/`AcknowledgedBy` with the real + UPN from the token (`preferred_username`). +- Surface audit entries in the Alert Center. + +### Phase 4 — In-app User Management (Admin only) +- Admin page to list tenant users + assign Admin/Analyst/Viewer in-app. +- Graph write calls via the existing app-only client. +- **Requires optional consent:** `AppRoleAssignment.ReadWrite.All` (write/high-privilege). + Documented as opt-in — if an org skips it, role management falls back to the + Azure Portal and the app stays read-only. + +--- + +## Track B — Hosting & Operations + +### Phase 5 — HTTPS / TLS + certificate auth 🔴 highest hosting priority +- TLS termination (reverse proxy: IIS / Nginx / Caddy, or Kestrel + cert). +- Switch Graph auth from client secret to **certificate auth** (more secure, + no rotation window, native PAM-vault support). +- Rotate the previously-exposed client secret. + +### Phase 6 — Docker deployment +- `docker-compose.yml` bringing up API + SQL in one command (primary install path). +- Keep IIS / Windows Service path documented for Microsoft shops. + +### Phase 7 — Database maturity +- Move from `EnsureCreated()` + raw idempotent SQL to **EF Core Migrations** + for clean versioned upgrades. +- Document scaling SQL Express → full SQL Server / Azure SQL (10 GB cap). +- Data retention / pruning (alerts, collection runs, audit log). +- Backup / restore guidance. + +### Phase 8 — Reliability & observability +- `GET /health` endpoint (DB reachable, Graph creds valid, last collection time). +- Graph throttling (429) retry with backoff. +- Collection partial-failure handling + background worker auto-restart. + +--- + +## Track C — Maturity + +### Phase 9 — CI +- GitHub Actions: build + run xUnit tests on every PR (now that community PRs are landing). + +### Phase 10 — Polish +- Structured logging + rolling log files with retention. +- API rate limiting. +- Versioning + upgrade docs. +- Concurrency semantics for multiple analysts on the same alert. + +--- + +## Explicitly NOT planned (out of scope) +- Multi-tenant SaaS — single-tenant install only. +- Per-entity alerting (large refactor). +- Maester / third-party tool integration. + +--- + +## Suggested order +Finish **A1 → A2** first (the headline gap everyone is asking about), then +**B5 (HTTPS + cert auth)** to make it genuinely deployable and defensible. +Tracks A and B are independent and can interleave thereafter. diff --git a/deploy.ps1 b/deploy.ps1 new file mode 100644 index 0000000..b615d22 --- /dev/null +++ b/deploy.ps1 @@ -0,0 +1,173 @@ +<# +.SYNOPSIS + Automated production run for Vigil365. Generates appsettings.Production.json, + trusts a local HTTPS dev certificate, and starts the published app over HTTPS. + Graph credentials are entered later in the browser via the setup wizard. + +.NOTES + Prerequisite you must do once in your tenant (cannot be automated locally): + - Create an Entra app registration (or run register-app.ps1) + - Add the -Url value below as a SPA redirect URI on that app registration + +.EXAMPLE + .\deploy.ps1 -TenantId -ClientId -AdminEmail you@contoso.com + +.EXAMPLE + # Re-publish first, custom URL + DB, then run: + .\deploy.ps1 -TenantId -ClientId -AdminEmail you@contoso.com ` + -Publish -Url https://localhost:5001 -SqlServer ".\SQLEXPRESS" +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string]$TenantId, + [Parameter(Mandatory)] [string]$ClientId, + [Parameter(Mandatory)] [string]$AdminEmail, + [string]$Hostname, + [int]$Port = 5001, + [string]$Url = "https://localhost:5001", + [string]$SqlServer = ".\SQLEXPRESS", + [string]$Database = "M365SecurityDashboard", + [string]$PublishPath, + [switch]$Publish, + [switch]$NoRun +) + +$ErrorActionPreference = "Stop" +$RepoRoot = $PSScriptRoot +if (-not $RepoRoot) { $RepoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path } +$RepoRoot = (Resolve-Path $RepoRoot).Path +if (-not $PublishPath) { $PublishPath = Join-Path $RepoRoot "publish" } + +# When a hostname is given, serve HTTPS on that name with a self-signed cert. +if ($Hostname) { $Url = "https://${Hostname}:${Port}" } + +# If a custom HTTPS host is supplied through -Url, generate/trust a certificate +# for that host instead of falling back to the localhost dev certificate. +if (-not $Hostname) { + try { + $uri = [Uri]$Url + if ($uri.Scheme -eq "https" -and + $uri.Host -and + $uri.Host -notin @("localhost", "127.0.0.1", "::1")) { + $Hostname = $uri.Host + $Port = $uri.Port + Write-Host "Using hostname '$Hostname' from -Url for HTTPS certificate generation." -ForegroundColor DarkGray + } + } catch { + # Leave validation to Kestrel/.NET later; this block only improves cert selection. + } +} + +Write-Host "`n=== Vigil365 production deploy ===`n" -ForegroundColor Cyan + +# 1. Publish if requested or if no artifact exists yet +$exe = Join-Path $PublishPath "M365SecurityDashboard.Api.exe" +if ($Publish -or -not (Test-Path $exe)) { + Write-Host "[1/4] Publishing (running install.ps1)..." -ForegroundColor Yellow + & (Join-Path $RepoRoot "install.ps1") -PublishPath $PublishPath +} else { + Write-Host "[1/4] Using existing publish at $PublishPath" -ForegroundColor DarkGray +} + +# 2. Prepare an HTTPS certificate. Production Kestrel does NOT auto-use the dev +# cert, so we export a PFX and configure Kestrel to use it. +# -Hostname -> self-signed cert for that internal name (trusted for this user) +# localhost -> the .NET dev cert +$useHttps = $Url.StartsWith("https://", [StringComparison]::OrdinalIgnoreCase) +$pfxPath = Join-Path $PublishPath "vigil365-https.pfx" +$pfxPass = $null +if ($useHttps -and $Hostname) { + Write-Host "[2/4] Creating self-signed certificate for '$Hostname'..." -ForegroundColor Yellow + $pfxPass = [guid]::NewGuid().ToString("N") + $sec = ConvertTo-SecureString $pfxPass -AsPlainText -Force + $cert = New-SelfSignedCertificate -DnsName $Hostname -FriendlyName "Vigil365 $Hostname" ` + -CertStoreLocation "Cert:\CurrentUser\My" -KeyExportPolicy Exportable ` + -NotAfter (Get-Date).AddYears(2) + Export-PfxCertificate -Cert $cert -FilePath $pfxPath -Password $sec | Out-Null + # Trust it for the current user so this machine's browsers accept it. + $cerTmp = Join-Path $PublishPath "vigil365-host.cer" + Export-Certificate -Cert $cert -FilePath $cerTmp | Out-Null + try { + Import-Certificate -FilePath $cerTmp -CertStoreLocation "Cert:\CurrentUser\Root" -ErrorAction Stop | Out-Null + } catch { + Write-Host " Import-Certificate failed, falling back to certutil..." -ForegroundColor DarkYellow + certutil.exe -user -addstore Root $cerTmp | Out-Null + } + Remove-Item $cerTmp -Force + Write-Host " Certificate created and trusted for the current user." -ForegroundColor Green + + # Map the hostname to localhost so the browser resolves it (needs admin to edit hosts). + $hostsFile = "$env:SystemRoot\System32\drivers\etc\hosts" + $hostsLine = "127.0.0.1`t$Hostname" + try { + if (-not (Select-String -Path $hostsFile -SimpleMatch $Hostname -Quiet)) { + Add-Content -Path $hostsFile -Value $hostsLine -ErrorAction Stop + Write-Host " Added hosts entry: $hostsLine" -ForegroundColor Green + } else { Write-Host " Hosts entry for '$Hostname' already present." -ForegroundColor DarkGray } + } catch { + Write-Host " Could not edit the hosts file (run as Administrator, or add manually):" -ForegroundColor DarkYellow + Write-Host " $hostsLine -> $hostsFile" -ForegroundColor DarkYellow + } +} elseif ($useHttps) { + Write-Host "[2/4] Preparing HTTPS dev certificate (localhost)..." -ForegroundColor Yellow + try { + dotnet dev-certs https --trust | Out-Null + $pfxPass = [guid]::NewGuid().ToString("N") + dotnet dev-certs https --export-path $pfxPath --password $pfxPass --format Pfx | Out-Null + Write-Host " Exported + trusted dev certificate." -ForegroundColor Green + } catch { + Write-Host " Certificate prep failed. Use -Hostname, a reverse proxy, or a real cert." -ForegroundColor DarkYellow + throw + } +} else { + Write-Host "[2/4] HTTP URL given; skipping certificate (use a proxy for TLS in prod)." -ForegroundColor DarkGray +} + +# 3. Generate appsettings.Production.json (login + DB config; secrets stay out of source) +Write-Host "[3/4] Writing appsettings.Production.json..." -ForegroundColor Yellow +$conn = "Server=$SqlServer;Database=$Database;Trusted_Connection=True;Encrypt=True;TrustServerCertificate=True" +$config = [ordered]@{ + ConnectionStrings = [ordered]@{ DefaultConnection = $conn } + AzureAd = [ordered]@{ + Instance = "https://login.microsoftonline.com/" + TenantId = $TenantId + ClientId = $ClientId + Audience = "api://$ClientId" + } + Auth = [ordered]@{ + RedirectUri = $Url + BootstrapAdminEmail = $AdminEmail + } +} +if ($useHttps) { + # Drive the HTTPS binding via Kestrel config (so we don't pass --urls). + $config["Kestrel"] = [ordered]@{ + Endpoints = [ordered]@{ + Https = [ordered]@{ + Url = $Url + Certificate = [ordered]@{ Path = "vigil365-https.pfx"; Password = $pfxPass } + } + } + } +} else { + # Plain HTTP behind a proxy — disable in-app HTTPS redirect to avoid loops. + $config["Security"] = [ordered]@{ RequireHttps = $false } +} +$target = Join-Path $PublishPath "appsettings.Production.json" +$config | ConvertTo-Json -Depth 8 | Set-Content -Path $target -Encoding UTF8 +Write-Host " Wrote $target" -ForegroundColor Green + +# 4. Run (from the publish folder so config + wwwroot resolve) +if ($NoRun) { + Write-Host "[4/4] -NoRun set; not starting the app." -ForegroundColor DarkGray + Write-Host "`nReminder: add '$Url' as a SPA redirect URI on your Entra app registration.`n" -ForegroundColor White + return +} + +Write-Host "[4/4] Starting Vigil365 in Production on $Url ..." -ForegroundColor Yellow +Write-Host " (Make sure '$Url' is a SPA redirect URI on your Entra app.)`n" -ForegroundColor DarkYellow +Push-Location $PublishPath +try { + $env:ASPNETCORE_ENVIRONMENT = "Production" + if ($useHttps) { & $exe } else { & $exe --urls $Url } +} finally { Pop-Location } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2d5fb98 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +# Vigil365 — one-command deployment. +# 1. cp .env.example .env and fill in the values +# 2. docker compose up -d +# 3. open http://localhost:8080, sign in, enter Graph creds in the Setup wizard +# +# TLS: this compose serves plain HTTP on :8080 (fine for localhost — Entra allows +# http://localhost redirect URIs). For a real hostname, put a TLS reverse proxy +# (Caddy/Nginx/Traefik) in front and set REDIRECT_URI to the https URL. + +services: + db: + image: mcr.microsoft.com/mssql/server:2022-latest + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: "${MSSQL_SA_PASSWORD}" + volumes: + - mssql-data:/var/opt/mssql + - ./backups:/var/opt/mssql/backup + restart: unless-stopped + + app: + build: . + depends_on: + - db + ports: + - "8080:8080" + environment: + ConnectionStrings__DefaultConnection: "Server=db,1433;Database=M365SecurityDashboard;User Id=sa;Password=${MSSQL_SA_PASSWORD};Encrypt=True;TrustServerCertificate=True;MultipleActiveResultSets=true" + AzureAd__Instance: "https://login.microsoftonline.com/" + AzureAd__TenantId: "${TENANT_ID}" + AzureAd__ClientId: "${CLIENT_ID}" + AzureAd__Audience: "api://${CLIENT_ID}" + Auth__RedirectUri: "${REDIRECT_URI:-http://localhost:8080}" + Auth__BootstrapAdminEmail: "${ADMIN_EMAIL}" + Logging__File__Path: "/app/logs/vigil365-.json" + volumes: + - dp-keys:/keys + - vigil365-logs:/app/logs + restart: unless-stopped + +volumes: + mssql-data: + dp-keys: + vigil365-logs: diff --git a/docs/ALERT_FIRST_IMPLEMENTATION_PLAN.md b/docs/ALERT_FIRST_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..d8774ee --- /dev/null +++ b/docs/ALERT_FIRST_IMPLEMENTATION_PLAN.md @@ -0,0 +1,169 @@ +# Vigil365 Alert-First Implementation Plan + +## Product decision + +Vigil365 is a **single-tenant Microsoft 365 security-alerting and investigation +tool**. It brings the important signals into one queue, helps an analyst decide +what matters, records the decision, and notifies the right people. + +It is deliberately **not** an AdminDroid-style reporting catalog, a tenant +management console, a remediation engine, or a multi-tenant MSP platform. + +**North-star workflow:** + +```text +Collect signal -> create/dedupe alert -> prioritise -> investigate -> decide +-> notify/escalate -> retain an auditable record +``` + +Every roadmap item must make one of those steps faster, clearer, or more +trustworthy. If it mainly adds dashboard breadth, report count, or write access +to Microsoft 365, it is out of scope. + +## Starting point (already delivered) + +- Entra authentication, role-based access, audit trail, alert policies, activity + and anomaly alerting, analyst notes/assignment, notifications, activity feed, + trends, retention, health checks, Graph retry handling, certificate Graph auth, + EF migrations, Docker/Windows deployment paths, and CI. +- The current product risk is not missing dashboards; it is operational polish and + making the alert journey feel effortless under real incident pressure. + +## Release 1 — Trust the queue + +**Goal:** An analyst can open Vigil365 and immediately know what needs attention +and whether the collector is trustworthy. + +### 1. Collection health in the alert UI + +- Display last successful collection time, duration, source failures, and next + scheduled run in the alert queue header. +- Add a compact degraded banner when collection is stale or partially failing; + link to source-level failure details. +- Provide a read-only collection-run history with filtering and copyable error + details. + +**Acceptance:** A user never mistakes stale data for a quiet tenant, and can +identify a failed Graph source without reading server logs. + +### 2. Incident queue ergonomics + +- Establish a single default queue: open alerts, severity-first, with clear + `New`, `Acknowledged`, `Snoozed`, `Resolved`, and `Escalated` states. +- Make the highest-value filters persistent: severity, policy, owner, age, + status, and affected entity. +- Save named investigation views locally first; add shared views only after the + model and permissions are proven. +- Keep bulk acknowledge/resolve, undo, keyboard focus, and deep links consistent + across desktop layouts. + +**Acceptance:** A triage analyst can move from login to a filtered, shareable +alert list in under 30 seconds. + +### 3. Operational logging and recovery + +- Add rolling, structured application logs with configurable retention and a + documented log location for Windows and Docker deployments. +- Include correlation ID, actor, alert ID/policy ID, collection-run ID, and + outcome in relevant log events; never log secrets or raw access tokens. +- Publish backup/restore and upgrade runbooks covering SQL data, Data Protection + keys, and app configuration. + +**Acceptance:** An operator can investigate a failed run and restore a test +instance using only the runbook. + +## Release 2 — Investigate without leaving the app + +**Goal:** An analyst can understand an alert's evidence and make a defensible +decision in one place. + +### 4. Unified alert evidence timeline + +- Turn the alert detail panel into a chronological timeline: trigger context, + related audit events, prior alerts for the same entity/policy, notes, + assignments, notifications, and state changes. +- Make the evidence source explicit (`Graph alert`, `audit event`, `trend`, or + `derived metric`) and retain a safe link to the relevant Microsoft portal. +- Add a concise analyst decision field: `true positive`, `benign`, `expected + change`, or `needs escalation`, with a required note for closure. + +**Acceptance:** A different analyst can explain why an alert was closed from the +record alone. + +### 5. Entity-centred investigation + +- Add a focused entity page for users and devices, with recent alerts, activity, + risk indicators, ownership, and portal links. +- Keep data bounded by an explicit time range and page it server-side. +- Link entity names consistently from the queue, detail panel, and activity feed. + +**Acceptance:** Investigating a user/device requires no manual cross-page search. + +### 6. Escalation-quality notifications + +- Send direct alert links, severity, affected entity, trigger evidence, current + owner, and a compact decision/status summary in email, Teams, and webhook + notifications. +- Add digest mode for low-severity notifications and a visible delivery-failure + alert for every configured channel. + +**Acceptance:** A notification lets a recipient judge urgency and reach the +exact record in one click. + +## Release 3 — Expand only high-value security coverage + +**Goal:** Add the signals a security team expects, without becoming a generic +reporting platform. + +### 7. Coverage packs, not report packs + +- Ship curated, versioned alert packs for identity privilege change, mailbox + forwarding, risky sign-ins, OAuth/app consent, Conditional Access changes, and + external sharing. +- Show a coverage scorecard that says which recommended detections are enabled, + unsupported, or missing permissions. +- Add a policy preview against retained audit events before enabling a policy to + control noise. + +**Acceptance:** An administrator can enable a relevant security baseline in +minutes and understand expected alert volume before doing so. + +### 8. Conditional Access and sharing risk + +- Prioritise CA gaps (MFA exclusions, legacy authentication exposure, uncovered + privileged users) and SharePoint/OneDrive external-sharing signals. +- Present each as an alertable risk with evidence and a Microsoft portal deep + link, never as an in-app remediation action. + +**Acceptance:** The product surfaces the highest-impact configuration gaps as +actionable security findings, not isolated dashboards. + +## Continuous quality gates + +- Unit/integration coverage for policy evaluation, state transitions, access + control, deduplication, notification delivery, and retention. +- Browser-level smoke tests for login, queue filtering, alert acknowledgement, + investigation notes, and a degraded-collection banner. +- Accessibility checks: keyboard-only queue/detail use, focus management, + contrast, labels, and screen-reader announcements. +- Performance budgets: paged API responses and no unbounded table rendering. +- Security gates: dependency scanning, SBOM/release artifact, secret scanning, + and a credential rotation checklist. + +## Explicit non-goals + +- M365 administration, write/remediation actions, bulk management, and rollback. +- Thousands of generic reports or a report-builder arms race. +- Multi-tenant/MSP hosting, partner branding, or cross-tenant data aggregation. +- Replacing a SIEM/SOAR; Vigil365 should link and export cleanly when those tools + are present. + +## Sequencing + +1. Release 1: collector trust, queue usability, logging/recovery. +2. Release 2: alert evidence, entity investigation, escalation quality. +3. Release 3: curated detection packs and the two highest-value coverage areas. + +Do not start Release 3 until Release 1's queue and reliability acceptance tests +pass. Better detection volume is harmful if the team cannot trust or process the +alerts it already has. diff --git a/docs/ENTERPRISE_BACKLOG.md b/docs/ENTERPRISE_BACKLOG.md new file mode 100644 index 0000000..8194268 --- /dev/null +++ b/docs/ENTERPRISE_BACKLOG.md @@ -0,0 +1,207 @@ +# Vigil365 — Single-Tenant Enterprise App + +The plan to make Vigil365 a **single-tenant, organisation-grade** product. This is +**Edition 1**. The **multi-tenant / MSP** edition is a separate, later effort — +see `MSP_MULTITENANT_PLAN.md` (Edition 2). + +Scope: **alerts & visibility only — no remediation.** The app stays read-only +against the tenant (a deliberate, low-privilege selling point). "Recommendations" +mean *guidance + deep links*, never actions taken by the app. + +Legend: 🔴 high value · 🟡 medium · 🟢 polish · ✅ already done + +--- + +## Enterprise-ready: definition of done + +Ship-the-edition gate — these must all be true to call it "Enterprise": + +**Done ✅** — Microsoft sign-in + token validation · **enforced** RBAC +(Admin/Analyst/Viewer, deny-by-default fallback policy) + in-app user management + +invites · audit trail · HTTPS + encryption at rest · one-command install + Setup +wizard · Trends & history · Compliance framework scoring (configurable, no-data = +"Not assessed") · affected-entity on alerts · CSP header · finish §0 (perfect the +tabs) · audit hardening (IP/UA + SHA-256 hash chain + CSV export + verify + sign-in +events) · `/health` endpoint · structured JSON logging + correlation IDs · +role-claim caching · data retention/pruning · rate limiting (300/min/IP) · +config-driven CORS · bounded Graph 429 retries · demo-data honesty (opt-in seeding ++ auto-purge) · hash routing + alert permalinks · 8-section IA with tabs · +alert engine: one open alert per policy, updated in place · design-system polish +pass (tokens, severity unification, dark mode, a11y foundations) · favicon + meta · +37 automated tests. + +**Required to ship 🔴** — **rotate the exposed client secret** *(owner action — +the only remaining gate item)*. Certificate auth ✅ and EF Core migrations ✅ +shipped July 4 2026 (Phase 0 of `IMPLEMENTATION_PLAN.md`). + +Everything else below is post-ship (v.next). Detail follows. + +--- + +## 0. Perfect the existing tabs FIRST (before any new capability) + +Polish what's already there — make every tab/detail genuinely useful — before +building new pages. + +- ✅ **Alert detail must show the affected entity** — clicking a triggered alert + shows affected entity list (UPN / device / detected at) with deep links. (Completed) +- ✅ **Every detail modal → drill to the real records** — audit each tab's detail + view so it shows the underlying entities, not just summary fields. (Completed) +- ✅ **Consistent detail layout** — same field order, copy-to-clipboard on IDs, + human-readable IDs/labels, relative + absolute timestamps everywhere. (Completed) +- ✅ **Cross-link** — from an alert → the user's/device's full record; from a KPI + tile → the filtered list behind it. (Completed) +- ✅ **Per-tab audit pass** — walk every page (Identity, Devices, Email, Incidents, + Compliance, CA, Licenses, Audit, Sign-in map) and fix the small gaps: empty + columns, truncation, unclear labels, missing counts, broken/empty states. (Completed) + +## A. New capabilities (cross-cutting) + +- ✅ **Trends & history** — snapshot key metrics each collection cycle (risky users, + MFA coverage, non-compliant devices, open critical/high, secure score, compliance + issues). Dedicated page with executive KPI tiles, hero charts, insights, and clean PDF output. (Completed) +- ✅ **Compliance framework scoring** — map collected signals dynamically to **CIS Controls v8 / NIST CSF 2.0 / ISO 27001 / GDPR Art. 32** controls; live calculated posture scorecard with control breakdown modal + deep jump links. (Completed) +- ✅ **Recommendations layer** — every finding pairs with *why it matters*, *fix steps*, + and a **deep link** to the right M365 portal blade. Guidance only, no actions. (Completed) +- ✅ **Alert coverage gap analysis** — compare the tenant against a best-practice + alerting baseline and surface **what's NOT being watched**: e.g. no alert on + privileged-role changes, mailbox forwarding rules, impossible-travel sign-ins, + MFA-disabled admins, new OAuth app grants, sudden risky-user spikes. Two outputs: + (1) one-click create the missing **Vigil365 Alert Center** policy from a template + (in scope — app's own DB), and (2) recommend the missing **native** Defender / + Entra / Purview alert policy with a **deep link** to create it in M365 (read-only — + guidance, not an action). Extends the existing 9 alert templates into a coverage + scorecard ("12 of 20 recommended alerts in place"). (Completed) +- 🟡 **Scheduled exec reports** — weekly/however email digest (PDF/HTML) summarising + posture + trends, for leadership. Reuses SMTP. +- 🟡 **Conditional Access gap analysis** — surface users/apps **not** covered by any CA + policy, MFA-exempt accounts, legacy-auth exposure. +- 🟡 **Entity drill-down** — click a user/device to see its full timeline (alerts, + sign-ins, risk history) on one detail page rather than a single modal. +- 🟡 **Zero Trust assessment score** — map signals to Microsoft's Zero Trust pillars + (identity, devices, apps, data, network, infrastructure) with a per-pillar readiness + score + gaps. Complements the compliance-framework scoring. (Erik's early suggestion.) +- 🟡 **SharePoint & OneDrive monitoring** — external sharing, anonymous links, DLP + matches, oversharing signals (Graph read-only). New coverage area. (Curt Blunt's ask.) +- 🟢 **Teams security** — guest access, external federation, risky app/connector + permissions across Teams. +- 🟢 **Global search** — cross-page search (user/device/alert) from the header. + +## B. Per-page depth gaps + +- 🟡 **Identity** — guest/external account governance, stale-account list, passwordless/ + MFA-method breakdown, per-user risk timeline. +- 🟡 **Devices** — vulnerability/patch posture, OS/version breakdown, config-drift view. +- 🟡 **Email** — top-targeted users, threat trend over time, per-message detail (read-only). +- 🟡 **Incidents** — **MITRE ATT&CK mapping**, incident ownership/assignment, severity + trend, link related alerts. +- 🟡 **Licenses & Users** — license **cost-optimization** (unused/duplicate SKUs, savings). +- 🟢 **Service Health / Audit / Sign-in map** — roughly at parity; add date-range presets. + +## C. UI/UX polish (concrete, found in review) + +- ✅ **Error boundary** — top-level boundary with theme-aware friendly card; stack + trace behind a "Technical details" disclosure. (Completed; per-page fallback still open.) +- ✅ **Loading skeletons** — pulse skeleton system (kpi/table/list/card) + DashboardSkeleton. (Completed) +- ✅ **Accessibility (foundations)** — dialog role + focus trap + focus return + (DetailModal), aria-live toasts, :focus-visible on all buttons, th scope=col, + card titles as h2, aria-current nav, aria-labels on icon buttons. (Completed; + remaining: keyboard access on clickable list rows, trap parity for PolicyModal + + triggered-alert modal.) +- 🟡 **Keyboard navigation** — partial: Esc closes modals, focus trap in DetailModal. + Still open: arrow-key nav in lists, Enter on clickable rows app-wide. +- ✅ **Empty vs error vs no-permission states** — unified StateMessage component + (empty / error / permission variants). (Completed) +- 🟡 **Large-list performance** — long tables render all rows; add virtualization or + server paging for big tenants. +- ✅ **Favicon + tab branding** — shield SVG favicon, theme-color, apple-touch-icon. (Completed) +- ✅ **index.html meta** — description / Open Graph / theme-color. (Completed) +- 🟢 **Consistent number/date formatting** — thousands separators, consistent relative + vs absolute time, explicit timezone label (UTC vs local) on every timestamp. +- 🟢 **Toasts** — make dismissible, stack, `aria-live=polite`, auto-expire consistently. +- 🟢 **Sticky table headers** on long lists; column min-widths to stop layout shift. +- 🟢 **Per-card "last updated" + manual refresh** affordance. +- 🟢 **Density toggle** (comfortable/compact) for big tenants. +- 🟢 **Mobile pass** — verify every page below 900px (sidebar drawer, table → cards). +- 🟢 **Tooltips/help** — explain each metric (what "risky user" means, thresholds). + +## D. Data quality / correctness details + +- 🟡 **Timezone clarity** — label timestamps UTC vs local; let user pick. (Formats are + now unified: two formatters, year-aware, en-US pinned — timezone labeling still open.) +- ✅ **Stale-data indicator** — Overview status banner: in-progress / failed / stale + (>3 cycles) / fresh, with source-failure counts. (Completed) +- 🟢 **Pagination/total counts** consistent across every list. +- 🟢 **CSV export parity** — ensure every table's export matches the visible/filtered rows. +- 🟢 **Deep-link correctness** — verify each "View in M365 portal" link resolves. + +## F. Gaps surfaced later (alerting depth, integrations, edge cases) + +**Alert workflow** (core to an alerting product) +- 🔴 Assignment / ownership per alert. +- 🟡 SLA tracking (time-to-ack / time-to-resolve) + escalation if unacked. +- ✅ Deduplication (policy alerts) — one open alert per policy, updated in place + while breached; duplicates auto-collapsed. (Completed. Cross-source root-cause + correlation still open.) +- 🟡 Comments / notes on an alert (collaboration). +- 🟡 Maintenance windows / quiet hours (deferred from the snooze PR). + +**Notifications** +- 🟡 More channels: native Slack, PagerDuty, ServiceNow, SIEM forward (Sentinel/Splunk). +- 🟡 Daily/weekly digest mode (reduce per-alert noise). +- 🟡 Per-user / per-role notification preferences. +- 🟡 Alert on notification **delivery failure** (don't fail silently). + +**Sovereign / government clouds** 🔴 +- Graph + login endpoints are hardcoded to commercial cloud + (`graph.microsoft.com` / `login.microsoftonline.com`). GCC, GCC High, and + 21Vianet use different endpoints — app won't run there. Make cloud-environment + configurable. + +**MSP / multi-tenant** (large audience — currently deferred) +- 🟡 White-label / branding (org logo on dashboard + reports). +- 🟢 Clear "single-tenant by design, MSP multi-tenant on the roadmap" stance. + +**Integration / openness** +- 🟡 Read **API** for external consumption (other tools / dashboards). +- 🟡 Config + policy **export/import** (portability + backup beyond the DB). + +**Supply-chain & release trust** (enterprises ask) +- 🟡 **SBOM**, signed releases, dependency scanning. +- 🟢 Versioned releases + semver + changelog. + +**Session & quality** +- 🟡 Idle timeout / auto sign-out. +- 🟢 In-app version ✅ (sidebar + login footer chip) · changelog; opt-in telemetry; + i18n; formal WCAG audit — still open. + +## E. Enterprise plumbing (tracked, lower priority for this product pass) + +- ✅ **Audit hardening** — IP/user-agent capture, sign-in + alert events, tamper-evident + SHA-256 hash chain, CSV export + verify endpoint, retention. (Completed; collection + history lives in CollectionRuns rather than the audit trail to avoid 15-min noise.) +- ✅ **/health endpoint** (DB + Graph + last-collection freshness; 503 when DB down). (Completed) +- ✅ **Role-claim caching** (60s TTL, evicted on role change/removal). (Completed) +- 🟡 **Setup permission verification** — check each required Graph permission is granted + (fixes ambiguous "Needs permission" on Secure Score). +- ✅ **Structured logging + correlation IDs** (JSON console outside Dev, X-Correlation-Id + echo + logging scope). (Completed) +- ✅ **Rate limiting** — fixed-window 300 req/min per client IP. (Completed) +- ✅ **Data retention/pruning** — nightly worker, per-dataset day windows in the + `Retention` config section; open alerts never pruned. (Completed) +- 🟢 **EF migrations** instead of EnsureCreated + raw DDL for versioned upgrades. + +--- + +## Suggested build order +1. **Error boundary + loading skeletons + empty/error/permission states** (foundation; touches every page) +2. **Trends & history** (Craig's ask; unlocks Overview trend cards + exec reports) +3. **Compliance framework scoring** (differentiator) +4. **Recommendations layer** (guidance + deep links) +5. **Accessibility + keyboard pass** +6. **CA gap analysis**, per-page depth, drill-down +7. **Audit hardening + /health** (plumbing) +8. **Polish**: favicon/meta, formatting, tooltips, density, mobile + +> Out of scope (deliberate): remediation actions (confirm-compromised, quarantine, +> isolate), multi-tenant SaaS, raw-log SIEM ingestion. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..e9171c3 --- /dev/null +++ b/docs/IMPLEMENTATION_PLAN.md @@ -0,0 +1,120 @@ +# Vigil365 — Implementation Plan (compiled July 2026) + +Compiled from: two engineering audits, the senior-designer detail audit, the veteran +SecOps critique, the card-level QA pass, and competitive research against +**AdminDroid** and **ManageEngine M365 Manager Plus**. + +**Product goal (north star):** *"AdminDroid tells your IT admin what happened in +M365; Vigil365 tells your security team what's wrong — with the audit trail to +prove nobody touched anything."* Security alerting & visibility, self-hosted, +strictly read-only against the tenant. We chase security depth, never their +1,800-report breadth or management actions. + +Effort: **S** ≤ ½ day · **M** 1–2 days · **L** 3+ days. + +--- + +## Phase 0 — Close the ship gate *(release blocker)* + +| # | Item | Effort | Notes | +|---|------|--------|-------| +| 0.1 | **Certificate auth for Graph** | M | Optional cert (thumbprint or PFX path) with fallback to client secret; setup wizard support; docs. Marketing line: "no client secrets stored anywhere". | +| 0.2 | **EF Core migrations** | M | Replace `EnsureCreated` + raw DDL with real migrations; baseline migration matching current schema; upgrade path for existing installs. | +| 0.3 | **Rotate exposed client secret** | — | **Owner action in Entra.** Blocks any public/prod use. | + +**Exit criteria:** fresh install and upgrade-from-current both work; Graph runs on a certificate; backlog "Required to ship" list is empty. + +--- + +## Phase 1 — Alert workbench *(viewer → tool people work in)* + +The category-wide weakness (competitors included). All local-DB; read-only promise intact. + +| # | Item | Effort | Notes | +|---|------|--------|-------| +| 1.1 | **Table toolkit**: sortable headers, server pagination ("1–50 of N" footer), bulk select + bulk ack/resolve | M | One shared component; Alert Queue + Alert Center first, then all tables. Backend: paging envelope (`total/page/items`) on triggered-alerts + alerts. | +| 1.2 | **Right slide-in detail panel** replacing centered alert modals | M | Category convention (Defender/Sentinel). Keep queue visible; prev/next navigation between alerts; reuse permalink sync. | +| 1.3 | **Assignment + analyst notes + local disposition** (reviewed / escalated / false-positive) on every alert incl. M365 ones | M | New columns + endpoints + audit events; shown in panel and queue. | +| 1.4 | **SLA age**: time-since-triggered / time-to-ack visible on queue rows; overdue highlight | S | Pure frontend from existing timestamps + one threshold setting. | +| 1.5 | **Permalinks in notifications** | S | Teams/email/webhook templates link `#/incidents?alert={id}` — routing already supports it. | + +**Exit criteria:** an analyst can sort the queue worst-first, bulk-ack noise, open a +side panel, assign to a teammate with a note, and click a Teams message straight to +the alert. + +--- + +## Phase 2 — Findability & layout standardisation + +| # | Item | Effort | Notes | +|---|------|--------|-------| +| 2.1 | **Global search in header (Ctrl+K)** across users / devices / alerts / pages | M | Client-side over already-loaded data first; the missing anchor component. | +| 2.2 | **One standard filter toolbar** (search → selects → date range → clear → export) same position on every list page; fix Email's hidden cross-filtering search | M | Extract ``; Email + Devices brought into pattern. | +| 2.3 | **Sticky section tabs** (travel with header) + restyle Alert Center inner tabs as underline style | S | Kills the double-pill-bar confusion. | +| 2.4 | **Undo toasts** (ack/resolve), **per-card "updated Xm ago"**, pause-auto-refresh control | M | Trust + control trio from the UX review. | +| 2.5 | **Metric tooltips + first-run checklist** ("3 steps to your first alert") | M | Onboarding + comprehension; kills the empty-dash first impression. | +| 2.6 | Time-range control on Alerts/Identity + timezone label on timestamps | M | | +| 2.7 | Card-slot hygiene: Notification Settings toggles out of badge slot; hover affordance distinguishing clickable KPI tiles | S | | + +--- + +## Phase 3 — Competitive core: activity alerting + reports + +The architectural gap vs AdminDroid (1,400 alertable activities vs our ~10 metric policies). + +| # | Item | Effort | Notes | +|---|------|--------|-------| +| 3.1 | **Audit-activity alerting engine** | L | New collector source: unified audit log / directory audit events → normalized `AuditEvent` store; new policy type `activity-match` (operation, actor, target, count-in-window); starter pack of ~25 security-relevant activity policies (forwarding rule created, role assignment, mass download, CA policy changed…). The coverage scorecard already *recommends* these — now they fire. | +| 3.2 | **Anomaly / comparison alerts** | M | "Metric ≥ N× its 30-day baseline" policy type on TrendSnapshots (AdminDroid's cleverest feature, cheap for us). | +| 3.3 | **Report library + scheduling** | L | 40–50 curated *security* reports (not admin breadth), schedulable email PDF/CSV; includes the **weekly exec digest** (posture + trends + top alerts). Reuses SMTP + Trends. | +| 3.4 | **Notification digest mode + delivery-failure alert** | S | Daily rollup option per channel; alert when a channel fails. | + +**Exit criteria:** "mailbox forwarding rule created" fires an alert within one +collection cycle; a CISO gets a Monday-morning PDF without asking. + +--- + +## Phase 4 — Investigation depth + +| # | Item | Effort | Notes | +|---|------|--------|-------| +| 4.1 | **Entity drill-down page** (`#/entity/{upn|device}`): timeline of alerts, sign-ins, risk history, devices | L | The dashboard→investigation-tool jump; data already collected. | +| 4.2 | **Incident ↔ alert join** via existing `incidentId` | S | Incident panel lists member alerts; alert panel links its incident. | +| 4.3 | CA gap analysis (users/apps not covered, MFA-exempt, legacy auth) | M | Existing backlog ask. | +| 4.4 | SharePoint/OneDrive sharing signals (new collector source) | M | Most-requested coverage extension. | + +--- + +## Phase 5 — Platform & product maturity *(parallelizable, lower urgency)* + +- **TanStack Query migration** (kills 26-useState god component, double-fetch class, per-page fetching) — L +- **Split 1,800-line Program.cs** into endpoint groups — M +- **List virtualization** for big tenants — M +- **Trends page re-skin** onto the design system (last off-system page) — M +- Modal focus-trap parity (PolicyModal, triggered-alert modal) + keyboard access on list rows — M +- Server-side-only policy evaluation (drop client-triggered evaluate) — S +- Mobile drawer nav (or commit to desktop-only formally) — L +- **Sovereign cloud endpoints** (GCC/GCC-High/21Vianet configurable) — M +- **Release machinery**: versioned releases + changelog + docs site + SBOM/signed releases + dependency scanning — M +- Idle timeout / auto sign-out — S + +--- + +## Sequencing & rationale + +``` +Phase 0 ─ ship gate (must finish first — everything else is v1.x) +Phase 1 ─ alert workbench (biggest daily-use gap; category-wide weakness = differentiation) +Phase 2 ─ findability/layout (cheap, high-visibility; can interleave with Phase 1) +Phase 3 ─ activity alerting (the strategic competitive bet; largest single build) +Phase 4 ─ investigation depth (builds on 1's panel + 3's events) +Phase 5 ─ platform maturity (continuous background track) +``` + +Rough calendar at current pace: Phase 0+1 ≈ one week · Phase 2 ≈ 2–3 days · +Phase 3 ≈ 1–2 weeks · Phase 4 ≈ one week. **Working sellable milestone after +Phase 3** — that's when the AdminDroid comparison stops losing. + +## Explicitly out of scope (unchanged) +Remediation actions · multi-tenant SaaS (Edition 2) · raw-log SIEM ingestion · +management/delegation features (M365 Manager Plus's turf) · report-count arms race. diff --git a/docs/MATURITY_PLAN.md b/docs/MATURITY_PLAN.md new file mode 100644 index 0000000..4eeb97b --- /dev/null +++ b/docs/MATURITY_PLAN.md @@ -0,0 +1,139 @@ +# Vigil365 — Product Maturity Plan (July 2026) + +Consolidated from four reviews: CISO security pass, principal-developer architecture +pass, product-maturity/QA pass, and UI/UX critique. Ordered small → big; each phase +is shippable. Security Phase A items are interleaved where they belong. + +> **Why earlier reviews missed things (process note):** every audit so far was run by +> the same context that built the features — each pass inherits the builder's blind +> spots and checks what it already knows to check. Corrective: independent fresh-eyes +> reviews (no shared assumptions) at the end of every phase, plus one session watching +> a real unfamiliar user per phase. Findings from the first independent sweep: +> CSV formula injection (3 exporters), 96 compiled DLLs committed in +> `publish-install-test/` — both invisible to prior passes. + +## Phase M0 — Confirmed bugs, fix first (hours) — from independent fresh-eyes audit +0a. **Tenant Activity page rendered twice** — duplicate `{page==="activityfeed"...}` + at main.tsx:557 AND :566; delete one (double fetch + double render). +0b. **"Tampering detected" shows as GREEN success toast** — UserManagementPage.tsx:147 + omits toast type; the scariest message in the product looks like success. Add "error". +0c. **Setup validation error shows as success toast** — SetupPage.tsx:30, same fix. +0d. **Overview "Total Active" vs queue disagree past 200 alerts** — Overview uses full + DB count (Program.cs:705), queue fetches pageSize=200 (main.tsx:323) with no + truncation indicator. Show "showing 200 of N" or reconcile the counts. +0e. **API failures invisible mid-session** — allSettled swallows every fetch error and + still stamps "Updated HH:MM" over stale cards (main.tsx:301-352); surface per-card + load failure + don't advance lastRefresh on total failure. +0f. **Entity investigation page unreachable except via Ctrl+K** — add "Open + investigation profile" from AlertDetailModal (openEntity only called in GlobalSearch). +0g. **info toasts render as success (green check)** — ToastContainer.tsx:24-26; give + info its own neutral styling. + +## Phase M1 — Small details, big signal (days) +1. **CSV formula-injection guard** in all three exporters (utils.ts `downloadCsv`, + DigestBuilder.Csv, audit-export Csv in Program.cs) + contract test. *(security)* +2. Purge committed `publish-install-test/` build output; gitignore it. +3. Replace 3 native `confirm()` (AlertCenter, Reports, UserManagement) with in-app modal. +4. Timezone labels + single `fmtDate` policy (local + UTC offset, ISO tooltip). +5. Error banner → tokenized styles + surface correlation ID ("quote ID X to admin"). +6. Per-card "updated Xm ago" + auto-ticking relative times. +7. **Standardized `` component** (loading/empty/error/no-permission with + retry affordance) — adopt in all cards. *(UX — added)* +8. 403-permission-hint pattern on every Graph-backed endpoint. +9. Idle timeout + absolute session expiry. *(security A2)* +10. Unknown `/api/*` → 404 (SPA fallback only for non-API paths). *(security A3)* +11. CI gates: `dotnet list package --vulnerable` + `npm audit --audit-level=high`. + +## Phase M2 — First hour (~1 week) +12. First-run checklist card (consent / permissions / SMTP / policies / first + collection) driven by /health + settings. +13. Setup wizard: add SMTP + notification-channel step. +14. In-app help links per page header; permission-matrix page from graph-permissions.md. +15. **Overview visual hierarchy: one dominant triage-status element** — needs owner + sign-off (a "Needs Attention" card was previously removed by owner). *(UX — added)* + +## Phase M3 — Alert-ops maturity (1–2 weeks) +16. Suppression rules (entity/policy scoped, expiring, audited) — top alert-fatigue fix. +17. Policy dry-run/backtest against stored history ("would have fired N times in 30d"). +18. MTTR & analyst metrics tab (data already stored: ack/resolve timestamps). +19. Policy export/import (JSON pack). +20. Server-side-only policy evaluation; remove client trigger. *(security B6)* + +## Phase M4 — Enterprise fit (1–2 weeks) +21. ✅ PDF export for exec digest; multi-recipient schedules. +22. ✅ Outbound API tokens + signed generic webhook-out (SIEM path). +23. ✅ Keyboard/a11y pass: table navigation, sortable-column announcements, skip-link, axe checks in CI. +24. ✅ Density toggle (compact/comfortable) + formalized typography scale. *(UX)* +25. 🟡 Remove remaining inline styles → drop `style-src 'unsafe-inline'` from CSP. In progress: the Reports page and shared shell are converted; 407 inline-style props remain across the frontend. + +## Phase M5 — Product machinery (ongoing; gates "1.0") +26. Tagged releases + changelog + upgrade-from-N−1 test; version chip from build metadata. +27. Playwright smoke suite in CI (login → overview → bundle-hash assert). +28. Metrics endpoint (collection duration, Graph call/429 counts). +29. String centralization (future i18n). +30. Program.cs split into endpoint-group modules. *(architecture B5 — do before M3/M4 + backend work if merge pain appears earlier)* +31. TanStack Query migration; pagination envelope + virtualization; route code-splitting. + +## Standing items (every phase) +- Independent fresh-eyes review at phase end (agent or human uninvolved in the build). +- **Watch one real unfamiliar user complete a triage flow; log every hesitation.** *(added)* +- Update this doc + ROADMAP.md as items land. + +## Trust-in-numbers cluster (M1/M2 — from UX audit; a security tool's credibility) +- Device compliance computed 3 different ways (Overview / DevicesPage / Compliance + control PR.DS-01) — pick one source of truth. +- Compliance controls that auto-pass on data-collected (PR.PT-02, DE.AE-01) or can + never pass (PR.AA-02 fails at 0 privileged users) — the headline "Controls Passing %" + is partly fake. Redefine pass logic per control. +- Identity KPI tiles reflect the *filtered* list and default "show resolved" ON — tiles + change as you type and exceed Overview's unresolved-only counts. +- ServiceHealth advisories double-counted / inconsistently included across queue, + Overview, sidebar badge — one inclusion rule. +- Sign-in "totals" (countries, failed sign-ins) computed from a sample of 100 but + labeled tenant-wide; NetworkPage derives "connectivity" from auth failures. +- Compliance thresholds + filter presets live in per-browser localStorage — two + analysts see different scores for the same tenant. Move to server or label as local. +- Exports export the visible page, not the dataset, while the badge shows the full total. +- Bulk ack/resolve reports only successes, hiding partial failures. + +## Consistency/copy cluster (M1 — from UX audit) +- In-app alert source has 3 names ("Vigil365 Alerts"/"SecurityDB"/"In-App DB"); nav/card/ + README disagree too. Pick one. +- 5 different severity vocabularies across filter dropdowns; CA controls show raw enum + tokens ("mfa","compliantDevice"); ReportsPage uses nonexistent `data-table` class. +- 3 pages break the pinned en-US date convention (header, Reports, Trends). +- Sidebar badges mean "unread delta" (unexplained) — every competitor means "open items". + +## README/docs vs reality (M2 — from UX audit) +- README promises a geographic sign-in MAP that doesn't exist (page is tables only). +- Claims all permissions are *.Read.All (Attack Simulation needs ReadWrite.All); + permission table omits several the UI actually requires. +- Documents 4 endpoint paths that don't exist; misdescribes triggered alerts as + localStorage; contradicts the Recommendations page ("never tells you what to change"). +- Never documents Reports, Trends, Recommendations, RBAC, notification channels — the + strongest demo features. Rewrite the README against the actual app. + +## Security pass results (inline audit, July 2026) — mostly clean +- **Confirmed clean:** every mutating endpoint role-gated (RequireAdmin/RequireAnalyst) + + deny-by-default fallback; last-admin lockout guard server-side; email HTML bodies + HtmlEncode all tenant-controlled fields, Teams/webhook use JsonSerializer (injection- + safe); secrets never logged, DPAPI-encrypted at rest; audit hash-chain append + serialized via SemaphoreSlim; 0 vulnerable NuGet/npm packages; setup/graph + + collector/run admin-gated. +- **🟠 M3 — SSRF hardening:** no validation that configured Teams/generic webhook URLs + aren't loopback/RFC1918/link-local/cloud-metadata (169.254.169.254). Admin-gated so + Medium, but a security product should validate egress. Reject internal targets or + document the trust boundary. +- **🟡 M5 — audit fail-open:** AuditLogger swallows persistence errors (action succeeds + even if the audit write fails). Consider a monitored failure counter / fail-closed + option for the audit trail specifically. +- **Note:** audit ChainLock is an in-process static — correct for single-instance; + revisit if ever horizontally scaled (ties into the MSP edition). + +## Known-external gaps (cannot be closed by code review) +Real-tenant scale validation (10k+ alerts) · third-party pen test · genuine usability +testing. State these honestly in ORG_READINESS.md rather than claiming coverage. + +## Open owner action 🔴 +Rotate the exposed Graph client secret in Entra (outstanding since July 3). diff --git a/docs/MSP_MULTITENANT_PLAN.md b/docs/MSP_MULTITENANT_PLAN.md new file mode 100644 index 0000000..0c3910c --- /dev/null +++ b/docs/MSP_MULTITENANT_PLAN.md @@ -0,0 +1,136 @@ +# Vigil365 — MSP Multi-Tenant Alerting Plan + +Take Vigil365 from **single-tenant** (one org, its own data) to **MSP multi-tenant**: +one deployment an MSP runs to monitor and alert across **many client M365 tenants** +from a single pane, with strict per-client isolation. + +> ⚠️ This is the single largest architectural change to the product. It touches the +> data model, collection pipeline, authentication, access control, UI, and — most +> critically — the security/isolation model. Treat it as a major version (v2), not +> a feature. Scope stays **read-only, alerts/visibility only**. + +--- + +## 0. The positioning shift (and the honest trade-off) + +| | Single-tenant (today) | MSP multi-tenant (target) | +|---|---|---| +| Data location | Customer's own tenant/host | **MSP's** host, holding *many* customers' security data | +| Trust model | "Your data never leaves your tenant" | MSP is now a data processor for N customers | +| Blast radius | One tenant | **All client tenants** if isolation fails | +| Buyer | IT team | MSP / MSSP | + +**Be explicit about this in marketing.** The "data stays in your tenant" line no +longer applies for the MSP edition — the MSP custodies client data. That demands a +DPA, strong isolation, and per-client audit. Keep the single-tenant edition as-is +for direct customers; multi-tenant is a separate deployment mode. + +--- + +## 1. Connecting to client tenants (the Microsoft side) + +- **Multi-tenant app registration** — the MSP registers ONE app, marked + multi-tenant. Each client admin grants **admin consent** in their tenant + (`https://login.microsoftonline.com/{clientTenantId}/adminconsent?client_id=...`). +- **GDAP (Granular Delegated Admin Privileges)** — the modern MSP delegation model + (replaces legacy DAP). Pair the app with least-privilege GDAP roles + (Security Reader / Global Reader) so the MSP gets scoped, time-bound read access. +- **Per-tenant app-only tokens** — acquire tokens per client tenant against the + client's authority (`/{clientTenantId}`). The MSP app's secret/cert is one; + the *token audience/authority* is per client. +- **Onboarding flow** — an admin "Add client tenant" wizard: enter/clientconsent → + record `{ TenantId, DisplayName, ConsentStatus, ConnectedAt }` → first collection. + +## 2. Data isolation (the make-or-break decision) + +**Recommended: single database + `TenantId` on every row + a mandatory EF Core +global query filter.** + +- Add `TenantId` (Guid/string) to **every** collected entity: SecurityAlerts, + CollectionRuns, TriggeredAlerts, TrendSnapshots, AuditEntries, NotificationLogs, etc. +- Enforce `modelBuilder.Entity().HasQueryFilter(e => e.TenantId == _current.TenantId)` + via an injected "current tenant" accessor — so a forgotten `.Where` can't leak + cross-tenant data. **This is the single most important safety control.** +- A `ClientTenant` table holds connection metadata + display name + consent status. +- **Alternative (stronger, heavier): database-per-tenant** — best isolation, far more + ops (migrations × N, backup × N). Offer as an enterprise option; default to + row-level for manageability. + +> **Mandatory:** isolation tests. For every read path, a test proving tenant A +> cannot see tenant B's rows. A cross-tenant leak in a security product is fatal. + +## 3. Collection pipeline changes + +- The background worker loops **all connected client tenants**: per tenant → + acquire token → run the existing collectors → **tag every row with `TenantId`**. +- **Scale/throttling:** stagger tenants, cap parallelism, handle Graph 429 per + tenant independently, and record per-tenant collection status + last-success. +- **Resilience:** one tenant failing (consent revoked, throttled) must not stop the + others. Per-tenant `CollectionRun` rows already fit this. +- Secure Score / MFA-coverage live calls become per-tenant too. + +## 4. Access control for MSP staff + +- Extend roles: **MSP-Admin** (all tenants + settings), **Analyst** (act on alerts), + **Viewer** (read) — plus **tenant scoping**: an engineer can be limited to a subset + of client tenants (`AppUser` ↔ allowed `TenantId`s mapping). +- The current in-app user model + claims transformation extend naturally; add a + per-user tenant-allowlist and enforce it alongside the global query filter. + +## 5. UI changes + +- **Tenant switcher** in the header: "All clients" aggregated view + per-client drill-down. +- **Cross-tenant alert feed** — one prioritized stream across all clients, each row + tagged with the client name + severity. +- **Per-client dashboards** — the existing pages, scoped to the selected tenant. +- **Client roster page** — connection/consent health per tenant, last collection, + alert counts. +- **White-label / branding** per client for reports (MSP logo + client name). + +## 6. Alerting & notifications + +- Alert policies: **global templates** applied across tenants + **per-tenant overrides**. +- Notification routing **per client** (different Teams/email/webhook per tenant, or + central MSP SOC inbox) — extend `NotificationSettings` to be per-tenant. +- Cross-tenant **digest** ("12 clients, 3 with critical alerts this week"). + +## 7. Security & compliance (raised stakes) + +- **Isolation tests** (see §2) — non-negotiable. +- **Per-tenant audit trail** — `AuditEntry.TenantId`; MSP-Admin actions scoped + logged. +- **GDAP least privilege + time-bound** access; surface consent/role expiry. +- **Encryption at rest** already in place (Data Protection) — keep; consider per-tenant + key separation for the strongest posture. +- **Bigger blast radius** → certificate auth for the MSP app (not a shared secret), + vault-stored, and rotation. (Pulls forward the cert-auth backlog item.) +- DPA + data-handling docs for the MSP-as-processor model. + +## 8. Phased rollout (each phase shippable) + +1. **Data model** — add `TenantId` everywhere + `ClientTenant` table + EF global + query filter + isolation tests. (Foundation; no UI yet.) +2. **Connection & collection** — multi-tenant app reg, client onboarding wizard, + per-tenant token acquisition, collector loop tagging by tenant. +3. **Access & UI** — tenant scoping for staff, tenant switcher, client roster, + aggregated + per-client views. +4. **Alerting** — per-tenant policies/routing, cross-tenant feed + digest. +5. **Hardening** — cert auth for the MSP app, per-tenant audit, white-label reports, + scale tuning, DPA docs. + +## 9. Risks & explicit non-goals + +- **Risk:** cross-tenant data leakage — mitigated by global query filter + tests; the + highest-priority correctness concern. +- **Risk:** scale (many tenants × frequent collection) — mitigated by staggering, + parallelism caps, per-tenant backoff. +- **Risk:** consent/GDAP churn — surface connection health, fail gracefully per tenant. +- **Non-goals (unchanged):** remediation actions, raw-log SIEM ingestion. Still + read-only alerting. +- **Keep both editions:** single-tenant (in-tenant, "data stays put") and MSP + multi-tenant (MSP-custodied) are different trust models — ship and message separately. + +## 10. Effort (honest) + +This is a **multi-week v2**, not a weekend. Phase 1 alone (TenantId + query filter + +isolation tests across the whole data model and every query) is significant and must +be done meticulously — it's the safety foundation everything else rests on. diff --git a/docs/OPERATIONS_RUNBOOK.md b/docs/OPERATIONS_RUNBOOK.md new file mode 100644 index 0000000..ec05da9 --- /dev/null +++ b/docs/OPERATIONS_RUNBOOK.md @@ -0,0 +1,116 @@ +# Vigil365 Operations Runbook + +This runbook covers the data required to recover Vigil365: the SQL Server +database, the Data Protection key ring, and deployment configuration. Test this +procedure on a non-production host before relying on it during an incident. + +## Recovery objective + +- **Database:** alert records, audit trail, policies, collection history, and + encrypted notification/Graph settings. +- **Data Protection keys:** required to decrypt settings encrypted by Vigil365. +- **Production configuration:** Entra IDs, redirect URI, SQL connection, and TLS + settings. Treat this as secret material. +- **Not required for recovery:** application logs; retain them according to the + host's incident-response policy. + +Take an encrypted, access-controlled backup at least daily and before every +application upgrade. Keep the SQL backup and its matching Data Protection key +backup together; restoring only the database can leave saved encrypted settings +unreadable. + +## Windows / SQL Server backup + +1. Create a restricted backup directory, for example `D:\Vigil365Backups`. +2. Use a SQL login or Windows account permitted to back up the database. +3. Run the following from an elevated PowerShell prompt, changing the SQL Server + instance and output path for the deployment: + +```powershell +$stamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$backup = "D:\Vigil365Backups\M365SecurityDashboard-$stamp.bak" +sqlcmd -S '.\SQLEXPRESS' -E -Q "BACKUP DATABASE [M365SecurityDashboard] TO DISK = N'$backup' WITH COPY_ONLY, COMPRESSION, CHECKSUM, STATS = 10" +``` + +4. Verify the backup before treating it as successful: + +```powershell +sqlcmd -S '.\SQLEXPRESS' -E -Q "RESTORE VERIFYONLY FROM DISK = N'$backup' WITH CHECKSUM" +``` + +5. Copy these files to the same protected backup set: + +```powershell +Copy-Item 'C:\Apps\Vigil365\keys' "D:\Vigil365Backups\keys-$stamp" -Recurse +Copy-Item 'C:\Apps\Vigil365\appsettings.Production.json' "D:\Vigil365Backups\appsettings.Production-$stamp.json" +``` + +Use the actual publish directory if it differs from `C:\Apps\Vigil365`. Do not +place backup sets in the application directory or a source-control checkout. + +## Docker backup + +The compose deployment persists SQL backups through the `./backups` bind mount. +Create it before the first backup and restrict its host permissions. + +```powershell +New-Item -ItemType Directory -Force backups | Out-Null +$stamp = Get-Date -Format 'yyyyMMdd-HHmmss' +docker compose exec -T db /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P $env:MSSQL_SA_PASSWORD ` + -Q "BACKUP DATABASE [M365SecurityDashboard] TO DISK = N'/var/opt/mssql/backup/M365SecurityDashboard-$stamp.bak' WITH COPY_ONLY, COMPRESSION, CHECKSUM, STATS = 10" +docker compose exec -T db /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P $env:MSSQL_SA_PASSWORD ` + -Q "RESTORE VERIFYONLY FROM DISK = N'/var/opt/mssql/backup/M365SecurityDashboard-$stamp.bak' WITH CHECKSUM" +docker compose cp app:/keys "backups/keys-$stamp" +Copy-Item .env "backups/.env-$stamp" # keep only in encrypted storage +``` + +`MSSQL_SA_PASSWORD` must be available in the current shell; do not put it in +shell history or source control. The `.env` file and key ring are secret +material. + +## Restore drill + +Perform restores first to an isolated SQL database/server and an isolated app +host. Never point a test restore at the production database. + +1. Stop the Vigil365 service/container that uses the target database. +2. Use `RESTORE FILELISTONLY FROM DISK = N''` to obtain the logical + data and log names from the chosen backup. +3. Restore to a new database name with `RESTORE DATABASE ... WITH MOVE ...` in + SQL Server Management Studio or with `sqlcmd`. Confirm `RESTORE VERIFYONLY` + succeeded before this step. +4. Restore the matching `keys` directory and production configuration with + restricted file permissions. +5. Start the app with the restored database connection. It will apply any + pending EF Core migrations automatically. +6. Open `/health`, sign in as an administrator, verify alert policies and recent + collection history, then test a non-production notification channel. + +Record the elapsed restore time and any manual corrections. The recovery process +is only proven after a documented restore drill succeeds. + +## Upgrade procedure + +1. Read the release notes and make a verified backup set. +2. Stop the Windows service or scale the container down. +3. Publish the new Windows build with `install.ps1` / `deploy.ps1`, or run: + +```powershell +docker compose build app +docker compose up -d +``` + +4. On startup Vigil365 applies pending EF Core migrations. Confirm the logs show + no migration failure, then verify `/health` and the **Collection Runs** page. +5. Keep the previous application artifact until the post-upgrade checks pass. + Database rollback requires restoring the verified backup; do not use an + arbitrary migration downgrade against production. + +## Post-incident checks + +- Review JSON logs using the correlation ID returned in `X-Correlation-Id`. +- Confirm the next scheduled collection completes without source failures. +- Rotate credentials if an app host, backup set, or Data Protection key ring may + have been exposed. +- Record the incident and recovery decision in Vigil365's audit trail and the + organization incident system. diff --git a/docs/ORG_READINESS.md b/docs/ORG_READINESS.md new file mode 100644 index 0000000..55e43b4 --- /dev/null +++ b/docs/ORG_READINESS.md @@ -0,0 +1,107 @@ +# Vigil365 — Organization Readiness & Robustness Plan + +How we move from a **personal dashboard** (one engineer installs it on their PC) +to a **product an organization/company deploys, trusts, and operates**. This is the +robustness/productization companion to: +- `docs/ENTERPRISE_BACKLOG.md` — features + UI/UX to add +- `ROADMAP.md` — auth + hosting phases + +Scope stays: **alerts & visibility, read-only, in-tenant. No remediation.** + +Legend: ✅ done · 🔵 WIP · ⬜ to build · 🎯 weekend target + +--- + +## The positioning shift + +| Dimension | Personal tool (today) | Organizational product (target) | +|-----------|----------------------|--------------------------------| +| Users | Single engineer | Team with roles (Admin/Analyst/Viewer) ✅ | +| Host | Personal laptop, localhost | Server / VM / container, real hostname ✅ | +| Identity | One person | Microsoft sign-in for the whole tenant ✅ | +| Trust | "It's mine" | Audit trail, encryption, compliance docs 🔵 WIP | +| Lifecycle | Runs until laptop sleeps | Service, monitored, backed up, upgradable ⬜ | +| Data | Throwaway | Retained, owned, recoverable ⬜ | +| Support | The builder | Docs, versioning, disclosure policy ✅/⬜ | + +## Robustness pillars + +### 1. Reliability ⬜ +- 🎯 **Health endpoint** `/health` — DB reachable, Graph creds valid, last-collection age. +- 🎯 **Graph throttling** — handle 429 with backoff/retry (partially present); make robust. +- ⬜ **Collection resilience** — one failing source must never abort the whole run; record + per-source status (partly there) + surface failures in UI. +- ⬜ **Background worker self-heal** — restart on crash; alert on repeated collection failure. +- ✅ DB-readiness retry on startup (Docker). + +### 2. Scalability ⬜ +- 🎯 **Role-claim caching** (short TTL) — stop a DB lookup on every request. +- ⬜ **Server-side paging + virtualization** for large tenants (big user/device/alert lists). +- ⬜ **Index review** on hot queries (alerts by date/severity, audit by timestamp ✅). +- ⬜ **Connection pooling / DbContext** tuning for concurrent users. +- ⬜ Document SQL Express 10 GB ceiling → SQL Server / Azure SQL upgrade path ✅(docs). +- ⬜ **Optional SQLite backend** — for lightweight single-org installs with no SQL + dependency (needs provider-agnostic schema / EF migrations; T-SQL DDL is SQL-Server-specific today). + +### 3. Security & trust ✅/🔵 WIP +- ✅ Microsoft sign-in + token validation; RBAC; HTTPS enforcement; encryption at rest + (cross-platform); SECURITY.md + threat model. +- 🎯 **Audit hardening** — IP/user-agent, sign-in + alert + collection events, + tamper-evident hash chain, export, retention. +- ⬜ **Certificate auth for Graph** (replace client secret) + secret-rotation flow. +- ⬜ **Seal the Data Protection key ring** (cert / Key Vault) for true at-rest protection. +- 🔴 **Rotate the previously-exposed client secret** before any real org use. +- ⬜ Content-Security-Policy header; basic rate limiting. + +### 4. Operability ⬜ +- ⬜ **Structured logging** + correlation IDs; log levels; rolling file logs with retention. +- 🎯 `/health` + simple metrics (collection duration, last success, error counts). +- ⬜ **Backup/restore guidance** (DB + Data Protection keys + appsettings). +- ⬜ **Upgrade path** — EF migrations instead of EnsureCreated + raw DDL; documented + version-to-version upgrade steps. +- ✅ Scripted install/deploy (install.ps1, deploy.ps1, register-app.ps1) + Docker compose. + +### 5. Data lifecycle ⬜ +- ⬜ **Retention/pruning** — configurable purge of old alerts, collection runs, audit + (GDPR/SOC2 expectation). +- ⬜ **Metric snapshots** for trends (also a feature) — define what we keep and how long. +- ⬜ **Export** — CSV today; consider scheduled exports / SIEM forward. + +### 6. Onboarding & adoption ✅/⬜ +- ✅ One-command install + first-run Setup wizard (no JSON editing). +- ✅ Pre-provision users + invite emails. +- ⬜ **Setup permission verification** — confirm each required Graph permission is granted + (fixes ambiguous "Needs permission"). +- ⬜ **In-app "getting started" checklist** — Graph configured? users invited? notifications set? +- ⬜ Sample/demo mode with synthetic data for evaluation without a tenant. + +## Deployment models for organizations +- ✅ **Single Windows server** — deploy.ps1 + Windows Service + HTTPS. +- ✅ **Docker / Linux** — docker compose (app + SQL); ⬜ verify on a real Docker host. +- ⬜ **Behind reverse proxy** — Caddy/Nginx/Traefik config + auto Let's Encrypt (public domain). +- ⬜ **HA / scale-out** (future) — stateless app + shared SQL + shared Data Protection keys. + +## What an organization needs before trusting it (adoption checklist) +1. ✅ Identity-based access + roles +2. 🔵 WIP Audit trail (who did what) — hardening +3. ✅ Encryption in transit + at rest +4. ⬜ Backup/restore + retention +5. ⬜ Health/monitoring +6. ✅ Security disclosure policy + threat model +7. ⬜ Versioned releases + upgrade docs +8. 🔴 Rotated, vault-stored credentials (no secrets in chat/files) + +--- + +## Weekend build map (suggested sequence) +1. **Foundation/robustness**: error boundary, loading skeletons, unified states, + `/health`, role caching, audit hardening. *(de-risks everything, builds trust)* +2. **Trends & history** → Overview trend cards → scheduled exec report. +3. **Compliance framework scoring** (CIS/NIST/ISO/GDPR) + **recommendations layer**. +4. **Accessibility + keyboard pass**, CA gap analysis, entity drill-down. +5. **Data lifecycle**: retention/pruning, EF migrations, backup docs. +6. **Polish**: favicon/meta, formatting, tooltips, density, mobile, demo mode. + +> Cut line for "organization-ready v1": pillars 1–4 (reliability, scale basics, +> security/trust, operability) + Trends + Compliance scoring. Everything else is +> fast-follow. diff --git a/docs/assets/roadmap-hero.png b/docs/assets/roadmap-hero.png new file mode 100644 index 0000000..1c53da0 Binary files /dev/null and b/docs/assets/roadmap-hero.png differ diff --git a/docs/assets/roadmap-hero.svg b/docs/assets/roadmap-hero.svg new file mode 100644 index 0000000..4e6aa2b --- /dev/null +++ b/docs/assets/roadmap-hero.svg @@ -0,0 +1,104 @@ + + + + + + + +Vigil365 +Self-hosted M365 security operations + + +153 stars + +Roadmap + +The road ahead +From a personal dashboard to an organisation-ready platform + + + +73% +Posture +Secure score trend + + + Risky users + 1 + + + + MFA coverage + 73% + + + + Critical alerts + 0 + + + + Compliance + +12% + + + +Shipping over the coming weeks + + + + + + + + + Access + roles · audit + + + + + + Trends + over time + + + + + + + Compliance + CIS · NIST · ZT + + + + + + + + + + Coverage + SP · OneDrive + + + + + + + Hardening + cert · audit · health + + + + + + + Deploy + Win · Docker + + + +Free · Open source · Self-hosted · Read-only +github.com/sameerk27/vigil365 + diff --git a/enterprise-install.ps1 b/enterprise-install.ps1 new file mode 100644 index 0000000..0e84254 --- /dev/null +++ b/enterprise-install.ps1 @@ -0,0 +1,82 @@ +<# +.SYNOPSIS + Installs Vigil365 as a managed Windows production service. + +.DESCRIPTION + This installer is intentionally designed for a server deployment: SQL Server + and TLS are external dependencies. It publishes the application, writes only + non-Graph bootstrap configuration, restricts local file permissions, and + installs a Windows service configured to restart after failures. +#> +[CmdletBinding(SupportsShouldProcess)] +param( + [string]$TenantId, + [string]$ClientId, + [string]$AdminEmail, + [string]$SqlConnectionString, + [string]$PublicUrl, + [string]$InstallPath = "C:\Program Files\Vigil365", + [string]$ServiceName = "Vigil365", + [int]$Port = 8080 +) + +$ErrorActionPreference = "Stop" +if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw "Run this installer from an elevated PowerShell session." +} +$repoRoot = (Resolve-Path $PSScriptRoot).Path +$api = Join-Path $repoRoot "src\M365SecurityDashboard.Api" +$client = Join-Path $repoRoot "src\m365-security-dashboard-client" +$exe = Join-Path $InstallPath "M365SecurityDashboard.Api.exe" + +foreach ($tool in "dotnet", "npm") { if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { throw "Required tool '$tool' is not available on PATH." } } +function Read-Required([string]$Name, [string]$Value) { + if ($Value) { return $Value } + do { $Value = Read-Host $Name } while ([string]::IsNullOrWhiteSpace($Value)) + return $Value.Trim() +} +Write-Host "`nVigil365 enterprise installer" -ForegroundColor Cyan +$TenantId = Read-Required "Entra Tenant ID" $TenantId +$ClientId = Read-Required "Entra Application (client) ID" $ClientId +$AdminEmail = Read-Required "First administrator email" $AdminEmail +$SqlConnectionString = Read-Required "SQL Server connection string" $SqlConnectionString +$PublicUrl = Read-Required "Public HTTPS URL (for example https://vigil365.contoso.com)" $PublicUrl +if ($PublicUrl -notmatch '^https://') { throw "The public URL must start with https://" } +if (Get-Service $ServiceName -ErrorAction SilentlyContinue) { + Stop-Service $ServiceName -Force -ErrorAction SilentlyContinue +} + +Write-Host "Building and publishing Vigil365..." -ForegroundColor Cyan +Push-Location $client +try { npm ci --no-audit --no-fund; npm run build } finally { Pop-Location } +New-Item -ItemType Directory -Force -Path $InstallPath | Out-Null +dotnet publish $api -c Release -o $InstallPath | Out-Host + +$config = [ordered]@{ + ConnectionStrings = [ordered]@{ DefaultConnection = $SqlConnectionString } + AzureAd = [ordered]@{ Instance = "https://login.microsoftonline.com/"; TenantId = $TenantId; ClientId = $ClientId; Audience = "api://$ClientId" } + Auth = [ordered]@{ RedirectUri = $PublicUrl; BootstrapAdminEmail = $AdminEmail } + Cors = [ordered]@{ AllowedOrigins = @($PublicUrl.TrimEnd('/')) } + Security = [ordered]@{ RequireHttps = $false } + DataProtection = [ordered]@{ KeyPath = (Join-Path $InstallPath "keys") } +} +$configPath = Join-Path $InstallPath "appsettings.Production.json" +$config | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $configPath -Encoding utf8 +New-Item -ItemType Directory -Force -Path (Join-Path $InstallPath "keys") | Out-Null + +# The service identity and local administrators can read config/keys; ordinary users cannot. +$acl = Get-Acl $InstallPath +$acl.SetAccessRuleProtection($true, $false) +foreach ($identity in @("BUILTIN\Administrators", "NT AUTHORITY\SYSTEM", "NT AUTHORITY\LOCAL SERVICE")) { + $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($identity, "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow"))) +} +Set-Acl -LiteralPath $InstallPath -AclObject $acl + +if (Get-Service $ServiceName -ErrorAction SilentlyContinue) { sc.exe delete $ServiceName | Out-Null; Start-Sleep -Seconds 2 } +$binPath = "`"$exe`" --environment Production --urls http://127.0.0.1:$Port" +sc.exe create $ServiceName binPath= $binPath start= auto obj= "NT AUTHORITY\LocalService" | Out-Null +sc.exe description $ServiceName "Vigil365 Microsoft 365 security monitoring service" | Out-Null +sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/15000/restart/60000 | Out-Null +sc.exe start $ServiceName | Out-Null + +Write-Host "Installed $ServiceName. Configure a TLS reverse proxy for $PublicUrl -> http://127.0.0.1:$Port, then add $PublicUrl as an Entra SPA redirect URI." -ForegroundColor Green diff --git a/enterprise-install.sh b/enterprise-install.sh new file mode 100644 index 0000000..5eb2aa5 --- /dev/null +++ b/enterprise-install.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Installs Vigil365 as a managed Linux production service behind a TLS proxy. +set -euo pipefail + +usage() { echo "Usage: sudo $0 [--tenant-id ID --client-id ID --admin-email EMAIL --sql-connection STRING --public-url https://host]"; } +tenant_id= client_id= admin_email= sql_connection= public_url= install_dir=/opt/vigil365 port=8080 +while [[ $# -gt 0 ]]; do + case "$1" in + --tenant-id) tenant_id="$2"; shift 2;; --client-id) client_id="$2"; shift 2;; + --admin-email) admin_email="$2"; shift 2;; --sql-connection) sql_connection="$2"; shift 2;; + --public-url) public_url="$2"; shift 2;; --install-dir) install_dir="$2"; shift 2;; --port) port="$2"; shift 2;; + -h|--help) usage; exit 0;; *) usage; exit 2;; + esac +done +[[ $EUID -eq 0 ]] || { echo "Run with sudo." >&2; exit 1; } +ask() { local label="$1" value="$2"; if [[ -n "$value" ]]; then printf '%s' "$value"; else read -r -p "$label: " value; [[ -n "$value" ]] || { echo "$label is required." >&2; exit 2; }; printf '%s' "$value"; fi; } +echo "Vigil365 enterprise installer" +tenant_id="$(ask 'Entra Tenant ID' "$tenant_id")" +client_id="$(ask 'Entra Application (client) ID' "$client_id")" +admin_email="$(ask 'First administrator email' "$admin_email")" +sql_connection="$(ask 'SQL Server connection string' "$sql_connection")" +public_url="$(ask 'Public HTTPS URL (for example https://vigil365.contoso.com)' "$public_url")" +[[ $public_url =~ ^https:// ]] || { echo "The public URL must start with https://" >&2; exit 2; } +command -v dotnet >/dev/null || { echo ".NET 8 SDK is required." >&2; exit 1; } +command -v npm >/dev/null || { echo "Node.js 20+ is required." >&2; exit 1; } +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +pushd "$repo_root/src/m365-security-dashboard-client" >/dev/null +npm ci --no-audit --no-fund +npm run build +popd >/dev/null +install -d -m 0750 -o root -g root "$install_dir" +dotnet publish "$repo_root/src/M365SecurityDashboard.Api" -c Release -o "$install_dir" +id -u vigil365 >/dev/null 2>&1 || useradd --system --home-dir "$install_dir" --shell /usr/sbin/nologin vigil365 +chown -R root:vigil365 "$install_dir" +chmod -R go-rwx "$install_dir" +install -d -m 0750 -o vigil365 -g vigil365 "$install_dir/keys" +cat > "$install_dir/appsettings.Production.json" < /etc/systemd/system/vigil365.service < http://127.0.0.1:$port, then add $public_url as an Entra SPA redirect URI." diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..9607102 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,93 @@ +<# +.SYNOPSIS + One-shot installer for Vigil365 - builds the frontend, publishes the API, and + (optionally) installs it as a Windows Service. Graph credentials are entered + later in the browser via the first-run setup wizard - no JSON editing required. + +.EXAMPLE + # Build + publish to .\publish and run interactively: + .\install.ps1 + + # Build, publish to a custom path, and install as a Windows service: + .\install.ps1 -PublishPath C:\Apps\Vigil365 -InstallService -Url http://localhost:8080 +#> +[CmdletBinding()] +param( + [string]$PublishPath, + [switch]$InstallService, + [string]$Url = "http://localhost:8080", + [string]$ServiceName = "Vigil365" +) + +$ErrorActionPreference = "Stop" + +# Resolve the repo root reliably. $PSScriptRoot is not always populated in the +# param() block, so compute it here and fall back to the invocation path. +$RepoRoot = $PSScriptRoot +if (-not $RepoRoot) { $RepoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path } +$RepoRoot = (Resolve-Path $RepoRoot).Path + +if (-not $PublishPath) { $PublishPath = Join-Path $RepoRoot "publish" } +$api = Join-Path $RepoRoot "src\M365SecurityDashboard.Api" +$client = Join-Path $RepoRoot "src\m365-security-dashboard-client" + +function Test-Tool($name, $hint) { + if (-not (Get-Command $name -ErrorAction SilentlyContinue)) { + throw "Required tool '$name' not found on PATH. $hint" + } +} + +Write-Host "`n=== Vigil365 installer ===`n" -ForegroundColor Cyan + +# 1. Prerequisites +Write-Host "[1/4] Checking prerequisites..." -ForegroundColor Yellow +Test-Tool "dotnet" "Install the .NET 8 SDK: https://dotnet.microsoft.com/download/dotnet/8.0" +Test-Tool "npm" "Install Node.js 20+: https://nodejs.org/" +Write-Host " .NET and Node found." -ForegroundColor Green + +# 2. Build the frontend into the API's wwwroot +Write-Host "[2/4] Building frontend..." -ForegroundColor Yellow +Push-Location $client +try { + npm install --no-audit --no-fund + npm run build +} finally { Pop-Location } +Write-Host " Frontend built into wwwroot." -ForegroundColor Green + +# 3. Publish the API +Write-Host "[3/4] Publishing API to $PublishPath ..." -ForegroundColor Yellow +dotnet publish $api -c Release -o $PublishPath | Out-Null +Write-Host " Published." -ForegroundColor Green + +# 4. Optionally install as a Windows service +$exe = Join-Path $PublishPath "M365SecurityDashboard.Api.exe" +if ($InstallService) { + Write-Host "[4/4] Installing Windows service '$ServiceName'..." -ForegroundColor Yellow + if (Get-Service $ServiceName -ErrorAction SilentlyContinue) { + sc.exe stop $ServiceName | Out-Null + sc.exe delete $ServiceName | Out-Null + Start-Sleep -Seconds 2 + } + $bin = "`"$exe`" --environment Production --urls $Url" + sc.exe create $ServiceName binPath= $bin start= auto | Out-Null + sc.exe start $ServiceName | Out-Null + Write-Host " Service '$ServiceName' installed and started on $Url." -ForegroundColor Green +} else { + Write-Host "[4/4] Skipping service install (use -InstallService to enable)." -ForegroundColor DarkGray +} + +# Next steps +Write-Host "`n=== Done ===`n" -ForegroundColor Cyan +Write-Host "Next steps:" -ForegroundColor White +Write-Host " 1. Make sure you have an Entra app registration (run register-app.ps1, or see README)." +Write-Host " 2. Set ConnectionStrings + AzureAd in appsettings.Production.json (DB + login)." +if (-not $InstallService) { + Write-Host " 3. Start the app from the publish folder (the working directory must be" -ForegroundColor White + Write-Host " the publish folder so config + wwwroot resolve; the Windows service" -ForegroundColor White + Write-Host " handles this automatically):" -ForegroundColor White + Write-Host " cd '$PublishPath'" -ForegroundColor Gray + Write-Host " `$env:ASPNETCORE_ENVIRONMENT='Production'; .\M365SecurityDashboard.Api.exe --urls $Url" -ForegroundColor Gray +} +Write-Host " 4. Open $Url, sign in (first user becomes Admin), then use the" -ForegroundColor White +Write-Host " in-app Setup wizard to enter your Graph credentials - no JSON editing." -ForegroundColor White +Write-Host "" diff --git a/register-app.ps1 b/register-app.ps1 new file mode 100644 index 0000000..9ec8c12 --- /dev/null +++ b/register-app.ps1 @@ -0,0 +1,144 @@ +<# +.SYNOPSIS + Scripts the Entra (Azure AD) app registration Vigil365 needs: read-only Microsoft + Graph application permissions, a SPA redirect URI, an exposed API scope + (access_as_user) for the dashboard login, a client secret, and admin consent. + + Outputs the TenantId / ClientId / ClientSecret and a ready-to-run deploy.ps1 line. + +.DESCRIPTION + Uses the Azure CLI (az). You must be signed in as a user who can create app + registrations and grant admin consent (Application Administrator / Cloud + Application Administrator / Global Administrator). + + Run it yourself — it creates an identity object and grants tenant consent in + YOUR tenant. Review before running. + +.EXAMPLE + az login + .\register-app.ps1 -RedirectUri https://localhost:5001 + +.EXAMPLE + .\register-app.ps1 -DisplayName "Vigil365 (Prod)" -RedirectUri https://vigil365.contoso.com +#> +[CmdletBinding()] +param( + [string]$DisplayName = "Vigil365", + [string]$RedirectUri = "https://localhost:5001", + [int]$SecretYears = 1 +) + +$ErrorActionPreference = "Stop" +$GraphAppId = "00000003-0000-0000-c000-000000000000" # Microsoft Graph + +# Read-only Graph application permissions the dashboard uses (see README). +$Permissions = @( + "SecurityEvents.Read.All", + "SecurityIncident.Read.All", + "IdentityRiskyUser.Read.All", + "IdentityRiskEvent.Read.All", + "AuditLog.Read.All", + "Reports.Read.All", + "DeviceManagementManagedDevices.Read.All", + "ServiceHealth.Read.All", + "Policy.Read.All", + "Directory.Read.All", + "ThreatHunting.Read.All", + "UserAuthenticationMethod.Read.All" +) + +function Require-Az { + if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + throw "Azure CLI (az) not found. Install: https://learn.microsoft.com/cli/azure/install-azure-cli" + } + try { az account show 1>$null 2>$null } catch { } + if ($LASTEXITCODE -ne 0) { throw "Not signed in. Run 'az login' first." } +} + +Write-Host "`n=== Vigil365 app registration ===`n" -ForegroundColor Cyan +Require-Az + +$tenantId = az account show --query tenantId -o tsv +Write-Host "Tenant: $tenantId" -ForegroundColor DarkGray + +# 1. Resolve permission names -> app-role GUIDs from the Graph service principal. +Write-Host "[1/6] Resolving Graph permission IDs..." -ForegroundColor Yellow +$graphSp = az ad sp show --id $GraphAppId | ConvertFrom-Json +$roleMap = @{} +foreach ($r in $graphSp.appRoles) { $roleMap[$r.value] = $r.id } + +$resourceAccess = @() +foreach ($p in $Permissions) { + if (-not $roleMap.ContainsKey($p)) { Write-Host " ! Unknown permission '$p' — skipping" -ForegroundColor DarkYellow; continue } + $resourceAccess += @{ id = $roleMap[$p]; type = "Role" } +} +Write-Host " Mapped $($resourceAccess.Count) permissions." -ForegroundColor Green + +# 2. Create the app registration. +Write-Host "[2/6] Creating app registration '$DisplayName'..." -ForegroundColor Yellow +$app = az ad app create --display-name $DisplayName --sign-in-audience AzureADMyOrg | ConvertFrom-Json +$appId = $app.appId +$objectId = $app.id +Write-Host " App (client) ID: $appId" -ForegroundColor Green + +# 3. Patch app: SPA redirect URI, Application ID URI, access_as_user scope, Graph permissions. +Write-Host "[3/6] Configuring SPA redirect, exposed API scope, and permissions..." -ForegroundColor Yellow +$scopeId = [guid]::NewGuid().ToString() +$patch = @{ + spa = @{ redirectUris = @($RedirectUri) } + identifierUris = @("api://$appId") + api = @{ + oauth2PermissionScopes = @(@{ + id = $scopeId + type = "User" + value = "access_as_user" + isEnabled = $true + adminConsentDisplayName = "Access Vigil365" + adminConsentDescription = "Allows the signed-in user to access Vigil365 on their behalf." + userConsentDisplayName = "Access Vigil365" + userConsentDescription = "Allows you to access Vigil365 on your behalf." + }) + } + requiredResourceAccess = @(@{ + resourceAppId = $GraphAppId + resourceAccess = $resourceAccess + }) +} +$patchJson = $patch | ConvertTo-Json -Depth 10 -Compress +$tmp = New-TemporaryFile +Set-Content -Path $tmp -Value $patchJson -Encoding UTF8 +az rest --method PATCH --uri "https://graph.microsoft.com/v1.0/applications/$objectId" ` + --headers "Content-Type=application/json" --body "@$tmp" | Out-Null +Remove-Item $tmp -Force +Write-Host " Configured." -ForegroundColor Green + +# 4. Ensure a service principal exists (needed for consent). +Write-Host "[4/6] Ensuring service principal..." -ForegroundColor Yellow +az ad sp create --id $appId 2>$null | Out-Null +Write-Host " Service principal ready." -ForegroundColor Green + +# 5. Create a client secret. +Write-Host "[5/6] Creating client secret..." -ForegroundColor Yellow +$cred = az ad app credential reset --id $appId --append --years $SecretYears --display-name "vigil365-deploy" | ConvertFrom-Json +$clientSecret = $cred.password +Write-Host " Secret created (shown once below)." -ForegroundColor Green + +# 6. Grant admin consent for the application permissions. +Write-Host "[6/6] Granting admin consent..." -ForegroundColor Yellow +try { + az ad app permission admin-consent --id $appId + Write-Host " Admin consent granted." -ForegroundColor Green +} catch { + Write-Host " Could not auto-consent. Grant it in the portal: Entra > App registrations >" -ForegroundColor DarkYellow + Write-Host " $DisplayName > API permissions > Grant admin consent." -ForegroundColor DarkYellow +} + +# Output +Write-Host "`n=== Done ===`n" -ForegroundColor Cyan +Write-Host "TenantId : $tenantId" +Write-Host "ClientId : $appId" +Write-Host "ClientSecret : $clientSecret (store securely — not shown again)" -ForegroundColor Yellow +Write-Host "RedirectUri : $RedirectUri" +Write-Host "`nNext — deploy with:" -ForegroundColor White +Write-Host " .\deploy.ps1 -TenantId $tenantId -ClientId $appId -AdminEmail you@yourdomain.com -Url $RedirectUri" -ForegroundColor Gray +Write-Host "`nThen enter the client secret in the in-app Setup wizard after signing in.`n" -ForegroundColor White diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1 new file mode 100644 index 0000000..d38c425 --- /dev/null +++ b/scripts/build-installer.ps1 @@ -0,0 +1,131 @@ +<# +.SYNOPSIS + Builds Vigil365-Setup.exe - a single self-contained installer. + +.DESCRIPTION + This is a RELEASE-time script, run by whoever ships Vigil365. It does all the + building here so the customer's server does not have to: the published + application is compressed and embedded inside the installer executable. + + The result needs nothing on the target machine - no source tree, no Node, no + .NET SDK, not even the .NET runtime. Both the application and the installer + are published self-contained. + + What the customer still needs is Azure CLI, and only because the wizard + registers the Entra application for them. The wizard installs it if missing. + +.PARAMETER SkipClient + Reuse the existing wwwroot instead of running npm. Only for iterating on the + installer itself - a shipped build must never skip it, or the SPA in the + payload is whatever happened to be lying around. + +.EXAMPLE + pwsh -File scripts/build-installer.ps1 +#> +[CmdletBinding()] +param( + [switch]$SkipClient, + [string]$OutDir = (Join-Path (Split-Path -Parent $PSScriptRoot) "dist") +) + +$ErrorActionPreference = "Stop" +$repo = Split-Path -Parent $PSScriptRoot +$installerProj = Join-Path $repo "src\M365SecurityDashboard.GuiInstaller" +$apiProj = Join-Path $repo "src\M365SecurityDashboard.Api\M365SecurityDashboard.Api.csproj" +$clientDir = Join-Path $repo "src\m365-security-dashboard-client" + +function Step($m) { Write-Host "`n== $m" -ForegroundColor Cyan } +function Ok($m) { Write-Host " OK $m" -ForegroundColor Green } + +$staging = Join-Path ([IO.Path]::GetTempPath()) "vigil365-payload" +$payload = Join-Path $installerProj "payload.zip" + +Step "Building the SPA" +if ($SkipClient) { + Write-Host " skipped (-SkipClient) - payload will reuse the existing wwwroot" -ForegroundColor Yellow +} else { + Push-Location $clientDir + try { + # `ci` not `install`: a shipped artifact should be built from the lockfile, + # not from whatever the ranges happen to resolve to today. + if (Test-Path (Join-Path $clientDir "package-lock.json")) { npm ci --no-audit --no-fund } + else { npm install --no-audit --no-fund } + if ($LASTEXITCODE -ne 0) { throw "npm install failed" } + npm run build + if ($LASTEXITCODE -ne 0) { throw "npm run build failed" } + } finally { Pop-Location } + Ok "vite build -> src/M365SecurityDashboard.Api/wwwroot" +} + +$indexPath = Join-Path $repo "src\M365SecurityDashboard.Api\wwwroot\index.html" +if (-not (Test-Path $indexPath)) { throw "No wwwroot/index.html - the SPA did not build, so the payload would ship without a UI." } + +Step "Publishing the application (self-contained)" +Remove-Item $staging -Recurse -Force -ErrorAction SilentlyContinue +# Self-contained so the target server needs no .NET at all. NOT trimmed: EF Core +# and the config binder resolve types by reflection, and trimming silently +# removes them - the failure shows up at runtime, not here. +dotnet publish $apiProj -c Release -r win-x64 --self-contained true -o $staging --nologo +if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed for the API" } + +if (-not (Test-Path (Join-Path $staging "wwwroot\index.html"))) { throw "Published output has no wwwroot - refusing to ship a payload with no UI." } +if (-not (Test-Path (Join-Path $staging "hostfxr.dll"))) { throw "Published output is not self-contained (no hostfxr.dll)." } + +# appsettings.Production.json is written by the wizard at install time from the +# customer's answers. Shipping one would overwrite theirs on every upgrade. +Remove-Item (Join-Path $staging "appsettings.Production.json") -Force -ErrorAction SilentlyContinue + +Ok ("{0} files, {1:N1} MB" -f (Get-ChildItem $staging -Recurse -File).Count, + ((Get-ChildItem $staging -Recurse | Measure-Object Length -Sum).Sum / 1MB)) + +Step "Compressing the payload" +Remove-Item $payload -Force -ErrorAction SilentlyContinue +Compress-Archive -Path (Join-Path $staging "*") -DestinationPath $payload -CompressionLevel Optimal +Ok ("payload.zip {0:N1} MB" -f ((Get-Item $payload).Length / 1MB)) + +Step "Building the installer" +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null +# Self-contained and single-file so the customer double-clicks one thing and it +# runs on a server with no .NET installed. +dotnet publish (Join-Path $installerProj "M365SecurityDashboard.GuiInstaller.csproj") ` + -c Release -r win-x64 --self-contained true ` + -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true ` + -o $OutDir --nologo +if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed for the installer" } + +$exe = Join-Path $OutDir "M365SecurityDashboard.GuiInstaller.exe" +if (-not (Test-Path $exe)) { throw "Installer executable was not produced." } + +$final = Join-Path $OutDir "Vigil365-Setup.exe" +# -Force does not reliably overwrite an existing destination here, so the second +# build in a row failed with "Cannot create a file when that file already +# exists". Clear it first - and say so plainly if the previous build is still +# open, because the raw error is an unexplained "access is denied". +if (Test-Path $final) { + try { Remove-Item $final -Force -ErrorAction Stop } + catch { + $running = Get-Process -Name "Vigil365-Setup" -ErrorAction SilentlyContinue + if ($running) { + throw "Vigil365-Setup.exe is still running (PID $($running.Id -join ', ')). Close it and build again." + } + throw "Could not replace $final - it is locked by another process. $($_.Exception.Message)" + } +} +Move-Item $exe $final + +# Loose files beside a single-file exe invite shipping the wrong thing. +Get-ChildItem $OutDir -File | Where-Object { $_.Name -ne "Vigil365-Setup.exe" } | Remove-Item -Force +Remove-Item $payload -Force -ErrorAction SilentlyContinue +Remove-Item $staging -Recurse -Force -ErrorAction SilentlyContinue + +Write-Host "`n== Done" -ForegroundColor Cyan +Ok ("{0} ({1:N1} MB)" -f $final, ((Get-Item $final).Length / 1MB)) +Write-Host @" + +Ship that one file. On the target server it needs no source tree, no Node, and +no .NET - it carries the application and the runtime with it. + +It must be run as Administrator: it registers a Windows service, creates a SQL +login, and may install a certificate. +"@ + diff --git a/scripts/check-version.ps1 b/scripts/check-version.ps1 new file mode 100644 index 0000000..a930b20 --- /dev/null +++ b/scripts/check-version.ps1 @@ -0,0 +1,38 @@ +<# +.SYNOPSIS + Fails if the API and client versions have drifted apart. + +.DESCRIPTION + The version is shown in the sidebar, on the login screen, in /health, and in + exported policy packs. Two independent declarations (the API's and + the client's package.json "version") will eventually disagree, and a support + report naming the wrong build is worse than no version at all. CI runs this so + a release cannot ship mismatched numbers. +#> +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" +$repo = Split-Path -Parent $PSScriptRoot + +$csprojPath = Join-Path $repo "src/M365SecurityDashboard.Api/M365SecurityDashboard.Api.csproj" +$packagePath = Join-Path $repo "src/m365-security-dashboard-client/package.json" + +$apiVersion = ([xml](Get-Content $csprojPath)).Project.PropertyGroup.Version | Where-Object { $_ } +$clientVersion = (Get-Content $packagePath -Raw | ConvertFrom-Json).version + +if (-not $apiVersion) { + Write-Host "FAIL No declared in $csprojPath" -ForegroundColor Red + exit 1 +} + +Write-Host "API (csproj) $apiVersion" +Write-Host "Client (package.json) $clientVersion" + +if ($apiVersion -ne $clientVersion) { + Write-Host "`nFAIL Versions differ. Update both, or run scripts/set-version.ps1 ." -ForegroundColor Red + exit 1 +} + +Write-Host "`nPASS Versions match ($apiVersion)." -ForegroundColor Green + diff --git a/scripts/deploy-public.ps1 b/scripts/deploy-public.ps1 new file mode 100644 index 0000000..1ce05f4 --- /dev/null +++ b/scripts/deploy-public.ps1 @@ -0,0 +1,185 @@ +<# +.SYNOPSIS + Serves Vigil365 on a real public hostname over HTTPS on port 443. + +.DESCRIPTION + deploy.ps1 targets a LOCAL install: a hosts-file alias, a self-signed + certificate, and a high port. This script is the public counterpart - it binds + every interface on 443 with a real certificate and opens the Windows firewall. + + READ THIS FIRST. Publishing Vigil365 to the internet changes its threat model: + the README's "no inbound exposure by default" no longer holds, /health and + /api/auth/config answer anonymously (exposing collection state and your tenant + and client ids), and the application has never had a third-party penetration + test. Prefer restricting inbound 443 to known source IPs, or fronting this with + a reverse proxy / WAF, rather than opening it to the world. + + Must be run from an ELEVATED PowerShell: binding 443 and writing firewall + rules both require administrator rights. + +.PARAMETER PfxPath + A real certificate for the public hostname. Without it the script falls back to + a self-signed certificate so you can verify the plumbing, but every visitor + will see a browser warning - do not leave that in place. + + To get a real certificate, run scripts/request-cert.ps1 first - it drives lego + against Let's Encrypt and prints the exact -PfxPath / -PfxPassword to pass here: + + winget install GoACME.lego # then open a NEW terminal for PATH + pwsh -File scripts/request-cert.ps1 -Hostname -Email + + Its default DNS-01 method needs no inbound ports, so it works behind CGNAT and + with port 80 closed. Pass -Method http instead if port 80 is reachable and you + want renewals to be automatable. + +.EXAMPLE + pwsh -File scripts/deploy-public.ps1 -Hostname vigil365.in -PfxPath C:\certs\vigil365.pfx -PfxPassword (Read-Host -AsSecureString) +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string]$Hostname, + [int]$Port = 443, + [string]$PfxPath, + [System.Security.SecureString]$PfxPassword, + [string]$PublishPath = (Join-Path (Split-Path -Parent $PSScriptRoot) "publish"), + [switch]$SkipFirewall, + [switch]$SkipDns, + [switch]$NoRun +) + +$ErrorActionPreference = "Stop" +$repo = Split-Path -Parent $PSScriptRoot +$fail = 0 + +function Step($msg) { Write-Host "`n== $msg" -ForegroundColor Cyan } +function Ok($msg) { Write-Host " OK $msg" -ForegroundColor Green } +function Warn($msg) { Write-Host " WARN $msg" -ForegroundColor Yellow } +function Bad($msg) { $script:fail++; Write-Host " FAIL $msg" -ForegroundColor Red } + +Step "Preflight" + +# Elevation - binding 443 and firewall changes both need it. +$admin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() + ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if ($admin) { Ok "running elevated" } else { Bad "not elevated - re-run PowerShell as Administrator" } + +# DNS must point at this connection, or nobody reaches the app. +if ($SkipDns) { + Warn "skipped DNS check by request (-SkipDns)" +} else { + try { + $resolved = (Resolve-DnsName $Hostname -Type A -ErrorAction Stop | + Where-Object { $_.IPAddress } | Select-Object -First 1).IPAddress + $public = (Invoke-RestMethod -Uri "https://api.ipify.org?format=json" -TimeoutSec 8).ip + if ($resolved -eq $public) { Ok "$Hostname -> $resolved (matches this connection)" } + else { Bad "$Hostname resolves to $resolved but this connection is $public - update the A record" } + Warn "residential IPs usually change; the A record will go stale unless it is static or dynamic-DNS updated" + } catch { + Bad "could not verify DNS: $($_.Exception.Message)" + } +} + +# Port must be free. Note netstat also lists OUTBOUND :443 connections - only a +# LISTENING socket is a conflict. +$listening = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue +if ($listening) { Bad "port $Port already has a listener (PID $($listening[0].OwningProcess))" } +else { Ok "port $Port is free" } + +if (-not (Test-Path (Join-Path $PublishPath "M365SecurityDashboard.Api.exe"))) { + Bad "no published build at $PublishPath - run: dotnet publish src/M365SecurityDashboard.Api -c Release -o publish" +} else { Ok "published build found" } + +if ($fail -gt 0) { Write-Host "`n$fail preflight check(s) failed. Nothing was changed.`n" -ForegroundColor Red; exit 1 } + +Step "Certificate" +$pfxOut = Join-Path $PublishPath "vigil365-public.pfx" +$pfxPlain = $null + +if ($PfxPath) { + if (-not (Test-Path $PfxPath)) { Bad "PFX not found: $PfxPath"; exit 1 } + Copy-Item $PfxPath $pfxOut -Force + if ($PfxPassword) { + $pfxPlain = [Runtime.InteropServices.Marshal]::PtrToStringAuto( + [Runtime.InteropServices.Marshal]::SecureStringToBSTR($PfxPassword)) + } + Ok "using supplied certificate" +} else { + Warn "no -PfxPath given - generating a SELF-SIGNED certificate" + Warn "every visitor will get a browser trust warning; replace before real use" + $pfxPlain = [Guid]::NewGuid().ToString("N") + $cert = New-SelfSignedCertificate -DnsName $Hostname -FriendlyName "Vigil365 $Hostname" ` + -CertStoreLocation "Cert:\CurrentUser\My" -KeyExportPolicy Exportable ` + -NotAfter (Get-Date).AddYears(1) + Export-PfxCertificate -Cert $cert -FilePath $pfxOut ` + -Password (ConvertTo-SecureString -String $pfxPlain -Force -AsPlainText) | Out-Null + Ok "self-signed certificate written to $pfxOut" +} + +Step "Configuration" +$url = if ($Port -eq 443) { "https://$Hostname" } else { "https://${Hostname}:$Port" } +$cfgPath = Join-Path $PublishPath "appsettings.Production.json" + +# Preserve everything already configured (connection string, AzureAd, admin +# email) and change only what public hosting requires. +$cfg = if (Test-Path $cfgPath) { Get-Content $cfgPath -Raw | ConvertFrom-Json } else { [pscustomobject]@{} } + +# Bind every interface - 127.0.0.1 would be unreachable from outside. [::] is +# dual-stack on Windows (covers IPv4 too), which matters here: this connection is +# behind carrier-grade NAT on IPv4, so IPv6 is the only path that accepts inbound. +$cfg | Add-Member -NotePropertyName Kestrel -NotePropertyValue ([pscustomobject]@{ + Endpoints = [pscustomobject]@{ + Https = [pscustomobject]@{ + Url = "https://[::]:$Port" + Certificate = [pscustomobject]@{ Path = (Split-Path -Leaf $pfxOut); Password = $pfxPlain } + } + } +}) -Force + +if (-not $cfg.Auth) { $cfg | Add-Member -NotePropertyName Auth -NotePropertyValue ([pscustomobject]@{}) -Force } +$cfg.Auth | Add-Member -NotePropertyName RedirectUri -NotePropertyValue $url -Force + +$cfg | Add-Member -NotePropertyName Cors -NotePropertyValue ([pscustomobject]@{ + AllowedOrigins = @($url) +}) -Force + +$cfg | ConvertTo-Json -Depth 8 | Set-Content $cfgPath -Encoding UTF8 +Ok "wrote $cfgPath (bind 0.0.0.0:$Port, redirect $url)" + +Step "Firewall" +if ($SkipFirewall) { + Warn "skipped by request - inbound $Port must be allowed some other way" +} else { + $ruleName = "Vigil365 HTTPS $Port" + Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue | Remove-NetFirewallRule + New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Action Allow ` + -Protocol TCP -LocalPort $Port -Profile Any | Out-Null + Ok "inbound TCP $Port allowed" + Warn "this allows the whole internet; restrict with -RemoteAddress on the rule if you can" +} + +Write-Host @" + +Still required, and only you can do these: + + 1. Router: forward external TCP $Port to this machine (192.168.x.x) on $Port. + Without it the port is open on Windows but unreachable from outside. + + 2. Entra: add "$url" as a SPA redirect URI on app registration + $(if ($cfg.AzureAd.ClientId) { $cfg.AzureAd.ClientId } else { "" }). + Sign-in fails with AADSTS50011 until this exists. + + 3. Certificate: replace the self-signed PFX with a real one if you used the + fallback, then re-run this script with -PfxPath. + +"@ -ForegroundColor Cyan + +if ($NoRun) { Write-Host "-NoRun set; not starting.`n"; exit 0 } + +Step "Starting" +Push-Location $PublishPath +try { + $env:ASPNETCORE_ENVIRONMENT = "Production" + Write-Host " $url`n" -ForegroundColor Green + & (Join-Path $PublishPath "M365SecurityDashboard.Api.exe") +} finally { Pop-Location } + diff --git a/scripts/request-cert.ps1 b/scripts/request-cert.ps1 new file mode 100644 index 0000000..f13b845 --- /dev/null +++ b/scripts/request-cert.ps1 @@ -0,0 +1,316 @@ +<# +.SYNOPSIS + Requests a real, publicly-trusted TLS certificate for Vigil365 from Let's + Encrypt using lego, and emits a .pfx that deploy-public.ps1 can consume. + +.DESCRIPTION + This replaces the self-signed certificate that deploy-public.ps1 falls back to. + Self-signed means every visitor sees a browser warning, which on a security + product trains people to click through TLS warnings - so it is not a resting + state, it is a placeholder. + + Three validation methods: + + dns (default) Solves DNS-01. You paste a TXT record into your DNS zone when + prompted. Needs NO inbound ports and NO firewall changes, so + it works behind CGNAT and with port 80 closed. INTERACTIVE - + lego waits on stdin, so run this yourself in a real terminal. + Cannot auto-renew: you repeat this every ~90 days. + + godaddy Same DNS-01 challenge, but lego creates and deletes the TXT + record itself through GoDaddy's API. No manual step and + renewals are unattended. Needs GODADDY_API_KEY and + GODADDY_API_SECRET in the environment. GoDaddy restricts this + API to accounts with 10+ domains or a Discount Domain Club + plan; smaller accounts get 403 and must use -Method dns. + + http Solves HTTP-01. lego binds port 80 and Let's Encrypt calls + back. Fully automatable for renewals, but port 80 must be + reachable from the internet. On an IPv6-only path (no A + record) Let's Encrypt validates over IPv6 - your router and + Windows firewall must both allow inbound TCP 80. Requires + elevation to bind 80. + + Certificate order of operations: + 1. pwsh -File scripts/request-cert.ps1 -Hostname vigil365.in -Email you@example.com + 2. deploy-public.ps1 with the -PfxPath / -PfxPassword it prints + +.PARAMETER Staging + Use the Let's Encrypt staging CA. The resulting certificate is NOT trusted by + browsers - it only proves the validation plumbing works. Worth it before a + first HTTP-01 attempt; less worth it for DNS-01, where the manual TXT step is + the toil and you would just do it twice. + +.EXAMPLE + pwsh -File scripts/request-cert.ps1 -Hostname vigil365.in -Email you@example.com + +.EXAMPLE + pwsh -File scripts/request-cert.ps1 -Hostname vigil365.in -Email you@example.com -Method http +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string]$Hostname, + [Parameter(ParameterSetName = "Issue", Mandatory)] [string]$Email, + [ValidateSet("dns", "godaddy", "http")] [string]$Method = "dns", + [switch]$Staging, + [int]$PropagationTimeout = 600, + [Parameter(ParameterSetName = "Check", Mandatory)] [switch]$CheckTxt, + [string]$OutDir = (Join-Path (Split-Path -Parent $PSScriptRoot) "certs") +) + +$ErrorActionPreference = "Stop" +$repo = Split-Path -Parent $PSScriptRoot + +function Step($m) { Write-Host "`n== $m" -ForegroundColor Cyan } +function Ok($m) { Write-Host " OK $m" -ForegroundColor Green } +function Warn($m) { Write-Host " WARN $m" -ForegroundColor Yellow } +function Bad($m) { Write-Host " FAIL $m" -ForegroundColor Red } + +# Asks the zone's AUTHORITATIVE nameservers, not a recursive resolver. A cached +# NXDOMAIN from a resolver looks identical to a record that was never saved, and +# only the authoritative answer distinguishes "not there yet" from "not there". +function Get-AcmeTxt { + param([string]$Zone) + $name = "_acme-challenge.$Zone" + $out = [ordered]@{ Name = $name; Servers = @() } + $ns = @() + try { + $ns = Resolve-DnsName $Zone -Type NS -Server 8.8.8.8 -ErrorAction Stop | + Where-Object { $_.Type -eq "NS" } | Select-Object -ExpandProperty NameHost + } catch { } + foreach ($n in $ns) { + $ip = $null + try { + $ip = Resolve-DnsName $n -Type A -Server 8.8.8.8 -ErrorAction Stop | + Where-Object { $_.Type -eq "A" } | Select-Object -First 1 -ExpandProperty IPAddress + } catch { } + if (-not $ip) { continue } + $vals = @() + try { + $vals = Resolve-DnsName $name -Type TXT -Server $ip -ErrorAction Stop | + Where-Object { $_.Type -eq "TXT" } | ForEach-Object { $_.Strings -join "" } + } catch { } + $out.Servers += [pscustomobject]@{ Host = $n; Ip = $ip; Values = $vals } + } + [pscustomobject]$out +} + +if ($CheckTxt) { + Step "Checking _acme-challenge.$Hostname on the authoritative nameservers" + $res = Get-AcmeTxt -Zone $Hostname + if ($res.Servers.Count -eq 0) { throw "Could not determine the authoritative nameservers for $Hostname." } + $found = $false + foreach ($s in $res.Servers) { + if ($s.Values.Count -gt 0) { $found = $true; foreach ($v in $s.Values) { Ok "$($s.Host) TXT `"$v`"" } } + else { Bad "$($s.Host) no TXT record" } + } + if ($found) { + Write-Host "`nRecord is live. Press Enter in the lego window now.`n" -ForegroundColor Green + } else { + Write-Host @" + +Not published yet. Either it has not saved, or it is still propagating. + +In GoDaddy's DNS manager the Name field must be exactly: + + _acme-challenge + +NOT the full _acme-challenge.$Hostname - GoDaddy appends the zone for you, so +pasting the FQDN creates _acme-challenge.$Hostname.$Hostname instead. + +The Value is the long string lego printed, with NO surrounding quotes. + +"@ -ForegroundColor Yellow + } + exit ($(if ($found) { 0 } else { 1 })) +} + +Step "Locating lego" + +# winget puts lego on PATH, but only for shells started AFTER the install - +# hence also probing the package directory. +$lego = (Get-Command lego -ErrorAction SilentlyContinue).Source +if (-not $lego) { + $lego = Get-ChildItem (Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Packages") ` + -Recurse -Filter "lego.exe" -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName +} +if (-not $lego) { + throw @" +lego not found. Install it, then open a NEW terminal (PATH is only refreshed for +new shells): + + winget install GoACME.lego +"@ +} +Ok "$lego" +Ok "version $((& $lego --version) -replace '^lego version\s*','')" + +Step "Preflight" + +# No AAAA/A record means nothing Let's Encrypt does can succeed, whichever +# challenge you pick - DNS-01 still requires the name to resolve for issuance. +$records = @() +foreach ($t in @("A", "AAAA")) { + try { $records += Resolve-DnsName -Name $Hostname -Type $t -ErrorAction Stop | + Where-Object { $_.Type -eq $t } } catch { } +} +if ($records.Count -eq 0) { + throw "$Hostname has no A or AAAA record. Point DNS at this connection first." +} +foreach ($r in $records) { Ok "$($r.Type) -> $($r.IPAddress)" } + +if ($Method -eq "http") { + $admin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() + ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + if (-not $admin) { throw "HTTP-01 binds port 80 - re-run from an ELEVATED PowerShell." } + Ok "running elevated" + + if (-not ($records | Where-Object { $_.Type -eq "A" })) { + Warn "No A record - Let's Encrypt will validate over IPv6." + Warn "Inbound TCP 80 must be open on BOTH the router and the Windows firewall." + } + + # A listener already on 80 means lego cannot bind it; better to say so now + # than to burn a failed-validation against the rate limit. + $busy = Get-NetTCPConnection -LocalPort 80 -State Listen -ErrorAction SilentlyContinue + if ($busy) { throw "Port 80 is already in use (PID $($busy[0].OwningProcess)). Stop it, or use -Method dns." } + Ok "port 80 free" +} + +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + +# Random per-issuance password. It ends up in appsettings.Production.json in +# plaintext (Kestrel needs to read it unattended), so it protects the .pfx at +# rest and in transit, not against someone who already has the published folder. +$pfxPassword = [Guid]::NewGuid().ToString("N") + +Step "Requesting certificate from Let's Encrypt" +Write-Host " hostname $Hostname" +$methodLabel = switch ($Method) { + "dns" { "DNS-01 (manual TXT)" } + "godaddy" { "DNS-01 (GoDaddy API - automatic)" } + "http" { "HTTP-01 (port 80)" } +} +Write-Host " method $methodLabel" +Write-Host " CA $(if ($Staging) { 'STAGING - result will NOT be trusted' } else { 'production' })" + +# Flag placement matters: lego 5.x global flags are only --help/--version/ +# --log.*/--config. Everything else belongs to the `run` SUBCOMMAND and must +# appear AFTER it. `lego --email ... run` fails with "flag provided but not +# defined: -email". +$legoArgs = @("--log.level", "info", "run", + "--accept-tos", + "--email", $Email, + "--domains", $Hostname, + "--path", $OutDir, + "--pfx", + "--pfx.format", "SHA256", # default is RC2, which modern Windows resists loading + "--pfx.password", $pfxPassword) + +if ($Staging) { $legoArgs += @("--server", "letsencrypt-staging") } + +switch ($Method) { + "dns" { + $legoArgs += @("--dns", "manual") + + # lego's manual provider polls for only 60s by default. GoDaddy's minimum + # TTL is 600s and its edge takes minutes to converge, so the default loses + # the race even when the record was saved correctly - the failure then reads + # as "time limit exceeded", which looks like a DNS fault rather than a + # too-short timeout. Give it room. + $env:MANUAL_PROPAGATION_TIMEOUT = "$PropagationTimeout" + $env:MANUAL_POLLING_INTERVAL = "5" + + Write-Host @" + + lego will print a TXT record, then wait. In GoDaddy's DNS manager: + + Type TXT + Name _acme-challenge <- just this, GoDaddy appends the zone + Value + TTL 600 (the minimum GoDaddy accepts) + + Save it, then confirm it is actually live from a SECOND terminal: + + pwsh -File scripts/request-cert.ps1 -Hostname $Hostname -CheckTxt + + Only press Enter in the lego window once that reports the record. + After you press Enter lego keeps polling for up to $PropagationTimeout seconds. + +"@ -ForegroundColor Yellow + } + + "godaddy" { + # lego reads these itself; the script only fails fast if they are absent so + # the run does not burn a Let's Encrypt failed-validation to tell you. + if (-not $env:GODADDY_API_KEY -or -not $env:GODADDY_API_SECRET) { + throw @" +GoDaddy API credentials not set. Create a PRODUCTION key at +https://developer.godaddy.com/keys then, in this terminal: + + `$env:GODADDY_API_KEY = '' + `$env:GODADDY_API_SECRET = '' + +Note: since 2024 GoDaddy restricts this API to accounts holding 10+ domains or +a Discount Domain Club plan. A single-domain account gets 403 ACCESS_DENIED - +if that happens, fall back to -Method dns. +"@ + } + $legoArgs += @("--dns", "godaddy") + $env:GODADDY_PROPAGATION_TIMEOUT = "$PropagationTimeout" + Ok "GODADDY_API_KEY / GODADDY_API_SECRET present" + Write-Host " No manual step - lego creates and removes the TXT record itself." -ForegroundColor Green + } + + "http" { + $legoArgs += @("--http", "--http.address", ":80") + } +} + +& $lego @legoArgs +if ($LASTEXITCODE -ne 0) { + throw "lego exited $LASTEXITCODE - certificate NOT issued. Nothing was changed." +} + +$pfx = Join-Path $OutDir "certificates\$Hostname.pfx" +if (-not (Test-Path $pfx)) { + # lego sanitises wildcards into a leading underscore. + $pfx = Get-ChildItem (Join-Path $OutDir "certificates") -Filter "*.pfx" | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName +} +if (-not $pfx -or -not (Test-Path $pfx)) { throw "lego reported success but no .pfx was found under $OutDir\certificates." } + +Step "Verifying the issued certificate" +$cert = New-Object Security.Cryptography.X509Certificates.X509Certificate2 $pfx, $pfxPassword +Ok "subject $($cert.Subject)" +Ok "issuer $($cert.Issuer)" +Ok "expires $($cert.NotAfter)" +if ($cert.Subject -eq $cert.Issuer) { Warn "Self-signed - this is not a CA-issued certificate." } + +Write-Host "`n== Next step" -ForegroundColor Cyan +Write-Host @" +Install it (ELEVATED PowerShell - binding 443 needs administrator): + + pwsh -File scripts/deploy-public.ps1 `` + -Hostname $Hostname `` + -PfxPath '$pfx' `` + -PfxPassword (ConvertTo-SecureString '$pfxPassword' -AsPlainText -Force) + +Then fully close and reopen the browser - TLS decisions are cached per session. + +If you previously ran trust-local-cert.ps1, remove the self-signed certificate +from your Root store afterwards; leaving it trusted means a stale certificate for +this hostname stays valid on this machine. +"@ +if ($Staging) { + Warn "`nThis is a STAGING certificate. Browsers will still warn. Re-run without -Staging for a real one." +} +if ($Method -eq "dns") { + Warn "`nDNS-01 manual does not auto-renew. This certificate expires $($cert.NotAfter.ToString('yyyy-MM-dd')) - repeat then." + Warn "To make renewals unattended, use -Method godaddy (your zone is on GoDaddy) or -Method http." +} +if ($Method -ne "dns") { + Write-Host "`nRenewable unattended: re-run the same command. Schedule it well before $($cert.NotAfter.ToString('yyyy-MM-dd'))." -ForegroundColor Green +} + diff --git a/scripts/set-version.ps1 b/scripts/set-version.ps1 new file mode 100644 index 0000000..6feb087 --- /dev/null +++ b/scripts/set-version.ps1 @@ -0,0 +1,41 @@ +<# +.SYNOPSIS + Sets the release version in both places at once. + +.DESCRIPTION + Updating the API's and the client's package.json by hand is how they + drift. This writes both, then verifies, so cutting a release is one command. + +.EXAMPLE + pwsh scripts/set-version.ps1 1.1.0 +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidatePattern('^\d+\.\d+\.\d+$')] + [string]$Version +) + +$ErrorActionPreference = "Stop" +$repo = Split-Path -Parent $PSScriptRoot + +$csprojPath = Join-Path $repo "src/M365SecurityDashboard.Api/M365SecurityDashboard.Api.csproj" +$packagePath = Join-Path $repo "src/m365-security-dashboard-client/package.json" + +# Targeted replacements - a full XML/JSON round-trip would reformat the files. +$csproj = Get-Content $csprojPath -Raw +$updated = [regex]::Replace($csproj, '[^<]*', "$Version", 1) +if ($updated -eq $csproj) { throw "No element found in $csprojPath" } +Set-Content $csprojPath $updated -NoNewline + +$package = Get-Content $packagePath -Raw +$updatedPkg = [regex]::Replace($package, '"version":\s*"[^"]*"', """version"": ""$Version""", 1) +if ($updatedPkg -eq $package) { throw "No version field found in $packagePath" } +Set-Content $packagePath $updatedPkg -NoNewline + +Write-Host "Set version to $Version in both projects.`n" -ForegroundColor Green +& (Join-Path $PSScriptRoot "check-version.ps1") + +Write-Host "`nNext: update CHANGELOG.md, commit, then tag:" -ForegroundColor Cyan +Write-Host " git tag -a v$Version -m ""Vigil365 v$Version""" -ForegroundColor Cyan + diff --git a/scripts/smoke-test.ps1 b/scripts/smoke-test.ps1 new file mode 100644 index 0000000..268ebff --- /dev/null +++ b/scripts/smoke-test.ps1 @@ -0,0 +1,118 @@ +<# +.SYNOPSIS + Post-deploy smoke check for a running Vigil365 instance. + +.DESCRIPTION + Catches the deployment failures that unit tests structurally cannot: + + * a stale wwwroot - index.html referencing a bundle that is not on disk + (this shipped twice before it was caught by hand) + * assets returning 404 + * the API being up but the database being unreachable + * auth regressions that leave a protected endpoint anonymous + * unknown /api paths falling through to the SPA instead of 404ing + + Read-only: issues GETs only, never mutates the tenant or the database. + +.EXAMPLE + pwsh scripts/smoke-test.ps1 -BaseUrl https://vigil365.local:5001 +#> +[CmdletBinding()] +param( + [string]$BaseUrl = "https://vigil365.local:5001", + # The dev/prod certificate is self-signed; skip validation for localhost checks. + [switch]$SkipCertCheck = $true +) + +$ErrorActionPreference = "Stop" +$script:Failures = 0 + +function Invoke-Check { + param([string]$Name, [scriptblock]$Test) + try { + & $Test + Write-Host " PASS $Name" -ForegroundColor Green + } catch { + $script:Failures++ + Write-Host " FAIL $Name" -ForegroundColor Red + Write-Host " $($_.Exception.Message)" -ForegroundColor DarkGray + } +} + +function Get-Url { + param([string]$Path, [int[]]$AllowStatus = @(200)) + $params = @{ Uri = "$BaseUrl$Path"; Method = "GET"; UseBasicParsing = $true } + if ($SkipCertCheck) { $params.SkipCertificateCheck = $true } + try { + $r = Invoke-WebRequest @params + } catch { + $code = $_.Exception.Response.StatusCode.value__ + if ($code -and $AllowStatus -contains $code) { return @{ StatusCode = $code; Content = "" } } + throw "GET $Path -> $(if ($code) { $code } else { $_.Exception.Message })" + } + if ($AllowStatus -notcontains $r.StatusCode) { throw "GET $Path -> $($r.StatusCode), expected $($AllowStatus -join '/')" } + return @{ StatusCode = $r.StatusCode; Content = $r.Content } +} + +Write-Host "Vigil365 smoke test -> $BaseUrl`n" + +Invoke-Check "API is healthy and the database is reachable" { + $body = (Get-Url "/health").Content | ConvertFrom-Json + if ($body.status -eq "unhealthy") { throw "health reports unhealthy" } + if (-not $body.checks.database.ok) { throw "database check failed: $($body.checks.database.error)" } +} + +Invoke-Check "SPA shell is served" { + $html = (Get-Url "/").Content + if ($html -notmatch '
') { throw "index.html has no #root mount point" } +} + +Invoke-Check "index.html references a bundle that actually exists" { + # The stale-wwwroot failure: publish leaves an index.html pointing at a hash + # that was never copied, so the app serves a blank page. + $html = (Get-Url "/").Content + $assets = [regex]::Matches($html, '/assets/[A-Za-z0-9._-]+\.(?:js|css)') | ForEach-Object { $_.Value } | Select-Object -Unique + if ($assets.Count -eq 0) { throw "index.html references no bundled assets" } + foreach ($asset in $assets) { + $r = Get-Url $asset + if ($r.StatusCode -ne 200) { throw "$asset -> $($r.StatusCode)" } + } + Write-Host " verified $($assets.Count) asset(s)" -ForegroundColor DarkGray +} + +Invoke-Check "pre-paint display preferences script is served" { + Get-Url "/display-prefs.js" | Out-Null +} + +Invoke-Check "protected endpoints reject anonymous callers" { + foreach ($path in @("/api/dashboard/overview", "/api/alert-policies", "/api/api-tokens")) { + $r = Get-Url $path -AllowStatus @(401, 403) + if ($r.StatusCode -notin 401, 403) { throw "$path was reachable anonymously ($($r.StatusCode))" } + } +} + +Invoke-Check "SIEM endpoints reject a forged API token" { + $params = @{ Uri = "$BaseUrl/api/siem/alerts"; Method = "GET"; UseBasicParsing = $true + Headers = @{ Authorization = "Bearer vig_not_a_real_token" } } + if ($SkipCertCheck) { $params.SkipCertificateCheck = $true } + try { + Invoke-WebRequest @params | Out-Null + throw "a forged token was accepted" + } catch { + $code = $_.Exception.Response.StatusCode.value__ + if ($code -ne 401) { throw "expected 401 for a forged token, got $code" } + } +} + +Invoke-Check "unknown /api paths 404 as JSON instead of serving the SPA" { + $r = Get-Url "/api/definitely-not-an-endpoint" -AllowStatus @(404) + if ($r.StatusCode -ne 404) { throw "expected 404, got $($r.StatusCode)" } +} + +Write-Host "" +if ($script:Failures -gt 0) { + Write-Host "$($script:Failures) check(s) failed." -ForegroundColor Red + exit 1 +} +Write-Host "All checks passed." -ForegroundColor Green + diff --git a/scripts/trust-local-cert.ps1 b/scripts/trust-local-cert.ps1 new file mode 100644 index 0000000..69aae3e --- /dev/null +++ b/scripts/trust-local-cert.ps1 @@ -0,0 +1,58 @@ +<# +.SYNOPSIS + Trusts the self-signed certificate on THIS machine so the browser stops + warning. Testing convenience only. + +.DESCRIPTION + This does not make the certificate valid - it tells this one computer to + accept it. Every other visitor still gets the warning, which on a security + product trains people to click through TLS warnings. Use it to test locally, + then replace the certificate with a real one (see deploy-public.ps1 header). + + Reads the PFX password from appsettings.Production.json so you do not have to + handle it. Run elevated to install for all users; without elevation it lands + in the current user's store, which is enough for your own browser. + +.EXAMPLE + pwsh -File scripts/trust-local-cert.ps1 +#> +[CmdletBinding()] +param( + [string]$PublishPath = (Join-Path (Split-Path -Parent $PSScriptRoot) "publish") +) + +$ErrorActionPreference = "Stop" + +$cfgPath = Join-Path $PublishPath "appsettings.Production.json" +if (-not (Test-Path $cfgPath)) { throw "No appsettings.Production.json at $cfgPath - run deploy-public.ps1 first." } + +$cfg = Get-Content $cfgPath -Raw | ConvertFrom-Json +$certNode = $cfg.Kestrel.Endpoints.Https.Certificate +if (-not $certNode.Path) { throw "No certificate configured in $cfgPath." } + +$pfx = Join-Path $PublishPath $certNode.Path +if (-not (Test-Path $pfx)) { throw "Certificate file not found: $pfx" } + +$loaded = New-Object Security.Cryptography.X509Certificates.X509Certificate2 ` + $pfx, $certNode.Password, "MachineKeySet,PersistKeySet" + +if ($loaded.Subject -ne $loaded.Issuer) { + Write-Host "This certificate is NOT self-signed - it is issued by:" -ForegroundColor Yellow + Write-Host " $($loaded.Issuer)" -ForegroundColor Yellow + Write-Host "If browsers still warn, the chain is likely incomplete rather than untrusted." -ForegroundColor Yellow + exit 0 +} + +$admin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() + ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +$storeLocation = if ($admin) { "LocalMachine" } else { "CurrentUser" } + +$store = New-Object Security.Cryptography.X509Certificates.X509Store "Root", $storeLocation +$store.Open("ReadWrite") +$store.Add($loaded) +$store.Close() + +Write-Host "`nTrusted '$($loaded.Subject)' in $storeLocation\Root (expires $($loaded.NotAfter))." -ForegroundColor Green +Write-Host "Fully close and reopen the browser - TLS decisions are cached per session.`n" +Write-Host "Remember: only THIS machine trusts it. Replace with a real certificate before anyone else uses the site." -ForegroundColor Yellow + diff --git a/src/M365SecurityDashboard.Api.Tests/ActivityPolicyTests.cs b/src/M365SecurityDashboard.Api.Tests/ActivityPolicyTests.cs new file mode 100644 index 0000000..c700d63 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/ActivityPolicyTests.cs @@ -0,0 +1,183 @@ +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace M365SecurityDashboard.Api.Tests; + +/// +/// Activity-based policies: fire when matching tenant audit events occur within +/// the sliding window; wildcard patterns; events outside the window are ignored; +/// affected entities carry the actor/target and serialize camelCase for the UI. +/// +public class ActivityPolicyTests +{ + private sealed class NullHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(); + } + + private static AlertEvaluator BuildEvaluator(AppDbContext db) + { + var options = Microsoft.Extensions.Options.Options.Create(new AlertingOptions { AutoResolveDebounceCycles = 2 }); + var sender = new NotificationSender( + new NullHttpClientFactory(), + new SecretProtector(new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider(), NullLogger.Instance), + NullLogger.Instance); + return new AlertEvaluator(db, sender, options, NullLogger.Instance); + } + + private static AlertPolicy ActivityPolicy(string pattern, int threshold = 1, int windowMinutes = 60) => new() + { + Id = Guid.NewGuid(), Name = $"Watch: {pattern}", Enabled = true, Kind = "activity", + Category = "identity", ActivityPattern = pattern, WindowMinutes = windowMinutes, + Threshold = threshold, Severity = "high", Condition = $"Activity \"{pattern}\" ≥ {threshold} in {windowMinutes}m", + SuppressionMinutes = 60, CreatedAt = DateTimeOffset.UtcNow.AddDays(-1), + }; + + private static AlertPolicy AnomalyPolicy(string metric, int threshold = 10, double multiplier = 3, int baselineDays = 30) => new() + { + Id = Guid.NewGuid(), Name = $"Spike: {metric}", Enabled = true, Kind = "anomaly", + Category = "identity", Metric = metric, Threshold = threshold, + BaselineMultiplier = multiplier, BaselineDays = baselineDays, + Severity = "high", Condition = $"{metric} ≥ {threshold} and ≥ {multiplier}× {baselineDays}d baseline", + SuppressionMinutes = 60, CreatedAt = DateTimeOffset.UtcNow.AddDays(-1), + }; + + private static void AddEvent(AppDbContext db, string activity, DateTimeOffset when, string? actor = "admin@contoso.com", string? target = null) + { + db.AuditEvents.Add(new AuditEvent + { + ExternalId = Guid.NewGuid().ToString(), Source = "directoryAudit", + Activity = activity, ActorUpn = actor, TargetName = target, + Result = "success", OccurredAt = when, CollectedAt = DateTimeOffset.UtcNow, + }); + db.SaveChanges(); + } + + private static void AddTrend(AppDbContext db, DateTimeOffset capturedAt, int failedSignInProxy) + { + db.TrendSnapshots.Add(new TrendSnapshot + { + CapturedAt = capturedAt, + HighAlertsCount = failedSignInProxy, + CriticalAlertsCount = failedSignInProxy, + RiskyUsersCount = failedSignInProxy, + NonCompliantDevicesCount = failedSignInProxy, + ComplianceIssuesCount = failedSignInProxy, + MfaCoveragePct = 95, + SecureScorePct = 70, + }); + } + + [Fact] + public async Task Fires_WhenMatchingEventInWindow() + { + using var db = TestAppDbContextFactory.Create(); + db.AlertPolicies.Add(ActivityPolicy("Add member to role")); + AddEvent(db, "Add member to role", DateTimeOffset.UtcNow.AddMinutes(-5), target: "Global Administrator"); + await db.SaveChangesAsync(); + + var fired = await BuildEvaluator(db).EvaluateAsync(CancellationToken.None); + + Assert.Equal(1, fired); + var alert = await db.TriggeredAlerts.SingleAsync(); + Assert.Equal(1, alert.MetricValue); + // camelCase entities — actor + activity→target title reach the UI. + Assert.Contains("\"userPrincipalName\":\"admin@contoso.com\"", alert.AffectedEntities); + Assert.Contains("Global Administrator", alert.AffectedEntities); + } + + [Fact] + public async Task Ignores_EventsOutsideWindow() + { + using var db = TestAppDbContextFactory.Create(); + db.AlertPolicies.Add(ActivityPolicy("Add member to role", windowMinutes: 60)); + AddEvent(db, "Add member to role", DateTimeOffset.UtcNow.AddHours(-3)); // stale + await db.SaveChangesAsync(); + + var fired = await BuildEvaluator(db).EvaluateAsync(CancellationToken.None); + + Assert.Equal(0, fired); + Assert.Empty(await db.TriggeredAlerts.ToListAsync()); + } + + [Fact] + public async Task WildcardPattern_MatchesVariants() + { + using var db = TestAppDbContextFactory.Create(); + db.AlertPolicies.Add(ActivityPolicy("*conditional access policy", threshold: 2)); + AddEvent(db, "Add conditional access policy", DateTimeOffset.UtcNow.AddMinutes(-10)); + AddEvent(db, "Update conditional access policy", DateTimeOffset.UtcNow.AddMinutes(-5)); + await db.SaveChangesAsync(); + + var fired = await BuildEvaluator(db).EvaluateAsync(CancellationToken.None); + + Assert.Equal(1, fired); + Assert.Equal(2, (await db.TriggeredAlerts.SingleAsync()).MetricValue); + } + + [Fact] + public async Task OpenActivityAlert_UpdatesInPlace_AndAutoResolvesWhenQuiet() + { + using var db = TestAppDbContextFactory.Create(); + db.AlertPolicies.Add(ActivityPolicy("Consent to application")); + AddEvent(db, "Consent to application", DateTimeOffset.UtcNow.AddMinutes(-5)); + await db.SaveChangesAsync(); + var evaluator = BuildEvaluator(db); + + Assert.Equal(1, await evaluator.EvaluateAsync(CancellationToken.None)); + AddEvent(db, "Consent to application", DateTimeOffset.UtcNow.AddMinutes(-1)); + Assert.Equal(0, await evaluator.EvaluateAsync(CancellationToken.None)); // updated in place + var alert = await db.TriggeredAlerts.SingleAsync(); + Assert.Equal(2, alert.MetricValue); + + // Events age out of the window → value drops below threshold → the + // debounce (2 cycles) auto-resolves the alert. + foreach (var e in db.AuditEvents) e.OccurredAt = DateTimeOffset.UtcNow.AddHours(-2); + await db.SaveChangesAsync(); + await evaluator.EvaluateAsync(CancellationToken.None); + await evaluator.EvaluateAsync(CancellationToken.None); + Assert.Equal("auto_resolved", (await db.TriggeredAlerts.SingleAsync()).Status); + } + + [Fact] + public async Task AnomalyPolicy_Fires_WhenLatestTrendSpikesAboveBaselineAndFloor() + { + using var db = TestAppDbContextFactory.Create(); + db.AlertPolicies.Add(AnomalyPolicy("highAlertCount", threshold: 10, multiplier: 3, baselineDays: 30)); + var now = DateTimeOffset.UtcNow; + AddTrend(db, now.AddDays(-10), 4); + AddTrend(db, now.AddDays(-8), 5); + AddTrend(db, now.AddDays(-6), 3); + AddTrend(db, now.AddMinutes(-5), 20); + await db.SaveChangesAsync(); + + var fired = await BuildEvaluator(db).EvaluateAsync(CancellationToken.None); + + Assert.Equal(1, fired); + var alert = await db.TriggeredAlerts.SingleAsync(); + Assert.Equal(20, alert.MetricValue); + Assert.Contains("highAlertCount", alert.AffectedEntities); + Assert.Contains("baselineAverage", alert.AffectedEntities); + } + + [Fact] + public async Task AnomalyPolicy_DoesNotFire_WhenLatestDoesNotClearBaselineMultiplier() + { + using var db = TestAppDbContextFactory.Create(); + db.AlertPolicies.Add(AnomalyPolicy("highAlertCount", threshold: 10, multiplier: 3, baselineDays: 30)); + var now = DateTimeOffset.UtcNow; + AddTrend(db, now.AddDays(-10), 8); + AddTrend(db, now.AddDays(-8), 9); + AddTrend(db, now.AddDays(-6), 10); + AddTrend(db, now.AddMinutes(-5), 20); + await db.SaveChangesAsync(); + + var fired = await BuildEvaluator(db).EvaluateAsync(CancellationToken.None); + + Assert.Equal(0, fired); + Assert.Empty(await db.TriggeredAlerts.ToListAsync()); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/AlertEvaluatorAutoResolveTests.cs b/src/M365SecurityDashboard.Api.Tests/AlertEvaluatorAutoResolveTests.cs index 4efaed3..86bd4ac 100644 --- a/src/M365SecurityDashboard.Api.Tests/AlertEvaluatorAutoResolveTests.cs +++ b/src/M365SecurityDashboard.Api.Tests/AlertEvaluatorAutoResolveTests.cs @@ -30,7 +30,7 @@ private static AlertEvaluator BuildEvaluator(AppDbContext db, int autoResolveDeb // accidental dispatch attempt writes no NotificationLog rows. var sender = new NotificationSender( new NullHttpClientFactory(), - new SecretProtector(NullLogger.Instance), + new SecretProtector(new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider(), NullLogger.Instance), NullLogger.Instance); return new AlertEvaluator( @@ -341,6 +341,33 @@ public async Task AutoResolve_PreservesAcknowledgedAt() Assert.Equal("dashboard", alert.AcknowledgedBy); } + [Fact] + public async Task Evaluate_CapturesAffectedEntities() + { + using var db = TestAppDbContextFactory.Create(); + var policy = RiskyUsersPolicy(); + db.AlertPolicies.Add(policy); + await db.SaveChangesAsync(); + + // Seed 3 open risky users (threshold is 3, so 3 open risky users triggers it) + SeedOpenRiskyUsers(db, count: 3); + + var evaluator = BuildEvaluator(db); + await evaluator.EvaluateAsync(CancellationToken.None); + + var triggered = await db.TriggeredAlerts.SingleAsync(); + Assert.Equal(3, triggered.MetricValue); + Assert.NotNull(triggered.AffectedEntities); + + // Verify JSON contents + using var doc = System.Text.Json.JsonDocument.Parse(triggered.AffectedEntities); + var array = doc.RootElement; + Assert.Equal(3, array.GetArrayLength()); + + var first = array[0]; + Assert.StartsWith("risky-", first.GetProperty("title").GetString()); // camelCase — PascalCase here was the "System / N/A" entity-row bug + } + /// No-op for tests that never make HTTP calls. private sealed class NullHttpClientFactory : IHttpClientFactory { diff --git a/src/M365SecurityDashboard.Api.Tests/AlertEvaluatorSingleOpenAlertTests.cs b/src/M365SecurityDashboard.Api.Tests/AlertEvaluatorSingleOpenAlertTests.cs new file mode 100644 index 0000000..4842f21 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/AlertEvaluatorSingleOpenAlertTests.cs @@ -0,0 +1,119 @@ +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace M365SecurityDashboard.Api.Tests; + +/// +/// The one-open-alert-per-policy contract: while a policy stays breached the +/// existing open alert is updated in place; duplicates from the old +/// fire-every-cycle behaviour are collapsed; a new row only appears after the +/// previous alert reached a terminal state. +/// +public class AlertEvaluatorSingleOpenAlertTests +{ + private const string Metric = "riskyUsersCount"; + + private sealed class NullHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(); + } + + private static AlertEvaluator BuildEvaluator(AppDbContext db) + { + var options = Microsoft.Extensions.Options.Options.Create(new AlertingOptions { AutoResolveDebounceCycles = 2 }); + var sender = new NotificationSender( + new NullHttpClientFactory(), + new SecretProtector(new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider(), NullLogger.Instance), + NullLogger.Instance); + return new AlertEvaluator(db, sender, options, NullLogger.Instance); + } + + private static AlertPolicy Policy() => new() + { + Id = Guid.NewGuid(), Name = "Risky Users", Enabled = true, Category = "identity", + Metric = Metric, Threshold = 1, Severity = "high", Condition = "Risky users ≥ 1", + SuppressionMinutes = 60, CreatedAt = DateTimeOffset.UtcNow.AddDays(-1), + }; + + private static void SeedRiskyUsers(AppDbContext db, int count) + { + for (var i = 0; i < count; i++) + db.SecurityAlerts.Add(new SecurityAlert + { + AlertType = "RiskyUser", Severity = AlertSeverity.High, Service = M365ServiceArea.EntraId, + Title = $"risky-{i}", DetectedAt = DateTimeOffset.UtcNow, IsResolved = false, + }); + db.SaveChanges(); + } + + [Fact] + public async Task SecondEvaluation_UpdatesOpenAlertInPlace_NoNewRow() + { + using var db = TestAppDbContextFactory.Create(); + db.AlertPolicies.Add(Policy()); + SeedRiskyUsers(db, 2); + await db.SaveChangesAsync(); + var evaluator = BuildEvaluator(db); + + var fired1 = await evaluator.EvaluateAsync(CancellationToken.None); + Assert.Equal(1, fired1); + + SeedRiskyUsers(db, 1); // metric rises to 3 while still breached + var fired2 = await evaluator.EvaluateAsync(CancellationToken.None); + + Assert.Equal(0, fired2); // no new row + var alerts = await db.TriggeredAlerts.ToListAsync(); + Assert.Single(alerts); + Assert.Equal(3, alerts[0].MetricValue); // updated in place + Assert.NotNull(alerts[0].LastEvaluatedAt); + } + + [Fact] + public async Task LegacyDuplicates_AreCollapsedToOne() + { + using var db = TestAppDbContextFactory.Create(); + var policy = Policy(); + db.AlertPolicies.Add(policy); + SeedRiskyUsers(db, 2); + // Three legacy open rows for the same policy (old behaviour). + for (var i = 0; i < 3; i++) + db.TriggeredAlerts.Add(new TriggeredAlert + { + Id = Guid.NewGuid(), PolicyId = policy.Id, PolicyName = policy.Name, + Severity = "high", Category = "identity", Condition = policy.Condition, + MetricValue = 2, Threshold = 1, Status = "new", + TriggeredAt = DateTimeOffset.UtcNow.AddHours(-i - 1), + }); + await db.SaveChangesAsync(); + + await BuildEvaluator(db).EvaluateAsync(CancellationToken.None); + + var open = await db.TriggeredAlerts.Where(t => t.Status == "new").ToListAsync(); + Assert.Single(open); // newest kept + var retired = await db.TriggeredAlerts.CountAsync(t => t.Status == "auto_resolved"); + Assert.Equal(2, retired); + } + + [Fact] + public async Task AfterResolution_StillBreached_FiresFreshAlert() + { + using var db = TestAppDbContextFactory.Create(); + db.AlertPolicies.Add(Policy()); + SeedRiskyUsers(db, 2); + await db.SaveChangesAsync(); + var evaluator = BuildEvaluator(db); + + await evaluator.EvaluateAsync(CancellationToken.None); + var first = await db.TriggeredAlerts.SingleAsync(); + first.Status = "resolved"; + await db.SaveChangesAsync(); + + var fired = await evaluator.EvaluateAsync(CancellationToken.None); + + Assert.Equal(1, fired); // breach persists after resolution → new alert + Assert.Equal(2, await db.TriggeredAlerts.CountAsync()); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/AlertMetricsTests.cs b/src/M365SecurityDashboard.Api.Tests/AlertMetricsTests.cs new file mode 100644 index 0000000..d967716 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/AlertMetricsTests.cs @@ -0,0 +1,98 @@ +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; +using Xunit; + +namespace M365SecurityDashboard.Api.Tests; + +public class AlertMetricsTests +{ + private static TriggeredAlert Alert( + string status, DateTimeOffset triggered, + DateTimeOffset? ack = null, DateTimeOffset? resolved = null, string? assignee = null) + => new() + { + Id = Guid.NewGuid(), + Status = status, + TriggeredAt = triggered, + AcknowledgedAt = ack, + ResolvedAt = resolved, + AssignedTo = assignee, + }; + + [Fact] + public void Compute_Empty_IsAllZeroNoNaN() + { + var r = AlertMetrics.Compute([]); + Assert.Equal(0, r.Total); + Assert.Equal(0, r.ResolutionRatePct); + Assert.Null(r.MttaMinutes); // no samples -> null, never NaN + Assert.Null(r.MttrMinutes); + Assert.Empty(r.ByAssignee); + } + + [Fact] + public void Compute_MttaAndMttr_AverageOnlyRealSamples() + { + var t = new DateTimeOffset(2026, 7, 1, 12, 0, 0, TimeSpan.Zero); + var alerts = new[] + { + // acked after 10 min, resolved after 30 min + Alert("resolved", t, ack: t.AddMinutes(10), resolved: t.AddMinutes(30)), + // acked after 20 min, resolved after 50 min + Alert("resolved", t, ack: t.AddMinutes(20), resolved: t.AddMinutes(50)), + // still open, never acked -> excluded from both averages + Alert("new", t), + }; + var r = AlertMetrics.Compute(alerts); + Assert.Equal(15, r.MttaMinutes); // (10+20)/2 + Assert.Equal(40, r.MttrMinutes); // (30+50)/2 + } + + [Fact] + public void Compute_ResolutionRate_CountsManualAndAuto() + { + var t = DateTimeOffset.UtcNow.AddHours(-1); + var alerts = new[] + { + Alert("resolved", t, resolved: t.AddMinutes(5)), + Alert("auto_resolved", t, resolved: t.AddMinutes(5)), + Alert("new", t), + Alert("acknowledged", t, ack: t.AddMinutes(1)), + }; + var r = AlertMetrics.Compute(alerts); + Assert.Equal(4, r.Total); + Assert.Equal(1, r.Resolved); + Assert.Equal(1, r.AutoResolved); + Assert.Equal(2, r.Open); + Assert.Equal(50.0, r.ResolutionRatePct); // 2 of 4 + } + + [Fact] + public void Compute_IgnoresNegativeDurations() + { + // A resolve timestamp before the trigger (clock skew / bad data) must not + // pull the average negative. + var t = DateTimeOffset.UtcNow; + var r = AlertMetrics.Compute([Alert("resolved", t, resolved: t.AddMinutes(-10))]); + Assert.Null(r.MttrMinutes); + } + + [Fact] + public void Compute_ByAssignee_SplitsOpenVsResolved_AutoResolvedHasNoAssigneeLoad() + { + var t = DateTimeOffset.UtcNow.AddHours(-2); + var alerts = new[] + { + Alert("new", t, assignee: "ana@x.com"), + Alert("acknowledged", t, ack: t.AddMinutes(2), assignee: "ana@x.com"), + Alert("resolved", t, resolved: t.AddMinutes(9), assignee: "ana@x.com"), + Alert("auto_resolved", t, resolved: t.AddMinutes(9)), // unassigned + }; + var r = AlertMetrics.Compute(alerts); + var ana = Assert.Single(r.ByAssignee); + Assert.Equal("ana@x.com", ana.Assignee); + Assert.Equal(2, ana.Open); // new + acknowledged + Assert.Equal(1, ana.Acknowledged); // the acknowledged-and-open one + Assert.Equal(1, ana.Resolved); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/ApiTokenServiceTests.cs b/src/M365SecurityDashboard.Api.Tests/ApiTokenServiceTests.cs new file mode 100644 index 0000000..fa30bbf --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/ApiTokenServiceTests.cs @@ -0,0 +1,100 @@ +using M365SecurityDashboard.Api.Services; +using Xunit; + +namespace M365SecurityDashboard.Api.Tests; + +/// +/// API tokens authenticate SIEM pulls without a browser session, so they are a +/// standing credential. These lock the properties that matter: the secret is +/// never stored, tokens are unpredictable, and scope is actually enforced. +/// +public class ApiTokenServiceTests +{ + [Fact] + public void Create_StoresOnlyAHash_NeverTheSecret() + { + var (row, raw) = ApiTokenService.Create("SIEM", "alerts:read", "admin@contoso.com", null); + + Assert.NotEqual(raw, row.TokenHash); + Assert.DoesNotContain(raw, row.TokenHash); + // A stolen database must not yield working tokens. + Assert.Equal(ApiTokenService.Hash(raw), row.TokenHash); + Assert.Equal(64, row.TokenHash.Length); // SHA-256 hex + } + + [Fact] + public void Create_PrefixIsShownableAndMatchesTheToken() + { + var (row, raw) = ApiTokenService.Create("SIEM", "alerts:read", null, null); + // The prefix is what the UI lists to identify a token; it must be a real + // prefix of the secret but far too short to be usable on its own. + Assert.StartsWith(row.Prefix, raw); + Assert.True(row.Prefix.Length <= 12); + Assert.True(raw.Length > row.Prefix.Length + 20); + } + + [Fact] + public void Create_TokensAreUnpredictable() + { + var tokens = Enumerable.Range(0, 200) + .Select(_ => ApiTokenService.Create("t", "alerts:read", null, null).rawToken) + .ToList(); + Assert.Equal(tokens.Count, tokens.Distinct().Count()); + Assert.All(tokens, t => Assert.StartsWith("vig_", t)); + } + + [Fact] + public void Hash_IsStableAndDistinct() + { + Assert.Equal(ApiTokenService.Hash("vig_abc"), ApiTokenService.Hash("vig_abc")); + Assert.NotEqual(ApiTokenService.Hash("vig_abc"), ApiTokenService.Hash("vig_abd")); + } + + [Theory] + [InlineData("alerts:read", "alerts:read", true)] + [InlineData("alerts:read,health:read", "health:read", true)] + [InlineData("alerts:read", "health:read", false)] + [InlineData("", "alerts:read", false)] + [InlineData(null, "alerts:read", false)] + public void HasScope_EnforcesTheRequestedScope(string? granted, string required, bool expected) + => Assert.Equal(expected, ApiTokenService.HasScope(granted, required)); + + [Fact] + public void HasScope_WildcardGrantsEverything() + => Assert.True(ApiTokenService.HasScope("*", "anything:read")); + + [Fact] + public void HasScope_IsCaseInsensitiveOnTheScopeName_ButWildcardIsExact() + { + Assert.True(ApiTokenService.HasScope("Alerts:Read", "alerts:read")); + // A literal "*" is the wildcard; a scope that merely contains one is not. + Assert.False(ApiTokenService.HasScope("alerts:*", "health:read")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void NormalizeScopes_FallsBackToTheReadOnlyDefault(string? input) + => Assert.Equal("alerts:read,health:read", ApiTokenService.NormalizeScopes(input)); + + [Fact] + public void NormalizeScopes_TrimsAndDeduplicates() + => Assert.Equal("alerts:read,health:read", + ApiTokenService.NormalizeScopes(" alerts:read , health:read ,alerts:read ")); + + [Fact] + public void Create_UsesAFallbackNameRatherThanStoringBlank() + => Assert.Equal("SIEM integration", ApiTokenService.Create(" ", "alerts:read", null, null).row.Name); + + [Fact] + public void Create_RecordsExpiryAndCreator() + { + var expires = DateTimeOffset.UtcNow.AddDays(30); + var (row, _) = ApiTokenService.Create("SIEM", "alerts:read", "admin@contoso.com", expires); + Assert.Equal(expires, row.ExpiresAt); + Assert.Equal("admin@contoso.com", row.CreatedBy); + Assert.Null(row.RevokedAt); + Assert.Null(row.LastUsedAt); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/AuditLoggerHashChainTests.cs b/src/M365SecurityDashboard.Api.Tests/AuditLoggerHashChainTests.cs new file mode 100644 index 0000000..6b31c5c --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/AuditLoggerHashChainTests.cs @@ -0,0 +1,59 @@ +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace M365SecurityDashboard.Api.Tests; + +public class AuditLoggerHashChainTests +{ + private static AuditLogger CreateLogger(Data.AppDbContext db) => + new(db, new HttpContextAccessor(), NullLogger.Instance); + + [Fact] + public async Task WriteAsync_ChainsEntriesByPrevHash() + { + using var db = TestAppDbContextFactory.Create(); + var logger = CreateLogger(db); + + await logger.WriteAsync("user.add", "user", "a@contoso.com", "added", CancellationToken.None); + await logger.WriteAsync("user.role_change", "user", "a@contoso.com", "Viewer -> Admin", CancellationToken.None); + + var entries = await db.AuditEntries.OrderBy(e => e.Id).ToListAsync(); + Assert.Equal(2, entries.Count); + Assert.Null(entries[0].PrevHash); + Assert.NotNull(entries[0].EntryHash); + Assert.Equal(entries[0].EntryHash, entries[1].PrevHash); + Assert.Equal(AuditLogger.ComputeHash(entries[0]), entries[0].EntryHash); + Assert.Equal(AuditLogger.ComputeHash(entries[1]), entries[1].EntryHash); + } + + [Fact] + public async Task TamperedDetails_ChangesComputedHash() + { + using var db = TestAppDbContextFactory.Create(); + var logger = CreateLogger(db); + + await logger.WriteAsync("policy.delete", "policy", "42", "Deleted policy X", CancellationToken.None); + var entry = await db.AuditEntries.SingleAsync(); + var originalHash = entry.EntryHash; + + entry.Details = "Deleted policy Y"; + Assert.NotEqual(originalHash, AuditLogger.ComputeHash(entry)); + } + + [Fact] + public async Task WriteAsync_WithoutHttpContext_RecordsSystemActor() + { + using var db = TestAppDbContextFactory.Create(); + var logger = CreateLogger(db); + + await logger.WriteAsync("retention.prune", "database", null, "pruned 10 rows", CancellationToken.None); + + var entry = await db.AuditEntries.SingleAsync(); + Assert.Equal("system", entry.ActorEmail); + Assert.Null(entry.IpAddress); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/BacktestMathTests.cs b/src/M365SecurityDashboard.Api.Tests/BacktestMathTests.cs new file mode 100644 index 0000000..f08c695 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/BacktestMathTests.cs @@ -0,0 +1,103 @@ +using M365SecurityDashboard.Api.Services; +using Xunit; + +namespace M365SecurityDashboard.Api.Tests; + +/// +/// The backtest number is what an analyst uses to pick a threshold. If it +/// over-counts, every policy looks unusably noisy; if it under-counts, a noisy +/// policy looks safe. These lock the episode semantics. +/// +public class BacktestMathTests +{ + private static readonly DateTimeOffset T0 = new(2026, 7, 1, 0, 0, 0, TimeSpan.Zero); + private static readonly TimeSpan Step = TimeSpan.FromMinutes(15); + private static readonly TimeSpan Window = TimeSpan.FromMinutes(60); + + [Fact] + public void CountEpisodes_SustainedBreachIsOneEpisode_NotOnePerCycle() + { + // The live evaluator keeps one open alert per policy, so a continuous + // breach must read as a single fire — this is the whole point. + var events = Enumerable.Range(0, 200).Select(i => T0.AddMinutes(i * 5)).ToList(); + var r = BacktestMath.CountEpisodes(events, T0, T0.AddHours(12), Window, Step, threshold: 3); + Assert.Equal(1, r.Episodes); + } + + [Fact] + public void CountEpisodes_SeparateBurstsAreSeparateEpisodes() + { + // Two bursts far enough apart that the window empties between them. + var events = new List + { + T0.AddMinutes(1), T0.AddMinutes(2), T0.AddMinutes(3), + T0.AddHours(8), T0.AddHours(8).AddMinutes(1), T0.AddHours(8).AddMinutes(2), + }; + var r = BacktestMath.CountEpisodes(events, T0, T0.AddHours(12), Window, Step, threshold: 3); + Assert.Equal(2, r.Episodes); + } + + [Fact] + public void CountEpisodes_BelowThresholdNeverFires() + { + var events = new List { T0.AddMinutes(1), T0.AddMinutes(2) }; + var r = BacktestMath.CountEpisodes(events, T0, T0.AddHours(6), Window, Step, threshold: 5); + Assert.Equal(0, r.Episodes); + Assert.Equal(2, r.MaxValue); // still reports what was actually seen + } + + [Fact] + public void CountEpisodes_NoEventsIsZeroNotCrash() + { + var r = BacktestMath.CountEpisodes([], T0, T0.AddDays(30), Window, Step, threshold: 1); + Assert.Equal(0, r.Episodes); + Assert.Equal(0, r.MaxValue); + Assert.Empty(r.FiredAt); + } + + [Fact] + public void CountEpisodes_ThresholdIsInclusive_MatchingTheEvaluator() + { + // Evaluator fires when value >= threshold (it skips when value < threshold). + var events = new List { T0.AddMinutes(1), T0.AddMinutes(2) }; + var r = BacktestMath.CountEpisodes(events, T0, T0.AddMinutes(30), Window, Step, threshold: 2); + Assert.Equal(1, r.Episodes); + } + + [Fact] + public void CountEpisodes_ReportsWhenItWouldHaveFired() + { + var events = new List { T0.AddHours(5), T0.AddHours(5).AddMinutes(1) }; + var r = BacktestMath.CountEpisodes(events, T0, T0.AddHours(12), Window, Step, threshold: 2); + var fired = Assert.Single(r.FiredAt); + Assert.InRange(fired, T0.AddHours(5), T0.AddHours(6)); + } + + [Fact] + public void CountEpisodesFromSeries_CountsRisingEdgesOnly() + { + var series = new List<(DateTimeOffset, int)> + { + (T0, 1), (T0.AddHours(1), 9), (T0.AddHours(2), 9), // one rise, stays up + (T0.AddHours(3), 0), // recovers + (T0.AddHours(4), 7), // rises again + }; + var r = BacktestMath.CountEpisodesFromSeries(series, threshold: 5); + Assert.Equal(2, r.Episodes); + Assert.Equal(9, r.MaxValue); + } + + [Fact] + public void CountEpisodesFromSeries_SortsUnorderedInput() + { + var series = new List<(DateTimeOffset, int)> + { + (T0.AddHours(4), 7), (T0, 1), (T0.AddHours(1), 9), (T0.AddHours(3), 0), (T0.AddHours(2), 9), + }; + Assert.Equal(2, BacktestMath.CountEpisodesFromSeries(series, threshold: 5).Episodes); + } + + [Fact] + public void CountEpisodesFromSeries_EmptyIsZero() + => Assert.Equal(0, BacktestMath.CountEpisodesFromSeries([], threshold: 1).Episodes); +} diff --git a/src/M365SecurityDashboard.Api.Tests/ConditionalAccessGapAnalyzerTests.cs b/src/M365SecurityDashboard.Api.Tests/ConditionalAccessGapAnalyzerTests.cs new file mode 100644 index 0000000..8778b96 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/ConditionalAccessGapAnalyzerTests.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using M365SecurityDashboard.Api.Services; +using Xunit; +using CaView = M365SecurityDashboard.Api.Services.ConditionalAccessGapAnalyzer.CaPolicyView; + +namespace M365SecurityDashboard.Api.Tests; + +public class ConditionalAccessGapAnalyzerTests +{ + private static CaView Policy(string name = "P", string state = "enabled", bool mfa = false, bool block = false, + bool allUsers = false, bool allApps = false, int exU = 0, int exG = 0, string[]? clients = null) + => new(name, state, mfa, block, allUsers, allApps, exU, exG, clients ?? ["all"]); + + [Fact] + public void Analyze_NoPolicies_IsCritical() + { + var f = ConditionalAccessGapAnalyzer.Analyze([]); + var only = Assert.Single(f); + Assert.Equal("critical", only.Severity); + Assert.Contains("no Conditional Access", only.Detail, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Analyze_HealthyBaseline_NoMfaOrLegacyFinding() + { + var policies = new[] + { + Policy(name: "Require MFA", mfa: true, allUsers: true, allApps: true), + Policy(name: "Block legacy", block: true, allUsers: true, allApps: true, clients: ["exchangeActiveSync", "other"]), + }; + var f = ConditionalAccessGapAnalyzer.Analyze(policies); + Assert.DoesNotContain(f, x => x.Title.Contains("MFA policy")); + Assert.DoesNotContain(f, x => x.Title.Contains("Legacy")); + Assert.Empty(f); // healthy + } + + [Fact] + public void Analyze_MfaExistsButNotAllUsers_FlagsBaselineGap() + { + var f = ConditionalAccessGapAnalyzer.Analyze([Policy(mfa: true, allUsers: false, allApps: true)]); + var mfa = Assert.Single(f, x => x.Title.Contains("No tenant-wide MFA")); + Assert.Equal("critical", mfa.Severity); + Assert.Contains("some enabled policies", mfa.Detail); + } + + [Fact] + public void Analyze_LegacyAuthOnlyBlockedByDisabledPolicy_StillFlags() + { + var policies = new[] + { + Policy(name: "MFA", mfa: true, allUsers: true, allApps: true), + Policy(name: "Legacy", state: "disabled", block: true, clients: ["other"]), + }; + var f = ConditionalAccessGapAnalyzer.Analyze(policies); + Assert.Contains(f, x => x.Title.Contains("Legacy") && x.Severity == "high"); + } + + [Fact] + public void Analyze_MfaExclusionsAndReportOnly_AreReported() + { + var policies = new[] + { + Policy(name: "MFA", mfa: true, allUsers: true, allApps: true, exU: 2, exG: 1), + Policy(name: "Block legacy", block: true, clients: ["other"]), + Policy(name: "Pilot", state: "enabledForReportingButNotEnforced", mfa: true), + }; + var f = ConditionalAccessGapAnalyzer.Analyze(policies); + var excl = Assert.Single(f, x => x.Title.Contains("MFA exemptions")); + Assert.Contains("2 users and 1 group", excl.Detail); + Assert.Contains(f, x => x.Title.Contains("report-only")); + } + + [Fact] + public void Parse_ExtractsMfaAllUsersAllAppsAndExclusions() + { + var json = JsonDocument.Parse(""" + { + "displayName": "Baseline", + "state": "enabled", + "conditions": { + "users": { "includeUsers": ["All"], "excludeUsers": ["a","b"], "excludeGroups": ["g1"] }, + "applications": { "includeApplications": ["All"] }, + "clientAppTypes": ["exchangeActiveSync","other"] + }, + "grantControls": { "builtInControls": ["mfa"] } + } + """).RootElement; + + var v = ConditionalAccessGapAnalyzer.Parse(json); + Assert.True(v.RequiresMfa); + Assert.True(v.IncludesAllUsers); + Assert.True(v.IncludesAllApps); + Assert.Equal(2, v.ExcludedUsers); + Assert.Equal(1, v.ExcludedGroups); + Assert.Contains("other", v.ClientAppTypes); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/CsvSanitizerTests.cs b/src/M365SecurityDashboard.Api.Tests/CsvSanitizerTests.cs new file mode 100644 index 0000000..dfa792f --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/CsvSanitizerTests.cs @@ -0,0 +1,56 @@ +using M365SecurityDashboard.Api.Services; +using Xunit; + +namespace M365SecurityDashboard.Api.Tests; + +/// +/// Locks the CSV export contract. Every exported field carries tenant-controlled +/// text (alert titles, display names, audit actors), so a regression here is a +/// live spreadsheet-formula injection in a security product's own export. +/// +public class CsvSanitizerTests +{ + [Theory] + [InlineData("=HYPERLINK(\"http://evil\",\"click\")")] + [InlineData("+1+1")] + [InlineData("-2+3")] + [InlineData("@SUM(A1:A9)")] + [InlineData("\tleading-tab")] + [InlineData("\rleading-cr")] + public void Neutralize_PrefixesFormulaTriggers(string dangerous) + { + var result = CsvSanitizer.Neutralize(dangerous); + Assert.StartsWith("'", result); + Assert.Equal("'" + dangerous, result); + } + + [Theory] + [InlineData("Risky user detected")] + [InlineData("user@contoso.com")] // @ only matters in first position + [InlineData("Score dropped 51-38")] // - only matters in first position + [InlineData("")] + public void Neutralize_LeavesSafeValuesUntouched(string safe) + { + Assert.Equal(safe, CsvSanitizer.Neutralize(safe)); + } + + [Fact] + public void Neutralize_NullBecomesEmpty() => Assert.Equal("", CsvSanitizer.Neutralize(null)); + + [Fact] + public void Field_AppliesFormulaGuardAndRfc4180Quoting() + { + // Contains a comma AND starts with '=' — must be both neutralised and quoted. + Assert.Equal("\"'=cmd,evil\"", CsvSanitizer.Field("=cmd,evil")); + } + + [Theory] + [InlineData("plain", "plain")] + [InlineData("has,comma", "\"has,comma\"")] + [InlineData("has\"quote", "\"has\"\"quote\"")] + [InlineData("has\nnewline", "\"has\nnewline\"")] + public void Field_QuotesOnlyWhenRequired(string input, string expected) + { + Assert.Equal(expected, CsvSanitizer.Field(input)); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/DataRetentionWorkerTests.cs b/src/M365SecurityDashboard.Api.Tests/DataRetentionWorkerTests.cs new file mode 100644 index 0000000..c72c768 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/DataRetentionWorkerTests.cs @@ -0,0 +1,80 @@ +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace M365SecurityDashboard.Api.Tests; + +public class DataRetentionWorkerTests +{ + private static SecurityAlert Alert(bool resolved, int ageDays) => new() + { + ExternalId = Guid.NewGuid().ToString(), + AlertType = "Test", + Service = M365ServiceArea.DefenderXdr, + Severity = AlertSeverity.Medium, + Title = "t", + DetectedAt = DateTimeOffset.UtcNow.AddDays(-ageDays), + LastUpdatedAt = DateTimeOffset.UtcNow.AddDays(-ageDays), + IsResolved = resolved, + }; + + [Fact] + public async Task PruneAsync_DeletesOnlyOldResolvedAlerts() + { + using var db = TestAppDbContextFactory.Create(); + db.SecurityAlerts.Add(Alert(resolved: true, ageDays: 120)); // pruned + db.SecurityAlerts.Add(Alert(resolved: true, ageDays: 10)); // kept: recent + db.SecurityAlerts.Add(Alert(resolved: false, ageDays: 400)); // kept: still open + await db.SaveChangesAsync(); + + var summary = await DataRetentionWorker.PruneAsync( + db, new RetentionOptions { ResolvedAlertsDays = 90 }, CancellationToken.None); + + Assert.Equal(1, summary.ResolvedAlerts); + Assert.Equal(2, await db.SecurityAlerts.CountAsync()); + Assert.True(await db.SecurityAlerts.AnyAsync(a => !a.IsResolved)); + } + + [Fact] + public async Task PruneAsync_ZeroDays_DisablesPruning() + { + using var db = TestAppDbContextFactory.Create(); + db.SecurityAlerts.Add(Alert(resolved: true, ageDays: 1000)); + db.AuditEntries.Add(new AuditEntry { Timestamp = DateTimeOffset.UtcNow.AddDays(-1000), Action = "x", ActorEmail = "a", TargetType = "t" }); + await db.SaveChangesAsync(); + + var summary = await DataRetentionWorker.PruneAsync( + db, new RetentionOptions { ResolvedAlertsDays = 0, AuditEntriesDays = 0 }, CancellationToken.None); + + Assert.Equal(0, summary.TotalDeleted); + Assert.Equal(1, await db.SecurityAlerts.CountAsync()); + Assert.Equal(1, await db.AuditEntries.CountAsync()); + } + + [Fact] + public async Task PruneAsync_KeepsOpenTriggeredAlertsRegardlessOfAge() + { + using var db = TestAppDbContextFactory.Create(); + db.TriggeredAlerts.Add(new TriggeredAlert + { + Id = Guid.NewGuid(), PolicyId = Guid.NewGuid(), PolicyName = "p", Severity = "high", + Category = "identity", Condition = "c", Status = "new", + TriggeredAt = DateTimeOffset.UtcNow.AddDays(-500), + }); + db.TriggeredAlerts.Add(new TriggeredAlert + { + Id = Guid.NewGuid(), PolicyId = Guid.NewGuid(), PolicyName = "p2", Severity = "high", + Category = "identity", Condition = "c", Status = "resolved", + TriggeredAt = DateTimeOffset.UtcNow.AddDays(-500), + }); + await db.SaveChangesAsync(); + + var summary = await DataRetentionWorker.PruneAsync( + db, new RetentionOptions { TriggeredAlertsDays = 180 }, CancellationToken.None); + + Assert.Equal(1, summary.TriggeredAlerts); + var remaining = await db.TriggeredAlerts.SingleAsync(); + Assert.Equal("new", remaining.Status); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/DigestBuilderTests.cs b/src/M365SecurityDashboard.Api.Tests/DigestBuilderTests.cs new file mode 100644 index 0000000..0932bb3 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/DigestBuilderTests.cs @@ -0,0 +1,72 @@ +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; +using Xunit; + +namespace M365SecurityDashboard.Api.Tests; + +public class DigestBuilderTests +{ + [Fact] + public async Task BuildAsync_NoData_ReportsEmptyButValidBody() + { + using var db = TestAppDbContextFactory.Create(); + var digest = await new DigestBuilder(db).BuildAsync(7, CancellationToken.None); + + Assert.False(digest.HasData); + Assert.Empty(digest.Metrics); + Assert.Empty(digest.TopAlerts); + Assert.Contains("Weekly Security Digest", digest.HtmlBody); + Assert.Contains("No posture snapshots", digest.HtmlBody); + } + + [Fact] + public async Task BuildAsync_ComputesWeekOverWeekDeltasAndTopAlerts() + { + using var db = TestAppDbContextFactory.Create(); + db.TrendSnapshots.Add(new TrendSnapshot { CapturedAt = DateTimeOffset.UtcNow.AddDays(-8), SecureScorePct = 40, CriticalAlertsCount = 2 }); + db.TrendSnapshots.Add(new TrendSnapshot { CapturedAt = DateTimeOffset.UtcNow, SecureScorePct = 45, CriticalAlertsCount = 5 }); + db.TriggeredAlerts.Add(new TriggeredAlert { Id = Guid.NewGuid(), PolicyName = "Low thing", Severity = "low", Status = "new", TriggeredAt = DateTimeOffset.UtcNow }); + db.TriggeredAlerts.Add(new TriggeredAlert { Id = Guid.NewGuid(), PolicyName = "Critical thing", Severity = "critical", Status = "new", TriggeredAt = DateTimeOffset.UtcNow }); + db.TriggeredAlerts.Add(new TriggeredAlert { Id = Guid.NewGuid(), PolicyName = "Resolved thing", Severity = "high", Status = "resolved", TriggeredAt = DateTimeOffset.UtcNow }); + await db.SaveChangesAsync(); + + var digest = await new DigestBuilder(db).BuildAsync(7, CancellationToken.None); + + Assert.True(digest.HasData); + var score = Assert.Single(digest.Metrics, m => m.Label == "Secure Score"); + Assert.Equal(5, score.Delta); // 45 − 40 + + // Only open alerts, critical ranked first, resolved excluded. + Assert.Equal(2, digest.TopAlerts.Count); + Assert.Equal("critical", digest.TopAlerts[0].Severity); + Assert.DoesNotContain(digest.TopAlerts, a => a.PolicyName == "Resolved thing"); + } + + [Fact] + public async Task BuildAsync_ExcludesCurrentlySnoozedAlertsFromTopList() + { + using var db = TestAppDbContextFactory.Create(); + db.TriggeredAlerts.Add(new TriggeredAlert { Id = Guid.NewGuid(), PolicyName = "snoozed", Severity = "critical", Status = "new", TriggeredAt = DateTimeOffset.UtcNow, SnoozedUntil = DateTimeOffset.UtcNow.AddHours(4) }); + db.TriggeredAlerts.Add(new TriggeredAlert { Id = Guid.NewGuid(), PolicyName = "active", Severity = "high", Status = "new", TriggeredAt = DateTimeOffset.UtcNow }); + db.TriggeredAlerts.Add(new TriggeredAlert { Id = Guid.NewGuid(), PolicyName = "snooze-expired", Severity = "medium", Status = "new", TriggeredAt = DateTimeOffset.UtcNow, SnoozedUntil = DateTimeOffset.UtcNow.AddHours(-1) }); + await db.SaveChangesAsync(); + + var digest = await new DigestBuilder(db).BuildAsync(7, CancellationToken.None); + + Assert.DoesNotContain(digest.TopAlerts, a => a.PolicyName == "snoozed"); // still snoozed → excluded + Assert.Contains(digest.TopAlerts, a => a.PolicyName == "active"); + Assert.Contains(digest.TopAlerts, a => a.PolicyName == "snooze-expired"); // snooze lapsed → included + } + + [Fact] + public async Task BuildAsync_CsvEscapesCommasInFields() + { + using var db = TestAppDbContextFactory.Create(); + db.TriggeredAlerts.Add(new TriggeredAlert { Id = Guid.NewGuid(), PolicyName = "Spike, sudden", Severity = "high", Condition = "count > 3", Status = "new", TriggeredAt = DateTimeOffset.UtcNow }); + await db.SaveChangesAsync(); + + var digest = await new DigestBuilder(db).BuildAsync(7, CancellationToken.None); + Assert.NotNull(digest.Csv); + Assert.Contains("\"Spike, sudden\"", digest.Csv); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/DigestPdfRendererTests.cs b/src/M365SecurityDashboard.Api.Tests/DigestPdfRendererTests.cs new file mode 100644 index 0000000..cc26bfe --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/DigestPdfRendererTests.cs @@ -0,0 +1,143 @@ +using System.Text; +using System.Text.RegularExpressions; +using M365SecurityDashboard.Api.Services; +using Xunit; + +namespace M365SecurityDashboard.Api.Tests; + +/// +/// The digest PDF is written by hand rather than pulled from a library (the +/// obvious candidates carry licences that do not suit an MIT project). That +/// trade means the structure has to be verified here: a PDF whose xref byte +/// offsets are wrong still "generates" fine and then fails to open in every +/// reader, which is the worst possible failure mode for an emailed report. +/// +public class DigestPdfRendererTests +{ + private static DigestBuilder.Digest Sample( + IReadOnlyList? metrics = null, + IReadOnlyList? alerts = null) => + new( + Subject: "Vigil365 digest", + HtmlBody: "

ignored by the PDF path

", + Csv: null, + GeneratedAt: new DateTimeOffset(2026, 7, 30, 9, 0, 0, TimeSpan.Zero), + Metrics: metrics ?? [new DigestBuilder.Metric("Secure Score", "51%", -2.4, "pts", true)], + TopAlerts: alerts ?? [new DigestBuilder.TopAlert("Privileged role assigned", "high", "count >= 1", 3, DateTimeOffset.UtcNow, "identity", "new", null)], + HasData: true); + + private static string Ascii(byte[] pdf) => Encoding.ASCII.GetString(pdf); + + [Fact] + public void Render_ProducesAPdfHeaderAndTrailer() + { + var text = Ascii(new DigestPdfRenderer().Render(Sample())); + Assert.StartsWith("%PDF-", text); + Assert.Contains("%%EOF", text); + Assert.Contains("/Type /Catalog", text); + } + + /// + /// The real correctness test: every xref entry must be the exact byte offset + /// of its object. Readers seek by these; if they drift the file is rejected. + /// + [Fact] + public void Render_XrefOffsetsPointAtTheirObjects() + { + var pdf = new DigestPdfRenderer().Render(Sample()); + var text = Ascii(pdf); + + // Locate the table via startxref rather than searching for "xref" — the + // word "startxref" itself contains it, and appears later in the file. + var startxref = Regex.Match(text, @"startxref\s+(\d+)"); + Assert.True(startxref.Success, "PDF has no startxref"); + var xrefIndex = int.Parse(startxref.Groups[1].Value); + Assert.True(xrefIndex > 0 && xrefIndex < pdf.Length, "startxref points outside the file"); + + // Entries look like "0000000123 00000 n" — skip the leading free entry. + var entries = Regex.Matches(text[xrefIndex..], @"^(\d{10}) 00000 n", RegexOptions.Multiline) + .Select(m => int.Parse(m.Groups[1].Value)) + .ToList(); + + Assert.NotEmpty(entries); + + for (var i = 0; i < entries.Count; i++) + { + var offset = entries[i]; + Assert.InRange(offset, 0, pdf.Length - 1); + + // At that byte offset the file must literally begin object i+1. + var expected = $"{i + 1} 0 obj"; + var actual = Encoding.ASCII.GetString(pdf, offset, Math.Min(expected.Length, pdf.Length - offset)); + Assert.Equal(expected, actual); + } + } + + [Fact] + public void Render_StartxrefPointsAtTheXrefTable() + { + var pdf = new DigestPdfRenderer().Render(Sample()); + var text = Ascii(pdf); + + var match = Regex.Match(text, @"startxref\s+(\d+)"); + Assert.True(match.Success, "PDF has no startxref"); + + var offset = int.Parse(match.Groups[1].Value); + Assert.InRange(offset, 0, pdf.Length - 4); + Assert.Equal("xref", Encoding.ASCII.GetString(pdf, offset, 4)); + } + + [Fact] + public void Render_EscapesParenthesesSoTextOperatorsCannotBreak() + { + // An unescaped ) would terminate the PDF string early and corrupt the page. + var digest = Sample(alerts: [new DigestBuilder.TopAlert( + "Odd (policy) name \\ here", "high", "value >= 1 (spike)", 1, DateTimeOffset.UtcNow, "identity", "new", null)]); + var text = Ascii(new DigestPdfRenderer().Render(digest)); + + Assert.Contains(@"Odd \(policy\) name \\ here", text); + } + + [Fact] + public void Render_ReplacesNonAsciiSoTheAsciiEncodingCannotMangleIt() + { + // The writer emits ASCII; em dashes and arrows must be folded, not dropped + // into '?' which would look like corruption in the report. + var digest = Sample(alerts: [new DigestBuilder.TopAlert( + "Role → admin", "high", "score — dropped ▼", 1, DateTimeOffset.UtcNow, "identity", "new", null)]); + var text = Ascii(new DigestPdfRenderer().Render(digest)); + + Assert.Contains("Role > admin", text); + Assert.Contains("score - dropped v", text); + Assert.DoesNotContain("?", text); + } + + [Fact] + public void Render_EmptyDigestStillProducesAValidPdf() + { + // A tenant with no data must still get an openable report, not a broken file. + var digest = Sample(metrics: [], alerts: []); + var pdf = new DigestPdfRenderer().Render(digest); + var text = Ascii(pdf); + + Assert.StartsWith("%PDF-", text); + Assert.Contains("%%EOF", text); + Assert.Contains("No posture snapshot captured yet.", text); + Assert.Contains("No open alerts.", text); + } + + [Fact] + public void Render_ManyAlertsDoesNotOverflowThePage() + { + // The writer stops at the bottom margin; it must not run off the page or + // emit negative coordinates. + var alerts = Enumerable.Range(0, 200) + .Select(i => new DigestBuilder.TopAlert($"Policy {i}", "low", "c", i, DateTimeOffset.UtcNow, "identity", "new", null)) + .ToList(); + var pdf = new DigestPdfRenderer().Render(Sample(alerts: alerts)); + var text = Ascii(pdf); + + Assert.Contains("%%EOF", text); + Assert.DoesNotMatch(new Regex(@"1 0 0 1 50 -\d+ Tm"), text); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/EntityProfileBuilderTests.cs b/src/M365SecurityDashboard.Api.Tests/EntityProfileBuilderTests.cs new file mode 100644 index 0000000..6b54fd6 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/EntityProfileBuilderTests.cs @@ -0,0 +1,84 @@ +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; +using Xunit; + +namespace M365SecurityDashboard.Api.Tests; + +public class EntityProfileBuilderTests +{ + private static SecurityAlert Alert(string? upn, string? device, AlertSeverity sev, bool resolved, DateTimeOffset at) => new() + { + ExternalId = Guid.NewGuid().ToString(), AlertType = "t", Service = M365ServiceArea.DefenderXdr, + Severity = sev, Title = "Alert", UserPrincipalName = upn, DeviceName = device, + DetectedAt = at, LastUpdatedAt = at, IsResolved = resolved, + }; + + private static AuditEvent Audit(string activity, string? actor, string? target, DateTimeOffset at, string? result = "success") => new() + { + ExternalId = Guid.NewGuid().ToString(), Activity = activity, ActorUpn = actor, TargetName = target, + OccurredAt = at, CollectedAt = at, Result = result, + }; + + [Fact] + public async Task BuildAsync_User_MergesAlertsAndActivityNewestFirst() + { + using var db = TestAppDbContextFactory.Create(); + var t0 = new DateTimeOffset(2026, 7, 10, 0, 0, 0, TimeSpan.Zero); + db.SecurityAlerts.Add(Alert("bob@x.com", null, AlertSeverity.High, resolved: false, t0.AddHours(1))); + db.SecurityAlerts.Add(Alert("other@x.com", null, AlertSeverity.Critical, resolved: false, t0.AddHours(5))); // different user + db.AuditEvents.Add(Audit("Add member to role", actor: "bob@x.com", target: "Global Admin", t0.AddHours(3))); + db.AuditEvents.Add(Audit("Reset password", actor: "admin@x.com", target: "bob@x.com", t0.AddHours(2))); + await db.SaveChangesAsync(); + + var p = await new EntityProfileBuilder(db).BuildAsync("user", "bob@x.com", 300, CancellationToken.None); + + Assert.True(p.Found); + Assert.Equal(1, p.Summary.AlertCount); // only bob's alert + Assert.Equal(1, p.Summary.OpenAlertCount); + Assert.Equal(2, p.Summary.ActivityCount); // acted + targeted + Assert.Equal(3, p.Timeline.Count); + // Newest first: the role add at +3h leads. + Assert.Equal(t0.AddHours(3), p.Timeline[0].At); + Assert.Equal("activity", p.Timeline[0].Type); + } + + [Fact] + public async Task BuildAsync_Device_UsesDeviceNameAndHasNoActorActivity() + { + using var db = TestAppDbContextFactory.Create(); + var t0 = new DateTimeOffset(2026, 7, 10, 0, 0, 0, TimeSpan.Zero); + db.SecurityAlerts.Add(Alert(null, "LAPTOP-01", AlertSeverity.Medium, resolved: true, t0)); + db.AuditEvents.Add(Audit("Update device", actor: "admin@x.com", target: "LAPTOP-01", t0.AddHours(1))); + await db.SaveChangesAsync(); + + var p = await new EntityProfileBuilder(db).BuildAsync("device", "LAPTOP-01", 300, CancellationToken.None); + + Assert.Equal(1, p.Summary.AlertCount); + Assert.Equal(0, p.Summary.OpenAlertCount); // resolved + Assert.Equal(1, p.Summary.ActivityCount); // matched by target name + Assert.Equal("device", p.Summary.Kind); + } + + [Fact] + public async Task BuildAsync_UnknownEntity_NotFoundButEmptyProfile() + { + using var db = TestAppDbContextFactory.Create(); + var p = await new EntityProfileBuilder(db).BuildAsync("user", "ghost@x.com", 300, CancellationToken.None); + Assert.False(p.Found); + Assert.Empty(p.Timeline); + Assert.Null(p.Summary.FirstSeen); + } + + [Fact] + public async Task BuildAsync_RespectsMaxItemsCap() + { + using var db = TestAppDbContextFactory.Create(); + var t0 = new DateTimeOffset(2026, 7, 10, 0, 0, 0, TimeSpan.Zero); + for (var i = 0; i < 10; i++) db.AuditEvents.Add(Audit($"act {i}", "bob@x.com", null, t0.AddMinutes(i))); + await db.SaveChangesAsync(); + + var p = await new EntityProfileBuilder(db).BuildAsync("user", "bob@x.com", 5, CancellationToken.None); + Assert.Equal(5, p.Timeline.Count); + Assert.Equal(10, p.Summary.ActivityCount); // summary counts all, timeline is capped + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/GraphCertificateAuthTests.cs b/src/M365SecurityDashboard.Api.Tests/GraphCertificateAuthTests.cs new file mode 100644 index 0000000..6f00322 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/GraphCertificateAuthTests.cs @@ -0,0 +1,120 @@ +using Azure.Identity; +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; + +namespace M365SecurityDashboard.Api.Tests; + +/// +/// Certificate-auth contract: cert config is preferred over the client secret, +/// the secret remains a working fallback, and IsConfigured accepts either. +/// +public class GraphCertificateAuthTests +{ + private static GraphOptions Base() => new() + { + TenantId = "11111111-1111-1111-1111-111111111111", + ClientId = "22222222-2222-2222-2222-222222222222", + }; + + [Fact] + public void IsConfigured_SecretOnly_True() + { + var o = Base(); o.ClientSecret = "s3cret"; + Assert.True(o.IsConfigured()); + Assert.True(o.HasSecret()); + Assert.False(o.HasCertificate()); + } + + [Fact] + public void IsConfigured_CertificateOnly_True() + { + var o = Base(); o.CertificateThumbprint = "AABBCCDDEEFF"; + Assert.True(o.IsConfigured()); + Assert.True(o.HasCertificate()); + Assert.False(o.HasSecret()); + } + + [Fact] + public void IsConfigured_NoCredential_False() + { + var o = Base(); + Assert.False(o.IsConfigured()); + } + + [Fact] + public void IsConfigured_PlaceholderSecret_NotACredential() + { + var o = Base(); o.ClientSecret = "YOUR_APP_CLIENT_SECRET"; + Assert.False(o.IsConfigured()); + } + + [Fact] + public void BuildCredential_SecretOnly_UsesClientSecretCredential() + { + var o = Base(); o.ClientSecret = "s3cret"; + Assert.IsType(GraphApiClient.BuildCredential(o)); + } + + [Fact] + public void BuildCredential_MissingPfxFile_ThrowsClearError() + { + var o = Base(); o.CertificatePath = @"Z:\does\not\exist.pfx"; + var ex = Assert.Throws(() => GraphApiClient.BuildCredential(o)); + Assert.Contains("not found", ex.Message); + } + + [Fact] + public void BuildCredential_UnknownThumbprint_ThrowsClearError() + { + var o = Base(); o.CertificateThumbprint = "0000000000000000000000000000000000000000"; + var ex = Assert.Throws(() => GraphApiClient.BuildCredential(o)); + Assert.Contains("thumbprint", ex.Message); + } + + [Fact] + public void BuildCredential_PfxFile_UsesCertificateCredential() + { + // Create a throwaway self-signed cert, export to PFX, load it back. + using var rsa = System.Security.Cryptography.RSA.Create(2048); + var req = new System.Security.Cryptography.X509Certificates.CertificateRequest( + "CN=vigil365-test", rsa, + System.Security.Cryptography.HashAlgorithmName.SHA256, + System.Security.Cryptography.RSASignaturePadding.Pkcs1); + using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + var pfxPath = Path.Combine(Path.GetTempPath(), $"vigil365-test-{Guid.NewGuid():N}.pfx"); + try + { + File.WriteAllBytes(pfxPath, cert.Export(System.Security.Cryptography.X509Certificates.X509ContentType.Pfx, "pw")); + var o = Base(); o.CertificatePath = pfxPath; o.CertificatePassword = "pw"; + Assert.IsType(GraphApiClient.BuildCredential(o)); + } + finally + { + File.Delete(pfxPath); + } + } + + [Fact] + public void BuildCredential_CertificatePreferredOverSecret() + { + using var rsa = System.Security.Cryptography.RSA.Create(2048); + var req = new System.Security.Cryptography.X509Certificates.CertificateRequest( + "CN=vigil365-test", rsa, + System.Security.Cryptography.HashAlgorithmName.SHA256, + System.Security.Cryptography.RSASignaturePadding.Pkcs1); + using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + var pfxPath = Path.Combine(Path.GetTempPath(), $"vigil365-test-{Guid.NewGuid():N}.pfx"); + try + { + File.WriteAllBytes(pfxPath, cert.Export(System.Security.Cryptography.X509Certificates.X509ContentType.Pfx, "pw")); + var o = Base(); + o.ClientSecret = "s3cret"; // both configured + o.CertificatePath = pfxPath; o.CertificatePassword = "pw"; + Assert.IsType(GraphApiClient.BuildCredential(o)); // cert wins + } + finally + { + File.Delete(pfxPath); + } + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/GraphCollectorAlertCountTests.cs b/src/M365SecurityDashboard.Api.Tests/GraphCollectorAlertCountTests.cs new file mode 100644 index 0000000..f47c13c --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/GraphCollectorAlertCountTests.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using Xunit; + +namespace M365SecurityDashboard.Api.Tests; + +public class GraphCollectorAlertCountTests : IDisposable +{ + private readonly AppDbContext _db; + + public GraphCollectorAlertCountTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _db = new AppDbContext(options); + } + + public void Dispose() + { + _db.Database.EnsureDeleted(); + _db.Dispose(); + } + + [Fact] + public async Task AlertCounting_CountsCriticalAndHighAcrossAllServices() + { + // Add critical alerts across multiple services + _db.SecurityAlerts.Add(new SecurityAlert { Id = 1, Title = "A1", Severity = AlertSeverity.Critical, Service = M365ServiceArea.DefenderXdr, IsResolved = false, DetectedAt = DateTimeOffset.UtcNow }); + _db.SecurityAlerts.Add(new SecurityAlert { Id = 2, Title = "A2", Severity = AlertSeverity.Critical, Service = M365ServiceArea.EntraId, IsResolved = false, DetectedAt = DateTimeOffset.UtcNow }); + _db.SecurityAlerts.Add(new SecurityAlert { Id = 3, Title = "A3", Severity = AlertSeverity.Critical, Service = M365ServiceArea.Intune, IsResolved = false, DetectedAt = DateTimeOffset.UtcNow }); + + // Add high alerts across multiple services + _db.SecurityAlerts.Add(new SecurityAlert { Id = 4, Title = "A4", Severity = AlertSeverity.High, Service = M365ServiceArea.DefenderXdr, IsResolved = false, DetectedAt = DateTimeOffset.UtcNow }); + _db.SecurityAlerts.Add(new SecurityAlert { Id = 5, Title = "A5", Severity = AlertSeverity.High, Service = M365ServiceArea.ExchangeOnline, IsResolved = false, DetectedAt = DateTimeOffset.UtcNow }); + + // Add a resolved alert (should not be counted) + _db.SecurityAlerts.Add(new SecurityAlert { Id = 6, Title = "A6", Severity = AlertSeverity.Critical, Service = M365ServiceArea.DefenderXdr, IsResolved = true, DetectedAt = DateTimeOffset.UtcNow }); + + await _db.SaveChangesAsync(); + + var open = _db.SecurityAlerts.Where(a => !a.IsResolved); + + var criticalAlertsCount = await open.CountAsync(a => a.Severity == AlertSeverity.Critical); + var highAlertsCount = await open.CountAsync(a => a.Severity == AlertSeverity.High); + + Assert.Equal(3, criticalAlertsCount); + Assert.Equal(2, highAlertsCount); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/GraphErrorHintTests.cs b/src/M365SecurityDashboard.Api.Tests/GraphErrorHintTests.cs new file mode 100644 index 0000000..319a635 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/GraphErrorHintTests.cs @@ -0,0 +1,79 @@ +using M365SecurityDashboard.Api.Services; +using Xunit; + +namespace M365SecurityDashboard.Api.Tests; + +/// +/// A collector failure is the moment the product most needs to be clear: the +/// admin sees "1 source failed" and must learn what to do about it. These lock +/// the translation from Graph's raw JSON to an actionable sentence. +/// +public class GraphErrorHintTests +{ + // The exact shape Graph returns when an application permission is missing. + private const string Real403 = + "403 Forbidden: {\"error\":{\"code\":\"accessDenied\",\"message\":\"Caller does not have required permissions for this API\"}}"; + + [Fact] + public void Describe_403WithKnownSource_NamesTheMissingPermission() + { + var msg = GraphErrorHint.Describe(Real403, "SharePoint sharing posture"); + Assert.Contains("SharePointTenantSettings.Read.All", msg); + Assert.Contains("admin consent", msg, System.StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("{", msg); // no raw JSON leaks through + } + + [Fact] + public void Describe_403WithUnknownSource_StillExplainsItIsAPermissionProblem() + { + var msg = GraphErrorHint.Describe(Real403, "Some Future Source"); + Assert.Contains("Permission denied", msg); + Assert.DoesNotContain("{", msg); + } + + [Theory] + [InlineData("Risky users", "IdentityRiskyUser.Read.All")] + [InlineData("Defender alerts", "SecurityAlert.Read.All")] + [InlineData("Non-compliant devices", "DeviceManagementManagedDevices.Read.All")] + [InlineData("Tenant audit events", "AuditLog.Read.All")] + [InlineData("SharePoint sharing posture", "SharePointTenantSettings.Read.All")] + public void PermissionFor_MapsEveryCollectorSource(string source, string expected) + => Assert.Equal(expected, GraphErrorHint.PermissionFor(source)); + + [Fact] + public void PermissionFor_UnknownSourceIsNull() + => Assert.Null(GraphErrorHint.PermissionFor("Not A Source")); + + [Theory] + [InlineData("429 TooManyRequests", "throttl")] + [InlineData("401 Unauthorized", "credentials")] + [InlineData("404 NotFound", "licensed")] + public void Describe_RecognisesOtherCommonFailures(string raw, string expectedFragment) + => Assert.Contains(expectedFragment, GraphErrorHint.Describe(raw), System.StringComparison.OrdinalIgnoreCase); + + [Fact] + public void Describe_UnrecognisedFailureKeepsOriginalDetail() + { + // Never hide detail we cannot improve on. + const string odd = "Socket closed unexpectedly while reading response"; + Assert.Equal(odd, GraphErrorHint.Describe(odd)); + } + + [Fact] + public void DescribeOrNull_ReturnsNullForUnrecognised_SoCallersKeepTheirSafeMessage() + { + // Endpoint variant must never echo an unrecognised exception into HTTP. + Assert.Null(GraphErrorHint.DescribeOrNull("Object reference not set to an instance of an object")); + } + + [Fact] + public void DescribeOrNull_403UsesTheCallerSuppliedPermission() + { + var msg = GraphErrorHint.DescribeOrNull(Real403, "SecurityEvents.Read.All"); + Assert.Contains("SecurityEvents.Read.All", msg); + } + + [Fact] + public void Describe_TrimsVeryLongUnrecognisedMessages() + => Assert.Equal(50, GraphErrorHint.Describe(new string('x', 500), null, 50).Length); +} diff --git a/src/M365SecurityDashboard.Api.Tests/M365SecurityDashboard.Api.Tests.csproj b/src/M365SecurityDashboard.Api.Tests/M365SecurityDashboard.Api.Tests.csproj index 8a32bde..76272d9 100644 --- a/src/M365SecurityDashboard.Api.Tests/M365SecurityDashboard.Api.Tests.csproj +++ b/src/M365SecurityDashboard.Api.Tests/M365SecurityDashboard.Api.Tests.csproj @@ -17,11 +17,22 @@ + + + + + + + + diff --git a/src/M365SecurityDashboard.Api.Tests/NotificationDigestTests.cs b/src/M365SecurityDashboard.Api.Tests/NotificationDigestTests.cs new file mode 100644 index 0000000..e0143fc --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/NotificationDigestTests.cs @@ -0,0 +1,47 @@ +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; +using Xunit; + +namespace M365SecurityDashboard.Api.Tests; + +public class NotificationDigestTests +{ + private static DateTimeOffset At(int hour) => new(2026, 7, 17, hour, 0, 0, TimeSpan.Zero); + + [Fact] + public void ShouldSendDigest_OnlyAtConfiguredHourWithADigestChannel() + { + var cfg = new NotificationSettings { EmailDigest = true, DigestHourUtc = 8 }; + Assert.True(ShouldAt(cfg, 8)); + Assert.False(ShouldAt(cfg, 7)); + Assert.False(ShouldAt(new NotificationSettings { DigestHourUtc = 8 }, 8)); // no digest channel enabled + } + + [Fact] + public void ShouldSendDigest_OncePerDay() + { + var cfg = new NotificationSettings { TeamsDigest = true, DigestHourUtc = 8, LastDigestAt = At(8) }; + Assert.False(NotificationDigestWorker.ShouldSendDigest(cfg, At(8))); // already sent today + // Next day at the digest hour → due again. + Assert.True(NotificationDigestWorker.ShouldSendDigest(cfg, At(8).AddDays(1))); + } + + [Fact] + public void PendingForDigest_FiltersBySeverityAndSortsMostSevereFirst() + { + var alerts = new[] + { + new TriggeredAlert { PolicyName = "low", Severity = "low", TriggeredAt = At(1) }, + new TriggeredAlert { PolicyName = "crit", Severity = "critical", TriggeredAt = At(2) }, + new TriggeredAlert { PolicyName = "med", Severity = "medium", TriggeredAt = At(3) }, + }; + + var pending = NotificationDigestWorker.PendingForDigest(alerts, minSeverity: "medium"); + Assert.Equal(2, pending.Count); + Assert.Equal("crit", pending[0].PolicyName); // critical ranked first + Assert.DoesNotContain(pending, a => a.PolicyName == "low"); + } + + private static bool ShouldAt(NotificationSettings cfg, int hour) => + NotificationDigestWorker.ShouldSendDigest(cfg, At(hour)); +} diff --git a/src/M365SecurityDashboard.Api.Tests/NotificationHealthTests.cs b/src/M365SecurityDashboard.Api.Tests/NotificationHealthTests.cs new file mode 100644 index 0000000..b5cb919 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/NotificationHealthTests.cs @@ -0,0 +1,58 @@ +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; +using Xunit; + +namespace M365SecurityDashboard.Api.Tests; + +public class NotificationHealthTests +{ + private static NotificationLog Log(string channel, bool ok, int minutesAgo, string? error = null) => new() + { + Channel = channel, Success = ok, Error = error, + SentAt = DateTimeOffset.UtcNow.AddMinutes(-minutesAgo), + }; + + [Fact] + public void Compute_CountsConsecutiveFailuresSinceLastSuccess() + { + var logs = new[] + { + Log("webhook", ok: false, minutesAgo: 1, error: "500"), + Log("webhook", ok: false, minutesAgo: 2), + Log("webhook", ok: true, minutesAgo: 3), // stops the streak + Log("webhook", ok: false, minutesAgo: 4), + }; + + var health = Assert.Single(NotificationHealth.Compute(logs)); + Assert.Equal("webhook", health.Channel); + Assert.Equal(2, health.ConsecutiveFailures); + Assert.False(health.Healthy); + Assert.Equal("500", health.LastError); + Assert.NotNull(health.LastSuccessAt); + } + + [Fact] + public void Compute_RecentSuccessMeansHealthy() + { + var logs = new[] { Log("teams", ok: true, minutesAgo: 1), Log("teams", ok: false, minutesAgo: 2) }; + var health = Assert.Single(NotificationHealth.Compute(logs)); + Assert.Equal(0, health.ConsecutiveFailures); + Assert.True(health.Healthy); + Assert.Null(health.LastError); + } + + [Fact] + public void FailingChannels_RespectsThreshold() + { + var logs = new[] + { + Log("email", ok: false, minutesAgo: 1), + Log("email", ok: false, minutesAgo: 2), + Log("teams", ok: false, minutesAgo: 1), + }; + + Assert.Single(NotificationHealth.FailingChannels(logs, threshold: 2)); // only email + Assert.Equal(2, NotificationHealth.FailingChannels(logs, threshold: 1).Count); // both + Assert.Empty(NotificationHealth.FailingChannels(logs, threshold: 3)); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/NotificationSenderTests.cs b/src/M365SecurityDashboard.Api.Tests/NotificationSenderTests.cs new file mode 100644 index 0000000..f4e3de5 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/NotificationSenderTests.cs @@ -0,0 +1,119 @@ +using System.Net; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; +using Xunit; + +namespace M365SecurityDashboard.Api.Tests; + +public class NotificationSenderTests : IDisposable +{ + private readonly AppDbContext _db; + private readonly SecretProtector _protector; + + public NotificationSenderTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _db = new AppDbContext(options); + _protector = new SecretProtector(new EphemeralDataProtectionProvider(), NullLogger.Instance); + } + + public void Dispose() + { + _db.Database.EnsureDeleted(); + _db.Dispose(); + } + + private class MockHttpMessageHandler : HttpMessageHandler + { + public HttpStatusCode StatusCodeToReturn { get; set; } = HttpStatusCode.OK; + public int RequestCount { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestCount++; + return Task.FromResult(new HttpResponseMessage(StatusCodeToReturn)); + } + } + + private class MockHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new HttpClient(handler); + } + + [Fact] + public async Task DispatchAsync_SkipWhenAlertSeverityBelowMinSeverity() + { + var handler = new MockHttpMessageHandler(); + var factory = new MockHttpClientFactory(handler); + var sender = new NotificationSender(factory, _protector, NullLogger.Instance); + + var cfg = new NotificationSettings { MinSeverity = "high", WebhookEnabled = true, WebhookUrl = "https://example.com/webhook" }; + var alert = new TriggeredAlert { Id = Guid.NewGuid(), PolicyId = Guid.NewGuid(), PolicyName = "Test Policy", Severity = "low", Status = "new", Condition = "c", MetricValue = 1, Threshold = 1, TriggeredAt = DateTimeOffset.UtcNow }; + + await sender.DispatchAsync(_db, cfg, alert, CancellationToken.None); + + Assert.Equal(0, handler.RequestCount); + Assert.Empty(_db.NotificationLogs); + } + + [Fact] + public async Task DispatchAsync_WebhookSuccess_LogsSuccessRow() + { + var handler = new MockHttpMessageHandler { StatusCodeToReturn = HttpStatusCode.OK }; + var factory = new MockHttpClientFactory(handler); + var sender = new NotificationSender(factory, _protector, NullLogger.Instance); + + var cfg = new NotificationSettings { MinSeverity = "low", WebhookEnabled = true, WebhookUrl = "https://example.com/webhook" }; + var alert = new TriggeredAlert { Id = Guid.NewGuid(), PolicyId = Guid.NewGuid(), PolicyName = "Critical Policy", Severity = "critical", Status = "new", Condition = "c", MetricValue = 10, Threshold = 1, TriggeredAt = DateTimeOffset.UtcNow }; + + await sender.DispatchAsync(_db, cfg, alert, CancellationToken.None); + await _db.SaveChangesAsync(); + + Assert.Equal(1, handler.RequestCount); + var log = Assert.Single(_db.NotificationLogs); + Assert.True(log.Success); + Assert.Equal("webhook", log.Channel); + } + + [Fact] + public async Task DispatchAsync_WebhookFailure_LogsErrorRow() + { + var handler = new MockHttpMessageHandler { StatusCodeToReturn = HttpStatusCode.InternalServerError }; + var factory = new MockHttpClientFactory(handler); + var sender = new NotificationSender(factory, _protector, NullLogger.Instance); + + var cfg = new NotificationSettings { MinSeverity = "low", WebhookEnabled = true, WebhookUrl = "https://example.com/webhook" }; + var alert = new TriggeredAlert { Id = Guid.NewGuid(), PolicyId = Guid.NewGuid(), PolicyName = "Fail Policy", Severity = "high", Status = "new", Condition = "c", MetricValue = 5, Threshold = 1, TriggeredAt = DateTimeOffset.UtcNow }; + + await sender.DispatchAsync(_db, cfg, alert, CancellationToken.None); + await _db.SaveChangesAsync(); + + Assert.Equal(1, handler.RequestCount); + var log = Assert.Single(_db.NotificationLogs); + Assert.False(log.Success); + Assert.NotNull(log.Error); + Assert.Contains("500", log.Error); + } + + [Fact] + public async Task SendInviteEmailAsync_SmtpNotConfigured_ReturnsError() + { + var handler = new MockHttpMessageHandler(); + var factory = new MockHttpClientFactory(handler); + var sender = new NotificationSender(factory, _protector, NullLogger.Instance); + + var cfg = new NotificationSettings { EmailEnabled = false, SmtpHost = "" }; + + var (ok, err) = await sender.SendInviteEmailAsync(cfg, "user@contoso.com", "Analyst", "https://vigil365.local", CancellationToken.None); + + Assert.False(ok); + Assert.NotNull(err); + Assert.Contains("not configured", err); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/PolicyPackTests.cs b/src/M365SecurityDashboard.Api.Tests/PolicyPackTests.cs new file mode 100644 index 0000000..293b3be --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/PolicyPackTests.cs @@ -0,0 +1,217 @@ +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; +using Xunit; + +namespace M365SecurityDashboard.Api.Tests; + +/// +/// Policy packs cross install boundaries, so the contract matters: runtime state +/// must not travel, recipients must not leak by default, and an invalid entry +/// must be rejected rather than coerced into the evaluator. +/// +public class PolicyPackTests +{ + private static AlertPolicy Sample() => new() + { + Id = Guid.NewGuid(), + Name = "Privileged role assigned", + Enabled = true, + Category = "identity", + Condition = "Activity \"Add member to role\" >= 1 in 60m", + Kind = "activity", + Metric = "", + ActivityPattern = "Add member to role", + WindowMinutes = 60, + BaselineMultiplier = 3.0, + BaselineDays = 30, + Threshold = 1, + Severity = "high", + SuppressionMinutes = 60, + NotifyEmail = "soc@contoso.com", + CreatedAt = DateTimeOffset.UtcNow.AddDays(-90), + LastTriggered = DateTimeOffset.UtcNow.AddDays(-2), + TriggerCount = 47, + }; + + [Fact] + public void ToPack_StripsRecipientByDefault() + { + // Packs get shared; an internal address must not ride along silently. + Assert.Null(PolicyPack.ToPack(Sample(), includeRecipients: false).NotifyEmail); + } + + [Fact] + public void ToPack_IncludesRecipientWhenExplicitlyRequested() + => Assert.Equal("soc@contoso.com", PolicyPack.ToPack(Sample(), includeRecipients: true).NotifyEmail); + + [Fact] + public void ToEntity_ResetsRuntimeState_NotCarriedFromAnotherInstall() + { + var entity = PolicyPack.ToEntity(PolicyPack.ToPack(Sample(), false)); + Assert.Equal(0, entity.TriggerCount); // never fired *here* + Assert.Null(entity.LastTriggered); + Assert.NotEqual(Guid.Empty, entity.Id); + } + + [Fact] + public void RoundTrip_PreservesEveryBehaviouralField() + { + var original = Sample(); + var restored = PolicyPack.ToEntity(PolicyPack.ToPack(original, includeRecipients: true)); + + Assert.Equal(original.Name, restored.Name); + Assert.Equal(original.Enabled, restored.Enabled); + Assert.Equal(original.Category, restored.Category); + Assert.Equal(original.Kind, restored.Kind); + Assert.Equal(original.Metric, restored.Metric); + Assert.Equal(original.ActivityPattern, restored.ActivityPattern); + Assert.Equal(original.WindowMinutes, restored.WindowMinutes); + Assert.Equal(original.BaselineMultiplier, restored.BaselineMultiplier); + Assert.Equal(original.BaselineDays, restored.BaselineDays); + Assert.Equal(original.Threshold, restored.Threshold); + Assert.Equal(original.Severity, restored.Severity); + Assert.Equal(original.SuppressionMinutes, restored.SuppressionMinutes); + Assert.Equal(original.NotifyEmail, restored.NotifyEmail); + } + + [Fact] + public void Validate_AcceptsAWellFormedPolicy() + => Assert.Null(PolicyPack.Validate(PolicyPack.ToPack(Sample(), false))); + + [Fact] + public void Validate_RejectsActivityPolicyWithNoPattern() + { + // Would sit in the evaluator matching nothing while looking protective. + var p = PolicyPack.ToPack(Sample(), false) with { ActivityPattern = " " }; + Assert.Contains("activity pattern", PolicyPack.Validate(p), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Validate_RejectsMetricPolicyWithNoMetric() + { + var p = PolicyPack.ToPack(Sample(), false) with { Kind = "metric", Metric = "" }; + Assert.Contains("metric", PolicyPack.Validate(p), StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(0)] + [InlineData(-5)] + public void Validate_RejectsNonPositiveThreshold(int threshold) + { + var p = PolicyPack.ToPack(Sample(), false) with { Threshold = threshold }; + Assert.Contains("Threshold", PolicyPack.Validate(p)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Validate_RejectsMissingName(string name) + { + var p = PolicyPack.ToPack(Sample(), false) with { Name = name }; + Assert.Contains("Name", PolicyPack.Validate(p)); + } + + [Fact] + public void Validate_RejectsUnknownKind() + { + var p = PolicyPack.ToPack(Sample(), false) with { Kind = "telepathy" }; + Assert.Contains("Kind", PolicyPack.Validate(p)); + } + + [Fact] + public void Validate_RejectsUnknownSeverity() + { + var p = PolicyPack.ToPack(Sample(), false) with { Severity = "catastrophic" }; + Assert.Contains("Severity", PolicyPack.Validate(p)); + } + + [Fact] + public void Validate_RejectsNullEntry() => Assert.NotNull(PolicyPack.Validate(null)); + + [Fact] + public void ApplyTo_PreservesIdentityAndHistory() + { + var target = Sample(); + var originalId = target.Id; + var originalCount = target.TriggerCount; + + PolicyPack.ApplyTo(target, PolicyPack.ToPack(Sample(), false) with { Threshold = 9, Severity = "low" }); + + Assert.Equal(originalId, target.Id); // same policy, updated in place + Assert.Equal(originalCount, target.TriggerCount); + Assert.Equal(9, target.Threshold); + Assert.Equal("low", target.Severity); + } + + [Fact] + public void ApplyTo_StrippedPackDoesNotClearLocalRecipient() + { + // Importing a shared pack must not silently break local alert routing. + var target = Sample(); + PolicyPack.ApplyTo(target, PolicyPack.ToPack(Sample(), includeRecipients: false)); + Assert.Equal("soc@contoso.com", target.NotifyEmail); + } + + [Fact] + public void ApplyTo_PackWithRecipientOverwritesLocal() + { + var target = Sample(); + PolicyPack.ApplyTo(target, PolicyPack.ToPack(Sample(), true) with { NotifyEmail = "new@contoso.com" }); + Assert.Equal("new@contoso.com", target.NotifyEmail); + } + + // ── Real-world shapes ──────────────────────────────────────────────────── + // Caught by validating the live tenant's 21 policies: 18 were rejected + // because validation checked tuning fields irrelevant to the policy's kind. + // Baseline columns default to 0 on every non-anomaly policy, and policies + // predating the Kind column carry "". + + [Fact] + public void Validate_AcceptsMetricPolicyWithZeroBaselineFields() + { + // Baseline settings only mean anything for anomaly policies. + var p = PolicyPack.ToPack(Sample(), false) with + { + Kind = "metric", Metric = "riskyUsersCount", ActivityPattern = null, + BaselineDays = 0, BaselineMultiplier = 0, WindowMinutes = 0, + }; + Assert.Null(PolicyPack.Validate(p)); + } + + [Fact] + public void Validate_AcceptsLegacyPolicyWithEmptyKind() + { + // The evaluator reads a blank Kind as "metric"; so must the pack. + var p = PolicyPack.ToPack(Sample(), false) with + { + Kind = "", Metric = "criticalAlertCount", ActivityPattern = null, + BaselineDays = 0, BaselineMultiplier = 0, WindowMinutes = 0, + }; + Assert.Null(PolicyPack.Validate(p)); + Assert.Equal("metric", PolicyPack.ToEntity(p).Kind); + } + + [Fact] + public void ToEntity_CoercesUnsetTuningFieldsToTheSameDefaultsAsPolicyCreate() + { + var p = PolicyPack.ToPack(Sample(), false) with + { + Kind = "metric", Metric = "riskyUsersCount", ActivityPattern = null, + WindowMinutes = 0, BaselineDays = 0, BaselineMultiplier = 0, SuppressionMinutes = -1, + }; + var e = PolicyPack.ToEntity(p); + Assert.Equal(60, e.WindowMinutes); + Assert.Equal(30, e.BaselineDays); + Assert.Equal(3.0, e.BaselineMultiplier); + Assert.Equal(60, e.SuppressionMinutes); + } + + [Fact] + public void ToEntity_NormalisesKindAndSeverityCasing() + { + var p = PolicyPack.ToPack(Sample(), false) with { Kind = "ACTIVITY", Severity = "HIGH" }; + var e = PolicyPack.ToEntity(p); + Assert.Equal("activity", e.Kind); + Assert.Equal("high", e.Severity); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/RecommendationsEngineTests.cs b/src/M365SecurityDashboard.Api.Tests/RecommendationsEngineTests.cs new file mode 100644 index 0000000..f0e7d9f --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/RecommendationsEngineTests.cs @@ -0,0 +1,72 @@ +using Xunit; +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; + +namespace M365SecurityDashboard.Api.Tests; + +public class RecommendationsEngineTests +{ + [Fact] + public async Task GetRecommendationsAsync_ReturnsAllGuidanceItemsWithCorrectAffectedCounts() + { + using var db = TestAppDbContextFactory.Create(); + db.SecurityAlerts.Add(new SecurityAlert { AlertType = "MfaStatus", Title = "MFA Missing", Severity = AlertSeverity.High, IsResolved = false }); + db.SecurityAlerts.Add(new SecurityAlert { AlertType = "RiskyUser", Title = "Risky User", Severity = AlertSeverity.Critical, IsResolved = false }); + await db.SaveChangesAsync(); + + var recs = await RecommendationsEngine.GetRecommendationsAsync(db); + + Assert.NotNull(recs); + Assert.True(recs.Count >= 6); + + var mfaRec = recs.FirstOrDefault(r => r.Id == "rec-mfa-registration"); + Assert.NotNull(mfaRec); + Assert.Equal(1, mfaRec.AffectedCount); + Assert.Contains("https://entra.microsoft.com", mfaRec.PortalDeepLink); + + var riskyRec = recs.FirstOrDefault(r => r.Id == "rec-risky-users"); + Assert.NotNull(riskyRec); + Assert.Equal(1, riskyRec.AffectedCount); + Assert.Equal("critical", riskyRec.Severity); + } + + [Fact] + public async Task GetAlertCoverageAsync_Evaluates20BaselineRulesCorrectly() + { + using var db = TestAppDbContextFactory.Create(); + db.AlertPolicies.Add(new AlertPolicy { Name = "Critical Security Alerts", Enabled = true }); + await db.SaveChangesAsync(); + + var scorecard = await RecommendationsEngine.GetAlertCoverageAsync(db); + + Assert.NotNull(scorecard); + Assert.Equal(20, scorecard.TotalRules); + + var critRule = scorecard.Rules.FirstOrDefault(r => r.Title == "Critical Security Alerts"); + Assert.NotNull(critRule); + Assert.True(critRule.IsActive); + + var mfaRule = scorecard.Rules.FirstOrDefault(r => r.Title == "MFA Not Registered"); + Assert.NotNull(mfaRule); + Assert.False(mfaRule.IsActive); + } + + [Fact] + public async Task EnableCoverageRuleAsync_CreatesNewAlertPolicyInDb() + { + using var db = TestAppDbContextFactory.Create(); + + var policy = await RecommendationsEngine.EnableCoverageRuleAsync(db, "base-02"); // MFA Not Registered + + Assert.NotNull(policy); + Assert.Equal("MFA Not Registered", policy.Name); + Assert.True(policy.Enabled); + + var saved = db.AlertPolicies.FirstOrDefault(p => p.Name == "MFA Not Registered"); + Assert.NotNull(saved); + Assert.True(saved.Enabled); + + var scorecard = await RecommendationsEngine.GetAlertCoverageAsync(db); + Assert.True(scorecard.Rules.First(r => r.Id == "base-02").IsActive); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/ReportScheduleTests.cs b/src/M365SecurityDashboard.Api.Tests/ReportScheduleTests.cs new file mode 100644 index 0000000..c2b5db7 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/ReportScheduleTests.cs @@ -0,0 +1,70 @@ +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; +using Xunit; + +namespace M365SecurityDashboard.Api.Tests; + +public class ReportScheduleTests +{ + private static DateTimeOffset Utc(int y, int mo, int d, int h) => new(y, mo, d, h, 0, 0, TimeSpan.Zero); + + [Fact] + public void NextRunAfter_Weekly_LandsOnConfiguredDayAndHour() + { + // Monday (DayOfWeek=1) at 07:00 UTC. + var s = new ReportSchedule { Cadence = "weekly", DayOfWeek = 1, HourUtc = 7 }; + // 2026-07-15 is a Wednesday; next Monday is 2026-07-20. + var next = s.NextRunAfter(Utc(2026, 7, 15, 12)); + Assert.Equal(DayOfWeek.Monday, next.DayOfWeek); + Assert.Equal(Utc(2026, 7, 20, 7), next); + } + + [Fact] + public void NextRunAfter_Daily_IsStrictlyAfterAnchor() + { + var s = new ReportSchedule { Cadence = "daily", HourUtc = 6 }; + // Already past 06:00 on the anchor day → rolls to next day. + var next = s.NextRunAfter(Utc(2026, 7, 15, 9)); + Assert.Equal(Utc(2026, 7, 16, 6), next); + } + + [Fact] + public void NextRunAfter_Monthly_UsesDayOfMonth() + { + var s = new ReportSchedule { Cadence = "monthly", DayOfMonth = 1, HourUtc = 8 }; + var next = s.NextRunAfter(Utc(2026, 7, 15, 8)); + Assert.Equal(Utc(2026, 8, 1, 8), next); + } + + [Fact] + public void IsDue_FiresOncePastScheduledTime_ThenNotAgainUntilNextPeriod() + { + var s = new ReportSchedule + { + Cadence = "weekly", DayOfWeek = 1, HourUtc = 7, Enabled = true, + CreatedAt = Utc(2026, 7, 13, 0), // Monday 00:00 — before that day's 07:00 slot + }; + + Assert.False(s.IsDue(Utc(2026, 7, 13, 6))); // before the 07:00 slot + Assert.True(s.IsDue(Utc(2026, 7, 13, 8))); // after it → due + + // After a run, not due again until the following Monday. + s.LastRunAt = Utc(2026, 7, 13, 8); + Assert.False(s.IsDue(Utc(2026, 7, 15, 12))); + Assert.True(s.IsDue(Utc(2026, 7, 20, 8))); + } + + [Fact] + public void IsDue_DisabledScheduleNeverFires() + { + var s = new ReportSchedule { Cadence = "daily", HourUtc = 0, Enabled = false, CreatedAt = Utc(2026, 7, 1, 0) }; + Assert.False(s.IsDue(Utc(2026, 12, 1, 12))); + } + + [Fact] + public void SplitRecipients_HandlesMixedSeparatorsAndDedupes() + { + var list = ReportScheduleWorker.SplitRecipients("a@x.com, b@x.com; a@x.com\n c@x.com"); + Assert.Equal(new[] { "a@x.com", "b@x.com", "c@x.com" }, list); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/RoleClaimsTransformationTests.cs b/src/M365SecurityDashboard.Api.Tests/RoleClaimsTransformationTests.cs new file mode 100644 index 0000000..088d439 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/RoleClaimsTransformationTests.cs @@ -0,0 +1,122 @@ +using System.Security.Claims; +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Xunit; + +namespace M365SecurityDashboard.Api.Tests; + +public class RoleClaimsTransformationTests : IDisposable +{ + private readonly AppDbContext _db; + private readonly MemoryCache _cache; + private readonly RoleClaimsTransformation _transformer; + + public RoleClaimsTransformationTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _db = new AppDbContext(options); + _cache = new MemoryCache(new MemoryCacheOptions()); + _transformer = new RoleClaimsTransformation(_db, _cache); + } + + public void Dispose() + { + _db.Database.EnsureDeleted(); + _db.Dispose(); + _cache.Dispose(); + } + + [Fact] + public async Task TransformAsync_UnauthenticatedPrincipal_ReturnsUnchanged() + { + var identity = new ClaimsIdentity(); // not authenticated + var principal = new ClaimsPrincipal(identity); + + var result = await _transformer.TransformAsync(principal); + + Assert.Empty(result.FindAll(ClaimTypes.Role)); + } + + [Fact] + public async Task TransformAsync_PrincipalAlreadyHasRoleClaim_ReturnsUnchanged() + { + var identity = new ClaimsIdentity("TestAuthType"); + identity.AddClaim(new Claim(ClaimTypes.Role, AppRoles.Admin)); + var principal = new ClaimsPrincipal(identity); + + var result = await _transformer.TransformAsync(principal); + + var roles = result.FindAll(ClaimTypes.Role).ToList(); + Assert.Single(roles); + Assert.Equal(AppRoles.Admin, roles[0].Value); + } + + [Fact] + public async Task TransformAsync_UserInDatabase_AddsRoleClaimFromDb() + { + var email = "analyst@contoso.com"; + _db.AppUsers.Add(new AppUser { Email = email, Role = AppRoles.Analyst, CreatedAt = DateTimeOffset.UtcNow }); + await _db.SaveChangesAsync(); + + var identity = new ClaimsIdentity("TestAuthType"); + identity.AddClaim(new Claim("preferred_username", email)); + var principal = new ClaimsPrincipal(identity); + + var result = await _transformer.TransformAsync(principal); + + var roleClaim = result.FindFirst(ClaimTypes.Role); + Assert.NotNull(roleClaim); + Assert.Equal(AppRoles.Analyst, roleClaim!.Value); + } + + [Fact] + public async Task TransformAsync_UserNotInDatabase_AddsViewerRoleClaim() + { + var email = "newuser@contoso.com"; + var identity = new ClaimsIdentity("TestAuthType"); + identity.AddClaim(new Claim("preferred_username", email)); + var principal = new ClaimsPrincipal(identity); + + var result = await _transformer.TransformAsync(principal); + + var roleClaim = result.FindFirst(ClaimTypes.Role); + Assert.NotNull(roleClaim); + Assert.Equal(AppRoles.Viewer, roleClaim!.Value); + } + + [Fact] + public async Task TransformAsync_SecondCall_ServesRoleFromCacheNotDb() + { + var email = "cached@contoso.com"; + _db.AppUsers.Add(new AppUser { Email = email, Role = AppRoles.Analyst, CreatedAt = DateTimeOffset.UtcNow }); + await _db.SaveChangesAsync(); + + ClaimsPrincipal MakePrincipal() + { + var identity = new ClaimsIdentity("TestAuthType"); + identity.AddClaim(new Claim("preferred_username", email)); + return new ClaimsPrincipal(identity); + } + + await _transformer.TransformAsync(MakePrincipal()); + + // Change the role in the DB without evicting — the cached value must win + // until the TTL expires or an admin endpoint evicts the key. + var user = await _db.AppUsers.SingleAsync(u => u.Email == email); + user.Role = AppRoles.Admin; + await _db.SaveChangesAsync(); + + var result = await _transformer.TransformAsync(MakePrincipal()); + Assert.Equal(AppRoles.Analyst, result.FindFirst(ClaimTypes.Role)!.Value); + + // After eviction (what the role-change endpoint does) the new role applies. + _cache.Remove(RoleClaimsTransformation.RoleCacheKey(email)); + var refreshed = await _transformer.TransformAsync(MakePrincipal()); + Assert.Equal(AppRoles.Admin, refreshed.FindFirst(ClaimTypes.Role)!.Value); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/SecretProtectorTests.cs b/src/M365SecurityDashboard.Api.Tests/SecretProtectorTests.cs new file mode 100644 index 0000000..2bbb561 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/SecretProtectorTests.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging.Abstractions; +using M365SecurityDashboard.Api.Services; +using Xunit; + +namespace M365SecurityDashboard.Api.Tests; + +public class SecretProtectorTests +{ + private readonly SecretProtector _protector; + + public SecretProtectorTests() + { + var provider = new EphemeralDataProtectionProvider(); + _protector = new SecretProtector(provider, NullLogger.Instance); + } + + [Fact] + public void Protect_Unprotect_RoundTrip_ReturnsOriginalPlaintext() + { + // Arrange + var secret = "SuperSecretPassword123!"; + + // Act + var encrypted = _protector.Protect(secret); + var decrypted = _protector.Unprotect(encrypted); + + // Assert + Assert.NotNull(encrypted); + Assert.StartsWith("dp:", encrypted!); + Assert.NotEqual(secret, encrypted); + Assert.Equal(secret, decrypted); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Protect_NullOrEmpty_ReturnsInput(string? input) + { + Assert.Equal(input, _protector.Protect(input)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Unprotect_NullOrEmpty_ReturnsInput(string? input) + { + Assert.Equal(input, _protector.Unprotect(input)); + } + + [Fact] + public void Protect_AlreadyProtectedDpPrefix_ReturnsUnchanged() + { + var alreadyEncrypted = "dp:someencryptedpayload"; + Assert.Equal(alreadyEncrypted, _protector.Protect(alreadyEncrypted)); + } + + [Fact] + [Trait("Category", "Security")] + public void Unprotect_MalformedDpPayload_ReturnsNullAndDoesNotThrow() + { + var malformed = "dp:thisisnotavalidbase64orprotectedpayload"; + var result = _protector.Unprotect(malformed); + Assert.Null(result); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/SharingPostureAnalyzerTests.cs b/src/M365SecurityDashboard.Api.Tests/SharingPostureAnalyzerTests.cs new file mode 100644 index 0000000..00bb02e --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/SharingPostureAnalyzerTests.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using M365SecurityDashboard.Api.Services; +using Xunit; +using View = M365SecurityDashboard.Api.Services.SharingPostureAnalyzer.SharingView; + +namespace M365SecurityDashboard.Api.Tests; + +public class SharingPostureAnalyzerTests +{ + private static View Settings( + string? cap = "externalUserSharingOnly", string? oneDrive = null, string? defaultLink = "internal", + int? expiry = 30, bool reshare = false, string[]? allowed = null, string[]? blocked = null) + => new(cap, oneDrive ?? cap, defaultLink, expiry, reshare, allowed ?? ["partner.com"], blocked ?? []); + + [Fact] + public void Analyze_HealthyGuestSharing_NoFindings() + { + var f = SharingPostureAnalyzer.Analyze(Settings()); + Assert.Empty(f); + } + + [Fact] + public void Analyze_AnyoneLinks_FlagsHigh_AndNeverExpiringLinks() + { + var f = SharingPostureAnalyzer.Analyze(Settings(cap: "externalUserAndGuestSharing", expiry: null)); + Assert.Contains(f, x => x.Severity == "high" && x.Title.Contains("\"Anyone\" links")); + Assert.Contains(f, x => x.Severity == "medium" && x.Title.Contains("never expire")); + // Ranked most severe first. + Assert.Equal("high", f[0].Severity); + } + + [Fact] + public void Analyze_ExpiryFinding_OnlyWhenAnyoneLinksEnabled() + { + // Guests-only sharing with no expiry set: anonymous-expiry finding must NOT fire. + var f = SharingPostureAnalyzer.Analyze(Settings(cap: "externalUserSharingOnly", expiry: null)); + Assert.DoesNotContain(f, x => x.Title.Contains("never expire")); + } + + [Fact] + public void Analyze_AnonymousDefaultLink_FlagsHigh() + { + var f = SharingPostureAnalyzer.Analyze(Settings(defaultLink: "anonymousAccess")); + Assert.Contains(f, x => x.Severity == "high" && x.Title.Contains("Default sharing link")); + } + + [Fact] + public void Analyze_ExternalResharing_And_NoDomainRestrictions() + { + var f = SharingPostureAnalyzer.Analyze(Settings(reshare: true, allowed: [], blocked: [])); + Assert.Contains(f, x => x.Title.Contains("re-share")); + Assert.Contains(f, x => x.Title.Contains("domain restrictions")); + } + + [Fact] + public void Analyze_OneDriveLooserThanSharePoint_Flags() + { + var f = SharingPostureAnalyzer.Analyze(Settings(cap: "externalUserSharingOnly", oneDrive: "externalUserAndGuestSharing")); + Assert.Contains(f, x => x.Title.Contains("OneDrive")); + } + + [Fact] + public void Analyze_SharingDisabled_IsClean() + { + var f = SharingPostureAnalyzer.Analyze(Settings(cap: "disabled", allowed: [], blocked: [], expiry: null, reshare: true)); + Assert.Empty(f); // resharing/domains are irrelevant when external sharing is off + } + + [Fact] + public void Parse_ExtractsFieldsFromGraphShape() + { + var json = JsonDocument.Parse(""" + { + "sharingCapability": "externalUserAndGuestSharing", + "sharingDefaultLinkType": "anonymousAccess", + "sharingLinkExpirationInDays": 14, + "isResharingByExternalUsersEnabled": true, + "sharingAllowedDomainList": ["contoso.com"], + "sharingBlockedDomainList": [] + } + """).RootElement; + + var v = SharingPostureAnalyzer.Parse(json); + Assert.Equal("externalUserAndGuestSharing", v.SharingCapability); + Assert.Equal("anonymousAccess", v.DefaultSharingLinkType); + Assert.Equal(14, v.AnonymousLinkExpirationDays); + Assert.True(v.ResharingByExternalUsersEnabled); + Assert.Single(v.AllowedDomains); + } +} diff --git a/src/M365SecurityDashboard.Api.Tests/SuppressionMatcherTests.cs b/src/M365SecurityDashboard.Api.Tests/SuppressionMatcherTests.cs new file mode 100644 index 0000000..ec4eed3 --- /dev/null +++ b/src/M365SecurityDashboard.Api.Tests/SuppressionMatcherTests.cs @@ -0,0 +1,121 @@ +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; +using Xunit; + +namespace M365SecurityDashboard.Api.Tests; + +/// +/// Suppression decides which alerts are never raised. A bug here hides real +/// security alerts silently, so the matching semantics are locked down hard. +/// +public class SuppressionMatcherTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 26, 12, 0, 0, TimeSpan.Zero); + private static readonly Guid PolicyA = Guid.NewGuid(); + private static readonly Guid PolicyB = Guid.NewGuid(); + + private static string Entities(params string[] upns) + => "[" + string.Join(",", upns.Select(u => $"{{\"userPrincipalName\":\"{u}\"}}")) + "]"; + + private static SuppressionRule Rule( + Guid? policyId = null, string? pattern = null, bool enabled = true, + DateTimeOffset? expires = null) + => new() { PolicyId = policyId, EntityPattern = pattern, Enabled = enabled, ExpiresAt = expires, Reason = "test" }; + + // ── Pattern matching ──────────────────────────────────────────────────── + [Theory] + [InlineData("svc-backup@x.com", "svc-backup@x.com", true)] // exact + [InlineData("SVC-BACKUP@X.COM", "svc-backup@x.com", true)] // case-insensitive + [InlineData("svc-*", "svc-backup@x.com", true)] // prefix + [InlineData("svc-*", "user@x.com", false)] + [InlineData("*@contractors.com", "bob@contractors.com", true)] // suffix + [InlineData("*@contractors.com", "bob@staff.com", false)] + [InlineData("*backup*", "svc-backup@x.com", true)] // contains + [InlineData("*backup*", "svc-restore@x.com", false)] + [InlineData("*", "anything", true)] + public void EntityMatches_HandlesWildcards(string pattern, string entity, bool expected) + => Assert.Equal(expected, SuppressionMatcher.EntityMatches(pattern, entity)); + + [Fact] + public void EntityMatches_NoPatternMeansNoRestriction() + => Assert.True(SuppressionMatcher.EntityMatches(null, "anyone@x.com")); + + [Fact] + public void EntityMatches_PatternWithNoEntityDoesNotMatch() + => Assert.False(SuppressionMatcher.EntityMatches("svc-*", null)); + + // ── Entity extraction ─────────────────────────────────────────────────── + [Fact] + public void ExtractEntities_ReadsCamelCaseKeys() + { + var json = "[{\"userPrincipalName\":\"a@x.com\"},{\"deviceName\":\"LAPTOP-1\"},{\"targetName\":\"Group A\"}]"; + var got = SuppressionMatcher.ExtractEntities(json); + Assert.Equal(["a@x.com", "LAPTOP-1", "Group A"], got); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not json at all")] + [InlineData("{\"notAnArray\":true}")] + public void ExtractEntities_ToleratesBadInput(string? json) + => Assert.Empty(SuppressionMatcher.ExtractEntities(json)); + + // ── Rule matching ─────────────────────────────────────────────────────── + [Fact] + public void FindMatch_PolicyWideRuleSuppressesThatPolicyOnly() + { + var rules = new[] { Rule(policyId: PolicyA) }; + Assert.NotNull(SuppressionMatcher.FindMatch(rules, PolicyA, null, Now)); + Assert.Null(SuppressionMatcher.FindMatch(rules, PolicyB, null, Now)); + } + + [Fact] + public void FindMatch_EntityRuleAppliesAcrossPolicies() + { + var rules = new[] { Rule(pattern: "svc-*") }; + Assert.NotNull(SuppressionMatcher.FindMatch(rules, PolicyA, Entities("svc-backup@x.com"), Now)); + Assert.NotNull(SuppressionMatcher.FindMatch(rules, PolicyB, Entities("svc-backup@x.com"), Now)); + Assert.Null(SuppressionMatcher.FindMatch(rules, PolicyA, Entities("real.user@x.com"), Now)); + } + + [Fact] + public void FindMatch_PolicyAndEntityMustBothMatch() + { + var rules = new[] { Rule(policyId: PolicyA, pattern: "svc-*") }; + Assert.NotNull(SuppressionMatcher.FindMatch(rules, PolicyA, Entities("svc-1@x.com"), Now)); + Assert.Null(SuppressionMatcher.FindMatch(rules, PolicyB, Entities("svc-1@x.com"), Now)); // wrong policy + Assert.Null(SuppressionMatcher.FindMatch(rules, PolicyA, Entities("real@x.com"), Now)); // wrong entity + } + + [Fact] + public void FindMatch_SuppressesWhenAnyAffectedEntityMatches() + { + var rules = new[] { Rule(pattern: "svc-*") }; + Assert.NotNull(SuppressionMatcher.FindMatch(rules, PolicyA, Entities("real@x.com", "svc-1@x.com"), Now)); + } + + [Fact] + public void FindMatch_IgnoresDisabledAndExpiredRules() + { + Assert.Null(SuppressionMatcher.FindMatch([Rule(policyId: PolicyA, enabled: false)], PolicyA, null, Now)); + Assert.Null(SuppressionMatcher.FindMatch([Rule(policyId: PolicyA, expires: Now.AddMinutes(-1))], PolicyA, null, Now)); + Assert.NotNull(SuppressionMatcher.FindMatch([Rule(policyId: PolicyA, expires: Now.AddMinutes(1))], PolicyA, null, Now)); + } + + [Fact] + public void FindMatch_UnscopedRuleNeverSuppressesEverything() + { + // A rule with neither policy nor entity would mute the whole product. + // The API rejects it, and the matcher refuses it as defence in depth. + Assert.Null(SuppressionMatcher.FindMatch([Rule()], PolicyA, Entities("a@x.com"), Now)); + } + + [Fact] + public void FindMatch_EntityRuleDoesNotSuppressAlertWithNoEntities() + { + // A metric policy with no affected entities must not be silenced by an + // entity-scoped rule — otherwise "suppress svc-*" would hide tenant-wide alerts. + Assert.Null(SuppressionMatcher.FindMatch([Rule(pattern: "svc-*")], PolicyA, null, Now)); + } +} diff --git a/src/M365SecurityDashboard.Api/Data/AlertingSchema.cs b/src/M365SecurityDashboard.Api/Data/AlertingSchema.cs index 19994ce..0938d20 100644 --- a/src/M365SecurityDashboard.Api/Data/AlertingSchema.cs +++ b/src/M365SecurityDashboard.Api/Data/AlertingSchema.cs @@ -3,9 +3,11 @@ namespace M365SecurityDashboard.Api.Data; /// -/// Idempotent DDL + seed data for the server-side alerting tables. Kept separate -/// so installs created before the alerting feature get the new tables without a -/// full EF migration (the app uses EnsureCreated, which never alters an existing DB). +/// LEGACY BRIDGE + seed data. The schema is now owned by EF migrations +/// (Data/Migrations); this idempotent DDL runs exactly once — when a +/// pre-migration database is baselined at startup — to bring any older install +/// up to the model the InitialCreate migration describes. Do not add new +/// schema changes here; add a migration instead. /// public static class AlertingSchema { @@ -41,11 +43,15 @@ [Condition] nvarchar(300) NOT NULL, [Status] nvarchar(20) NOT NULL, [AcknowledgedAt] datetimeoffset NULL, [AcknowledgedBy] nvarchar(120) NULL, - [Notified] bit NOT NULL + [Notified] bit NOT NULL, + [AffectedEntities] nvarchar(max) NULL ); IF OBJECT_ID(N'[NotificationSettings]', N'U') IS NULL CREATE TABLE [NotificationSettings] ( + -- Deliberately NOT an identity column: this is a singleton row with + -- a fixed key of 1, and the model supplies it. Identity here would + -- make SQL Server reject the explicit key. [Id] int NOT NULL PRIMARY KEY, [TeamsEnabled] bit NOT NULL, [TeamsWebhookUrl] nvarchar(2048) NULL, @@ -88,6 +94,70 @@ IF COL_LENGTH(N'[TriggeredAlerts]', 'BelowThresholdStreakCount') IS NULL IF COL_LENGTH(N'[TriggeredAlerts]', 'LastEvaluatedAt') IS NULL ALTER TABLE [TriggeredAlerts] ADD [LastEvaluatedAt] datetimeoffset NULL; + + IF COL_LENGTH(N'[TriggeredAlerts]', 'AffectedEntities') IS NULL + ALTER TABLE [TriggeredAlerts] ADD [AffectedEntities] nvarchar(max) NULL; + + IF OBJECT_ID(N'[TrendSnapshots]', N'U') IS NULL + BEGIN + CREATE TABLE [TrendSnapshots] ( + [Id] uniqueidentifier NOT NULL PRIMARY KEY, + [CapturedAt] datetimeoffset NOT NULL, + [RiskyUsersCount] int NOT NULL, + [MfaCoveragePct] float NOT NULL, + [NonCompliantDevicesCount] int NOT NULL, + [CriticalAlertsCount] int NOT NULL, + [HighAlertsCount] int NOT NULL, + [SecureScorePct] float NOT NULL, + [ComplianceIssuesCount] int NOT NULL + ); + CREATE INDEX [IX_TrendSnapshots_CapturedAt] ON [TrendSnapshots] ([CapturedAt]); + END + + IF OBJECT_ID(N'[AppUsers]', N'U') IS NULL + CREATE TABLE [AppUsers] ( + [Email] nvarchar(320) NOT NULL PRIMARY KEY, + [DisplayName] nvarchar(200) NULL, + [Role] nvarchar(20) NOT NULL, + [CreatedAt] datetimeoffset NOT NULL, + [LastSeenAt] datetimeoffset NOT NULL + ); + + IF OBJECT_ID(N'[AuditEntries]', N'U') IS NULL + CREATE TABLE [AuditEntries] ( + [Id] bigint IDENTITY(1,1) NOT NULL PRIMARY KEY, + [Timestamp] datetimeoffset NOT NULL, + [ActorEmail] nvarchar(320) NOT NULL, + [Action] nvarchar(60) NOT NULL, + [TargetType] nvarchar(40) NOT NULL, + [TargetId] nvarchar(320) NULL, + [Details] nvarchar(500) NULL + ); + IF OBJECT_ID(N'[AuditEntries]', N'U') IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditEntries_Timestamp') + CREATE INDEX [IX_AuditEntries_Timestamp] ON [AuditEntries]([Timestamp]); + + IF COL_LENGTH(N'[AuditEntries]', 'IpAddress') IS NULL + ALTER TABLE [AuditEntries] ADD [IpAddress] nvarchar(45) NULL; + + IF COL_LENGTH(N'[AuditEntries]', 'UserAgent') IS NULL + ALTER TABLE [AuditEntries] ADD [UserAgent] nvarchar(300) NULL; + + IF COL_LENGTH(N'[AuditEntries]', 'PrevHash') IS NULL + ALTER TABLE [AuditEntries] ADD [PrevHash] nvarchar(64) NULL; + + IF COL_LENGTH(N'[AuditEntries]', 'EntryHash') IS NULL + ALTER TABLE [AuditEntries] ADD [EntryHash] nvarchar(64) NULL; + + IF OBJECT_ID(N'[GraphConfig]', N'U') IS NULL + CREATE TABLE [GraphConfig] ( + -- Singleton row with a fixed key; see NotificationSettings above. + [Id] int NOT NULL PRIMARY KEY, + [TenantId] nvarchar(100) NOT NULL, + [ClientId] nvarchar(100) NOT NULL, + [ClientSecret] nvarchar(1024) NULL, + [UpdatedAt] datetimeoffset NOT NULL + ); """; private static readonly (string Name, string Category, string Metric, int Threshold, string Severity, string Condition)[] Defaults = @@ -101,29 +171,116 @@ private static readonly (string Name, string Category, string Metric, int Thresh ("Service Health Advisory", "identity", "serviceIssueCount", 1, "medium", "Active M365 service issues ≥ 1"), ]; + /// + /// Activity-based starter pack: alerts on WHAT HAPPENED in the tenant + /// (directory-audit activities), not on metric counts. Pattern supports * + /// as wildcard against Graph activityDisplayName. + /// + private static readonly (string Name, string Category, string Pattern, string Severity)[] ActivityDefaults = + [ + ("Privileged role assignment", "identity", "Add member to role", "critical"), + ("Eligible role assignment (PIM)", "identity", "Add eligible member to role", "high"), + ("App consent granted", "identity", "Consent to application", "high"), + ("Application credential added", "identity", "*Certificates and secrets management*", "high"), + ("Conditional Access policy changed", "identity", "*conditional access policy", "high"), + ("Federation settings changed", "identity", "Set federation settings on domain", "critical"), + ("New application registered", "identity", "Add application", "medium"), + ("Service principal added", "identity", "Add service principal", "medium"), + ("User deleted", "identity", "Delete user", "medium"), + ("Admin password reset", "identity", "Reset user password", "medium"), + ("Account disabled", "identity", "Disable account", "medium"), + ]; + public static void SeedDefaultPolicies(AppDbContext db) { - if (db.AlertPolicies.Any()) return; var now = DateTimeOffset.UtcNow; - foreach (var d in Defaults) + + if (!db.AlertPolicies.Any()) + { + foreach (var d in Defaults) + { + db.AlertPolicies.Add(new AlertPolicy + { + Id = Guid.NewGuid(), + Name = d.Name, + Enabled = true, + Category = d.Category, + Metric = d.Metric, + Threshold = d.Threshold, + Severity = d.Severity, + Condition = d.Condition, + SuppressionMinutes = 60, + CreatedAt = now, + TriggerCount = 0, + }); + } + } + + // Seed the activity pack independently so existing installs (which + // already have metric policies) still receive it once. + if (!db.AlertPolicies.Any(p => p.Kind == "activity")) { - db.AlertPolicies.Add(new AlertPolicy + foreach (var a in ActivityDefaults) { - Id = Guid.NewGuid(), - Name = d.Name, - Enabled = true, - Category = d.Category, - Metric = d.Metric, - Threshold = d.Threshold, - Severity = d.Severity, - Condition = d.Condition, - SuppressionMinutes = 60, - CreatedAt = now, - TriggerCount = 0, - }); + db.AlertPolicies.Add(new AlertPolicy + { + Id = Guid.NewGuid(), + Name = a.Name, + Enabled = true, + Kind = "activity", + Category = a.Category, + Metric = "", + ActivityPattern = a.Pattern, + WindowMinutes = 60, + Threshold = 1, + Severity = a.Severity, + Condition = $"Activity \"{a.Pattern}\" ≥ 1 in 60m", + SuppressionMinutes = 60, + CreatedAt = now, + TriggerCount = 0, + }); + } } + + // Anomaly starter pack: fire on spikes vs the tenant's own 30-day + // baseline, not on absolute numbers — catches "3× more risky users + // than normal" even in tenants where "normal" isn't zero. + if (!db.AlertPolicies.Any(p => p.Kind == "anomaly")) + { + (string Name, string Metric, int Floor, string Severity)[] anomalies = + [ + ("Risky user spike", "riskyUsersCount", 3, "high"), + ("High-severity alert spike", "highAlertCount", 10, "high"), + ("Non-compliant device spike", "nonCompliantDevicesCount", 5, "medium"), + ]; + foreach (var a in anomalies) + { + db.AlertPolicies.Add(new AlertPolicy + { + Id = Guid.NewGuid(), + Name = a.Name, + Enabled = true, + Kind = "anomaly", + Category = "identity", + Metric = a.Metric, + Threshold = a.Floor, + BaselineMultiplier = 3.0, + BaselineDays = 30, + Severity = a.Severity, + Condition = $"{a.Metric} ≥ 3× 30-day baseline (floor {a.Floor})", + SuppressionMinutes = 60, + CreatedAt = now, + TriggerCount = 0, + }); + } + } + + // The model defaults Id to 1 and the column is not store-generated, so + // no key is set here. This is the first write a fresh install performs; + // when the column was an identity it rejected the explicit key and took + // the whole service down on startup. if (!db.NotificationSettings.Any()) - db.NotificationSettings.Add(new NotificationSettings { Id = 1 }); + db.NotificationSettings.Add(new NotificationSettings()); db.SaveChanges(); } } diff --git a/src/M365SecurityDashboard.Api/Data/AppDbContext.cs b/src/M365SecurityDashboard.Api/Data/AppDbContext.cs index ecbfec2..670f4f7 100644 --- a/src/M365SecurityDashboard.Api/Data/AppDbContext.cs +++ b/src/M365SecurityDashboard.Api/Data/AppDbContext.cs @@ -11,6 +11,15 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon public DbSet TriggeredAlerts => Set(); public DbSet NotificationSettings => Set(); public DbSet NotificationLogs => Set(); + public DbSet TrendSnapshots => Set(); + public DbSet AppUsers => Set(); + public DbSet AuditEntries => Set(); + public DbSet GraphConfig => Set(); + public DbSet AlertNotes => Set(); + public DbSet SuppressionRules => Set(); + public DbSet AuditEvents => Set(); + public DbSet ReportSchedules => Set(); + public DbSet ApiTokens => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -44,9 +53,22 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(t => t.PolicyId); }); + modelBuilder.Entity(entity => + { + entity.HasKey(s => s.Id); + // The evaluator loads enabled rules on every cycle. + entity.HasIndex(s => s.Enabled); + entity.HasIndex(s => s.PolicyId); + }); + modelBuilder.Entity(entity => { entity.HasKey(s => s.Id); + // Singleton row with a fixed key of 1 (see the model's default). EF's + // convention would make an int key an identity column, and then the + // explicit 1 gets sent in the INSERT and SQL Server rejects it — + // which crashed every fresh install on its first startup write. + entity.Property(s => s.Id).ValueGeneratedNever(); }); modelBuilder.Entity(entity => @@ -54,5 +76,55 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasKey(l => l.Id); entity.HasIndex(l => l.SentAt); }); + + modelBuilder.Entity(entity => + { + entity.HasKey(t => t.Id); + entity.HasIndex(t => t.CapturedAt); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(u => u.Email); + entity.Property(u => u.Email).HasMaxLength(320); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(a => a.Id); + entity.HasIndex(a => a.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(g => g.Id); + // Singleton row with a fixed key of 1, as for NotificationSettings + // above. Left as an identity column this fails the moment someone + // saves Graph credentials on the setup page. + entity.Property(g => g.Id).ValueGeneratedNever(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(n => n.Id); + entity.HasIndex(n => new { n.TargetKind, n.TargetId }); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => new { e.Source, e.ExternalId }).IsUnique(); + entity.HasIndex(e => e.OccurredAt); + entity.HasIndex(e => e.Activity); + entity.Property(e => e.RawJson).HasColumnType("nvarchar(max)"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(t => t.Id); + entity.HasIndex(t => t.TokenHash).IsUnique(); + entity.HasIndex(t => t.Prefix); + entity.HasIndex(t => t.RevokedAt); + }); } } diff --git a/src/M365SecurityDashboard.Api/Data/DesignTimeDbContextFactory.cs b/src/M365SecurityDashboard.Api/Data/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..ffb4190 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/DesignTimeDbContextFactory.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace M365SecurityDashboard.Api.Data; + +/// +/// Used only by the `dotnet ef` design-time tools. Prevents the tools from +/// booting the real Program (which would run DB retries and seeding just to +/// scaffold a migration). The connection string is never opened during +/// `migrations add` — it only anchors the provider. +/// +public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public AppDbContext CreateDbContext(string[] args) + { + var options = new DbContextOptionsBuilder() + .UseSqlServer("Server=.\\SQLEXPRESS;Database=M365SecurityDashboard;Trusted_Connection=True;Encrypt=True;TrustServerCertificate=True") + .Options; + return new AppDbContext(options); + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260704083927_InitialCreate.Designer.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260704083927_InitialCreate.Designer.cs new file mode 100644 index 0000000..608f928 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260704083927_InitialCreate.Designer.cs @@ -0,0 +1,527 @@ +// +using System; +using M365SecurityDashboard.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260704083927_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("LastTriggered") + .HasColumnType("datetimeoffset"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotifyEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SuppressionMinutes") + .HasColumnType("int"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggerCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.ToTable("AlertPolicies"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AppUser", b => + { + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastSeenAt") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Email"); + + b.ToTable("AppUsers"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EntryHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("PrevHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetId") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserAgent") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.CollectionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertsUpserted") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("SourceFailureDetails") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceFailures") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StartedAt"); + + b.ToTable("CollectionRuns"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.GraphConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.ToTable("GraphConfig"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Error") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Target") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TriggeredAlertId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SentAt"); + + b.ToTable("NotificationLogs"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DefaultRecipient") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("EmailEnabled") + .HasColumnType("bit"); + + b.Property("FromAddress") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("MinSeverity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SmtpHost") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SmtpPassword") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SmtpPort") + .HasColumnType("int"); + + b.Property("SmtpUseSsl") + .HasColumnType("bit"); + + b.Property("SmtpUsername") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TeamsEnabled") + .HasColumnType("bit"); + + b.Property("TeamsWebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("WebhookEnabled") + .HasColumnType("bit"); + + b.Property("WebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.HasKey("Id"); + + b.ToTable("NotificationSettings"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.SecurityAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("DetectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeviceName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExternalId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("IsResolved") + .HasColumnType("bit"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("PortalUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasColumnType("int"); + + b.Property("Severity") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UserPrincipalName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("DetectedAt"); + + b.HasIndex("Service", "AlertType", "ExternalId") + .IsUnique() + .HasFilter("[ExternalId] IS NOT NULL"); + + b.HasIndex("Service", "Severity", "IsResolved"); + + b.ToTable("SecurityAlerts"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TrendSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CapturedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ComplianceIssuesCount") + .HasColumnType("int"); + + b.Property("CriticalAlertsCount") + .HasColumnType("int"); + + b.Property("HighAlertsCount") + .HasColumnType("int"); + + b.Property("MfaCoveragePct") + .HasColumnType("float"); + + b.Property("NonCompliantDevicesCount") + .HasColumnType("int"); + + b.Property("RiskyUsersCount") + .HasColumnType("int"); + + b.Property("SecureScorePct") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("CapturedAt"); + + b.ToTable("TrendSnapshots"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TriggeredAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetimeoffset"); + + b.Property("AcknowledgedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AffectedEntities") + .HasColumnType("nvarchar(max)"); + + b.Property("BelowThresholdStreakCount") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LastEvaluatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MetricValue") + .HasColumnType("int"); + + b.Property("Notified") + .HasColumnType("bit"); + + b.Property("PolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SnoozedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("SnoozedUntil") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggeredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("PolicyId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.ToTable("TriggeredAlerts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260704083927_InitialCreate.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260704083927_InitialCreate.cs new file mode 100644 index 0000000..e072e5d --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260704083927_InitialCreate.cs @@ -0,0 +1,317 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AlertPolicies", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Enabled = table.Column(type: "bit", nullable: false), + Category = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + Condition = table.Column(type: "nvarchar(300)", maxLength: 300, nullable: false), + Metric = table.Column(type: "nvarchar(60)", maxLength: 60, nullable: false), + Threshold = table.Column(type: "int", nullable: false), + Severity = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + NotifyEmail = table.Column(type: "nvarchar(320)", maxLength: 320, nullable: true), + SuppressionMinutes = table.Column(type: "int", nullable: false), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + LastTriggered = table.Column(type: "datetimeoffset", nullable: true), + TriggerCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AlertPolicies", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AppUsers", + columns: table => new + { + Email = table.Column(type: "nvarchar(320)", maxLength: 320, nullable: false), + DisplayName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + Role = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + LastSeenAt = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppUsers", x => x.Email); + }); + + migrationBuilder.CreateTable( + name: "AuditEntries", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Timestamp = table.Column(type: "datetimeoffset", nullable: false), + ActorEmail = table.Column(type: "nvarchar(320)", maxLength: 320, nullable: false), + Action = table.Column(type: "nvarchar(60)", maxLength: 60, nullable: false), + TargetType = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + TargetId = table.Column(type: "nvarchar(320)", maxLength: 320, nullable: true), + Details = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + IpAddress = table.Column(type: "nvarchar(45)", maxLength: 45, nullable: true), + UserAgent = table.Column(type: "nvarchar(300)", maxLength: 300, nullable: true), + PrevHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + EntryHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditEntries", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "CollectionRuns", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + StartedAt = table.Column(type: "datetimeoffset", nullable: false), + CompletedAt = table.Column(type: "datetimeoffset", nullable: true), + Status = table.Column(type: "int", nullable: false), + AlertsUpserted = table.Column(type: "int", nullable: false), + SourceFailures = table.Column(type: "int", nullable: false), + Error = table.Column(type: "nvarchar(4000)", maxLength: 4000, nullable: true), + SourceFailureDetails = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CollectionRuns", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "GraphConfig", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + TenantId = table.Column(type: "nvarchar(max)", nullable: false), + ClientId = table.Column(type: "nvarchar(max)", nullable: false), + ClientSecret = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedAt = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GraphConfig", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "NotificationLogs", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + TriggeredAlertId = table.Column(type: "uniqueidentifier", nullable: false), + PolicyName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Channel = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + Target = table.Column(type: "nvarchar(320)", maxLength: 320, nullable: true), + Success = table.Column(type: "bit", nullable: false), + Error = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: true), + SentAt = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_NotificationLogs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "NotificationSettings", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + TeamsEnabled = table.Column(type: "bit", nullable: false), + TeamsWebhookUrl = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: true), + EmailEnabled = table.Column(type: "bit", nullable: false), + SmtpHost = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + SmtpPort = table.Column(type: "int", nullable: false), + SmtpUseSsl = table.Column(type: "bit", nullable: false), + SmtpUsername = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + SmtpPassword = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true), + FromAddress = table.Column(type: "nvarchar(320)", maxLength: 320, nullable: true), + DefaultRecipient = table.Column(type: "nvarchar(320)", maxLength: 320, nullable: true), + WebhookEnabled = table.Column(type: "bit", nullable: false), + WebhookUrl = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: true), + MinSeverity = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_NotificationSettings", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SecurityAlerts", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ExternalId = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + AlertType = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: false), + Service = table.Column(type: "int", nullable: false), + Severity = table.Column(type: "int", nullable: false), + Title = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: false), + Description = table.Column(type: "nvarchar(4000)", maxLength: 4000, nullable: true), + UserPrincipalName = table.Column(type: "nvarchar(320)", maxLength: 320, nullable: true), + DeviceName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + PortalUrl = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: true), + DetectedAt = table.Column(type: "datetimeoffset", nullable: false), + LastUpdatedAt = table.Column(type: "datetimeoffset", nullable: false), + IsResolved = table.Column(type: "bit", nullable: false), + RawJson = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SecurityAlerts", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TrendSnapshots", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + CapturedAt = table.Column(type: "datetimeoffset", nullable: false), + RiskyUsersCount = table.Column(type: "int", nullable: false), + MfaCoveragePct = table.Column(type: "float", nullable: false), + NonCompliantDevicesCount = table.Column(type: "int", nullable: false), + CriticalAlertsCount = table.Column(type: "int", nullable: false), + HighAlertsCount = table.Column(type: "int", nullable: false), + SecureScorePct = table.Column(type: "float", nullable: false), + ComplianceIssuesCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrendSnapshots", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TriggeredAlerts", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + PolicyId = table.Column(type: "uniqueidentifier", nullable: false), + PolicyName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Severity = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + Category = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + Condition = table.Column(type: "nvarchar(300)", maxLength: 300, nullable: false), + MetricValue = table.Column(type: "int", nullable: false), + Threshold = table.Column(type: "int", nullable: false), + TriggeredAt = table.Column(type: "datetimeoffset", nullable: false), + Status = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + AcknowledgedAt = table.Column(type: "datetimeoffset", nullable: true), + AcknowledgedBy = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: true), + Notified = table.Column(type: "bit", nullable: false), + SnoozedUntil = table.Column(type: "datetimeoffset", nullable: true), + SnoozedBy = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: true), + BelowThresholdStreakCount = table.Column(type: "int", nullable: false), + LastEvaluatedAt = table.Column(type: "datetimeoffset", nullable: true), + AffectedEntities = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TriggeredAlerts", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_AlertPolicies_Enabled", + table: "AlertPolicies", + column: "Enabled"); + + migrationBuilder.CreateIndex( + name: "IX_AuditEntries_Timestamp", + table: "AuditEntries", + column: "Timestamp"); + + migrationBuilder.CreateIndex( + name: "IX_CollectionRuns_StartedAt", + table: "CollectionRuns", + column: "StartedAt"); + + migrationBuilder.CreateIndex( + name: "IX_NotificationLogs_SentAt", + table: "NotificationLogs", + column: "SentAt"); + + migrationBuilder.CreateIndex( + name: "IX_SecurityAlerts_DetectedAt", + table: "SecurityAlerts", + column: "DetectedAt"); + + migrationBuilder.CreateIndex( + name: "IX_SecurityAlerts_Service_AlertType_ExternalId", + table: "SecurityAlerts", + columns: new[] { "Service", "AlertType", "ExternalId" }, + unique: true, + filter: "[ExternalId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_SecurityAlerts_Service_Severity_IsResolved", + table: "SecurityAlerts", + columns: new[] { "Service", "Severity", "IsResolved" }); + + migrationBuilder.CreateIndex( + name: "IX_TrendSnapshots_CapturedAt", + table: "TrendSnapshots", + column: "CapturedAt"); + + migrationBuilder.CreateIndex( + name: "IX_TriggeredAlerts_PolicyId", + table: "TriggeredAlerts", + column: "PolicyId"); + + migrationBuilder.CreateIndex( + name: "IX_TriggeredAlerts_Status", + table: "TriggeredAlerts", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_TriggeredAlerts_TriggeredAt", + table: "TriggeredAlerts", + column: "TriggeredAt"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AlertPolicies"); + + migrationBuilder.DropTable( + name: "AppUsers"); + + migrationBuilder.DropTable( + name: "AuditEntries"); + + migrationBuilder.DropTable( + name: "CollectionRuns"); + + migrationBuilder.DropTable( + name: "GraphConfig"); + + migrationBuilder.DropTable( + name: "NotificationLogs"); + + migrationBuilder.DropTable( + name: "NotificationSettings"); + + migrationBuilder.DropTable( + name: "SecurityAlerts"); + + migrationBuilder.DropTable( + name: "TrendSnapshots"); + + migrationBuilder.DropTable( + name: "TriggeredAlerts"); + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260704085104_AlertWorkbench.Designer.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260704085104_AlertWorkbench.Designer.cs new file mode 100644 index 0000000..0625df5 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260704085104_AlertWorkbench.Designer.cs @@ -0,0 +1,577 @@ +// +using System; +using M365SecurityDashboard.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260704085104_AlertWorkbench")] + partial class AlertWorkbench + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("TargetKind", "TargetId"); + + b.ToTable("AlertNotes"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("LastTriggered") + .HasColumnType("datetimeoffset"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotifyEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SuppressionMinutes") + .HasColumnType("int"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggerCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.ToTable("AlertPolicies"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AppUser", b => + { + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastSeenAt") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Email"); + + b.ToTable("AppUsers"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EntryHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("PrevHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetId") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserAgent") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.CollectionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertsUpserted") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("SourceFailureDetails") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceFailures") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StartedAt"); + + b.ToTable("CollectionRuns"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.GraphConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.ToTable("GraphConfig"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Error") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Target") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TriggeredAlertId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SentAt"); + + b.ToTable("NotificationLogs"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DefaultRecipient") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("EmailEnabled") + .HasColumnType("bit"); + + b.Property("FromAddress") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("MinSeverity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SmtpHost") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SmtpPassword") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SmtpPort") + .HasColumnType("int"); + + b.Property("SmtpUseSsl") + .HasColumnType("bit"); + + b.Property("SmtpUsername") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TeamsEnabled") + .HasColumnType("bit"); + + b.Property("TeamsWebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("WebhookEnabled") + .HasColumnType("bit"); + + b.Property("WebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.HasKey("Id"); + + b.ToTable("NotificationSettings"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.SecurityAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("DetectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeviceName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Disposition") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ExternalId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("IsResolved") + .HasColumnType("bit"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("PortalUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasColumnType("int"); + + b.Property("Severity") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UserPrincipalName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("DetectedAt"); + + b.HasIndex("Service", "AlertType", "ExternalId") + .IsUnique() + .HasFilter("[ExternalId] IS NOT NULL"); + + b.HasIndex("Service", "Severity", "IsResolved"); + + b.ToTable("SecurityAlerts"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TrendSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CapturedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ComplianceIssuesCount") + .HasColumnType("int"); + + b.Property("CriticalAlertsCount") + .HasColumnType("int"); + + b.Property("HighAlertsCount") + .HasColumnType("int"); + + b.Property("MfaCoveragePct") + .HasColumnType("float"); + + b.Property("NonCompliantDevicesCount") + .HasColumnType("int"); + + b.Property("RiskyUsersCount") + .HasColumnType("int"); + + b.Property("SecureScorePct") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("CapturedAt"); + + b.ToTable("TrendSnapshots"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TriggeredAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetimeoffset"); + + b.Property("AcknowledgedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AffectedEntities") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("BelowThresholdStreakCount") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LastEvaluatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MetricValue") + .HasColumnType("int"); + + b.Property("Notified") + .HasColumnType("bit"); + + b.Property("PolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SnoozedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("SnoozedUntil") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggeredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("PolicyId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.ToTable("TriggeredAlerts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260704085104_AlertWorkbench.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260704085104_AlertWorkbench.cs new file mode 100644 index 0000000..649d6fd --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260704085104_AlertWorkbench.cs @@ -0,0 +1,77 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + /// + public partial class AlertWorkbench : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AssignedTo", + table: "TriggeredAlerts", + type: "nvarchar(320)", + maxLength: 320, + nullable: true); + + migrationBuilder.AddColumn( + name: "AssignedTo", + table: "SecurityAlerts", + type: "nvarchar(320)", + maxLength: 320, + nullable: true); + + migrationBuilder.AddColumn( + name: "Disposition", + table: "SecurityAlerts", + type: "nvarchar(30)", + maxLength: 30, + nullable: true); + + migrationBuilder.CreateTable( + name: "AlertNotes", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + TargetKind = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + TargetId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Author = table.Column(type: "nvarchar(320)", maxLength: 320, nullable: false), + Text = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: false), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AlertNotes", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_AlertNotes_TargetKind_TargetId", + table: "AlertNotes", + columns: new[] { "TargetKind", "TargetId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AlertNotes"); + + migrationBuilder.DropColumn( + name: "AssignedTo", + table: "TriggeredAlerts"); + + migrationBuilder.DropColumn( + name: "AssignedTo", + table: "SecurityAlerts"); + + migrationBuilder.DropColumn( + name: "Disposition", + table: "SecurityAlerts"); + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260711050247_ActivityAlerting.Designer.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260711050247_ActivityAlerting.Designer.cs new file mode 100644 index 0000000..e5d5e2d --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260711050247_ActivityAlerting.Designer.cs @@ -0,0 +1,654 @@ +// +using System; +using M365SecurityDashboard.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260711050247_ActivityAlerting")] + partial class ActivityAlerting + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("TargetKind", "TargetId"); + + b.ToTable("AlertNotes"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivityPattern") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("LastTriggered") + .HasColumnType("datetimeoffset"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotifyEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SuppressionMinutes") + .HasColumnType("int"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggerCount") + .HasColumnType("int"); + + b.Property("WindowMinutes") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.ToTable("AlertPolicies"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AppUser", b => + { + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastSeenAt") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Email"); + + b.ToTable("AppUsers"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EntryHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("PrevHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetId") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserAgent") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Activity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorApp") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorUpn") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Category") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("CollectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Result") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TargetName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("Activity"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.CollectionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertsUpserted") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("SourceFailureDetails") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceFailures") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StartedAt"); + + b.ToTable("CollectionRuns"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.GraphConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.ToTable("GraphConfig"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Error") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Target") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TriggeredAlertId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SentAt"); + + b.ToTable("NotificationLogs"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DefaultRecipient") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("EmailEnabled") + .HasColumnType("bit"); + + b.Property("FromAddress") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("MinSeverity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SmtpHost") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SmtpPassword") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SmtpPort") + .HasColumnType("int"); + + b.Property("SmtpUseSsl") + .HasColumnType("bit"); + + b.Property("SmtpUsername") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TeamsEnabled") + .HasColumnType("bit"); + + b.Property("TeamsWebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("WebhookEnabled") + .HasColumnType("bit"); + + b.Property("WebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.HasKey("Id"); + + b.ToTable("NotificationSettings"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.SecurityAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("DetectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeviceName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Disposition") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ExternalId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("IsResolved") + .HasColumnType("bit"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("PortalUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasColumnType("int"); + + b.Property("Severity") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UserPrincipalName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("DetectedAt"); + + b.HasIndex("Service", "AlertType", "ExternalId") + .IsUnique() + .HasFilter("[ExternalId] IS NOT NULL"); + + b.HasIndex("Service", "Severity", "IsResolved"); + + b.ToTable("SecurityAlerts"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TrendSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CapturedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ComplianceIssuesCount") + .HasColumnType("int"); + + b.Property("CriticalAlertsCount") + .HasColumnType("int"); + + b.Property("HighAlertsCount") + .HasColumnType("int"); + + b.Property("MfaCoveragePct") + .HasColumnType("float"); + + b.Property("NonCompliantDevicesCount") + .HasColumnType("int"); + + b.Property("RiskyUsersCount") + .HasColumnType("int"); + + b.Property("SecureScorePct") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("CapturedAt"); + + b.ToTable("TrendSnapshots"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TriggeredAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetimeoffset"); + + b.Property("AcknowledgedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AffectedEntities") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("BelowThresholdStreakCount") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LastEvaluatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MetricValue") + .HasColumnType("int"); + + b.Property("Notified") + .HasColumnType("bit"); + + b.Property("PolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SnoozedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("SnoozedUntil") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggeredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("PolicyId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.ToTable("TriggeredAlerts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260711050247_ActivityAlerting.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260711050247_ActivityAlerting.cs new file mode 100644 index 0000000..a623bb6 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260711050247_ActivityAlerting.cs @@ -0,0 +1,95 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + /// + public partial class ActivityAlerting : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ActivityPattern", + table: "AlertPolicies", + type: "nvarchar(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "Kind", + table: "AlertPolicies", + type: "nvarchar(20)", + maxLength: 20, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "WindowMinutes", + table: "AlertPolicies", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "AuditEvents", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ExternalId = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Source = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + Activity = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Category = table.Column(type: "nvarchar(80)", maxLength: 80, nullable: true), + ActorUpn = table.Column(type: "nvarchar(320)", maxLength: 320, nullable: true), + ActorApp = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + TargetName = table.Column(type: "nvarchar(320)", maxLength: 320, nullable: true), + Result = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: true), + OccurredAt = table.Column(type: "datetimeoffset", nullable: false), + CollectedAt = table.Column(type: "datetimeoffset", nullable: false), + RawJson = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditEvents", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_AuditEvents_Activity", + table: "AuditEvents", + column: "Activity"); + + migrationBuilder.CreateIndex( + name: "IX_AuditEvents_OccurredAt", + table: "AuditEvents", + column: "OccurredAt"); + + migrationBuilder.CreateIndex( + name: "IX_AuditEvents_Source_ExternalId", + table: "AuditEvents", + columns: new[] { "Source", "ExternalId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AuditEvents"); + + migrationBuilder.DropColumn( + name: "ActivityPattern", + table: "AlertPolicies"); + + migrationBuilder.DropColumn( + name: "Kind", + table: "AlertPolicies"); + + migrationBuilder.DropColumn( + name: "WindowMinutes", + table: "AlertPolicies"); + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260717094102_AnomalyAlertPolicies.Designer.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260717094102_AnomalyAlertPolicies.Designer.cs new file mode 100644 index 0000000..6138909 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260717094102_AnomalyAlertPolicies.Designer.cs @@ -0,0 +1,660 @@ +// +using System; +using M365SecurityDashboard.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260717094102_AnomalyAlertPolicies")] + partial class AnomalyAlertPolicies + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("TargetKind", "TargetId"); + + b.ToTable("AlertNotes"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivityPattern") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BaselineDays") + .HasColumnType("int"); + + b.Property("BaselineMultiplier") + .HasColumnType("float"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("LastTriggered") + .HasColumnType("datetimeoffset"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotifyEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SuppressionMinutes") + .HasColumnType("int"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggerCount") + .HasColumnType("int"); + + b.Property("WindowMinutes") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.ToTable("AlertPolicies"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AppUser", b => + { + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastSeenAt") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Email"); + + b.ToTable("AppUsers"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EntryHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("PrevHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetId") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserAgent") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Activity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorApp") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorUpn") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Category") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("CollectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Result") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TargetName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("Activity"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.CollectionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertsUpserted") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("SourceFailureDetails") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceFailures") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StartedAt"); + + b.ToTable("CollectionRuns"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.GraphConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.ToTable("GraphConfig"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Error") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Target") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TriggeredAlertId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SentAt"); + + b.ToTable("NotificationLogs"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DefaultRecipient") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("EmailEnabled") + .HasColumnType("bit"); + + b.Property("FromAddress") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("MinSeverity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SmtpHost") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SmtpPassword") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SmtpPort") + .HasColumnType("int"); + + b.Property("SmtpUseSsl") + .HasColumnType("bit"); + + b.Property("SmtpUsername") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TeamsEnabled") + .HasColumnType("bit"); + + b.Property("TeamsWebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("WebhookEnabled") + .HasColumnType("bit"); + + b.Property("WebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.HasKey("Id"); + + b.ToTable("NotificationSettings"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.SecurityAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("DetectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeviceName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Disposition") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ExternalId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("IsResolved") + .HasColumnType("bit"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("PortalUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasColumnType("int"); + + b.Property("Severity") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UserPrincipalName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("DetectedAt"); + + b.HasIndex("Service", "AlertType", "ExternalId") + .IsUnique() + .HasFilter("[ExternalId] IS NOT NULL"); + + b.HasIndex("Service", "Severity", "IsResolved"); + + b.ToTable("SecurityAlerts"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TrendSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CapturedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ComplianceIssuesCount") + .HasColumnType("int"); + + b.Property("CriticalAlertsCount") + .HasColumnType("int"); + + b.Property("HighAlertsCount") + .HasColumnType("int"); + + b.Property("MfaCoveragePct") + .HasColumnType("float"); + + b.Property("NonCompliantDevicesCount") + .HasColumnType("int"); + + b.Property("RiskyUsersCount") + .HasColumnType("int"); + + b.Property("SecureScorePct") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("CapturedAt"); + + b.ToTable("TrendSnapshots"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TriggeredAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetimeoffset"); + + b.Property("AcknowledgedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AffectedEntities") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("BelowThresholdStreakCount") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LastEvaluatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MetricValue") + .HasColumnType("int"); + + b.Property("Notified") + .HasColumnType("bit"); + + b.Property("PolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SnoozedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("SnoozedUntil") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggeredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("PolicyId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.ToTable("TriggeredAlerts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260717094102_AnomalyAlertPolicies.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260717094102_AnomalyAlertPolicies.cs new file mode 100644 index 0000000..bb3be4a --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260717094102_AnomalyAlertPolicies.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + /// + public partial class AnomalyAlertPolicies : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BaselineDays", + table: "AlertPolicies", + type: "int", + nullable: false, + defaultValue: 30); + + migrationBuilder.AddColumn( + name: "BaselineMultiplier", + table: "AlertPolicies", + type: "float", + nullable: false, + defaultValue: 3.0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "BaselineDays", + table: "AlertPolicies"); + + migrationBuilder.DropColumn( + name: "BaselineMultiplier", + table: "AlertPolicies"); + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260717152103_ReportSchedules.Designer.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260717152103_ReportSchedules.Designer.cs new file mode 100644 index 0000000..cd49e1e --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260717152103_ReportSchedules.Designer.cs @@ -0,0 +1,720 @@ +// +using System; +using M365SecurityDashboard.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260717152103_ReportSchedules")] + partial class ReportSchedules + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("TargetKind", "TargetId"); + + b.ToTable("AlertNotes"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivityPattern") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BaselineDays") + .HasColumnType("int"); + + b.Property("BaselineMultiplier") + .HasColumnType("float"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("LastTriggered") + .HasColumnType("datetimeoffset"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotifyEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SuppressionMinutes") + .HasColumnType("int"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggerCount") + .HasColumnType("int"); + + b.Property("WindowMinutes") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.ToTable("AlertPolicies"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AppUser", b => + { + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastSeenAt") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Email"); + + b.ToTable("AppUsers"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EntryHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("PrevHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetId") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserAgent") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Activity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorApp") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorUpn") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Category") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("CollectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Result") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TargetName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("Activity"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.CollectionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertsUpserted") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("SourceFailureDetails") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceFailures") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StartedAt"); + + b.ToTable("CollectionRuns"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.GraphConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.ToTable("GraphConfig"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Error") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Target") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TriggeredAlertId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SentAt"); + + b.ToTable("NotificationLogs"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DefaultRecipient") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("EmailEnabled") + .HasColumnType("bit"); + + b.Property("FromAddress") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("MinSeverity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SmtpHost") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SmtpPassword") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SmtpPort") + .HasColumnType("int"); + + b.Property("SmtpUseSsl") + .HasColumnType("bit"); + + b.Property("SmtpUsername") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TeamsEnabled") + .HasColumnType("bit"); + + b.Property("TeamsWebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("WebhookEnabled") + .HasColumnType("bit"); + + b.Property("WebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.HasKey("Id"); + + b.ToTable("NotificationSettings"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.ReportSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Cadence") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("DayOfMonth") + .HasColumnType("int"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("HourUtc") + .HasColumnType("int"); + + b.Property("IncludeCsv") + .HasColumnType("bit"); + + b.Property("LastRunAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastRunStatus") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Recipients") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReportType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.ToTable("ReportSchedules"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.SecurityAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("DetectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeviceName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Disposition") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ExternalId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("IsResolved") + .HasColumnType("bit"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("PortalUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasColumnType("int"); + + b.Property("Severity") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UserPrincipalName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("DetectedAt"); + + b.HasIndex("Service", "AlertType", "ExternalId") + .IsUnique() + .HasFilter("[ExternalId] IS NOT NULL"); + + b.HasIndex("Service", "Severity", "IsResolved"); + + b.ToTable("SecurityAlerts"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TrendSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CapturedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ComplianceIssuesCount") + .HasColumnType("int"); + + b.Property("CriticalAlertsCount") + .HasColumnType("int"); + + b.Property("HighAlertsCount") + .HasColumnType("int"); + + b.Property("MfaCoveragePct") + .HasColumnType("float"); + + b.Property("NonCompliantDevicesCount") + .HasColumnType("int"); + + b.Property("RiskyUsersCount") + .HasColumnType("int"); + + b.Property("SecureScorePct") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("CapturedAt"); + + b.ToTable("TrendSnapshots"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TriggeredAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetimeoffset"); + + b.Property("AcknowledgedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AffectedEntities") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("BelowThresholdStreakCount") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LastEvaluatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MetricValue") + .HasColumnType("int"); + + b.Property("Notified") + .HasColumnType("bit"); + + b.Property("PolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SnoozedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("SnoozedUntil") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggeredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("PolicyId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.ToTable("TriggeredAlerts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260717152103_ReportSchedules.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260717152103_ReportSchedules.cs new file mode 100644 index 0000000..3795500 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260717152103_ReportSchedules.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + /// + public partial class ReportSchedules : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ReportSchedules", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: false), + ReportType = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + Cadence = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + DayOfWeek = table.Column(type: "int", nullable: false), + DayOfMonth = table.Column(type: "int", nullable: false), + HourUtc = table.Column(type: "int", nullable: false), + Recipients = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: false), + IncludeCsv = table.Column(type: "bit", nullable: false), + Enabled = table.Column(type: "bit", nullable: false), + LastRunAt = table.Column(type: "datetimeoffset", nullable: true), + LastRunStatus = table.Column(type: "nvarchar(400)", maxLength: 400, nullable: true), + CreatedBy = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ReportSchedules", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ReportSchedules"); + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260717153341_NotificationDigest.Designer.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260717153341_NotificationDigest.Designer.cs new file mode 100644 index 0000000..dd06fb3 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260717153341_NotificationDigest.Designer.cs @@ -0,0 +1,741 @@ +// +using System; +using M365SecurityDashboard.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260717153341_NotificationDigest")] + partial class NotificationDigest + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("TargetKind", "TargetId"); + + b.ToTable("AlertNotes"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivityPattern") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BaselineDays") + .HasColumnType("int"); + + b.Property("BaselineMultiplier") + .HasColumnType("float"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("LastTriggered") + .HasColumnType("datetimeoffset"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotifyEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SuppressionMinutes") + .HasColumnType("int"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggerCount") + .HasColumnType("int"); + + b.Property("WindowMinutes") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.ToTable("AlertPolicies"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AppUser", b => + { + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastSeenAt") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Email"); + + b.ToTable("AppUsers"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EntryHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("PrevHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetId") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserAgent") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Activity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorApp") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorUpn") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Category") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("CollectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Result") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TargetName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("Activity"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.CollectionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertsUpserted") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("SourceFailureDetails") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceFailures") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StartedAt"); + + b.ToTable("CollectionRuns"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.GraphConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.ToTable("GraphConfig"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Error") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Target") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TriggeredAlertId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SentAt"); + + b.ToTable("NotificationLogs"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DefaultRecipient") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("DigestHourUtc") + .HasColumnType("int"); + + b.Property("EmailDigest") + .HasColumnType("bit"); + + b.Property("EmailEnabled") + .HasColumnType("bit"); + + b.Property("FailureAlertThreshold") + .HasColumnType("int"); + + b.Property("FromAddress") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("LastDigestAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastFailureAlertAt") + .HasColumnType("datetimeoffset"); + + b.Property("MinSeverity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SmtpHost") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SmtpPassword") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SmtpPort") + .HasColumnType("int"); + + b.Property("SmtpUseSsl") + .HasColumnType("bit"); + + b.Property("SmtpUsername") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TeamsDigest") + .HasColumnType("bit"); + + b.Property("TeamsEnabled") + .HasColumnType("bit"); + + b.Property("TeamsWebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("WebhookDigest") + .HasColumnType("bit"); + + b.Property("WebhookEnabled") + .HasColumnType("bit"); + + b.Property("WebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.HasKey("Id"); + + b.ToTable("NotificationSettings"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.ReportSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Cadence") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("DayOfMonth") + .HasColumnType("int"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("HourUtc") + .HasColumnType("int"); + + b.Property("IncludeCsv") + .HasColumnType("bit"); + + b.Property("LastRunAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastRunStatus") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Recipients") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReportType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.ToTable("ReportSchedules"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.SecurityAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("DetectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeviceName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Disposition") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ExternalId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("IsResolved") + .HasColumnType("bit"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("PortalUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasColumnType("int"); + + b.Property("Severity") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UserPrincipalName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("DetectedAt"); + + b.HasIndex("Service", "AlertType", "ExternalId") + .IsUnique() + .HasFilter("[ExternalId] IS NOT NULL"); + + b.HasIndex("Service", "Severity", "IsResolved"); + + b.ToTable("SecurityAlerts"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TrendSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CapturedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ComplianceIssuesCount") + .HasColumnType("int"); + + b.Property("CriticalAlertsCount") + .HasColumnType("int"); + + b.Property("HighAlertsCount") + .HasColumnType("int"); + + b.Property("MfaCoveragePct") + .HasColumnType("float"); + + b.Property("NonCompliantDevicesCount") + .HasColumnType("int"); + + b.Property("RiskyUsersCount") + .HasColumnType("int"); + + b.Property("SecureScorePct") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("CapturedAt"); + + b.ToTable("TrendSnapshots"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TriggeredAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetimeoffset"); + + b.Property("AcknowledgedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AffectedEntities") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("BelowThresholdStreakCount") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LastEvaluatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MetricValue") + .HasColumnType("int"); + + b.Property("Notified") + .HasColumnType("bit"); + + b.Property("PolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SnoozedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("SnoozedUntil") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggeredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("PolicyId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.ToTable("TriggeredAlerts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260717153341_NotificationDigest.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260717153341_NotificationDigest.cs new file mode 100644 index 0000000..712e707 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260717153341_NotificationDigest.cs @@ -0,0 +1,94 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + /// + public partial class NotificationDigest : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DigestHourUtc", + table: "NotificationSettings", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "EmailDigest", + table: "NotificationSettings", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "FailureAlertThreshold", + table: "NotificationSettings", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "LastDigestAt", + table: "NotificationSettings", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastFailureAlertAt", + table: "NotificationSettings", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "TeamsDigest", + table: "NotificationSettings", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "WebhookDigest", + table: "NotificationSettings", + type: "bit", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DigestHourUtc", + table: "NotificationSettings"); + + migrationBuilder.DropColumn( + name: "EmailDigest", + table: "NotificationSettings"); + + migrationBuilder.DropColumn( + name: "FailureAlertThreshold", + table: "NotificationSettings"); + + migrationBuilder.DropColumn( + name: "LastDigestAt", + table: "NotificationSettings"); + + migrationBuilder.DropColumn( + name: "LastFailureAlertAt", + table: "NotificationSettings"); + + migrationBuilder.DropColumn( + name: "TeamsDigest", + table: "NotificationSettings"); + + migrationBuilder.DropColumn( + name: "WebhookDigest", + table: "NotificationSettings"); + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260725093240_AlertResolvedTimestamp.Designer.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260725093240_AlertResolvedTimestamp.Designer.cs new file mode 100644 index 0000000..1c19fbb --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260725093240_AlertResolvedTimestamp.Designer.cs @@ -0,0 +1,748 @@ +// +using System; +using M365SecurityDashboard.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260725093240_AlertResolvedTimestamp")] + partial class AlertResolvedTimestamp + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("TargetKind", "TargetId"); + + b.ToTable("AlertNotes"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivityPattern") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BaselineDays") + .HasColumnType("int"); + + b.Property("BaselineMultiplier") + .HasColumnType("float"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("LastTriggered") + .HasColumnType("datetimeoffset"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotifyEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SuppressionMinutes") + .HasColumnType("int"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggerCount") + .HasColumnType("int"); + + b.Property("WindowMinutes") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.ToTable("AlertPolicies"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AppUser", b => + { + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastSeenAt") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Email"); + + b.ToTable("AppUsers"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EntryHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("PrevHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetId") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserAgent") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Activity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorApp") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorUpn") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Category") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("CollectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Result") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TargetName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("Activity"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.CollectionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertsUpserted") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("SourceFailureDetails") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceFailures") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StartedAt"); + + b.ToTable("CollectionRuns"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.GraphConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.ToTable("GraphConfig"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Error") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Target") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TriggeredAlertId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SentAt"); + + b.ToTable("NotificationLogs"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DefaultRecipient") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("DigestHourUtc") + .HasColumnType("int"); + + b.Property("EmailDigest") + .HasColumnType("bit"); + + b.Property("EmailEnabled") + .HasColumnType("bit"); + + b.Property("FailureAlertThreshold") + .HasColumnType("int"); + + b.Property("FromAddress") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("LastDigestAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastFailureAlertAt") + .HasColumnType("datetimeoffset"); + + b.Property("MinSeverity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SmtpHost") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SmtpPassword") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SmtpPort") + .HasColumnType("int"); + + b.Property("SmtpUseSsl") + .HasColumnType("bit"); + + b.Property("SmtpUsername") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TeamsDigest") + .HasColumnType("bit"); + + b.Property("TeamsEnabled") + .HasColumnType("bit"); + + b.Property("TeamsWebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("WebhookDigest") + .HasColumnType("bit"); + + b.Property("WebhookEnabled") + .HasColumnType("bit"); + + b.Property("WebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.HasKey("Id"); + + b.ToTable("NotificationSettings"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.ReportSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Cadence") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("DayOfMonth") + .HasColumnType("int"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("HourUtc") + .HasColumnType("int"); + + b.Property("IncludeCsv") + .HasColumnType("bit"); + + b.Property("LastRunAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastRunStatus") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Recipients") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReportType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.ToTable("ReportSchedules"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.SecurityAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("DetectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeviceName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Disposition") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ExternalId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("IsResolved") + .HasColumnType("bit"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("PortalUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasColumnType("int"); + + b.Property("Severity") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UserPrincipalName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("DetectedAt"); + + b.HasIndex("Service", "AlertType", "ExternalId") + .IsUnique() + .HasFilter("[ExternalId] IS NOT NULL"); + + b.HasIndex("Service", "Severity", "IsResolved"); + + b.ToTable("SecurityAlerts"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TrendSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CapturedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ComplianceIssuesCount") + .HasColumnType("int"); + + b.Property("CriticalAlertsCount") + .HasColumnType("int"); + + b.Property("HighAlertsCount") + .HasColumnType("int"); + + b.Property("MfaCoveragePct") + .HasColumnType("float"); + + b.Property("NonCompliantDevicesCount") + .HasColumnType("int"); + + b.Property("RiskyUsersCount") + .HasColumnType("int"); + + b.Property("SecureScorePct") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("CapturedAt"); + + b.ToTable("TrendSnapshots"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TriggeredAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetimeoffset"); + + b.Property("AcknowledgedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AffectedEntities") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("BelowThresholdStreakCount") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LastEvaluatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MetricValue") + .HasColumnType("int"); + + b.Property("Notified") + .HasColumnType("bit"); + + b.Property("PolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ResolvedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SnoozedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("SnoozedUntil") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggeredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("PolicyId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.ToTable("TriggeredAlerts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260725093240_AlertResolvedTimestamp.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260725093240_AlertResolvedTimestamp.cs new file mode 100644 index 0000000..3ca59fc --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260725093240_AlertResolvedTimestamp.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + /// + public partial class AlertResolvedTimestamp : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ResolvedAt", + table: "TriggeredAlerts", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "ResolvedBy", + table: "TriggeredAlerts", + type: "nvarchar(120)", + maxLength: 120, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ResolvedAt", + table: "TriggeredAlerts"); + + migrationBuilder.DropColumn( + name: "ResolvedBy", + table: "TriggeredAlerts"); + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260726131255_SuppressionRules.Designer.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260726131255_SuppressionRules.Designer.cs new file mode 100644 index 0000000..44431dd --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260726131255_SuppressionRules.Designer.cs @@ -0,0 +1,794 @@ +// +using System; +using M365SecurityDashboard.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260726131255_SuppressionRules")] + partial class SuppressionRules + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("TargetKind", "TargetId"); + + b.ToTable("AlertNotes"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivityPattern") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BaselineDays") + .HasColumnType("int"); + + b.Property("BaselineMultiplier") + .HasColumnType("float"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("LastTriggered") + .HasColumnType("datetimeoffset"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotifyEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SuppressionMinutes") + .HasColumnType("int"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggerCount") + .HasColumnType("int"); + + b.Property("WindowMinutes") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.ToTable("AlertPolicies"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AppUser", b => + { + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastSeenAt") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Email"); + + b.ToTable("AppUsers"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EntryHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("PrevHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetId") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserAgent") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Activity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorApp") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorUpn") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Category") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("CollectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Result") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TargetName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("Activity"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.CollectionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertsUpserted") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("SourceFailureDetails") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceFailures") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StartedAt"); + + b.ToTable("CollectionRuns"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.GraphConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.ToTable("GraphConfig"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Error") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Target") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TriggeredAlertId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SentAt"); + + b.ToTable("NotificationLogs"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DefaultRecipient") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("DigestHourUtc") + .HasColumnType("int"); + + b.Property("EmailDigest") + .HasColumnType("bit"); + + b.Property("EmailEnabled") + .HasColumnType("bit"); + + b.Property("FailureAlertThreshold") + .HasColumnType("int"); + + b.Property("FromAddress") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("LastDigestAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastFailureAlertAt") + .HasColumnType("datetimeoffset"); + + b.Property("MinSeverity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SmtpHost") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SmtpPassword") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SmtpPort") + .HasColumnType("int"); + + b.Property("SmtpUseSsl") + .HasColumnType("bit"); + + b.Property("SmtpUsername") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TeamsDigest") + .HasColumnType("bit"); + + b.Property("TeamsEnabled") + .HasColumnType("bit"); + + b.Property("TeamsWebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("WebhookDigest") + .HasColumnType("bit"); + + b.Property("WebhookEnabled") + .HasColumnType("bit"); + + b.Property("WebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.HasKey("Id"); + + b.ToTable("NotificationSettings"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.ReportSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Cadence") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("DayOfMonth") + .HasColumnType("int"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("HourUtc") + .HasColumnType("int"); + + b.Property("IncludeCsv") + .HasColumnType("bit"); + + b.Property("LastRunAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastRunStatus") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Recipients") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReportType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.ToTable("ReportSchedules"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.SecurityAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("DetectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeviceName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Disposition") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ExternalId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("IsResolved") + .HasColumnType("bit"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("PortalUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasColumnType("int"); + + b.Property("Severity") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UserPrincipalName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("DetectedAt"); + + b.HasIndex("Service", "AlertType", "ExternalId") + .IsUnique() + .HasFilter("[ExternalId] IS NOT NULL"); + + b.HasIndex("Service", "Severity", "IsResolved"); + + b.ToTable("SecurityAlerts"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.SuppressionRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EntityPattern") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastSuppressedAt") + .HasColumnType("datetimeoffset"); + + b.Property("PolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SuppressedCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("PolicyId"); + + b.ToTable("SuppressionRules"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TrendSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CapturedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ComplianceIssuesCount") + .HasColumnType("int"); + + b.Property("CriticalAlertsCount") + .HasColumnType("int"); + + b.Property("HighAlertsCount") + .HasColumnType("int"); + + b.Property("MfaCoveragePct") + .HasColumnType("float"); + + b.Property("NonCompliantDevicesCount") + .HasColumnType("int"); + + b.Property("RiskyUsersCount") + .HasColumnType("int"); + + b.Property("SecureScorePct") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("CapturedAt"); + + b.ToTable("TrendSnapshots"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TriggeredAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetimeoffset"); + + b.Property("AcknowledgedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AffectedEntities") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("BelowThresholdStreakCount") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LastEvaluatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MetricValue") + .HasColumnType("int"); + + b.Property("Notified") + .HasColumnType("bit"); + + b.Property("PolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ResolvedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SnoozedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("SnoozedUntil") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggeredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("PolicyId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.ToTable("TriggeredAlerts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260726131255_SuppressionRules.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260726131255_SuppressionRules.cs new file mode 100644 index 0000000..fb996e8 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260726131255_SuppressionRules.cs @@ -0,0 +1,52 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + /// + public partial class SuppressionRules : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SuppressionRules", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + PolicyId = table.Column(type: "uniqueidentifier", nullable: true), + EntityPattern = table.Column(type: "nvarchar(320)", maxLength: 320, nullable: true), + Reason = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + ExpiresAt = table.Column(type: "datetimeoffset", nullable: true), + Enabled = table.Column(type: "bit", nullable: false), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + CreatedBy = table.Column(type: "nvarchar(320)", maxLength: 320, nullable: true), + SuppressedCount = table.Column(type: "int", nullable: false), + LastSuppressedAt = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SuppressionRules", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_SuppressionRules_Enabled", + table: "SuppressionRules", + column: "Enabled"); + + migrationBuilder.CreateIndex( + name: "IX_SuppressionRules_PolicyId", + table: "SuppressionRules", + column: "PolicyId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SuppressionRules"); + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260730043439_M4EnterpriseIntegrations.Designer.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260730043439_M4EnterpriseIntegrations.Designer.cs new file mode 100644 index 0000000..14c25df --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260730043439_M4EnterpriseIntegrations.Designer.cs @@ -0,0 +1,855 @@ +// +using System; +using M365SecurityDashboard.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260730043439_M4EnterpriseIntegrations")] + partial class M4EnterpriseIntegrations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("TargetKind", "TargetId"); + + b.ToTable("AlertNotes"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivityPattern") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BaselineDays") + .HasColumnType("int"); + + b.Property("BaselineMultiplier") + .HasColumnType("float"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("LastTriggered") + .HasColumnType("datetimeoffset"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotifyEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SuppressionMinutes") + .HasColumnType("int"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggerCount") + .HasColumnType("int"); + + b.Property("WindowMinutes") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.ToTable("AlertPolicies"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.ApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastUsedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Scopes") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("Prefix"); + + b.HasIndex("RevokedAt"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AppUser", b => + { + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastSeenAt") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Email"); + + b.ToTable("AppUsers"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EntryHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("PrevHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetId") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserAgent") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Activity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorApp") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorUpn") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Category") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("CollectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Result") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TargetName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("Activity"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.CollectionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertsUpserted") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("SourceFailureDetails") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceFailures") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StartedAt"); + + b.ToTable("CollectionRuns"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.GraphConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.ToTable("GraphConfig"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Error") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Target") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TriggeredAlertId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SentAt"); + + b.ToTable("NotificationLogs"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DefaultRecipient") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("DigestHourUtc") + .HasColumnType("int"); + + b.Property("EmailDigest") + .HasColumnType("bit"); + + b.Property("EmailEnabled") + .HasColumnType("bit"); + + b.Property("FailureAlertThreshold") + .HasColumnType("int"); + + b.Property("FromAddress") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("LastDigestAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastFailureAlertAt") + .HasColumnType("datetimeoffset"); + + b.Property("MinSeverity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SmtpHost") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SmtpPassword") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SmtpPort") + .HasColumnType("int"); + + b.Property("SmtpUseSsl") + .HasColumnType("bit"); + + b.Property("SmtpUsername") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TeamsDigest") + .HasColumnType("bit"); + + b.Property("TeamsEnabled") + .HasColumnType("bit"); + + b.Property("TeamsWebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("WebhookDigest") + .HasColumnType("bit"); + + b.Property("WebhookEnabled") + .HasColumnType("bit"); + + b.Property("WebhookSigningSecret") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("WebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.HasKey("Id"); + + b.ToTable("NotificationSettings"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.ReportSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Cadence") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("DayOfMonth") + .HasColumnType("int"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("HourUtc") + .HasColumnType("int"); + + b.Property("IncludeCsv") + .HasColumnType("bit"); + + b.Property("IncludePdf") + .HasColumnType("bit"); + + b.Property("LastRunAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastRunStatus") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Recipients") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReportType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.ToTable("ReportSchedules"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.SecurityAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("DetectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeviceName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Disposition") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ExternalId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("IsResolved") + .HasColumnType("bit"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("PortalUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasColumnType("int"); + + b.Property("Severity") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UserPrincipalName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("DetectedAt"); + + b.HasIndex("Service", "AlertType", "ExternalId") + .IsUnique() + .HasFilter("[ExternalId] IS NOT NULL"); + + b.HasIndex("Service", "Severity", "IsResolved"); + + b.ToTable("SecurityAlerts"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.SuppressionRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EntityPattern") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastSuppressedAt") + .HasColumnType("datetimeoffset"); + + b.Property("PolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SuppressedCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("PolicyId"); + + b.ToTable("SuppressionRules"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TrendSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CapturedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ComplianceIssuesCount") + .HasColumnType("int"); + + b.Property("CriticalAlertsCount") + .HasColumnType("int"); + + b.Property("HighAlertsCount") + .HasColumnType("int"); + + b.Property("MfaCoveragePct") + .HasColumnType("float"); + + b.Property("NonCompliantDevicesCount") + .HasColumnType("int"); + + b.Property("RiskyUsersCount") + .HasColumnType("int"); + + b.Property("SecureScorePct") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("CapturedAt"); + + b.ToTable("TrendSnapshots"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TriggeredAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetimeoffset"); + + b.Property("AcknowledgedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AffectedEntities") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("BelowThresholdStreakCount") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LastEvaluatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MetricValue") + .HasColumnType("int"); + + b.Property("Notified") + .HasColumnType("bit"); + + b.Property("PolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ResolvedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SnoozedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("SnoozedUntil") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggeredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("PolicyId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.ToTable("TriggeredAlerts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260730043439_M4EnterpriseIntegrations.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260730043439_M4EnterpriseIntegrations.cs new file mode 100644 index 0000000..423d4a8 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260730043439_M4EnterpriseIntegrations.cs @@ -0,0 +1,82 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + /// + public partial class M4EnterpriseIntegrations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IncludePdf", + table: "ReportSchedules", + type: "bit", + nullable: false, + // Adding the non-null column backfills existing schedules. Their + // email digest should gain the new executive PDF by default. + defaultValue: true); + + migrationBuilder.AddColumn( + name: "WebhookSigningSecret", + table: "NotificationSettings", + type: "nvarchar(512)", + maxLength: 512, + nullable: true); + + migrationBuilder.CreateTable( + name: "ApiTokens", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: false), + Prefix = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + TokenHash = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Scopes = table.Column(type: "nvarchar(400)", maxLength: 400, nullable: false), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + CreatedBy = table.Column(type: "nvarchar(320)", maxLength: 320, nullable: true), + ExpiresAt = table.Column(type: "datetimeoffset", nullable: true), + LastUsedAt = table.Column(type: "datetimeoffset", nullable: true), + RevokedAt = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ApiTokens", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ApiTokens_Prefix", + table: "ApiTokens", + column: "Prefix"); + + migrationBuilder.CreateIndex( + name: "IX_ApiTokens_RevokedAt", + table: "ApiTokens", + column: "RevokedAt"); + + migrationBuilder.CreateIndex( + name: "IX_ApiTokens_TokenHash", + table: "ApiTokens", + column: "TokenHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ApiTokens"); + + migrationBuilder.DropColumn( + name: "IncludePdf", + table: "ReportSchedules"); + + migrationBuilder.DropColumn( + name: "WebhookSigningSecret", + table: "NotificationSettings"); + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260804070609_AddWeeklyDigest.Designer.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260804070609_AddWeeklyDigest.Designer.cs new file mode 100644 index 0000000..bd478e6 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260804070609_AddWeeklyDigest.Designer.cs @@ -0,0 +1,860 @@ +// +using System; +using M365SecurityDashboard.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260804070609_AddWeeklyDigest")] + partial class AddWeeklyDigest + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("TargetKind", "TargetId"); + + b.ToTable("AlertNotes"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivityPattern") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BaselineDays") + .HasColumnType("int"); + + b.Property("BaselineMultiplier") + .HasColumnType("float"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("LastTriggered") + .HasColumnType("datetimeoffset"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotifyEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SuppressionMinutes") + .HasColumnType("int"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggerCount") + .HasColumnType("int"); + + b.Property("WindowMinutes") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.ToTable("AlertPolicies"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.ApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastUsedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Scopes") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("Prefix"); + + b.HasIndex("RevokedAt"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AppUser", b => + { + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastSeenAt") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Email"); + + b.ToTable("AppUsers"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EntryHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("PrevHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetId") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserAgent") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Activity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorApp") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorUpn") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Category") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("CollectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Result") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TargetName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("Activity"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.CollectionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertsUpserted") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("SourceFailureDetails") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceFailures") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StartedAt"); + + b.ToTable("CollectionRuns"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.GraphConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.ToTable("GraphConfig"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Error") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Target") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TriggeredAlertId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SentAt"); + + b.ToTable("NotificationLogs"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DefaultRecipient") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("DigestFrequency") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("DigestHourUtc") + .HasColumnType("int"); + + b.Property("EmailDigest") + .HasColumnType("bit"); + + b.Property("EmailEnabled") + .HasColumnType("bit"); + + b.Property("FailureAlertThreshold") + .HasColumnType("int"); + + b.Property("FromAddress") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("LastDigestAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastFailureAlertAt") + .HasColumnType("datetimeoffset"); + + b.Property("MinSeverity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SmtpHost") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SmtpPassword") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SmtpPort") + .HasColumnType("int"); + + b.Property("SmtpUseSsl") + .HasColumnType("bit"); + + b.Property("SmtpUsername") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TeamsDigest") + .HasColumnType("bit"); + + b.Property("TeamsEnabled") + .HasColumnType("bit"); + + b.Property("TeamsWebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("WebhookDigest") + .HasColumnType("bit"); + + b.Property("WebhookEnabled") + .HasColumnType("bit"); + + b.Property("WebhookSigningSecret") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("WebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.HasKey("Id"); + + b.ToTable("NotificationSettings"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.ReportSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Cadence") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("DayOfMonth") + .HasColumnType("int"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("HourUtc") + .HasColumnType("int"); + + b.Property("IncludeCsv") + .HasColumnType("bit"); + + b.Property("IncludePdf") + .HasColumnType("bit"); + + b.Property("LastRunAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastRunStatus") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Recipients") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReportType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.ToTable("ReportSchedules"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.SecurityAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("DetectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeviceName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Disposition") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ExternalId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("IsResolved") + .HasColumnType("bit"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("PortalUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasColumnType("int"); + + b.Property("Severity") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UserPrincipalName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("DetectedAt"); + + b.HasIndex("Service", "AlertType", "ExternalId") + .IsUnique() + .HasFilter("[ExternalId] IS NOT NULL"); + + b.HasIndex("Service", "Severity", "IsResolved"); + + b.ToTable("SecurityAlerts"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.SuppressionRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EntityPattern") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastSuppressedAt") + .HasColumnType("datetimeoffset"); + + b.Property("PolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SuppressedCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("PolicyId"); + + b.ToTable("SuppressionRules"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TrendSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CapturedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ComplianceIssuesCount") + .HasColumnType("int"); + + b.Property("CriticalAlertsCount") + .HasColumnType("int"); + + b.Property("HighAlertsCount") + .HasColumnType("int"); + + b.Property("MfaCoveragePct") + .HasColumnType("float"); + + b.Property("NonCompliantDevicesCount") + .HasColumnType("int"); + + b.Property("RiskyUsersCount") + .HasColumnType("int"); + + b.Property("SecureScorePct") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("CapturedAt"); + + b.ToTable("TrendSnapshots"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TriggeredAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetimeoffset"); + + b.Property("AcknowledgedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AffectedEntities") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("BelowThresholdStreakCount") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LastEvaluatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MetricValue") + .HasColumnType("int"); + + b.Property("Notified") + .HasColumnType("bit"); + + b.Property("PolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ResolvedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SnoozedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("SnoozedUntil") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggeredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("PolicyId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.ToTable("TriggeredAlerts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260804070609_AddWeeklyDigest.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260804070609_AddWeeklyDigest.cs new file mode 100644 index 0000000..a15a72b --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260804070609_AddWeeklyDigest.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + /// + public partial class AddWeeklyDigest : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DigestFrequency", + table: "NotificationSettings", + type: "nvarchar(20)", + maxLength: 20, + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DigestFrequency", + table: "NotificationSettings"); + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260804084245_AddGraphConfigSovereign.Designer.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260804084245_AddGraphConfigSovereign.Designer.cs new file mode 100644 index 0000000..fdb6034 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260804084245_AddGraphConfigSovereign.Designer.cs @@ -0,0 +1,866 @@ +// +using System; +using M365SecurityDashboard.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260804084245_AddGraphConfigSovereign")] + partial class AddGraphConfigSovereign + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("TargetKind", "TargetId"); + + b.ToTable("AlertNotes"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivityPattern") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BaselineDays") + .HasColumnType("int"); + + b.Property("BaselineMultiplier") + .HasColumnType("float"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("LastTriggered") + .HasColumnType("datetimeoffset"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotifyEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SuppressionMinutes") + .HasColumnType("int"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggerCount") + .HasColumnType("int"); + + b.Property("WindowMinutes") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.ToTable("AlertPolicies"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.ApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastUsedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Scopes") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("Prefix"); + + b.HasIndex("RevokedAt"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AppUser", b => + { + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastSeenAt") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Email"); + + b.ToTable("AppUsers"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EntryHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("PrevHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetId") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserAgent") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Activity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorApp") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorUpn") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Category") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("CollectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Result") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TargetName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("Activity"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.CollectionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertsUpserted") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("SourceFailureDetails") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceFailures") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StartedAt"); + + b.ToTable("CollectionRuns"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.GraphConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BaseUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("LoginInstance") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.ToTable("GraphConfig"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Error") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Target") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TriggeredAlertId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SentAt"); + + b.ToTable("NotificationLogs"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DefaultRecipient") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("DigestFrequency") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("DigestHourUtc") + .HasColumnType("int"); + + b.Property("EmailDigest") + .HasColumnType("bit"); + + b.Property("EmailEnabled") + .HasColumnType("bit"); + + b.Property("FailureAlertThreshold") + .HasColumnType("int"); + + b.Property("FromAddress") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("LastDigestAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastFailureAlertAt") + .HasColumnType("datetimeoffset"); + + b.Property("MinSeverity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SmtpHost") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SmtpPassword") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SmtpPort") + .HasColumnType("int"); + + b.Property("SmtpUseSsl") + .HasColumnType("bit"); + + b.Property("SmtpUsername") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TeamsDigest") + .HasColumnType("bit"); + + b.Property("TeamsEnabled") + .HasColumnType("bit"); + + b.Property("TeamsWebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("WebhookDigest") + .HasColumnType("bit"); + + b.Property("WebhookEnabled") + .HasColumnType("bit"); + + b.Property("WebhookSigningSecret") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("WebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.HasKey("Id"); + + b.ToTable("NotificationSettings"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.ReportSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Cadence") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("DayOfMonth") + .HasColumnType("int"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("HourUtc") + .HasColumnType("int"); + + b.Property("IncludeCsv") + .HasColumnType("bit"); + + b.Property("IncludePdf") + .HasColumnType("bit"); + + b.Property("LastRunAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastRunStatus") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Recipients") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReportType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.ToTable("ReportSchedules"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.SecurityAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("DetectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeviceName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Disposition") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ExternalId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("IsResolved") + .HasColumnType("bit"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("PortalUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasColumnType("int"); + + b.Property("Severity") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UserPrincipalName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("DetectedAt"); + + b.HasIndex("Service", "AlertType", "ExternalId") + .IsUnique() + .HasFilter("[ExternalId] IS NOT NULL"); + + b.HasIndex("Service", "Severity", "IsResolved"); + + b.ToTable("SecurityAlerts"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.SuppressionRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EntityPattern") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastSuppressedAt") + .HasColumnType("datetimeoffset"); + + b.Property("PolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SuppressedCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("PolicyId"); + + b.ToTable("SuppressionRules"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TrendSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CapturedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ComplianceIssuesCount") + .HasColumnType("int"); + + b.Property("CriticalAlertsCount") + .HasColumnType("int"); + + b.Property("HighAlertsCount") + .HasColumnType("int"); + + b.Property("MfaCoveragePct") + .HasColumnType("float"); + + b.Property("NonCompliantDevicesCount") + .HasColumnType("int"); + + b.Property("RiskyUsersCount") + .HasColumnType("int"); + + b.Property("SecureScorePct") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("CapturedAt"); + + b.ToTable("TrendSnapshots"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TriggeredAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetimeoffset"); + + b.Property("AcknowledgedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AffectedEntities") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("BelowThresholdStreakCount") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LastEvaluatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MetricValue") + .HasColumnType("int"); + + b.Property("Notified") + .HasColumnType("bit"); + + b.Property("PolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ResolvedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SnoozedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("SnoozedUntil") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggeredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("PolicyId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.ToTable("TriggeredAlerts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260804084245_AddGraphConfigSovereign.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260804084245_AddGraphConfigSovereign.cs new file mode 100644 index 0000000..7d7344b --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260804084245_AddGraphConfigSovereign.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + /// + public partial class AddGraphConfigSovereign : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BaseUrl", + table: "GraphConfig", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "LoginInstance", + table: "GraphConfig", + type: "nvarchar(max)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "BaseUrl", + table: "GraphConfig"); + + migrationBuilder.DropColumn( + name: "LoginInstance", + table: "GraphConfig"); + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260805034041_SingletonKeysNotIdentity.Designer.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260805034041_SingletonKeysNotIdentity.Designer.cs new file mode 100644 index 0000000..2ce1917 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260805034041_SingletonKeysNotIdentity.Designer.cs @@ -0,0 +1,860 @@ +// +using System; +using M365SecurityDashboard.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260805034041_SingletonKeysNotIdentity")] + partial class SingletonKeysNotIdentity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("TargetKind", "TargetId"); + + b.ToTable("AlertNotes"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivityPattern") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BaselineDays") + .HasColumnType("int"); + + b.Property("BaselineMultiplier") + .HasColumnType("float"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("LastTriggered") + .HasColumnType("datetimeoffset"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotifyEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SuppressionMinutes") + .HasColumnType("int"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggerCount") + .HasColumnType("int"); + + b.Property("WindowMinutes") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.ToTable("AlertPolicies"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.ApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastUsedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Scopes") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("Prefix"); + + b.HasIndex("RevokedAt"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AppUser", b => + { + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastSeenAt") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Email"); + + b.ToTable("AppUsers"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EntryHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("PrevHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetId") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserAgent") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Activity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorApp") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorUpn") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Category") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("CollectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Result") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TargetName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("Activity"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.CollectionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertsUpserted") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("SourceFailureDetails") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceFailures") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StartedAt"); + + b.ToTable("CollectionRuns"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.GraphConfig", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("BaseUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("LoginInstance") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.ToTable("GraphConfig"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Error") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Target") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TriggeredAlertId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SentAt"); + + b.ToTable("NotificationLogs"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationSettings", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("DefaultRecipient") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("DigestFrequency") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("DigestHourUtc") + .HasColumnType("int"); + + b.Property("EmailDigest") + .HasColumnType("bit"); + + b.Property("EmailEnabled") + .HasColumnType("bit"); + + b.Property("FailureAlertThreshold") + .HasColumnType("int"); + + b.Property("FromAddress") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("LastDigestAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastFailureAlertAt") + .HasColumnType("datetimeoffset"); + + b.Property("MinSeverity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SmtpHost") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SmtpPassword") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SmtpPort") + .HasColumnType("int"); + + b.Property("SmtpUseSsl") + .HasColumnType("bit"); + + b.Property("SmtpUsername") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TeamsDigest") + .HasColumnType("bit"); + + b.Property("TeamsEnabled") + .HasColumnType("bit"); + + b.Property("TeamsWebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("WebhookDigest") + .HasColumnType("bit"); + + b.Property("WebhookEnabled") + .HasColumnType("bit"); + + b.Property("WebhookSigningSecret") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("WebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.HasKey("Id"); + + b.ToTable("NotificationSettings"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.ReportSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Cadence") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("DayOfMonth") + .HasColumnType("int"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("HourUtc") + .HasColumnType("int"); + + b.Property("IncludeCsv") + .HasColumnType("bit"); + + b.Property("IncludePdf") + .HasColumnType("bit"); + + b.Property("LastRunAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastRunStatus") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Recipients") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReportType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.ToTable("ReportSchedules"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.SecurityAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("DetectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeviceName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Disposition") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ExternalId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("IsResolved") + .HasColumnType("bit"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("PortalUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasColumnType("int"); + + b.Property("Severity") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UserPrincipalName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("DetectedAt"); + + b.HasIndex("Service", "AlertType", "ExternalId") + .IsUnique() + .HasFilter("[ExternalId] IS NOT NULL"); + + b.HasIndex("Service", "Severity", "IsResolved"); + + b.ToTable("SecurityAlerts"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.SuppressionRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EntityPattern") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastSuppressedAt") + .HasColumnType("datetimeoffset"); + + b.Property("PolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SuppressedCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("PolicyId"); + + b.ToTable("SuppressionRules"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TrendSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CapturedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ComplianceIssuesCount") + .HasColumnType("int"); + + b.Property("CriticalAlertsCount") + .HasColumnType("int"); + + b.Property("HighAlertsCount") + .HasColumnType("int"); + + b.Property("MfaCoveragePct") + .HasColumnType("float"); + + b.Property("NonCompliantDevicesCount") + .HasColumnType("int"); + + b.Property("RiskyUsersCount") + .HasColumnType("int"); + + b.Property("SecureScorePct") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("CapturedAt"); + + b.ToTable("TrendSnapshots"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TriggeredAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetimeoffset"); + + b.Property("AcknowledgedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AffectedEntities") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("BelowThresholdStreakCount") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LastEvaluatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MetricValue") + .HasColumnType("int"); + + b.Property("Notified") + .HasColumnType("bit"); + + b.Property("PolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ResolvedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SnoozedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("SnoozedUntil") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggeredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("PolicyId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.ToTable("TriggeredAlerts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/20260805034041_SingletonKeysNotIdentity.cs b/src/M365SecurityDashboard.Api/Data/Migrations/20260805034041_SingletonKeysNotIdentity.cs new file mode 100644 index 0000000..adc40c2 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/20260805034041_SingletonKeysNotIdentity.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + /// + /// Removes IDENTITY from the two singleton configuration tables. + /// + /// NotificationSettings and GraphConfig each hold exactly one row with a + /// fixed key of 1, which the model supplies. InitialCreate made those keys + /// identity columns by EF convention, so the explicit key was rejected with + /// "Cannot insert explicit value for identity column". NotificationSettings is + /// seeded during startup, so a fresh install crashed before it ever served a + /// request; GraphConfig would have failed the first time anyone saved Graph + /// credentials on the setup page. + /// + /// Written by hand because the scaffolded AlterColumn emits a plain + /// ALTER COLUMN, and SQL Server rejects that with "To change the IDENTITY + /// property of a column, the column needs to be dropped and recreated." + /// + /// The rebuild adds a plain int column, copies the values across, drops the + /// identity column and renames — rather than recreating the table from a + /// column list, which would silently rot as later migrations add columns. + /// + public partial class SingletonKeysNotIdentity : Migration + { + private const string DropIdentity = """ + IF EXISTS (SELECT 1 FROM sys.identity_columns + WHERE OBJECT_NAME(object_id) = '{0}' AND name = 'Id') + BEGIN + ALTER TABLE [{0}] ADD [Id_tmp] int NULL; + EXEC('UPDATE [{0}] SET [Id_tmp] = [Id]'); + ALTER TABLE [{0}] DROP CONSTRAINT [PK_{0}]; + ALTER TABLE [{0}] DROP COLUMN [Id]; + EXEC sp_rename '{0}.Id_tmp', 'Id', 'COLUMN'; + ALTER TABLE [{0}] ALTER COLUMN [Id] int NOT NULL; + ALTER TABLE [{0}] ADD CONSTRAINT [PK_{0}] PRIMARY KEY ([Id]); + END + """; + + private const string AddIdentity = """ + IF NOT EXISTS (SELECT 1 FROM sys.identity_columns + WHERE OBJECT_NAME(object_id) = '{0}' AND name = 'Id') + BEGIN + ALTER TABLE [{0}] DROP CONSTRAINT [PK_{0}]; + ALTER TABLE [{0}] DROP COLUMN [Id]; + -- Renumbers rather than preserving the old keys. These tables hold + -- a single row, so that row becomes 1 again. + ALTER TABLE [{0}] ADD [Id] int IDENTITY(1,1) NOT NULL; + ALTER TABLE [{0}] ADD CONSTRAINT [PK_{0}] PRIMARY KEY ([Id]); + END + """; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(string.Format(DropIdentity, "NotificationSettings")); + migrationBuilder.Sql(string.Format(DropIdentity, "GraphConfig")); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(string.Format(AddIdentity, "GraphConfig")); + migrationBuilder.Sql(string.Format(AddIdentity, "NotificationSettings")); + } + } +} diff --git a/src/M365SecurityDashboard.Api/Data/Migrations/AppDbContextModelSnapshot.cs b/src/M365SecurityDashboard.Api/Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..43f3f37 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,857 @@ +// +using System; +using M365SecurityDashboard.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace M365SecurityDashboard.Api.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("TargetKind", "TargetId"); + + b.ToTable("AlertNotes"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AlertPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActivityPattern") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("BaselineDays") + .HasColumnType("int"); + + b.Property("BaselineMultiplier") + .HasColumnType("float"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("LastTriggered") + .HasColumnType("datetimeoffset"); + + b.Property("Metric") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotifyEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SuppressionMinutes") + .HasColumnType("int"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggerCount") + .HasColumnType("int"); + + b.Property("WindowMinutes") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.ToTable("AlertPolicies"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.ApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastUsedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Scopes") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("Prefix"); + + b.HasIndex("RevokedAt"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AppUser", b => + { + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastSeenAt") + .HasColumnType("datetimeoffset"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Email"); + + b.ToTable("AppUsers"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("EntryHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("PrevHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TargetId") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserAgent") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Activity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorApp") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ActorUpn") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Category") + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("CollectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Result") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TargetName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("Activity"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("Source", "ExternalId") + .IsUnique(); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.CollectionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertsUpserted") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("SourceFailureDetails") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceFailures") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StartedAt"); + + b.ToTable("CollectionRuns"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.GraphConfig", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("BaseUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("LoginInstance") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.ToTable("GraphConfig"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Error") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Target") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("TriggeredAlertId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SentAt"); + + b.ToTable("NotificationLogs"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.NotificationSettings", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("DefaultRecipient") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("DigestFrequency") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("DigestHourUtc") + .HasColumnType("int"); + + b.Property("EmailDigest") + .HasColumnType("bit"); + + b.Property("EmailEnabled") + .HasColumnType("bit"); + + b.Property("FailureAlertThreshold") + .HasColumnType("int"); + + b.Property("FromAddress") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("LastDigestAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastFailureAlertAt") + .HasColumnType("datetimeoffset"); + + b.Property("MinSeverity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SmtpHost") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SmtpPassword") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SmtpPort") + .HasColumnType("int"); + + b.Property("SmtpUseSsl") + .HasColumnType("bit"); + + b.Property("SmtpUsername") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TeamsDigest") + .HasColumnType("bit"); + + b.Property("TeamsEnabled") + .HasColumnType("bit"); + + b.Property("TeamsWebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("WebhookDigest") + .HasColumnType("bit"); + + b.Property("WebhookEnabled") + .HasColumnType("bit"); + + b.Property("WebhookSigningSecret") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("WebhookUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.HasKey("Id"); + + b.ToTable("NotificationSettings"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.ReportSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Cadence") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("DayOfMonth") + .HasColumnType("int"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("HourUtc") + .HasColumnType("int"); + + b.Property("IncludeCsv") + .HasColumnType("bit"); + + b.Property("IncludePdf") + .HasColumnType("bit"); + + b.Property("LastRunAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastRunStatus") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Recipients") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReportType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.ToTable("ReportSchedules"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.SecurityAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Description") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("DetectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeviceName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Disposition") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ExternalId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("IsResolved") + .HasColumnType("bit"); + + b.Property("LastUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("PortalUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RawJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasColumnType("int"); + + b.Property("Severity") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UserPrincipalName") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.HasKey("Id"); + + b.HasIndex("DetectedAt"); + + b.HasIndex("Service", "AlertType", "ExternalId") + .IsUnique() + .HasFilter("[ExternalId] IS NOT NULL"); + + b.HasIndex("Service", "Severity", "IsResolved"); + + b.ToTable("SecurityAlerts"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.SuppressionRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EntityPattern") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastSuppressedAt") + .HasColumnType("datetimeoffset"); + + b.Property("PolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SuppressedCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Enabled"); + + b.HasIndex("PolicyId"); + + b.ToTable("SuppressionRules"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TrendSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CapturedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ComplianceIssuesCount") + .HasColumnType("int"); + + b.Property("CriticalAlertsCount") + .HasColumnType("int"); + + b.Property("HighAlertsCount") + .HasColumnType("int"); + + b.Property("MfaCoveragePct") + .HasColumnType("float"); + + b.Property("NonCompliantDevicesCount") + .HasColumnType("int"); + + b.Property("RiskyUsersCount") + .HasColumnType("int"); + + b.Property("SecureScorePct") + .HasColumnType("float"); + + b.HasKey("Id"); + + b.HasIndex("CapturedAt"); + + b.ToTable("TrendSnapshots"); + }); + + modelBuilder.Entity("M365SecurityDashboard.Api.Models.TriggeredAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetimeoffset"); + + b.Property("AcknowledgedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("AffectedEntities") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignedTo") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("BelowThresholdStreakCount") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Condition") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LastEvaluatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MetricValue") + .HasColumnType("int"); + + b.Property("Notified") + .HasColumnType("bit"); + + b.Property("PolicyId") + .HasColumnType("uniqueidentifier"); + + b.Property("PolicyName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ResolvedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SnoozedBy") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("SnoozedUntil") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Threshold") + .HasColumnType("int"); + + b.Property("TriggeredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("PolicyId"); + + b.HasIndex("Status"); + + b.HasIndex("TriggeredAt"); + + b.ToTable("TriggeredAlerts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/M365SecurityDashboard.Api/Endpoints/AdminEndpoints.cs b/src/M365SecurityDashboard.Api/Endpoints/AdminEndpoints.cs new file mode 100644 index 0000000..4afbd27 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Endpoints/AdminEndpoints.cs @@ -0,0 +1,199 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; + +namespace M365SecurityDashboard.Api.Endpoints; + +/// Admin-only endpoints: in-app user management and the tamper-evident audit log. +public static class AdminEndpoints +{ + public static void MapAdminEndpoints(this WebApplication app) + { + // ── User management (Admin only) ───────────────────────────────────────────────── + // Roles are managed entirely in-app — no Entra App Roles, no Graph write permission. + app.MapGet("/api/admin/users", async (AppDbContext db, CancellationToken ct) => + Results.Ok(await db.AppUsers.OrderBy(u => u.Email).ToListAsync(ct))) + .RequireAuthorization("RequireAdmin"); + + // Pre-provision (invite) a user by email + role before they ever sign in. + // LastSeenAt = DateTimeOffset.MinValue marks "invited, not yet signed in". + app.MapPost("/api/admin/users", async ( + AddUserRequest input, AppDbContext db, NotificationSender sender, AuditLogger audit, IConfiguration config, CancellationToken ct) => + { + var email = (input.Email ?? "").Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(email) || !new System.ComponentModel.DataAnnotations.EmailAddressAttribute().IsValid(email)) + return Results.BadRequest(new { error = "A valid email address is required." }); + + if (!AppRoles.IsValid(input.Role)) + return Results.BadRequest(new { error = "Invalid role. Must be Admin, Analyst, or Viewer." }); + + if (await db.AppUsers.AnyAsync(u => u.Email == email, ct)) + return Results.Conflict(new { error = $"A user with email '{email}' already exists." }); + + var user = new AppUser + { + Email = email, + DisplayName = string.IsNullOrWhiteSpace(input.DisplayName) ? null : input.DisplayName.Trim(), + Role = input.Role, + CreatedAt = DateTimeOffset.UtcNow, + LastSeenAt = DateTimeOffset.MinValue + }; + db.AppUsers.Add(user); + await db.SaveChangesAsync(ct); + await audit.WriteAsync("user.add", "user", email, $"added with role {user.Role}", ct); + + string? inviteError = null; + if (input.SendInvite) + { + var cfg = await db.NotificationSettings.FirstOrDefaultAsync(ct) ?? new NotificationSettings { Id = 1 }; + var url = config["Auth:RedirectUri"] ?? "http://localhost:5000"; + var (ok, error) = await sender.SendInviteEmailAsync(cfg, email, user.Role, url, ct); + if (!ok) inviteError = error; + } + return Results.Ok(new { user, inviteSent = input.SendInvite && inviteError is null, inviteError }); + }).RequireAuthorization("RequireAdmin"); + + // (Re)send the access-notification email to a pre-provisioned/existing user. + app.MapPost("/api/admin/users/{email}/invite", async ( + string email, AppDbContext db, NotificationSender sender, AuditLogger audit, IConfiguration config, CancellationToken ct) => + { + email = email.Trim().ToLowerInvariant(); + var user = await db.AppUsers.FirstOrDefaultAsync(u => u.Email == email, ct); + if (user is null) return Results.NotFound(); + + var cfg = await db.NotificationSettings.FirstOrDefaultAsync(ct) ?? new NotificationSettings { Id = 1 }; + var url = config["Auth:RedirectUri"] ?? "http://localhost:5000"; + var (ok, error) = await sender.SendInviteEmailAsync(cfg, email, user.Role, url, ct); + if (ok) await audit.WriteAsync("user.invite", "user", email, "invite email sent", ct); + return ok ? Results.Ok(new { ok = true }) : Results.BadRequest(new { error }); + }).RequireAuthorization("RequireAdmin"); + + app.MapPut("/api/admin/users/{email}/role", async ( + string email, RoleChangeRequest input, AppDbContext db, AuditLogger audit, + Microsoft.Extensions.Caching.Memory.IMemoryCache cache, + System.Security.Claims.ClaimsPrincipal caller, CancellationToken ct) => + { + if (!AppRoles.IsValid(input.Role)) + return Results.BadRequest(new { error = "Invalid role. Must be Admin, Analyst, or Viewer." }); + + email = email.Trim().ToLowerInvariant(); + var user = await db.AppUsers.FirstOrDefaultAsync(u => u.Email == email, ct); + if (user is null) return Results.NotFound(); + + // Lockout guard: don't allow demoting the last remaining Admin. + if (user.Role == AppRoles.Admin && input.Role != AppRoles.Admin) + { + var adminCount = await db.AppUsers.CountAsync(u => u.Role == AppRoles.Admin, ct); + if (adminCount <= 1) + return Results.BadRequest(new { error = "Cannot demote the last Admin. Promote another user to Admin first." }); + } + + var oldRole = user.Role; + user.Role = input.Role; + await db.SaveChangesAsync(ct); + cache.Remove(RoleClaimsTransformation.RoleCacheKey(email)); + await audit.WriteAsync("user.role_change", "user", email, $"role {oldRole} -> {input.Role}", ct); + return Results.Ok(user); + }).RequireAuthorization("RequireAdmin"); + + app.MapDelete("/api/admin/users/{email}", async ( + string email, AppDbContext db, AuditLogger audit, + Microsoft.Extensions.Caching.Memory.IMemoryCache cache, + System.Security.Claims.ClaimsPrincipal caller, CancellationToken ct) => + { + email = email.Trim().ToLowerInvariant(); + var user = await db.AppUsers.FirstOrDefaultAsync(u => u.Email == email, ct); + if (user is null) return Results.NotFound(); + + // Don't allow removing yourself or the last Admin. + if (email == AuthHelpers.GetEmail(caller)) + return Results.BadRequest(new { error = "You cannot remove your own account." }); + if (user.Role == AppRoles.Admin && await db.AppUsers.CountAsync(u => u.Role == AppRoles.Admin, ct) <= 1) + return Results.BadRequest(new { error = "Cannot remove the last Admin." }); + + var removedRole = user.Role; + db.AppUsers.Remove(user); + await db.SaveChangesAsync(ct); + cache.Remove(RoleClaimsTransformation.RoleCacheKey(email)); + await audit.WriteAsync("user.remove", "user", email, $"removed (was {removedRole})", ct); + return Results.NoContent(); + }).RequireAuthorization("RequireAdmin"); + + // Audit trail of security-relevant actions (Admin only). + app.MapGet("/api/admin/audit-log", async (AppDbContext db, CancellationToken ct) => + Results.Ok(await db.AuditEntries.AsNoTracking().OrderByDescending(a => a.Timestamp).Take(200).ToListAsync(ct))) + .RequireAuthorization("RequireAdmin"); + + // Full audit trail as CSV (Admin only). The export itself is audited. + app.MapGet("/api/admin/audit-log/export", async (AppDbContext db, AuditLogger audit, CancellationToken ct) => + { + var entries = await db.AuditEntries.AsNoTracking() + .OrderBy(a => a.Id) + .Take(100_000) + .ToListAsync(ct); + + // Shared encoder: RFC-4180 quoting + formula-injection guard. Audit actor + // names and details are tenant-controlled, so this export is a live vector. + static string Csv(string? v) => CsvSanitizer.Field(v); + + var sb = new System.Text.StringBuilder(); + sb.AppendLine("Id,TimestampUtc,ActorEmail,Action,TargetType,TargetId,Details,IpAddress,UserAgent,PrevHash,EntryHash"); + foreach (var e in entries) + sb.AppendLine(string.Join(',', + e.Id, + e.Timestamp.UtcDateTime.ToString("O"), + Csv(e.ActorEmail), Csv(e.Action), Csv(e.TargetType), Csv(e.TargetId), + Csv(e.Details), Csv(e.IpAddress), Csv(e.UserAgent), Csv(e.PrevHash), Csv(e.EntryHash))); + + await audit.WriteAsync("audit.export", "audit_log", null, $"exported {entries.Count} entries as CSV", ct); + return Results.File( + System.Text.Encoding.UTF8.GetBytes(sb.ToString()), + "text/csv", + $"vigil365-audit-log-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}.csv"); + }).RequireAuthorization("RequireAdmin"); + + // Verify the tamper-evident hash chain (Admin only). Recomputes every entry's + // hash and checks the PrevHash linkage in Id order. Entries written before the + // hash chain existed (EntryHash NULL) are counted as "legacy" and skipped — + // verification starts from the first hashed entry. + app.MapGet("/api/admin/audit-log/verify", async (AppDbContext db, CancellationToken ct) => + { + var entries = await db.AuditEntries.AsNoTracking().OrderBy(a => a.Id).ToListAsync(ct); + + var legacy = 0; var checked_ = 0; + long? firstBrokenId = null; + string? expectedPrev = null; var chainStarted = false; + + foreach (var e in entries) + { + if (e.EntryHash is null) // pre-hash-chain row + { + legacy++; + if (chainStarted && firstBrokenId is null) firstBrokenId = e.Id; // gap inside the chain + continue; + } + + if (chainStarted && e.PrevHash != expectedPrev && firstBrokenId is null) + firstBrokenId = e.Id; + if (AuditLogger.ComputeHash(e) != e.EntryHash && firstBrokenId is null) + firstBrokenId = e.Id; + + expectedPrev = e.EntryHash; + chainStarted = true; + checked_++; + } + + return Results.Ok(new + { + valid = firstBrokenId is null, + total = entries.Count, + verified = checked_, + legacyUnhashed = legacy, + firstBrokenId, + verifiedAt = DateTimeOffset.UtcNow + }); + }).RequireAuthorization("RequireAdmin"); + } +} diff --git a/src/M365SecurityDashboard.Api/Endpoints/AlertsEndpoints.cs b/src/M365SecurityDashboard.Api/Endpoints/AlertsEndpoints.cs new file mode 100644 index 0000000..957dbc0 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Endpoints/AlertsEndpoints.cs @@ -0,0 +1,471 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; + +namespace M365SecurityDashboard.Api.Endpoints; + +/// Alert Center: the collected-alert inventory, policy CRUD/import/export/backtest, suppression rules, the triggered-alert workflow, workbench triage, and analyst notes. +public static class AlertsEndpoints +{ + public static void MapAlertsEndpoints(this WebApplication app) + { + app.MapGet("/api/alerts", async ( + AppDbContext db, + string? search, + AlertSeverity? severity, + M365ServiceArea? service, + bool? resolved, + int page = 1, + int pageSize = 50, + CancellationToken ct = default) => + { + page = page < 1 ? 1 : page; + pageSize = pageSize is < 1 or > 200 ? 50 : pageSize; + + var query = db.SecurityAlerts.AsNoTracking().AsQueryable(); + if (!string.IsNullOrWhiteSpace(search)) + { + query = query.Where(a => + a.Title.Contains(search) || + (a.UserPrincipalName != null && a.UserPrincipalName.Contains(search)) || + (a.DeviceName != null && a.DeviceName.Contains(search)) || + (a.ExternalId != null && a.ExternalId.Contains(search))); + } + if (severity.HasValue) query = query.Where(a => a.Severity == severity.Value); + if (service.HasValue) query = query.Where(a => a.Service == service.Value); + if (resolved.HasValue) query = query.Where(a => a.IsResolved == resolved.Value); + + var total = await query.CountAsync(ct); + var items = await query.OrderByDescending(a => a.DetectedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(ct); + + return Results.Ok(new { total, page, pageSize, items }); + }); + + app.MapGet("/api/alert-coverage", async (AppDbContext db, CancellationToken ct) => + Results.Ok(await RecommendationsEngine.GetAlertCoverageAsync(db, ct))); + + app.MapPost("/api/alert-coverage/enable/{id}", async (AppDbContext db, string id, AuditLogger audit, CancellationToken ct) => + { + var policy = await RecommendationsEngine.EnableCoverageRuleAsync(db, id, ct); + if (policy == null) return Results.BadRequest(new { error = "Rule not found or cannot be enabled via API." }); + await audit.WriteAsync("coverage.enable", "policy", policy.Id.ToString(), $"Enabled baseline coverage rule {policy.Name}", ct); + return Results.Ok(await RecommendationsEngine.GetAlertCoverageAsync(db, ct)); + }).RequireAuthorization("RequireAnalyst"); + + // ───────────────────────────────────────────────────────────────────────────── + // Alert Center — server-side policies, triggered alerts, notifications + // ───────────────────────────────────────────────────────────────────────────── + + // Policies CRUD + app.MapGet("/api/alert-policies", async (AppDbContext db, CancellationToken ct) => + Results.Ok(await db.AlertPolicies.OrderByDescending(p => p.CreatedAt).ToListAsync(ct))); + + app.MapPost("/api/alert-policies", async (AppDbContext db, AlertPolicy input, AuditLogger audit, CancellationToken ct) => + { + input.Id = input.Id == Guid.Empty ? Guid.NewGuid() : input.Id; + input.CreatedAt = DateTimeOffset.UtcNow; + input.TriggerCount = 0; + if (input.SuppressionMinutes <= 0) input.SuppressionMinutes = 60; + if (input.WindowMinutes <= 0) input.WindowMinutes = 60; + if (input.BaselineMultiplier <= 0) input.BaselineMultiplier = 3.0; + if (input.BaselineDays <= 0) input.BaselineDays = 30; + db.AlertPolicies.Add(input); + await db.SaveChangesAsync(ct); + await audit.WriteAsync("policy.create", "policy", input.Id.ToString(), $"Created policy {input.Name} ({input.Category})", ct); + return Results.Ok(input); + }).RequireAuthorization("RequireAnalyst"); + + app.MapPut("/api/alert-policies/{id:guid}", async (AppDbContext db, Guid id, AlertPolicy input, AuditLogger audit, CancellationToken ct) => + { + var p = await db.AlertPolicies.FindAsync([id], ct); + if (p is null) return Results.NotFound(); + p.Name = input.Name; + p.Enabled = input.Enabled; + p.Category = input.Category; + p.Condition = input.Condition; + p.Kind = string.IsNullOrWhiteSpace(input.Kind) ? "metric" : input.Kind; + p.Metric = input.Metric; + p.ActivityPattern = input.ActivityPattern; + p.WindowMinutes = input.WindowMinutes <= 0 ? 60 : input.WindowMinutes; + p.BaselineMultiplier = input.BaselineMultiplier <= 0 ? 3.0 : input.BaselineMultiplier; + p.BaselineDays = input.BaselineDays <= 0 ? 30 : input.BaselineDays; + p.Threshold = input.Threshold; + p.Severity = input.Severity; + p.NotifyEmail = input.NotifyEmail; + p.SuppressionMinutes = input.SuppressionMinutes <= 0 ? 60 : input.SuppressionMinutes; + await db.SaveChangesAsync(ct); + await audit.WriteAsync("policy.update", "policy", id.ToString(), $"Updated policy {p.Name}", ct); + return Results.Ok(p); + }).RequireAuthorization("RequireAnalyst"); + + app.MapDelete("/api/alert-policies/{id:guid}", async (AppDbContext db, Guid id, AuditLogger audit, CancellationToken ct) => + { + var p = await db.AlertPolicies.FindAsync([id], ct); + if (p is null) return Results.NotFound(); + db.AlertPolicies.Remove(p); + await db.SaveChangesAsync(ct); + await audit.WriteAsync("policy.delete", "policy", id.ToString(), $"Deleted policy {p.Name}", ct); + return Results.NoContent(); + }).RequireAuthorization("RequireAnalyst"); + + // Export the policy set as a portable pack (JSON). Recipients are stripped + // unless explicitly requested — packs get shared, and NotifyEmail is an + // internal address. + app.MapGet("/api/alert-policies/export", async ( + AppDbContext db, bool? includeRecipients, AuditLogger audit, CancellationToken ct) => + { + var withRecipients = includeRecipients ?? false; + var policies = await db.AlertPolicies.AsNoTracking().OrderBy(p => p.Name).ToListAsync(ct); + var pack = new PolicyPack.Pack( + PolicyPack.CurrentVersion, + DateTimeOffset.UtcNow, + $"Vigil365 {typeof(Program).Assembly.GetName().Version?.ToString(3) ?? "1.0.0"}", + withRecipients, + policies.Select(p => PolicyPack.ToPack(p, withRecipients)).ToList()); + + await audit.WriteAsync("policy.export", "policy", "*", + $"Exported {pack.Policies.Count} policies (recipients {(withRecipients ? "included" : "stripped")})", ct); + return Results.Ok(pack); + }).RequireAuthorization("RequireAnalyst"); + + // Import a policy pack. Matches existing policies by name (ids differ across + // installs). mode=skip keeps existing policies untouched; mode=update overwrites + // them in place, preserving their id and trigger history. Every entry is + // validated first — an invalid policy is reported, never coerced into place. + app.MapPost("/api/alert-policies/import", async ( + AppDbContext db, PolicyPack.Pack pack, string? mode, AuditLogger audit, CancellationToken ct) => + { + if (pack is null || pack.Policies is null) + return Results.BadRequest(new { error = "Not a valid policy pack." }); + + if (pack.PackVersion > PolicyPack.CurrentVersion) + return Results.BadRequest(new { error = $"This pack was made by a newer Vigil365 (pack version {pack.PackVersion}; this install supports {PolicyPack.CurrentVersion})." }); + + var update = string.Equals(mode, "update", StringComparison.OrdinalIgnoreCase); + var existing = await db.AlertPolicies.ToListAsync(ct); + + var imported = new List(); + var updated = new List(); + var skipped = new List(); + var rejected = new List(); + + foreach (var entry in pack.Policies) + { + var error = PolicyPack.Validate(entry); + if (error is not null) + { + rejected.Add(new { name = entry?.Name ?? "(unnamed)", error }); + continue; + } + + var match = existing.FirstOrDefault(p => + string.Equals(p.Name, entry.Name.Trim(), StringComparison.OrdinalIgnoreCase)); + + if (match is not null) + { + if (!update) { skipped.Add(match.Name); continue; } + PolicyPack.ApplyTo(match, entry); + updated.Add(match.Name); + } + else + { + var created = PolicyPack.ToEntity(entry); + db.AlertPolicies.Add(created); + existing.Add(created); // a pack with duplicate names must not create both + imported.Add(created.Name); + } + } + + if (imported.Count > 0 || updated.Count > 0) + { + await db.SaveChangesAsync(ct); + await audit.WriteAsync("policy.import", "policy", "*", + $"Imported {imported.Count}, updated {updated.Count}, skipped {skipped.Count}, rejected {rejected.Count}", ct); + } + + return Results.Ok(new + { + importedCount = imported.Count, + updatedCount = updated.Count, + skippedCount = skipped.Count, + rejectedCount = rejected.Count, + imported, + updated, + skipped, + rejected, + }); + }).RequireAuthorization("RequireAnalyst"); + + // Policy dry-run: "if this policy had been enabled, how often would it have + // fired?" Accepts an unsaved draft so a threshold can be tested before it is + // committed. Read-only — never writes alerts or touches policy state. + app.MapPost("/api/alert-policies/backtest", async ( + AlertPolicy draft, PolicyBacktester backtester, IOptions graph, + int? days, CancellationToken ct) => + { + if (draft.Threshold < 1) + return Results.BadRequest(new { error = "Threshold must be at least 1." }); + + var interval = TimeSpan.FromMinutes(Math.Max(1, graph.Value.CollectionIntervalMinutes)); + var result = await backtester.RunAsync(draft, days ?? 30, interval, ct); + return Results.Ok(result); + }).RequireAuthorization("RequireAnalyst"); + + // ── Suppression rules ─────────────────────────────────────────────────────── + // Standing rules that stop known-noisy alerts being raised at all. Mutations are + // Admin-only and audited: suppressing an alert class is a security decision. + app.MapGet("/api/suppression-rules", async (AppDbContext db, CancellationToken ct) => + { + var rules = await db.SuppressionRules.AsNoTracking() + .OrderByDescending(s => s.CreatedAt) + .ToListAsync(ct); + // Join policy names so the UI does not have to resolve GUIDs itself. + var names = await db.AlertPolicies.AsNoTracking() + .ToDictionaryAsync(p => p.Id, p => p.Name, ct); + var now = DateTimeOffset.UtcNow; + return Results.Ok(rules.Select(r => new + { + r.Id, r.PolicyId, + policyName = r.PolicyId is Guid pid && names.TryGetValue(pid, out var n) ? n : null, + r.EntityPattern, r.Reason, r.ExpiresAt, r.Enabled, + r.CreatedAt, r.CreatedBy, r.SuppressedCount, r.LastSuppressedAt, + expired = r.ExpiresAt is not null && r.ExpiresAt <= now, + })); + }).RequireAuthorization("RequireAnalyst"); + + app.MapPost("/api/suppression-rules", async ( + SuppressionRuleRequest input, AppDbContext db, AuditLogger audit, + System.Security.Claims.ClaimsPrincipal caller, CancellationToken ct) => + { + if (string.IsNullOrWhiteSpace(input.Reason)) + return Results.BadRequest(new { error = "A reason is required — an unexplained suppression cannot be reviewed later." }); + if (input.PolicyId is null && string.IsNullOrWhiteSpace(input.EntityPattern)) + return Results.BadRequest(new { error = "Scope the rule to a policy, an entity pattern, or both. A rule with neither would suppress every alert." }); + + var rule = new SuppressionRule + { + PolicyId = input.PolicyId, + EntityPattern = string.IsNullOrWhiteSpace(input.EntityPattern) ? null : input.EntityPattern.Trim(), + Reason = input.Reason.Trim(), + ExpiresAt = input.ExpiresAt, + Enabled = input.Enabled ?? true, + CreatedBy = caller.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value ?? caller.Identity?.Name, + }; + db.SuppressionRules.Add(rule); + await db.SaveChangesAsync(ct); + await audit.WriteAsync("suppression.create", "suppression", rule.Id.ToString(), + $"Suppression added (policy={rule.PolicyId?.ToString() ?? "any"}, entity={rule.EntityPattern ?? "any"}): {rule.Reason}", ct); + return Results.Ok(rule); + }).RequireAuthorization("RequireAdmin"); + + app.MapPut("/api/suppression-rules/{id:guid}", async ( + Guid id, SuppressionRuleRequest input, AppDbContext db, AuditLogger audit, CancellationToken ct) => + { + var rule = await db.SuppressionRules.FindAsync([id], ct); + if (rule is null) return Results.NotFound(); + + if (input.Reason is not null) rule.Reason = input.Reason.Trim(); + if (input.Enabled is not null) rule.Enabled = input.Enabled.Value; + rule.ExpiresAt = input.ExpiresAt; + if (input.EntityPattern is not null) + rule.EntityPattern = string.IsNullOrWhiteSpace(input.EntityPattern) ? null : input.EntityPattern.Trim(); + + if (rule.PolicyId is null && string.IsNullOrWhiteSpace(rule.EntityPattern)) + return Results.BadRequest(new { error = "A rule must stay scoped to a policy or an entity pattern." }); + + await db.SaveChangesAsync(ct); + await audit.WriteAsync("suppression.update", "suppression", id.ToString(), + $"Suppression updated (enabled={rule.Enabled})", ct); + return Results.Ok(rule); + }).RequireAuthorization("RequireAdmin"); + + app.MapDelete("/api/suppression-rules/{id:guid}", async ( + Guid id, AppDbContext db, AuditLogger audit, CancellationToken ct) => + { + var rule = await db.SuppressionRules.FindAsync([id], ct); + if (rule is null) return Results.NotFound(); + db.SuppressionRules.Remove(rule); + await db.SaveChangesAsync(ct); + await audit.WriteAsync("suppression.delete", "suppression", id.ToString(), + $"Suppression removed: {rule.Reason}", ct); + return Results.NoContent(); + }).RequireAuthorization("RequireAdmin"); + + // Triggered alerts + app.MapGet("/api/triggered-alerts", async (AppDbContext db, CancellationToken ct) => + Results.Ok(await db.TriggeredAlerts.OrderByDescending(t => t.TriggeredAt).Take(500).ToListAsync(ct))); + + app.MapPost("/api/triggered-alerts/{id:guid}/acknowledge", async ( + AppDbContext db, Guid id, System.Security.Claims.ClaimsPrincipal caller, AuditLogger audit, CancellationToken ct) => + { + var t = await db.TriggeredAlerts.FindAsync([id], ct); + if (t is null) return Results.NotFound(); + t.Status = "acknowledged"; + t.AcknowledgedAt = DateTimeOffset.UtcNow; + t.AcknowledgedBy = AuthHelpers.GetEmail(caller); + await db.SaveChangesAsync(ct); + await audit.WriteAsync("alert.acknowledge", "triggered_alert", id.ToString(), $"Acknowledged alert for policy {t.PolicyName}", ct); + return Results.Ok(t); + }).RequireAuthorization("RequireAnalyst"); + + // Alert-ops metrics: MTTA, MTTR, resolution rate, analyst workload over a window. + // Reads timestamps already captured on the triggered-alert workflow. + app.MapGet("/api/triggered-alerts/metrics", async (AppDbContext db, int? days, CancellationToken ct) => + { + var windowDays = days is > 0 and <= 365 ? days.Value : 30; + var since = DateTimeOffset.UtcNow.AddDays(-windowDays); + var rows = await db.TriggeredAlerts.AsNoTracking() + .Where(t => t.TriggeredAt >= since) + .ToListAsync(ct); + return Results.Ok(new { windowDays, metrics = AlertMetrics.Compute(rows) }); + }).RequireAuthorization("RequireAnalyst"); + + app.MapPost("/api/triggered-alerts/{id:guid}/resolve", async (AppDbContext db, Guid id, AuditLogger audit, System.Security.Claims.ClaimsPrincipal caller, CancellationToken ct) => + { + var t = await db.TriggeredAlerts.FindAsync([id], ct); + if (t is null) return Results.NotFound(); + t.Status = "resolved"; + t.ResolvedAt = DateTimeOffset.UtcNow; + t.ResolvedBy = caller.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value + ?? caller.Identity?.Name ?? "dashboard"; + await db.SaveChangesAsync(ct); + await audit.WriteAsync("alert.resolve", "triggered_alert", id.ToString(), $"Resolved alert for policy {t.PolicyName}", ct); + return Results.Ok(t); + }).RequireAuthorization("RequireAnalyst"); + + // Per-alert snooze. Body: { "until": "2026-06-22T18:00:00Z" } or { "durationHours": 4|24|168 }. + // Until wins if both are supplied; durationHours defaults to 24 if neither is supplied. + app.MapPost("/api/triggered-alerts/{id:guid}/snooze", async ( + AppDbContext db, Guid id, SnoozeRequest input, System.Security.Claims.ClaimsPrincipal caller, AuditLogger audit, CancellationToken ct) => + { + var t = await db.TriggeredAlerts.FindAsync([id], ct); + if (t is null) return Results.NotFound(); + if (t.Status is "resolved" or "auto_resolved") + return Results.BadRequest(new { error = "Cannot snooze a terminal alert." }); + + var until = input.Until + ?? (input.DurationHours is { } h ? DateTimeOffset.UtcNow.AddHours(Math.Clamp(h, 1, 8760)) : DateTimeOffset.UtcNow.AddHours(24)); + t.SnoozedUntil = until; + t.SnoozedBy = AuthHelpers.GetEmail(caller); + await db.SaveChangesAsync(ct); + await audit.WriteAsync("alert.snooze", "triggered_alert", id.ToString(), $"Snoozed alert for policy {t.PolicyName} until {until:u}", ct); + return Results.Ok(t); + }).RequireAuthorization("RequireAnalyst"); + + // Reopen a triggered alert (undo for acknowledge/resolve). Returns it to "new". + app.MapPost("/api/triggered-alerts/{id:guid}/reopen", async ( + AppDbContext db, Guid id, AuditLogger audit, CancellationToken ct) => + { + var t = await db.TriggeredAlerts.FindAsync([id], ct); + if (t is null) return Results.NotFound(); + var was = t.Status; + t.Status = "new"; + t.AcknowledgedAt = null; + t.AcknowledgedBy = null; + t.BelowThresholdStreakCount = 0; + await db.SaveChangesAsync(ct); + await audit.WriteAsync("alert.reopen", "triggered_alert", id.ToString(), $"reopened (was {was})", ct); + return Results.Ok(t); + }).RequireAuthorization("RequireAnalyst"); + + app.MapPost("/api/triggered-alerts/{id:guid}/unsnooze", async ( + AppDbContext db, Guid id, AuditLogger audit, CancellationToken ct) => + { + var t = await db.TriggeredAlerts.FindAsync([id], ct); + if (t is null) return Results.NotFound(); + t.SnoozedUntil = null; + t.SnoozedBy = null; + await db.SaveChangesAsync(ct); + await audit.WriteAsync("alert.unsnooze", "triggered_alert", id.ToString(), $"Unsnoozed alert for policy {t.PolicyName}", ct); + return Results.Ok(t); + }).RequireAuthorization("RequireAnalyst"); + + // ───────────────────────────────────────────────────────────────────────────── + // Alert workbench — local triage state. Never writes to Microsoft 365. + // ───────────────────────────────────────────────────────────────────────────── + + // Assign / set disposition on a collected M365 security alert. + app.MapPost("/api/alerts/{id:long}/workbench", async ( + long id, WorkbenchRequest input, AppDbContext db, AuditLogger audit, CancellationToken ct) => + { + var alert = await db.SecurityAlerts.FindAsync([id], ct); + if (alert is null) return Results.NotFound(); + + if (input.Disposition is not null) + { + var d = input.Disposition.Trim().ToLowerInvariant(); + if (d != "" && d != "reviewed" && d != "escalated" && d != "false_positive") + return Results.BadRequest(new { error = "Disposition must be reviewed, escalated, false_positive, or empty to clear." }); + alert.Disposition = d == "" ? null : d; + await audit.WriteAsync("alert.disposition", "alert", id.ToString(), $"disposition set to {(alert.Disposition ?? "none")}", ct); + } + if (input.AssignedTo is not null) + { + alert.AssignedTo = string.IsNullOrWhiteSpace(input.AssignedTo) ? null : input.AssignedTo.Trim().ToLowerInvariant(); + await audit.WriteAsync("alert.assign", "alert", id.ToString(), alert.AssignedTo is null ? "unassigned" : $"assigned to {alert.AssignedTo}", ct); + } + await db.SaveChangesAsync(ct); + return Results.Ok(alert); + }).RequireAuthorization("RequireAnalyst"); + + // Assign a triggered policy alert. + app.MapPost("/api/triggered-alerts/{id:guid}/assign", async ( + Guid id, WorkbenchRequest input, AppDbContext db, AuditLogger audit, CancellationToken ct) => + { + var t = await db.TriggeredAlerts.FindAsync([id], ct); + if (t is null) return Results.NotFound(); + t.AssignedTo = string.IsNullOrWhiteSpace(input.AssignedTo) ? null : input.AssignedTo!.Trim().ToLowerInvariant(); + await db.SaveChangesAsync(ct); + await audit.WriteAsync("alert.assign", "triggered_alert", id.ToString(), + t.AssignedTo is null ? "unassigned" : $"assigned to {t.AssignedTo}", ct); + return Results.Ok(t); + }).RequireAuthorization("RequireAnalyst"); + + // Analyst notes — append-only, on either alert kind. + app.MapGet("/api/alert-notes/{kind}/{targetId}", async ( + string kind, string targetId, AppDbContext db, CancellationToken ct) => + { + if (kind != "security" && kind != "policy") return Results.BadRequest(new { error = "Kind must be security or policy." }); + return Results.Ok(await db.AlertNotes.AsNoTracking() + .Where(n => n.TargetKind == kind && n.TargetId == targetId) + .OrderBy(n => n.CreatedAt) + .ToListAsync(ct)); + }); + + app.MapPost("/api/alert-notes/{kind}/{targetId}", async ( + string kind, string targetId, NoteRequest input, AppDbContext db, AuditLogger audit, + System.Security.Claims.ClaimsPrincipal caller, CancellationToken ct) => + { + if (kind != "security" && kind != "policy") return Results.BadRequest(new { error = "Kind must be security or policy." }); + var text = (input.Text ?? "").Trim(); + if (text.Length == 0) return Results.BadRequest(new { error = "Note text is required." }); + if (text.Length > 2000) text = text[..2000]; + + var note = new AlertNote + { + TargetKind = kind, + TargetId = targetId, + Author = AuthHelpers.GetEmail(caller), + Text = text, + CreatedAt = DateTimeOffset.UtcNow, + }; + db.AlertNotes.Add(note); + await db.SaveChangesAsync(ct); + await audit.WriteAsync("alert.note", kind == "security" ? "alert" : "triggered_alert", targetId, "note added", ct); + return Results.Ok(note); + }).RequireAuthorization("RequireAnalyst"); + + // Manually run an evaluation pass (used by the dashboard "refresh" + on-demand check). + // Analyst+: evaluation dispatches real notifications, so it must not be open to abuse. + app.MapPost("/api/alert-policies/evaluate", async (AlertEvaluator evaluator, CancellationToken ct) => + { + var fired = await evaluator.EvaluateAsync(ct); + return Results.Ok(new { fired }); + }).RequireAuthorization("RequireAnalyst"); + } +} diff --git a/src/M365SecurityDashboard.Api/Endpoints/AuthHealthEndpoints.cs b/src/M365SecurityDashboard.Api/Endpoints/AuthHealthEndpoints.cs new file mode 100644 index 0000000..c17e0c9 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Endpoints/AuthHealthEndpoints.cs @@ -0,0 +1,139 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; + +namespace M365SecurityDashboard.Api.Endpoints; + +/// Anonymous health probe plus the sign-in surface (/api/auth/config, /api/auth/me). +public static class AuthHealthEndpoints +{ + public static void MapAuthHealthEndpoints(this WebApplication app) + { + // Health endpoint for monitoring / orchestration (Docker HEALTHCHECK, k8s probes, + // uptime monitors). Reports DB connectivity, Graph configuration, and freshness of + // the last collection run. No Graph call is made — probes fire frequently and must + // stay cheap. 200 = healthy/degraded (app can serve traffic), 503 = DB unreachable. + app.MapGet("/health", async (AppDbContext db, IOptions options, CancellationToken ct) => + { + var dbOk = false; + string? dbError = null; + object? lastCollection = null; + var collectionFresh = (bool?)null; + + try + { + dbOk = await db.Database.CanConnectAsync(ct); + if (dbOk) + { + var lastRun = await db.CollectionRuns.AsNoTracking() + .OrderByDescending(r => r.StartedAt).FirstOrDefaultAsync(ct); + if (lastRun is not null) + { + var staleAfter = TimeSpan.FromMinutes(Math.Max(options.Value.CollectionIntervalMinutes, 1) * 2); + collectionFresh = DateTimeOffset.UtcNow - lastRun.StartedAt <= staleAfter; + lastCollection = new + { + startedAt = lastRun.StartedAt, + status = lastRun.Status.ToString(), + alertsUpserted = lastRun.AlertsUpserted, + fresh = collectionFresh + }; + } + } + } + catch (Exception ex) { dbError = ex.Message; } + + var graphConfigured = options.Value.IsConfigured(); + var status = !dbOk ? "unhealthy" + : !graphConfigured || collectionFresh == false ? "degraded" + : "healthy"; + + var body = new + { + status, + version = typeof(Program).Assembly.GetName().Version?.ToString(3), + checks = new + { + database = new { ok = dbOk, error = dbError }, + graph = new { configured = graphConfigured }, + collection = lastCollection + }, + checkedAt = DateTimeOffset.UtcNow + }; + return dbOk ? Results.Ok(body) : Results.Json(body, statusCode: StatusCodes.Status503ServiceUnavailable); + }).AllowAnonymous(); + + // Public endpoint — returns only the non-secret config needed to initialise MSAL in the browser. + // The login identity comes from AzureAd (set in appsettings.Production.json / user secrets); + // fall back to Graph for older single-section setups. + app.MapGet("/api/auth/config", (IConfiguration config) => + { + string Pick(string azureAdKey, string graphKey) + { + var v = config[azureAdKey]; + if (!string.IsNullOrWhiteSpace(v) && !v.StartsWith("YOUR_", StringComparison.OrdinalIgnoreCase)) return v; + var g = config[graphKey]; + return (!string.IsNullOrWhiteSpace(g) && !g.StartsWith("YOUR_", StringComparison.OrdinalIgnoreCase)) ? g : ""; + } + return Results.Ok(new + { + instance = config["AzureAd:Instance"] ?? "https://login.microsoftonline.com/", + clientId = Pick("AzureAd:ClientId", "Graph:ClientId"), + tenantId = Pick("AzureAd:TenantId", "Graph:TenantId"), + redirectUri = config["Auth:RedirectUri"] ?? "http://localhost:5173" + }); + }).AllowAnonymous(); + + // Returns the signed-in user's identity and role, and upserts their AppUsers row. + // Bootstrap: if Auth:BootstrapAdminEmail is configured, only that email becomes + // Admin on first sign-in; otherwise the first user to ever sign in becomes Admin. + // Everyone else defaults to Viewer until an Admin promotes them. + app.MapGet("/api/auth/me", async ( + System.Security.Claims.ClaimsPrincipal principal, AppDbContext db, IConfiguration config, + Microsoft.Extensions.Caching.Memory.IMemoryCache cache, AuditLogger audit, CancellationToken ct) => + { + var email = AuthHelpers.GetEmail(principal); + var displayName = AuthHelpers.GetDisplayName(principal); + if (string.IsNullOrEmpty(email)) return Results.BadRequest(new { error = "Token has no email claim." }); + + var now = DateTimeOffset.UtcNow; + var user = await db.AppUsers.FirstOrDefaultAsync(u => u.Email == email, ct); + var isFirstSignIn = false; + var isNewSession = false; + if (user is null) + { + var bootstrapEmail = (config["Auth:BootstrapAdminEmail"] ?? "").Trim().ToLowerInvariant(); + string role; + if (!string.IsNullOrEmpty(bootstrapEmail)) + role = email == bootstrapEmail ? AppRoles.Admin : AppRoles.Viewer; + else + role = await db.AppUsers.AnyAsync(ct) ? AppRoles.Viewer : AppRoles.Admin; + + user = new AppUser { Email = email, DisplayName = displayName, Role = role, CreatedAt = now, LastSeenAt = now }; + db.AppUsers.Add(user); + isFirstSignIn = true; + // The claims transformation may have cached the default Viewer role for + // this email before the row existed — evict so the real role applies now. + cache.Remove(RoleClaimsTransformation.RoleCacheKey(email)); + } + else + { + // Treat a gap of > 1h since the last request as a new sign-in session so + // the audit trail covers sign-ins without logging every page load. + isNewSession = now - user.LastSeenAt > TimeSpan.FromHours(1); + user.LastSeenAt = now; + if (!string.IsNullOrEmpty(displayName)) user.DisplayName = displayName; + } + await db.SaveChangesAsync(ct); + + if (isFirstSignIn) + await audit.WriteAsync("auth.first_signin", "user", email, $"first sign-in, role {user.Role}", ct); + else if (isNewSession) + await audit.WriteAsync("auth.signin", "user", email, $"signed in as {user.Role}", ct); + + return Results.Ok(new { name = user.DisplayName ?? "", email = user.Email, role = user.Role }); + }); + } +} diff --git a/src/M365SecurityDashboard.Api/Endpoints/DashboardEndpoints.cs b/src/M365SecurityDashboard.Api/Endpoints/DashboardEndpoints.cs new file mode 100644 index 0000000..03ec815 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Endpoints/DashboardEndpoints.cs @@ -0,0 +1,1204 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; + +namespace M365SecurityDashboard.Api.Endpoints; + +/// Read-only dashboard panels (tenant posture, identity, devices, Defender/Purview signals) plus the derived security recommendations feed. +public static class DashboardEndpoints +{ + public static void MapDashboardEndpoints(this WebApplication app) + { + app.MapGet("/api/dashboard/overview", async (AppDbContext db, CancellationToken ct) => + { + var since = DateTimeOffset.UtcNow.AddDays(-30); + // Service-health advisories are availability noise, not security signal — + // they get their own count and never inflate the alert KPIs. + var alerts = db.SecurityAlerts.AsNoTracking() + .Where(a => !a.IsResolved && a.Service != M365ServiceArea.ServiceHealth); + var totalActive = await alerts.CountAsync(ct); + var high = await alerts.CountAsync(a => a.Severity == AlertSeverity.High || a.Severity == AlertSeverity.Critical, ct); + var critical = await alerts.CountAsync(a => a.Severity == AlertSeverity.Critical, ct); + var advisories = await db.SecurityAlerts.AsNoTracking() + .CountAsync(a => !a.IsResolved && a.Service == M365ServiceArea.ServiceHealth, ct); + var lastRun = await db.CollectionRuns.AsNoTracking().OrderByDescending(r => r.StartedAt).FirstOrDefaultAsync(ct); + + var byService = await alerts + .GroupBy(a => a.Service) + .Select(g => new { service = g.Key.ToString(), count = g.Count() }) + .OrderByDescending(x => x.count) + .ToListAsync(ct); + + var trends = await db.SecurityAlerts.AsNoTracking() + .Where(a => a.DetectedAt >= since && a.Service != M365ServiceArea.ServiceHealth) + .GroupBy(a => new { Date = a.DetectedAt.Date, a.Severity }) + .Select(g => new { date = g.Key.Date, severity = g.Key.Severity.ToString(), count = g.Count() }) + .OrderBy(x => x.date) + .ToListAsync(ct); + + return Results.Ok(new + { + totalActive, + highPriority = high, + criticalCount = critical, + serviceAdvisories = advisories, + lastRun, + byService, + trends, + generatedAt = DateTimeOffset.UtcNow + }); + }); + + // ── New dashboard endpoints ──────────────────────────────────────────────── + + // Secure Score trend (direct Graph call) + app.MapGet("/api/dashboard/securescore", async ( + IServiceProvider services, IOptions options, ILogger logger, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, currentScore = 0.0, maxScore = 100.0, percentage = 0.0, trend = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + var items = await graph.GetCollectionAsync("/v1.0/security/secureScores?$top=30", ct); + if (items.Count == 0) + return Results.Ok(new { configured = true, currentScore = 0.0, maxScore = 100.0, percentage = 0.0, trend = Array.Empty() }); + + var latest = items[0]; + var currentScore = latest.TryGetProperty("currentScore", out var cs) && cs.ValueKind == JsonValueKind.Number ? cs.GetDouble() : 0; + var maxScore = latest.TryGetProperty("maxScore", out var ms) && ms.ValueKind == JsonValueKind.Number ? ms.GetDouble() : 100; + if (maxScore == 0) maxScore = 100; + var percentage = Math.Round(currentScore / maxScore * 100, 1); + + var trend = items.Select(s => + { + var sc = s.TryGetProperty("currentScore", out var sv) && sv.ValueKind == JsonValueKind.Number ? sv.GetDouble() : 0; + var mx = s.TryGetProperty("maxScore", out var mv) && mv.ValueKind == JsonValueKind.Number ? mv.GetDouble() : 100; + var dt = s.TryGetProperty("createdDateTime", out var dv) ? dv.GetString() : null; + return new { date = dt != null && dt.Length >= 10 ? dt[..10] : dt, score = sc, maxScore = mx == 0 ? 100 : mx }; + }).Where(x => x.date != null).OrderBy(x => x.date).ToList(); + + return Results.Ok(new { configured = true, currentScore, maxScore, percentage, trend }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to retrieve secure score trend from Graph."); + return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", currentScore = 0.0, maxScore = 100.0, percentage = 0.0, trend = Array.Empty() }); + } + }); + + app.MapGet("/api/dashboard/trends", async (AppDbContext db, CancellationToken ct) => + { + var cutoff = DateTimeOffset.UtcNow.AddDays(-90); + var snapshots = await db.TrendSnapshots.AsNoTracking() + .Where(t => t.CapturedAt >= cutoff) + .OrderBy(t => t.CapturedAt) + .Select(t => new + { + t.Id, + CapturedAt = t.CapturedAt.ToString("o"), + t.RiskyUsersCount, + t.MfaCoveragePct, + t.NonCompliantDevicesCount, + t.CriticalAlertsCount, + t.HighAlertsCount, + t.SecureScorePct, + t.ComplianceIssuesCount + }) + .ToListAsync(ct); + + return Results.Ok(snapshots); + }); + + // Identity summary: MFA from DB + guests & admin activity from Graph + app.MapGet("/api/dashboard/identity", async ( + AppDbContext db, IServiceProvider services, IOptions options, CancellationToken ct) => + { + // MFA stats from already-collected alerts + var mfaAlerts = await db.SecurityAlerts.AsNoTracking() + .Where(a => a.AlertType == "MfaStatus").ToListAsync(ct); + var mfaRegistered = mfaAlerts.Count(a => a.IsResolved); + var mfaTotal = mfaAlerts.Count; + var mfaPct = mfaTotal > 0 ? Math.Round((double)mfaRegistered / mfaTotal * 100, 1) : 0.0; + + // Sign-in summary from DB + var since24h = DateTimeOffset.UtcNow.AddHours(-24); + var signInAlerts = await db.SecurityAlerts.AsNoTracking() + .Where(a => (a.AlertType == "RiskySignIn" || a.AlertType == "FailedSignIn") && a.DetectedAt >= since24h) + .ToListAsync(ct); + var foreignSignIns = signInAlerts.Where(a => a.AlertType == "RiskySignIn") + .OrderByDescending(a => a.DetectedAt).Take(5) + .Select(a => new { title = a.Title, userPrincipalName = a.UserPrincipalName, detectedAt = a.DetectedAt }) + .ToList(); + + // Risky users from DB + var riskyUsers = await db.SecurityAlerts.AsNoTracking() + .CountAsync(a => a.AlertType == "RiskyUser" && !a.IsResolved, ct); + + // Guest accounts and admin activity from Graph (best-effort, time-boxed). + // These are live Graph calls; under throttling they could otherwise stack + // up 15s retry backoffs and hang the whole request. Cap them so the page + // always returns the (fast) DB-backed data within a few seconds. + int guestTotal = 0; + int guestInactive90d = 0; + bool guestLicenseRequired = false; + var guestDomains = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var mfaMethods = new Dictionary(StringComparer.OrdinalIgnoreCase); + var staleAccountsList = new List(); + + object[] recentActivity = []; + if (options.Value.IsConfigured()) + { + var graph = services.GetRequiredService(); + using var budget = CancellationTokenSource.CreateLinkedTokenSource(ct); + budget.CancelAfter(TimeSpan.FromSeconds(15)); + var gct = budget.Token; + + try + { + // Guest Governance (requires Azure AD P1/P2 for signInActivity) + var guests = await graph.GetCollectionAsync( + "/v1.0/users?$filter=userType eq 'Guest'&$select=id,displayName,userPrincipalName,signInActivity", gct); + guestTotal = guests.Count; + + var threshold90 = DateTimeOffset.UtcNow.AddDays(-90); + foreach (var guest in guests) + { + var upn = guest.TryGetProperty("userPrincipalName", out var p) ? p.GetString() : null; + if (upn != null) + { + var parts = upn.Split("#EXT#@"); + if (parts.Length == 2) + { + var extDomain = parts[1]; + guestDomains[extDomain] = guestDomains.GetValueOrDefault(extDomain) + 1; + } + else if (upn.Contains("@")) + { + var domain = upn.Split('@')[1]; + guestDomains[domain] = guestDomains.GetValueOrDefault(domain) + 1; + } + } + + DateTimeOffset? lastSignIn = null; + if (guest.TryGetProperty("signInActivity", out var sia) && sia.ValueKind == JsonValueKind.Object && + sia.TryGetProperty("lastSignInDateTime", out var lsd) && lsd.ValueKind == JsonValueKind.String && + DateTimeOffset.TryParse(lsd.GetString(), out var dt)) + { + lastSignIn = dt; + } + + // If lastSignIn is null, they've either never signed in or we don't have the data (or no P1/P2). + // If they don't have P1/P2, signInActivity won't be returned at all for any user. + if (lastSignIn == null || lastSignIn < threshold90) + { + guestInactive90d++; + } + } + + // Heuristic: If we have guests but absolutely none of them have signInActivity, we likely lack P1/P2. + // A proper way would be to check the tenant SKUs, but this works well enough. + if (guestTotal > 0 && guests.All(g => !g.TryGetProperty("signInActivity", out _))) + { + guestLicenseRequired = true; + } + } + catch { /* permission not granted, or budget elapsed – skip */ } + + try + { + // MFA Methods Breakdown + var regDetails = await graph.GetCollectionAsync( + "/v1.0/reports/authenticationMethods/userRegistrationDetails", gct); + foreach (var user in regDetails) + { + if (user.TryGetProperty("methodsRegistered", out var methods) && methods.ValueKind == JsonValueKind.Array) + { + foreach (var method in methods.EnumerateArray()) + { + var methodStr = method.GetString(); + if (!string.IsNullOrEmpty(methodStr)) + { + mfaMethods[methodStr] = mfaMethods.GetValueOrDefault(methodStr) + 1; + } + } + } + } + } + catch { /* permission not granted, or budget elapsed – skip */ } + + try + { + // Stale Accounts (Internal users, enabled, no sign in > 90 days) + var users = await graph.GetCollectionAsync( + "/v1.0/users?$filter=userType eq 'Member' and accountEnabled eq true&$select=id,displayName,userPrincipalName,signInActivity&$top=200", gct); + var threshold90 = DateTimeOffset.UtcNow.AddDays(-90); + + var allStale = new List<(string upn, string name, DateTimeOffset? lastSignIn, int days)>(); + foreach (var user in users) + { + var upn = user.TryGetProperty("userPrincipalName", out var p) ? p.GetString() : null; + var name = user.TryGetProperty("displayName", out var d) ? d.GetString() : null; + + DateTimeOffset? lastSignIn = null; + if (user.TryGetProperty("signInActivity", out var sia) && sia.ValueKind == JsonValueKind.Object && + sia.TryGetProperty("lastSignInDateTime", out var lsd) && lsd.ValueKind == JsonValueKind.String && + DateTimeOffset.TryParse(lsd.GetString(), out var dt)) + { + lastSignIn = dt; + } + + if (lastSignIn == null || lastSignIn < threshold90) + { + var daysSince = lastSignIn.HasValue ? (int)(DateTimeOffset.UtcNow - lastSignIn.Value).TotalDays : 999; + if (upn != null && name != null) + { + allStale.Add((upn, name, lastSignIn, daysSince)); + } + } + } + + staleAccountsList = allStale.OrderByDescending(x => x.days).Take(10) + .Select(x => (object)new { upn = x.upn, displayName = x.name, lastSignIn = x.lastSignIn, daysSince = x.days == 999 ? -1 : x.days }) + .ToList(); + } + catch { /* permission not granted, or budget elapsed – skip */ } + + try + { + // Single page only — we want the latest 10, not the entire audit + // history. GetCollectionAsync would follow @odata.nextLink through + // every page (thousands of records). + var audits = await graph.GetSinglePageAsync( + "/v1.0/auditLogs/directoryAudits?$top=10&$orderby=activityDateTime desc", gct); + recentActivity = audits.Select(a => (object)new + { + activityDateTime = a.TryGetProperty("activityDateTime", out var dt) ? dt.GetString() : null, + activityDisplayName = a.TryGetProperty("activityDisplayName", out var n) ? n.GetString() : null, + initiatedByUser = a.TryGetProperty("initiatedBy", out var ib) && + ib.TryGetProperty("user", out var u) && + u.TryGetProperty("userPrincipalName", out var upn) ? upn.GetString() : null, + result = a.TryGetProperty("result", out var r) ? r.GetString() : null + }).ToArray(); + } + catch { /* permission not granted, or budget elapsed – skip */ } + } + + int totalMfaMethods = mfaMethods.Values.Sum(); + var friendlyMfaMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "microsoftAuthenticatorPush", "Microsoft Authenticator" }, + { "mobilePhone", "Phone/SMS" }, + { "email", "Email" }, + { "fido2", "FIDO2 / Security Key" }, + { "softwareOath", "OATH Token (App)" }, + { "windowsHelloForBusiness", "Windows Hello" } + }; + + var mfaMethodsBreakdown = mfaMethods.Select(kv => new + { + method = friendlyMfaMap.TryGetValue(kv.Key, out var friendly) ? friendly : kv.Key, + count = kv.Value, + pct = totalMfaMethods > 0 ? Math.Round((double)kv.Value / totalMfaMethods * 100, 1) : 0 + }).OrderByDescending(x => x.count).ToList(); + + var guestDomainsList = guestDomains.Select(kv => new { domain = kv.Key, count = kv.Value }) + .OrderByDescending(x => x.count).Take(10).ToList(); + + return Results.Ok(new + { + configured = true, + mfa = new { registered = mfaRegistered, total = mfaTotal, percentage = mfaPct }, + guests = new { total = guestTotal, active = guestTotal, inactive90d = guestInactive90d, licenseRequired = guestLicenseRequired, domains = guestDomainsList }, + mfaMethods = mfaMethodsBreakdown, + staleAccounts = staleAccountsList, + riskyUsers, + signIns = new + { + total = signInAlerts.Count, + failed = signInAlerts.Count(a => a.AlertType == "FailedSignIn"), + risky = signInAlerts.Count(a => a.AlertType == "RiskySignIn"), + foreign = foreignSignIns.Count + }, + foreignSignIns, + recentAdminActivity = recentActivity + }); + }); + + // Device compliance summary from DB + app.MapGet("/api/dashboard/devices", async ( + AppDbContext db, IServiceProvider services, IOptions options, CancellationToken ct) => + { + var deviceAlerts = await db.SecurityAlerts.AsNoTracking() + .Where(a => a.Service == M365ServiceArea.Intune && !a.IsResolved).ToListAsync(ct); + + var nonCompliant = deviceAlerts.Count(a => a.AlertType == "NonCompliantDevice"); + var notCheckedIn = deviceAlerts.Count(a => a.AlertType == "DeviceNotCheckedIn"); + + // Try to get total device count from Graph + int totalDevices = 120; + var osBuckets = new List(); + if (options.Value.IsConfigured()) + { + try + { + var graph = services.GetRequiredService(); + var all = await graph.GetCollectionAsync( + "/v1.0/deviceManagement/managedDevices?$select=id,operatingSystem,osVersion&$top=500", ct); + totalDevices = all.Count; + + var buckets = new Dictionary<(string os, string version), int>(); + foreach (var d in all) + { + var os = d.TryGetProperty("operatingSystem", out var o) ? o.GetString() ?? "Unknown" : "Unknown"; + var version = d.TryGetProperty("osVersion", out var v) ? v.GetString() ?? "Unknown" : "Unknown"; + + if (os.Contains("Windows", StringComparison.OrdinalIgnoreCase)) os = "Windows"; + else if (os.Contains("Mac", StringComparison.OrdinalIgnoreCase)) os = "macOS"; + else if (os.Contains("iOS", StringComparison.OrdinalIgnoreCase) || os.Contains("iPad", StringComparison.OrdinalIgnoreCase)) os = "iOS"; + else if (os.Contains("Android", StringComparison.OrdinalIgnoreCase)) os = "Android"; + else if (os.Contains("Linux", StringComparison.OrdinalIgnoreCase)) os = "Linux"; + + var key = (os, version); + buckets[key] = buckets.GetValueOrDefault(key) + 1; + } + + osBuckets = buckets.Select(kv => (object)new { os = kv.Key.os, version = kv.Key.version, count = kv.Value }) + .OrderByDescending(x => ((dynamic)x).count).ToList(); + } + catch { /* skip */ } + } + + var nonCompliantDevices = deviceAlerts + .Where(a => a.AlertType == "NonCompliantDevice") + .OrderByDescending(a => a.LastUpdatedAt).Take(5) + .Select(a => new { a.DeviceName, a.UserPrincipalName, a.Description, a.LastUpdatedAt }) + .ToList(); + + double compliancePct = totalDevices > 0 && totalDevices > nonCompliant + ? Math.Round((double)(totalDevices - nonCompliant) / totalDevices * 100, 1) : 0; + + return Results.Ok(new { nonCompliant, notCheckedIn, totalDevices, compliancePct, nonCompliantDevices, osBuckets }); + }); + + // Service health summary from DB + app.MapGet("/api/dashboard/servicehealth", async (AppDbContext db, CancellationToken ct) => + { + var issues = await db.SecurityAlerts.AsNoTracking() + .Where(a => a.Service == M365ServiceArea.ServiceHealth && !a.IsResolved) + .OrderByDescending(a => a.DetectedAt).ToListAsync(ct); + + return Results.Ok(new + { + total = issues.Count, + issues = issues.Select(i => new + { + title = i.Title, + description = i.Description, + severity = i.Severity.ToString(), + detectedAt = i.DetectedAt, + portalUrl = i.PortalUrl + }) + }); + }); + + // ── Enterprise feature endpoints ────────────────────────────────────────────── + + // License usage (subscribedSkus) + app.MapGet("/api/dashboard/licenses", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, skus = Array.Empty(), totalPurchased = 0, totalConsumed = 0 }); + try + { + var graph = services.GetRequiredService(); + var skus = await graph.GetCollectionAsync("/v1.0/subscribedSkus", ct); + var result = skus.Select(s => + { + var name = s.TryGetProperty("skuPartNumber", out var n) ? n.GetString() : "Unknown"; + var consumed = s.TryGetProperty("consumedUnits", out var c) && c.ValueKind == JsonValueKind.Number ? c.GetInt32() : 0; + var purchased = s.TryGetProperty("prepaidUnits", out var p) && + p.TryGetProperty("enabled", out var e) && e.ValueKind == JsonValueKind.Number ? e.GetInt32() : 0; + return new { name, consumed, purchased, available = Math.Max(0, purchased - consumed) }; + }).Where(s => s.purchased > 0).ToList(); + return Results.Ok(new { configured = true, skus = result, totalPurchased = result.Sum(s => s.purchased), totalConsumed = result.Sum(s => s.consumed) }); + } + catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", skus = Array.Empty(), totalPurchased = 0, totalConsumed = 0 }); } + }); + + // Inactive users (last sign-in > 90 days) + app.MapGet("/api/dashboard/inactive-users", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, inactive90Count = 0, neverSignedInCount = 0, totalUsers = 0, inactive90 = Array.Empty(), neverSignedIn = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + var users = await graph.GetCollectionAsync( + "/v1.0/users?$select=id,displayName,userPrincipalName,signInActivity,accountEnabled,assignedLicenses&$top=200", ct); + var threshold90 = DateTimeOffset.UtcNow.AddDays(-90); + var result = users.Select(u => + { + var upn = u.TryGetProperty("userPrincipalName", out var p) ? p.GetString() : null; + var name = u.TryGetProperty("displayName", out var d) ? d.GetString() : null; + var enabled = !u.TryGetProperty("accountEnabled", out var ae) || ae.GetBoolean(); + DateTimeOffset? lastSignIn = null; + if (u.TryGetProperty("signInActivity", out var sia) && sia.ValueKind == JsonValueKind.Object && + sia.TryGetProperty("lastSignInDateTime", out var lsd) && lsd.ValueKind == JsonValueKind.String && + DateTimeOffset.TryParse(lsd.GetString(), out var dt)) lastSignIn = dt; + var hasLicense = u.TryGetProperty("assignedLicenses", out var al) && al.ValueKind == JsonValueKind.Array && al.GetArrayLength() > 0; + var daysSince = lastSignIn.HasValue ? (int)(DateTimeOffset.UtcNow - lastSignIn.Value).TotalDays : -1; + return new { upn, name, enabled, lastSignIn, hasLicense, daysSince }; + }).Where(u => u.upn != null && !u.upn.Contains("#EXT#") && u.enabled).ToList(); + + var inactive90 = result.Where(u => u.lastSignIn == null || u.lastSignIn < threshold90).OrderBy(u => u.lastSignIn).Take(20).ToList(); + var neverSignedIn = result.Where(u => u.lastSignIn == null).Take(20).ToList(); + return Results.Ok(new { configured = true, inactive90Count = inactive90.Count, neverSignedInCount = neverSignedIn.Count, totalUsers = result.Count, inactive90, neverSignedIn }); + } + catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", inactive90Count = 0, neverSignedInCount = 0, totalUsers = 0, inactive90 = Array.Empty(), neverSignedIn = Array.Empty() }); } + }); + + // Password expiry + app.MapGet("/api/dashboard/password-expiry", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, expiringSoonCount = 0, expiredCount = 0, neverExpiresCount = 0, totalUsers = 0, expiringSoon = Array.Empty(), expired = Array.Empty(), neverExpire = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + var users = await graph.GetCollectionAsync( + "/v1.0/users?$select=id,displayName,userPrincipalName,passwordPolicies,lastPasswordChangeDateTime,accountEnabled&$top=200", ct); + var now = DateTimeOffset.UtcNow; + var result = users.Select(u => + { + var upn = u.TryGetProperty("userPrincipalName", out var p) ? p.GetString() : null; + var name = u.TryGetProperty("displayName", out var d) ? d.GetString() : null; + var enabled = !u.TryGetProperty("accountEnabled", out var ae) || ae.GetBoolean(); + var policies = u.TryGetProperty("passwordPolicies", out var pp) ? pp.GetString() : null; + var neverExpires = policies != null && policies.Contains("DisablePasswordExpiration"); + DateTimeOffset? lastChanged = null; + if (u.TryGetProperty("lastPasswordChangeDateTime", out var lcd) && lcd.ValueKind == JsonValueKind.String && + DateTimeOffset.TryParse(lcd.GetString(), out var dt)) lastChanged = dt; + var daysSinceChange = lastChanged.HasValue ? (int)(now - lastChanged.Value).TotalDays : -1; + var daysUntilExpiry = neverExpires || daysSinceChange < 0 ? -1 : 90 - daysSinceChange; + return new { upn, name, enabled, neverExpires, lastChanged, daysSinceChange, daysUntilExpiry }; + }).Where(u => u.upn != null && !u.upn.Contains("#EXT#") && u.enabled).ToList(); + + var expiringSoon = result.Where(u => !u.neverExpires && u.daysUntilExpiry >= 0 && u.daysUntilExpiry <= 14).OrderBy(u => u.daysUntilExpiry).Take(20).ToList(); + var expired = result.Where(u => !u.neverExpires && u.daysUntilExpiry < 0 && u.lastChanged.HasValue).Take(20).ToList(); + var neverExpire = result.Where(u => u.neverExpires).Take(10).ToList(); + return Results.Ok(new { configured = true, expiringSoonCount = expiringSoon.Count, expiredCount = expired.Count, neverExpiresCount = neverExpire.Count, totalUsers = result.Count, expiringSoon, expired, neverExpire }); + } + catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", expiringSoonCount = 0, expiredCount = 0, neverExpiresCount = 0, totalUsers = 0, expiringSoon = Array.Empty(), expired = Array.Empty(), neverExpire = Array.Empty() }); } + }); + + // Conditional Access policies + app.MapGet("/api/dashboard/conditional-access", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, enabled = 0, disabled = 0, reportOnly = 0, policies = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + var policies = await graph.GetCollectionAsync("/v1.0/identity/conditionalAccess/policies", ct); + var result = policies.Select(p => + { + var name = p.TryGetProperty("displayName", out var n) ? n.GetString() : "Unnamed"; + var state = p.TryGetProperty("state", out var s) ? s.GetString() : "unknown"; + var inclUsers = "All Users"; var exclUsers = "None"; var apps = "All Apps"; + if (p.TryGetProperty("conditions", out var cond)) + { + if (cond.TryGetProperty("users", out var u)) + { + if (u.TryGetProperty("includeUsers", out var inc) && inc.ValueKind == JsonValueKind.Array) + inclUsers = inc.EnumerateArray().Select(x => x.GetString()).FirstOrDefault() == "All" ? "All Users" : $"{inc.GetArrayLength()} users"; + if (u.TryGetProperty("excludeUsers", out var exc) && exc.ValueKind == JsonValueKind.Array && exc.GetArrayLength() > 0) + exclUsers = $"{exc.GetArrayLength()} excluded"; + if (u.TryGetProperty("includeGroups", out var grp) && grp.ValueKind == JsonValueKind.Array && grp.GetArrayLength() > 0 && inclUsers == "All Users") + inclUsers = $"{grp.GetArrayLength()} groups"; + } + if (cond.TryGetProperty("applications", out var ap) && ap.TryGetProperty("includeApplications", out var incA) && incA.ValueKind == JsonValueKind.Array) + apps = incA.EnumerateArray().Select(x => x.GetString()).FirstOrDefault() == "All" ? "All Apps" : $"{incA.GetArrayLength()} apps"; + } + var controls = new List(); + if (p.TryGetProperty("grantControls", out var gc) && gc.ValueKind == JsonValueKind.Object && + gc.TryGetProperty("builtInControls", out var bic) && bic.ValueKind == JsonValueKind.Array) + controls.AddRange(bic.EnumerateArray().Select(x => x.GetString() ?? "").Where(x => x.Length > 0)); + return new { name, state, inclUsers, exclUsers, apps, controls = controls.ToArray() }; + }).ToList(); + return Results.Ok(new { configured = true, enabled = result.Count(p => p.state == "enabled"), disabled = result.Count(p => p.state == "disabled"), reportOnly = result.Count(p => p.state == "enabledForReportingButNotEnforced"), policies = result }); + } + catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", enabled = 0, disabled = 0, reportOnly = 0, policies = Array.Empty() }); } + }); + + // Conditional Access gap analysis — coverage holes across the CA policy set. + app.MapGet("/api/dashboard/ca-gaps", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, policyCount = 0, findings = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + var raw = await graph.GetCollectionAsync("/v1.0/identity/conditionalAccess/policies", ct); + var views = raw.Select(ConditionalAccessGapAnalyzer.Parse).ToList(); + var findings = ConditionalAccessGapAnalyzer.Analyze(views); + return Results.Ok(new + { + configured = true, + policyCount = views.Count, + enabledCount = views.Count(v => string.Equals(v.State, "enabled", StringComparison.OrdinalIgnoreCase)), + findings, + }); + } + catch (Exception ex) + { + app.Logger.LogError(ex, "CA gap analysis error"); + return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", policyCount = 0, findings = Array.Empty() }); + } + }).RequireAuthorization("RequireAnalyst"); + + // SharePoint/OneDrive external-sharing posture (tenant settings analysis). + app.MapGet("/api/dashboard/sharing-posture", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, findings = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + var items = await graph.GetCollectionAsync("/v1.0/admin/sharepoint/settings", ct); + if (items.Count == 0) + return Results.Ok(new { configured = true, error = "SharePoint settings returned no data.", findings = Array.Empty() }); + var view = SharingPostureAnalyzer.Parse(items[0]); + var findings = SharingPostureAnalyzer.Analyze(view); + return Results.Ok(new + { + configured = true, + sharingCapability = view.SharingCapability, + oneDriveSharingCapability = view.OneDriveSharingCapability, + defaultLinkType = view.DefaultSharingLinkType, + findings, + }); + } + catch (Exception ex) + { + app.Logger.LogError(ex, "Sharing posture analysis error"); + // Most common cause: SharePointTenantSettings.Read.All not granted. + var perm = GraphErrorHint.DescribeOrNull(ex.Message, "SharePointTenantSettings.Read.All"); + return Results.Ok(new { configured = true, error = perm ?? "An error occurred. Check server logs for details.", findings = Array.Empty() }); + } + }).RequireAuthorization("RequireAnalyst"); + + // Sign-in locations + app.MapGet("/api/dashboard/signin-locations", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, total = 0, countries = 0, failures = 0, byCountry = Array.Empty(), recent = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + // Single page only — the latest 100 sign-ins for the location map. + // GetCollectionAsync would paginate through the entire sign-in history. + var signIns = await graph.GetSinglePageAsync( + "/v1.0/auditLogs/signIns?$top=100&$select=location,userPrincipalName,createdDateTime,status,appDisplayName&$orderby=createdDateTime desc", ct); + var result = signIns.Select(s => + { + var upn = s.TryGetProperty("userPrincipalName", out var p) ? p.GetString() : null; + var appName = s.TryGetProperty("appDisplayName", out var a) ? a.GetString() : null; + var created = s.TryGetProperty("createdDateTime", out var cd) ? cd.GetString() : null; + string? city = null, country = null; + if (s.TryGetProperty("location", out var loc) && loc.ValueKind == JsonValueKind.Object) + { + if (loc.TryGetProperty("city", out var cv)) city = cv.GetString(); + if (loc.TryGetProperty("countryOrRegion", out var cov)) country = cov.GetString(); + } + var success = s.TryGetProperty("status", out var st) && st.ValueKind == JsonValueKind.Object && + st.TryGetProperty("errorCode", out var ec) && ec.ValueKind == JsonValueKind.Number && ec.GetInt32() == 0; + return new { upn, app = appName, created, city, country, success }; + }).ToList(); + var byCountry = result.Where(s => s.country != null) + .GroupBy(s => s.country!) + .Select(g => new { country = g.Key, count = g.Count(), failures = g.Count(s => !s.success) }) + .OrderByDescending(g => g.count).Take(15).ToList(); + return Results.Ok(new { configured = true, total = result.Count, countries = byCountry.Count, failures = result.Count(s => !s.success), byCountry, recent = result.Take(20).ToList() }); + } + catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", total = 0, countries = 0, failures = 0, byCountry = Array.Empty(), recent = Array.Empty() }); } + }); + + // Unified Defender alerts (alerts_v2 — all products) + app.MapGet("/api/dashboard/defender-alerts", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, total = 0, bySeverity = new Dictionary(), bySource = new Dictionary(), alerts = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + var items = await graph.GetCollectionAsync( + "/v1.0/security/alerts_v2?$top=100&$filter=status ne 'resolved'&$orderby=createdDateTime desc", ct); + + var alerts = items.Select(a => new + { + id = a.TryGetProperty("id", out var id) ? id.GetString() : null, + title = a.TryGetProperty("title", out var t) ? t.GetString() : null, + description = a.TryGetProperty("description", out var d) ? d.GetString() : null, + severity = a.TryGetProperty("severity", out var s) ? s.GetString() : "unknown", + status = a.TryGetProperty("status", out var st) ? st.GetString() : "unknown", + classification = a.TryGetProperty("classification", out var cl) ? cl.GetString() : null, + serviceSource = a.TryGetProperty("serviceSource", out var ss) ? ss.GetString() : null, + detectionSource = a.TryGetProperty("detectionSource", out var ds) ? ds.GetString() : null, + category = a.TryGetProperty("category", out var cat) ? cat.GetString() : null, + createdDateTime = a.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null, + lastUpdateDateTime = a.TryGetProperty("lastUpdateDateTime", out var lu) ? lu.GetString() : null, + assignedTo = a.TryGetProperty("assignedTo", out var at) && at.ValueKind == JsonValueKind.String ? at.GetString() : null, + alertWebUrl = a.TryGetProperty("alertWebUrl", out var url) ? url.GetString() : null, + incidentId = a.TryGetProperty("incidentId", out var inc) ? inc.GetString() : null, + mitreTechniques = a.TryGetProperty("mitreTechniques", out var mt) && mt.ValueKind == JsonValueKind.Array + ? mt.EnumerateArray().Select(x => x.GetString()).OfType().ToArray() + : Array.Empty(), + recommendedActions = a.TryGetProperty("recommendedActions", out var ra) && ra.ValueKind == JsonValueKind.String ? ra.GetString() : null, + actorDisplayName = a.TryGetProperty("actorDisplayName", out var actor) && actor.ValueKind == JsonValueKind.String ? actor.GetString() : null, + threatDisplayName = a.TryGetProperty("threatDisplayName", out var threat) && threat.ValueKind == JsonValueKind.String ? threat.GetString() : null, + }).ToList(); + + var bySeverity = alerts.GroupBy(a => a.severity ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); + var bySource = alerts.GroupBy(a => a.serviceSource ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); + return Results.Ok(new { configured = true, total = alerts.Count, bySeverity, bySource, alerts }); + } + catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", total = 0, alerts = Array.Empty() }); } + }); + + // Security incidents (grouped correlated alerts) + app.MapGet("/api/dashboard/security-incidents", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, total = 0, bySeverity = new Dictionary(), incidents = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + var items = await graph.GetCollectionAsync( + "/v1.0/security/incidents?$top=50&$filter=status eq 'active'&$expand=alerts&$orderby=createdDateTime desc", ct); + + var trend = new Dictionary(); + var mitre = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var incidents = items.Select(i => { + var id = i.TryGetProperty("id", out var idProp) ? idProp.GetString() : null; + var displayName = i.TryGetProperty("displayName", out var n) ? n.GetString() : null; + var severity = i.TryGetProperty("severity", out var s) ? s.GetString() : "unknown"; + var status = i.TryGetProperty("status", out var st) ? st.GetString() : "unknown"; + var classification = i.TryGetProperty("classification", out var cl) ? cl.GetString() : null; + var createdDateTime = i.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null; + var lastUpdateDateTime = i.TryGetProperty("lastUpdateDateTime", out var lu) ? lu.GetString() : null; + var assignedTo = i.TryGetProperty("assignedTo", out var at) && at.ValueKind == JsonValueKind.String ? at.GetString() : null; + var incidentWebUrl = i.TryGetProperty("incidentWebUrl", out var url) ? url.GetString() : null; + var customTags = i.TryGetProperty("customTags", out var tags) && tags.ValueKind == JsonValueKind.Array + ? tags.EnumerateArray().Select(x => x.GetString()).OfType().ToArray() + : Array.Empty(); + var description = i.TryGetProperty("description", out var desc) && desc.ValueKind == JsonValueKind.String ? desc.GetString() : null; + var recommendedActions = i.TryGetProperty("recommendedActions", out var ra) && ra.ValueKind == JsonValueKind.String ? ra.GetString() : null; + + if (DateTimeOffset.TryParse(createdDateTime, out var dt)) + { + var dStr = dt.ToString("yyyy-MM-dd"); + trend[dStr] = trend.GetValueOrDefault(dStr) + 1; + } + + if (i.TryGetProperty("alerts", out var al) && al.ValueKind == JsonValueKind.Array) + { + foreach (var alert in al.EnumerateArray()) + { + if (alert.TryGetProperty("mitreTechniques", out var mt) && mt.ValueKind == JsonValueKind.Array) + { + foreach (var t in mt.EnumerateArray()) + { + var tStr = t.GetString(); + if (!string.IsNullOrEmpty(tStr)) mitre[tStr] = mitre.GetValueOrDefault(tStr) + 1; + } + } + } + } + + return new { + id, displayName, severity, status, classification, createdDateTime, lastUpdateDateTime, + assignedTo, incidentWebUrl, customTags, description, recommendedActions + }; + }).ToList(); + + var bySeverity = incidents.GroupBy(i => i.severity ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); + + var mitreList = mitre.Select(kv => new { technique = kv.Key, count = kv.Value }) + .OrderByDescending(x => x.count).Take(5).ToList(); + var trendList = trend.Select(kv => new { date = kv.Key, value = kv.Value }) + .OrderBy(x => x.date).ToList(); + + return Results.Ok(new { configured = true, total = incidents.Count, bySeverity, incidents, mitre = mitreList, trend = trendList }); + } + catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", total = 0, incidents = Array.Empty() }); } + }); + + // Privileged roles + app.MapGet("/api/dashboard/privileged-roles", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, roles = Array.Empty(), totalPrivilegedUsers = 0 }); + try + { + var graph = services.GetRequiredService(); + var highPriv = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Global Administrator", "Security Administrator", "Compliance Administrator", + "SharePoint Administrator", "Exchange Administrator", "User Administrator", + "Privileged Role Administrator", "Global Reader", "Billing Administrator" + }; + var directoryRoles = await graph.GetCollectionAsync("/v1.0/directoryRoles", ct); + var roles = new List(); + var totalPrivilegedUsers = 0; + foreach (var role in directoryRoles) + { + var roleName = role.TryGetProperty("displayName", out var dn) ? dn.GetString() : null; + if (roleName == null || !highPriv.Contains(roleName)) continue; + var roleId = role.TryGetProperty("id", out var id) ? id.GetString() : null; + var members = new List(); + try + { + if (roleId != null) + { + var memberItems = await graph.GetCollectionAsync($"/v1.0/directoryRoles/{roleId}/members?$select=displayName,userPrincipalName", ct); + members = memberItems.Select(m => (object)new + { + displayName = m.TryGetProperty("displayName", out var md) ? md.GetString() : null, + userPrincipalName = m.TryGetProperty("userPrincipalName", out var mu) ? mu.GetString() : null + }).ToList(); + } + } + catch { /* 403 or per-role failure — leave members empty */ } + totalPrivilegedUsers += members.Count; + roles.Add(new { roleId, roleName, memberCount = members.Count, members }); + } + return Results.Ok(new { configured = true, roles, totalPrivilegedUsers }); + } + catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", roles = Array.Empty(), totalPrivilegedUsers = 0 }); } + }); + + // DLP alerts + app.MapGet("/api/dashboard/dlp-alerts", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, total = 0, alerts = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + var items = await graph.GetCollectionAsync( + "/v1.0/security/alerts_v2?$top=50&$orderby=createdDateTime desc&$filter=category eq 'DataLossPrevention'", ct); + var alerts = items.Select(a => new + { + id = a.TryGetProperty("id", out var id) ? id.GetString() : null, + title = a.TryGetProperty("title", out var t) ? t.GetString() : null, + severity = a.TryGetProperty("severity", out var s) ? s.GetString() : "unknown", + status = a.TryGetProperty("status", out var st) ? st.GetString() : "unknown", + category = a.TryGetProperty("category", out var cat) ? cat.GetString() : null, + serviceSource = a.TryGetProperty("serviceSource", out var ss) ? ss.GetString() : null, + createdDateTime = a.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null, + description = a.TryGetProperty("description", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null, + alertWebUrl = a.TryGetProperty("alertWebUrl", out var url) ? url.GetString() : null, + }).ToList(); + var bySeverity = alerts.GroupBy(a => a.severity ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); + var bySource = alerts.GroupBy(a => a.serviceSource ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); + return Results.Ok(new { configured = true, total = alerts.Count, bySeverity, bySource, alerts }); + } + catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", total = 0, alerts = Array.Empty() }); } + }); + + // MDE vulnerabilities / endpoint alerts + app.MapGet("/api/dashboard/mde-vulnerabilities", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, total = 0, alerts = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + var items = await graph.GetCollectionAsync( + "/v1.0/security/alerts_v2?$top=50&$filter=serviceSource eq 'microsoftDefenderForEndpoint'&$orderby=createdDateTime desc", ct); + var alerts = items.Select(a => new + { + id = a.TryGetProperty("id", out var id) ? id.GetString() : null, + title = a.TryGetProperty("title", out var t) ? t.GetString() : null, + severity = a.TryGetProperty("severity", out var s) ? s.GetString() : "unknown", + status = a.TryGetProperty("status", out var st) ? st.GetString() : "unknown", + category = a.TryGetProperty("category", out var cat) ? cat.GetString() : null, + createdDateTime = a.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null, + description = a.TryGetProperty("description", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null, + alertWebUrl = a.TryGetProperty("alertWebUrl", out var url) ? url.GetString() : null, + mitreTechniques = a.TryGetProperty("mitreTechniques", out var mt) && mt.ValueKind == JsonValueKind.Array + ? mt.EnumerateArray().Select(x => x.GetString()).OfType().ToArray() + : Array.Empty(), + }).ToList(); + var bySeverity = alerts.GroupBy(a => a.severity ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); + var byCategory = alerts.GroupBy(a => a.category ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); + return Results.Ok(new { configured = true, total = alerts.Count, bySeverity, byCategory, alerts }); + } + catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", total = 0, alerts = Array.Empty() }); } + }); + + // PIM role activations + app.MapGet("/api/dashboard/pim", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, total = 0, activations = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + var items = await graph.GetCollectionAsync( + "/v1.0/roleManagement/directory/roleAssignments?$top=20&$expand=roleDefinition($select=displayName)", ct); + var activations = items.Select(a => + { + string? principalDisplayName = null, principalUpn = null, roleName = null; + if (a.TryGetProperty("principal", out var p) && p.ValueKind == JsonValueKind.Object) + { + if (p.TryGetProperty("displayName", out var pd)) principalDisplayName = pd.GetString(); + if (p.TryGetProperty("userPrincipalName", out var pu)) principalUpn = pu.GetString(); + } + if (a.TryGetProperty("roleDefinition", out var rd) && rd.ValueKind == JsonValueKind.Object && + rd.TryGetProperty("displayName", out var rdn)) roleName = rdn.GetString(); + return new + { + id = a.TryGetProperty("id", out var id) ? id.GetString() : null, + action = "Assigned", + status = "Active", + createdDateTime = (string?)null, + justification = (string?)null, + principalDisplayName, + principalUpn, + roleName + }; + }).ToList(); + return Results.Ok(new { configured = true, total = activations.Count, activations }); + } + catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", total = 0, activations = Array.Empty() }); } + }); + + // Email protection (Defender for Office 365) + app.MapGet("/api/dashboard/email-protection", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, total = 0, alerts = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + var items = await graph.GetCollectionAsync( + "/v1.0/security/alerts_v2?$top=50&$filter=serviceSource eq 'microsoftDefenderForOffice365'&$orderby=createdDateTime desc", ct); + var topTargetedUsers = new Dictionary(StringComparer.OrdinalIgnoreCase); + var trend = new Dictionary(); + + var alerts = items.Select(a => { + var id = a.TryGetProperty("id", out var idProp) ? idProp.GetString() : null; + var title = a.TryGetProperty("title", out var t) ? t.GetString() : null; + var severity = a.TryGetProperty("severity", out var s) ? s.GetString() : "unknown"; + var status = a.TryGetProperty("status", out var st) ? st.GetString() : "unknown"; + var category = a.TryGetProperty("category", out var cat) ? cat.GetString() : null; + var createdDateTime = a.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null; + var description = a.TryGetProperty("description", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null; + var alertWebUrl = a.TryGetProperty("alertWebUrl", out var url) ? url.GetString() : null; + + string? upn = null; + if (a.TryGetProperty("evidence", out var ev) && ev.ValueKind == JsonValueKind.Array) + { + foreach (var e in ev.EnumerateArray()) + { + if (e.TryGetProperty("entityType", out var et) && et.GetString() == "User" && + e.TryGetProperty("userAccount", out var ua) && ua.ValueKind == JsonValueKind.Object && + ua.TryGetProperty("userPrincipalName", out var upnProp)) + { + upn = upnProp.GetString(); + if (!string.IsNullOrEmpty(upn)) break; + } + else if (e.TryGetProperty("entityType", out var et2) && et2.GetString() == "Mailbox" && + e.TryGetProperty("mailbox", out var mb) && mb.ValueKind == JsonValueKind.Object && + mb.TryGetProperty("userPrincipalName", out var upnProp2)) + { + upn = upnProp2.GetString(); + if (!string.IsNullOrEmpty(upn)) break; + } + } + } + + if (!string.IsNullOrEmpty(upn)) + { + topTargetedUsers[upn] = topTargetedUsers.GetValueOrDefault(upn) + 1; + } + + if (DateTimeOffset.TryParse(createdDateTime, out var dt)) + { + var dStr = dt.ToString("yyyy-MM-dd"); + trend[dStr] = trend.GetValueOrDefault(dStr) + 1; + } + + return new { id, title, severity, status, category, createdDateTime, description, alertWebUrl, userPrincipalName = upn }; + }).ToList(); + + var byCategory = alerts.GroupBy(a => a.category ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); + var bySeverity = alerts.GroupBy(a => a.severity ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); + + var topUsersList = topTargetedUsers.Select(kv => new { user = kv.Key, count = kv.Value }) + .OrderByDescending(x => x.count).Take(5).ToList(); + var trendList = trend.Select(kv => new { date = kv.Key, value = kv.Value }) + .OrderBy(x => x.date).ToList(); + + return Results.Ok(new { configured = true, total = alerts.Count, byCategory, bySeverity, alerts, topTargetedUsers = topUsersList, trend = trendList }); + } + catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", total = 0, alerts = Array.Empty() }); } + }); + + // Purview sensitivity labels + app.MapGet("/api/dashboard/purview", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, labelCount = 0, labels = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + var items = await graph.GetSinglePageAsync("https://graph.microsoft.com/beta/security/informationProtection/sensitivityLabels", ct); + var labels = items.Select(l => new + { + id = l.TryGetProperty("id", out var id) ? id.GetString() : null, + name = l.TryGetProperty("name", out var n) ? n.GetString() : null, + description = l.TryGetProperty("description", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null, + color = l.TryGetProperty("color", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() : null, + sensitivity = l.TryGetProperty("sensitivity", out var s) && s.ValueKind == JsonValueKind.Number ? s.GetInt32() : 0, + isActive = l.TryGetProperty("isActive", out var ia) && (ia.ValueKind == JsonValueKind.True || ia.ValueKind == JsonValueKind.False) && ia.GetBoolean(), + }).ToList(); + return Results.Ok(new { configured = true, labelCount = labels.Count, labels }); + } + catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", labelCount = 0, labels = Array.Empty() }); } + }); + + // MDI alerts (Defender for Identity — on-prem AD lateral movement, credential theft) + app.MapGet("/api/dashboard/mdi-alerts", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, total = 0, alerts = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + var items = await graph.GetCollectionAsync( + "/v1.0/security/alerts_v2?$top=50&$filter=serviceSource eq 'microsoftDefenderForIdentity'&$orderby=createdDateTime desc", ct); + var alerts = items.Select(a => new + { + id = a.TryGetProperty("id", out var id) ? id.GetString() : null, + title = a.TryGetProperty("title", out var t) ? t.GetString() : null, + severity = a.TryGetProperty("severity", out var s) ? s.GetString() : "unknown", + status = a.TryGetProperty("status", out var st) ? st.GetString() : "unknown", + category = a.TryGetProperty("category", out var cat) ? cat.GetString() : null, + createdDateTime = a.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null, + description = a.TryGetProperty("description", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null, + alertWebUrl = a.TryGetProperty("alertWebUrl", out var url) ? url.GetString() : null, + mitreTechniques = a.TryGetProperty("mitreTechniques", out var mt) && mt.ValueKind == JsonValueKind.Array + ? mt.EnumerateArray().Select(x => x.GetString()).OfType().ToArray() + : Array.Empty(), + }).ToList(); + var bySeverity = alerts.GroupBy(a => a.severity ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); + var byCategory = alerts.GroupBy(a => a.category ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); + return Results.Ok(new { configured = true, total = alerts.Count, bySeverity, byCategory, alerts }); + } + catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", total = 0, alerts = Array.Empty() }); } + }); + + // MCAS alerts (Defender for Cloud Apps — SaaS anomalies, impossible travel, mass download) + app.MapGet("/api/dashboard/mcas-alerts", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, total = 0, alerts = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + var items = await graph.GetCollectionAsync( + "/v1.0/security/alerts_v2?$top=50&$filter=serviceSource eq 'microsoftDefenderForCloudApps'&$orderby=createdDateTime desc", ct); + var alerts = items.Select(a => new + { + id = a.TryGetProperty("id", out var id) ? id.GetString() : null, + title = a.TryGetProperty("title", out var t) ? t.GetString() : null, + severity = a.TryGetProperty("severity", out var s) ? s.GetString() : "unknown", + status = a.TryGetProperty("status", out var st) ? st.GetString() : "unknown", + category = a.TryGetProperty("category", out var cat) ? cat.GetString() : null, + createdDateTime = a.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null, + description = a.TryGetProperty("description", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null, + alertWebUrl = a.TryGetProperty("alertWebUrl", out var url) ? url.GetString() : null, + }).ToList(); + var bySeverity = alerts.GroupBy(a => a.severity ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); + var byCategory = alerts.GroupBy(a => a.category ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); + return Results.Ok(new { configured = true, total = alerts.Count, bySeverity, byCategory, alerts }); + } + catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", total = 0, alerts = Array.Empty() }); } + }); + + // Insider Risk Management (Purview IRM — data exfiltration, departing employees) + app.MapGet("/api/dashboard/insider-risk", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, total = 0, alerts = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + var items = await graph.GetCollectionAsync( + "/v1.0/security/alerts_v2?$top=50&$filter=serviceSource eq 'microsoftPurviewInsiderRiskManagement'&$orderby=createdDateTime desc", ct); + var alerts = items.Select(a => new + { + id = a.TryGetProperty("id", out var id) ? id.GetString() : null, + title = a.TryGetProperty("title", out var t) ? t.GetString() : null, + severity = a.TryGetProperty("severity", out var s) ? s.GetString() : "unknown", + status = a.TryGetProperty("status", out var st) ? st.GetString() : "unknown", + category = a.TryGetProperty("category", out var cat) ? cat.GetString() : null, + createdDateTime = a.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null, + description = a.TryGetProperty("description", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null, + alertWebUrl = a.TryGetProperty("alertWebUrl", out var url) ? url.GetString() : null, + }).ToList(); + var bySeverity = alerts.GroupBy(a => a.severity ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); + return Results.Ok(new { configured = true, total = alerts.Count, bySeverity, alerts }); + } + catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", total = 0, alerts = Array.Empty() }); } + }); + + // Entra ID Risk Detections (25+ specific detection types: leaked creds, password spray, nation-state IPs) + app.MapGet("/api/dashboard/risk-detections", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, total = 0, detections = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + var items = await graph.GetSinglePageAsync( + "/v1.0/identityProtection/riskDetections?$top=50", ct); + var detections = items.Select(d => + { + string? city = null, country = null; + if (d.TryGetProperty("location", out var loc) && loc.ValueKind == JsonValueKind.Object) + { + if (loc.TryGetProperty("city", out var cv)) city = cv.GetString(); + if (loc.TryGetProperty("countryOrRegion", out var cov)) country = cov.GetString(); + } + return new + { + id = d.TryGetProperty("id", out var id) ? id.GetString() : null, + riskEventType = d.TryGetProperty("riskEventType", out var ret) ? ret.GetString() : null, + riskLevel = d.TryGetProperty("riskLevel", out var rl) ? rl.GetString() : "unknown", + riskState = d.TryGetProperty("riskState", out var rs) ? rs.GetString() : "unknown", + userDisplayName = d.TryGetProperty("userDisplayName", out var udn) ? udn.GetString() : null, + userPrincipalName = d.TryGetProperty("userPrincipalName", out var upn) ? upn.GetString() : null, + lastUpdatedDateTime = d.TryGetProperty("lastUpdatedDateTime", out var lu) ? lu.GetString() : null, + activityDateTime = d.TryGetProperty("activityDateTime", out var ad) ? ad.GetString() : null, + ipAddress = d.TryGetProperty("ipAddress", out var ip) && ip.ValueKind == JsonValueKind.String ? ip.GetString() : null, + city, country + }; + }).ToList(); + var byType = detections.GroupBy(d => d.riskEventType ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); + var byLevel = detections.GroupBy(d => d.riskLevel ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); + return Results.Ok(new { configured = true, total = detections.Count, byType, byLevel, detections }); + } + catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", total = 0, detections = Array.Empty() }); } + }); + + // MDI Identity Sensor Health Issues (requires IdentityBaseline.Read.All) + app.MapGet("/api/dashboard/identity-health", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, total = 0, issues = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + var items = await graph.GetCollectionAsync("/v1.0/security/identities/healthIssues", ct); + var issues = items.Select(i => new + { + id = i.TryGetProperty("id", out var id) ? id.GetString() : null, + displayName = i.TryGetProperty("displayName", out var n) ? n.GetString() : null, + issueType = i.TryGetProperty("issueType", out var it) ? it.GetString() : null, + severity = i.TryGetProperty("severity", out var s) ? s.GetString() : "unknown", + status = i.TryGetProperty("status", out var st) ? st.GetString() : "unknown", + description = i.TryGetProperty("description", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null, + recommendations = i.TryGetProperty("recommendations", out var r) && r.ValueKind == JsonValueKind.String ? r.GetString() : null, + createdDateTime = i.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null, + domainNames = i.TryGetProperty("domainNames", out var dn) && dn.ValueKind == JsonValueKind.Array + ? dn.EnumerateArray().Select(x => x.GetString()).OfType().ToArray() + : Array.Empty(), + sensorDNSNames = i.TryGetProperty("sensorDNSNames", out var sdn) && sdn.ValueKind == JsonValueKind.Array + ? sdn.EnumerateArray().Select(x => x.GetString()).OfType().ToArray() + : Array.Empty(), + }).ToList(); + var bySeverity = issues.GroupBy(i => i.severity ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); + return Results.Ok(new { configured = true, total = issues.Count, bySeverity, issues }); + } + catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", total = 0, issues = Array.Empty() }); } + }); + + // Attack Simulation & Training (requires AttackSimulation.ReadWrite.All) + app.MapGet("/api/dashboard/attack-simulation", async ( + IServiceProvider services, IOptions options, CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.Ok(new { configured = false, total = 0, simulations = Array.Empty() }); + try + { + var graph = services.GetRequiredService(); + var items = await graph.GetCollectionAsync( + "/v1.0/security/attackSimulation/simulations?$top=20", ct); + var simulations = items.Select(s => + { + int targeted = 0, clicked = 0, didNotClick = 0; double compromisedRate = 0; + if (s.TryGetProperty("report", out var rpt) && rpt.ValueKind == JsonValueKind.Object) + { + if (rpt.TryGetProperty("numberOfUsersTargeted", out var nut) && nut.ValueKind == JsonValueKind.Number) targeted = nut.GetInt32(); + if (rpt.TryGetProperty("simulationEventsContent", out var sec) && sec.ValueKind == JsonValueKind.Object) + { + if (sec.TryGetProperty("compromisedRate", out var cr2) && cr2.ValueKind == JsonValueKind.Number) compromisedRate = cr2.GetDouble(); + if (sec.TryGetProperty("clickedPhishingLinkCount", out var cpl) && cpl.ValueKind == JsonValueKind.Number) clicked = cpl.GetInt32(); + if (sec.TryGetProperty("didNotClickLinkCount", out var dnc) && dnc.ValueKind == JsonValueKind.Number) didNotClick = dnc.GetInt32(); + } + } + return new + { + id = s.TryGetProperty("id", out var id) ? id.GetString() : null, + displayName = s.TryGetProperty("displayName", out var n) ? n.GetString() : null, + attackType = s.TryGetProperty("attackType", out var at) ? at.GetString() : null, + status = s.TryGetProperty("status", out var st) ? st.GetString() : "unknown", + createdDateTime = s.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null, + completionDateTime = s.TryGetProperty("completionDateTime", out var cd) && cd.ValueKind == JsonValueKind.String ? cd.GetString() : null, + numberOfUsersTargeted = targeted, + compromisedRate, + clickedPhishingLinkCount = clicked, + didNotClickLinkCount = didNotClick, + }; + }).ToList(); + var totalTargeted = simulations.Sum(s => s.numberOfUsersTargeted); + var avgCompromiseRate = simulations.Count > 0 + ? Math.Round(simulations.Average(s => s.compromisedRate), 1) : 0.0; + return Results.Ok(new { configured = true, total = simulations.Count, totalTargeted, avgCompromiseRate, simulations }); + } + catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = GraphErrorHint.DescribeOrNull(ex.Message) ?? "An error occurred. Check server logs for details.", total = 0, simulations = Array.Empty() }); } + }); + + app.MapGet("/api/recommendations", async (AppDbContext db, CancellationToken ct) => + Results.Ok(await RecommendationsEngine.GetRecommendationsAsync(db, ct))); + } +} diff --git a/src/M365SecurityDashboard.Api/Endpoints/IntegrationsEndpoints.cs b/src/M365SecurityDashboard.Api/Endpoints/IntegrationsEndpoints.cs new file mode 100644 index 0000000..cd6e2e0 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Endpoints/IntegrationsEndpoints.cs @@ -0,0 +1,94 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; + +namespace M365SecurityDashboard.Api.Endpoints; + +/// Machine-to-machine integration endpoints: API token management and the token-authenticated SIEM export feeds. +public static class IntegrationsEndpoints +{ + public static void MapIntegrationsEndpoints(this WebApplication app) + { + // API tokens for SIEM/read-only machine integrations. The raw token is returned + // once on create; only a SHA-256 hash is stored. + app.MapGet("/api/api-tokens", async (AppDbContext db, CancellationToken ct) => + Results.Ok(await db.ApiTokens.AsNoTracking() + .OrderByDescending(t => t.CreatedAt) + .Select(t => new { t.Id, t.Name, t.Prefix, t.Scopes, t.CreatedAt, t.CreatedBy, t.ExpiresAt, t.LastUsedAt, t.RevokedAt }) + .ToListAsync(ct))) + .RequireAuthorization("RequireAdmin"); + + app.MapPost("/api/api-tokens", async ( + AppDbContext db, AuditLogger audit, System.Security.Claims.ClaimsPrincipal user, + ApiTokenCreateRequest input, CancellationToken ct) => + { + var (row, rawToken) = ApiTokenService.Create( + input.Name ?? "SIEM integration", + input.Scopes ?? "alerts:read,health:read", + AuthHelpers.GetEmail(user), + input.ExpiresAt); + db.ApiTokens.Add(row); + await db.SaveChangesAsync(ct); + await audit.WriteAsync("api_token.create", "api_token", row.Id.ToString(), row.Name, ct); + return Results.Ok(new { row.Id, row.Name, row.Prefix, row.Scopes, row.CreatedAt, row.ExpiresAt, token = rawToken }); + }).RequireAuthorization("RequireAdmin"); + + app.MapPost("/api/api-tokens/{id:guid}/revoke", async (AppDbContext db, AuditLogger audit, Guid id, CancellationToken ct) => + { + var token = await db.ApiTokens.FirstOrDefaultAsync(t => t.Id == id, ct); + if (token is null) return Results.NotFound(); + token.RevokedAt ??= DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + await audit.WriteAsync("api_token.revoke", "api_token", id.ToString(), token.Name, ct); + return Results.Ok(new { ok = true }); + }).RequireAuthorization("RequireAdmin"); + + // SIEM export endpoints use API-token auth, not the browser's Entra delegated token. + app.MapGet("/api/siem/alerts", async (HttpContext ctx, ApiTokenService tokens, AppDbContext db, CancellationToken ct) => + { + var token = await tokens.ValidateAsync(ReadApiToken(ctx), "alerts:read", ct); + if (token is null) return Results.Unauthorized(); + var since = DateTimeOffset.UtcNow.AddDays(-7); + if (DateTimeOffset.TryParse(ctx.Request.Query["since"], out var parsed)) since = parsed; + var alerts = await db.TriggeredAlerts.AsNoTracking() + .Where(a => a.TriggeredAt >= since) + .OrderByDescending(a => a.TriggeredAt) + .Take(1000) + .Select(a => new + { + a.Id, a.PolicyId, a.PolicyName, a.Severity, a.Category, a.Condition, + a.MetricValue, a.Threshold, a.TriggeredAt, a.Status, a.AffectedEntities, + source = "Vigil365" + }) + .ToListAsync(ct); + return Results.Ok(new { generatedAt = DateTimeOffset.UtcNow, count = alerts.Count, alerts }); + }).AllowAnonymous(); + + app.MapGet("/api/siem/health", async (HttpContext ctx, ApiTokenService tokens, AppDbContext db, CancellationToken ct) => + { + var token = await tokens.ValidateAsync(ReadApiToken(ctx), "health:read", ct); + if (token is null) return Results.Unauthorized(); + var latestRun = await db.CollectionRuns.AsNoTracking().OrderByDescending(r => r.StartedAt).FirstOrDefaultAsync(ct); + var notificationHealth = NotificationHealth.Compute(await db.NotificationLogs.AsNoTracking().OrderByDescending(l => l.SentAt).Take(200).ToListAsync(ct)); + return Results.Ok(new + { + generatedAt = DateTimeOffset.UtcNow, + latestRun, + notificationChannels = notificationHealth, + openTriggeredAlerts = await db.TriggeredAlerts.AsNoTracking().CountAsync(a => a.Status == "new" || a.Status == "acknowledged", ct) + }); + }).AllowAnonymous(); + } + + private static string? ReadApiToken(HttpContext ctx) + { + var apiKey = ctx.Request.Headers["X-Api-Key"].ToString(); + if (!string.IsNullOrWhiteSpace(apiKey)) return apiKey.Trim(); + var auth = ctx.Request.Headers.Authorization.ToString(); + return auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? auth["Bearer ".Length..].Trim() + : null; + } +} diff --git a/src/M365SecurityDashboard.Api/Endpoints/NotificationsEndpoints.cs b/src/M365SecurityDashboard.Api/Endpoints/NotificationsEndpoints.cs new file mode 100644 index 0000000..444c610 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Endpoints/NotificationsEndpoints.cs @@ -0,0 +1,100 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; + +namespace M365SecurityDashboard.Api.Endpoints; + +/// Notification channel settings, test dispatch, delivery log, and per-channel delivery health. +public static class NotificationsEndpoints +{ + public static void MapNotificationsEndpoints(this WebApplication app) + { + // Notification settings (single row). Password is write-only — never returned. + app.MapGet("/api/notification-settings", async (AppDbContext db, SecretProtector protector, CancellationToken ct) => + { + var s = await db.NotificationSettings.FirstOrDefaultAsync(ct) ?? new NotificationSettings { Id = 1 }; + return Results.Ok(new + { + s.TeamsEnabled, TeamsWebhookUrl = protector.Unprotect(s.TeamsWebhookUrl), + s.EmailEnabled, s.SmtpHost, s.SmtpPort, s.SmtpUseSsl, s.SmtpUsername, + hasSmtpPassword = !string.IsNullOrEmpty(s.SmtpPassword), + s.FromAddress, s.DefaultRecipient, + s.WebhookEnabled, WebhookUrl = protector.Unprotect(s.WebhookUrl), + hasWebhookSigningSecret = !string.IsNullOrEmpty(s.WebhookSigningSecret), + s.MinSeverity, + s.TeamsDigest, s.EmailDigest, s.WebhookDigest, s.DigestHourUtc, s.FailureAlertThreshold, + }); + }).RequireAuthorization("RequireAdmin"); + + app.MapPut("/api/notification-settings", async (AppDbContext db, SecretProtector protector, AuditLogger audit, NotificationSettings input, CancellationToken ct) => + { + var s = await db.NotificationSettings.FirstOrDefaultAsync(ct); + // Id is store-generated; setting it makes EF include it in the INSERT + // and SQL Server rejects that against an identity column. + if (s is null) { s = new NotificationSettings(); db.NotificationSettings.Add(s); } + s.TeamsEnabled = input.TeamsEnabled; + s.TeamsWebhookUrl = protector.Protect(input.TeamsWebhookUrl); + s.EmailEnabled = input.EmailEnabled; + s.SmtpHost = input.SmtpHost; + s.SmtpPort = input.SmtpPort <= 0 ? 587 : input.SmtpPort; + s.SmtpUseSsl = input.SmtpUseSsl; + s.SmtpUsername = input.SmtpUsername; + if (!string.IsNullOrEmpty(input.SmtpPassword)) s.SmtpPassword = protector.Protect(input.SmtpPassword); // keep existing if blank + s.FromAddress = input.FromAddress; + s.DefaultRecipient = input.DefaultRecipient; + s.WebhookEnabled = input.WebhookEnabled; + s.WebhookUrl = protector.Protect(input.WebhookUrl); + if (!string.IsNullOrWhiteSpace(input.WebhookSigningSecret)) + s.WebhookSigningSecret = protector.Protect(input.WebhookSigningSecret); + s.MinSeverity = string.IsNullOrWhiteSpace(input.MinSeverity) ? "low" : input.MinSeverity; + s.TeamsDigest = input.TeamsDigest; + s.EmailDigest = input.EmailDigest; + s.WebhookDigest = input.WebhookDigest; + s.DigestHourUtc = Math.Clamp(input.DigestHourUtc, 0, 23); + s.FailureAlertThreshold = input.FailureAlertThreshold <= 0 ? 3 : input.FailureAlertThreshold; + await db.SaveChangesAsync(ct); + await audit.WriteAsync("settings.update", "settings", "notifications", "notification settings updated", ct); + return Results.Ok(new { ok = true }); + }).RequireAuthorization("RequireAdmin"); + + // Send a test notification through all enabled channels + app.MapPost("/api/notification-settings/test", async (AppDbContext db, NotificationSender sender, CancellationToken ct) => + { + var cfg = await db.NotificationSettings.FirstOrDefaultAsync(ct); + if (cfg is null) return Results.Ok(new { ok = false, message = "No settings configured" }); + var test = new TriggeredAlert + { + Id = Guid.NewGuid(), + PolicyName = "Test Notification", + Severity = "high", + Category = "test", + Condition = "Manual test from Vigil365 settings", + MetricValue = 1, + Threshold = 1, + TriggeredAt = DateTimeOffset.UtcNow, + Status = "new", + }; + await sender.DispatchAsync(db, cfg, test, ct); + await db.SaveChangesAsync(ct); + var logs = await db.NotificationLogs.Where(l => l.TriggeredAlertId == test.Id).ToListAsync(ct); + return Results.Ok(new { ok = logs.Any(l => l.Success), results = logs.Select(l => new { l.Channel, l.Success, l.Error }) }); + }).RequireAuthorization("RequireAdmin"); + + // Notification delivery history + app.MapGet("/api/notification-log", async (AppDbContext db, CancellationToken ct) => + Results.Ok(await db.NotificationLogs.OrderByDescending(l => l.SentAt).Take(200).ToListAsync(ct))) + .RequireAuthorization("RequireAnalyst"); + + // Per-channel delivery health (consecutive failures, last success/error). + app.MapGet("/api/notification-health", async (AppDbContext db, CancellationToken ct) => + { + var cfg = await db.NotificationSettings.AsNoTracking().FirstOrDefaultAsync(ct); + var recent = await db.NotificationLogs.AsNoTracking().OrderByDescending(l => l.SentAt).Take(200).ToListAsync(ct); + var health = NotificationHealth.Compute(recent); + var threshold = cfg?.FailureAlertThreshold ?? 3; + return Results.Ok(new { threshold, channels = health, anyFailing = health.Any(h => h.ConsecutiveFailures >= threshold) }); + }).RequireAuthorization("RequireAnalyst"); + } +} diff --git a/src/M365SecurityDashboard.Api/Endpoints/PlatformEndpoints.cs b/src/M365SecurityDashboard.Api/Endpoints/PlatformEndpoints.cs new file mode 100644 index 0000000..fb1bc33 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Endpoints/PlatformEndpoints.cs @@ -0,0 +1,85 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; + +namespace M365SecurityDashboard.Api.Endpoints; + +/// +/// Collection control, the tenant audit-event feed, and entity investigation — +/// the platform-level surfaces that sit underneath the dashboards rather than +/// belonging to any one of them. +/// +public static class PlatformEndpoints +{ + public static void MapPlatformEndpoints(this WebApplication app) + { + // ── Collection runs ───────────────────────────────────────────────── + app.MapGet("/api/collector/runs", async (AppDbContext db, CancellationToken ct) => + await db.CollectionRuns.AsNoTracking().OrderByDescending(r => r.StartedAt).Take(20).ToListAsync(ct)); + + app.MapPost("/api/collector/run", async ( + IServiceProvider services, + Microsoft.Extensions.Options.IOptions options, + CancellationToken ct) => + { + if (!options.Value.IsConfigured()) + return Results.BadRequest(new { error = "Microsoft Graph is not configured. Complete the setup wizard first." }); + + var collector = services.GetRequiredService(); + try + { + var run = await collector.CollectAsync(ct); + return Results.Ok(run); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("already in progress")) + { + return Results.Conflict(new { error = "A collection run is already in progress." }); + } + }).RequireAuthorization("RequireAnalyst"); + + // ── Tenant audit events (activity feed backing activity-based policies) ───── + app.MapGet("/api/audit-events", async ( + AppDbContext db, string? search, string? activity, int days = 7, + int page = 1, int pageSize = 50, CancellationToken ct = default) => + { + page = page < 1 ? 1 : page; + pageSize = pageSize is < 1 or > 200 ? 50 : pageSize; + var since = DateTimeOffset.UtcNow.AddDays(-Math.Clamp(days, 1, 90)); + + var q = db.AuditEvents.AsNoTracking().Where(e => e.OccurredAt >= since); + if (!string.IsNullOrWhiteSpace(activity)) + { + var safePattern = activity + .Replace("[", "[[]") + .Replace("%", "[%]") + .Replace("_", "[_]") + .Replace("*", "%"); + q = q.Where(e => EF.Functions.Like(e.Activity, safePattern)); + } + if (!string.IsNullOrWhiteSpace(search)) + q = q.Where(e => + e.Activity.Contains(search) || + (e.ActorUpn != null && e.ActorUpn.Contains(search)) || + (e.TargetName != null && e.TargetName.Contains(search))); + + var total = await q.CountAsync(ct); + var items = await q.OrderByDescending(e => e.OccurredAt) + .Skip((page - 1) * pageSize).Take(pageSize) + .Select(e => new { e.Id, e.Activity, e.Category, e.ActorUpn, e.ActorApp, e.TargetName, e.Result, e.OccurredAt }) + .ToListAsync(ct); + return Results.Ok(new { total, page, pageSize, items }); + }); + + // ── Entity investigation profile (drill-down) ────────────────────────────── + // GET /api/entity/{kind}/{id} — kind = user|device. Merges the entity's alerts + // and tenant audit activity into one reverse-chronological timeline. + app.MapGet("/api/entity/{kind}/{id}", async (EntityProfileBuilder builder, string kind, string id, CancellationToken ct) => + { + if (string.IsNullOrWhiteSpace(id)) return Results.BadRequest(new { error = "Entity id is required." }); + var profile = await builder.BuildAsync(kind, id, maxItems: 300, ct); + return Results.Ok(profile); + }).RequireAuthorization("RequireAnalyst"); + } +} diff --git a/src/M365SecurityDashboard.Api/Endpoints/ReportsEndpoints.cs b/src/M365SecurityDashboard.Api/Endpoints/ReportsEndpoints.cs new file mode 100644 index 0000000..be5c248 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Endpoints/ReportsEndpoints.cs @@ -0,0 +1,92 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; + +namespace M365SecurityDashboard.Api.Endpoints; + +/// Scheduled executive-digest reports: schedule CRUD, immediate run-now dispatch, and live digest preview. +public static class ReportsEndpoints +{ + public static void MapReportsEndpoints(this WebApplication app) + { + // ── Scheduled reports (executive digest) ─────────────────────────────────── + + app.MapGet("/api/report-schedules", async (AppDbContext db, CancellationToken ct) => + Results.Ok(await db.ReportSchedules.AsNoTracking().OrderBy(s => s.Name).ToListAsync(ct))) + .RequireAuthorization("RequireAnalyst"); + + // Live preview of the digest HTML/CSV without sending anything. + app.MapGet("/api/reports/exec-digest/preview", async (DigestBuilder builder, int? windowDays, CancellationToken ct) => + { + var digest = await builder.BuildAsync(windowDays ?? 7, ct); + return Results.Ok(new { digest.Subject, digest.HtmlBody, digest.Csv, digest.GeneratedAt, digest.HasData, digest.Metrics, digest.TopAlerts }); + }).RequireAuthorization("RequireAnalyst"); + + app.MapPost("/api/report-schedules", async (AppDbContext db, AuditLogger audit, System.Security.Claims.ClaimsPrincipal user, ReportSchedule input, CancellationToken ct) => + { + var s = new ReportSchedule + { + Id = Guid.NewGuid(), + Name = string.IsNullOrWhiteSpace(input.Name) ? "Weekly executive digest" : input.Name.Trim(), + ReportType = "exec-digest", + Cadence = input.Cadence is "daily" or "weekly" or "monthly" ? input.Cadence : "weekly", + DayOfWeek = Math.Clamp(input.DayOfWeek, 0, 6), + DayOfMonth = Math.Clamp(input.DayOfMonth, 1, 28), + HourUtc = Math.Clamp(input.HourUtc, 0, 23), + Recipients = input.Recipients ?? "", + IncludeCsv = input.IncludeCsv, + IncludePdf = input.IncludePdf, + Enabled = input.Enabled, + CreatedBy = user.Identity?.Name, + CreatedAt = DateTimeOffset.UtcNow, + }; + db.ReportSchedules.Add(s); + await db.SaveChangesAsync(ct); + await audit.WriteAsync("report.schedule.create", "report", s.Id.ToString(), s.Name, ct); + return Results.Ok(s); + }).RequireAuthorization("RequireAdmin"); + + app.MapPut("/api/report-schedules/{id:guid}", async (AppDbContext db, AuditLogger audit, Guid id, ReportSchedule input, CancellationToken ct) => + { + var s = await db.ReportSchedules.FirstOrDefaultAsync(x => x.Id == id, ct); + if (s is null) return Results.NotFound(); + s.Name = string.IsNullOrWhiteSpace(input.Name) ? s.Name : input.Name.Trim(); + s.Cadence = input.Cadence is "daily" or "weekly" or "monthly" ? input.Cadence : s.Cadence; + s.DayOfWeek = Math.Clamp(input.DayOfWeek, 0, 6); + s.DayOfMonth = Math.Clamp(input.DayOfMonth, 1, 28); + s.HourUtc = Math.Clamp(input.HourUtc, 0, 23); + s.Recipients = input.Recipients ?? ""; + s.IncludeCsv = input.IncludeCsv; + s.IncludePdf = input.IncludePdf; + s.Enabled = input.Enabled; + await db.SaveChangesAsync(ct); + await audit.WriteAsync("report.schedule.update", "report", s.Id.ToString(), s.Name, ct); + return Results.Ok(s); + }).RequireAuthorization("RequireAdmin"); + + app.MapDelete("/api/report-schedules/{id:guid}", async (AppDbContext db, AuditLogger audit, Guid id, CancellationToken ct) => + { + var s = await db.ReportSchedules.FirstOrDefaultAsync(x => x.Id == id, ct); + if (s is null) return Results.NotFound(); + db.ReportSchedules.Remove(s); + await db.SaveChangesAsync(ct); + await audit.WriteAsync("report.schedule.delete", "report", id.ToString(), s.Name, ct); + return Results.Ok(new { ok = true }); + }).RequireAuthorization("RequireAdmin"); + + // Send this report immediately, regardless of cadence. + app.MapPost("/api/report-schedules/{id:guid}/run-now", async (IServiceProvider sp, AppDbContext db, AuditLogger audit, Guid id, CancellationToken ct) => + { + var s = await db.ReportSchedules.FirstOrDefaultAsync(x => x.Id == id, ct); + if (s is null) return Results.NotFound(); + var (ok, status) = await ReportScheduleWorker.DispatchAsync(sp, db, s, ct); + s.LastRunAt = DateTimeOffset.UtcNow; + s.LastRunStatus = status; + await db.SaveChangesAsync(ct); + await audit.WriteAsync("report.schedule.run", "report", id.ToString(), status, ct); + return Results.Ok(new { ok, status }); + }).RequireAuthorization("RequireAdmin"); + } +} diff --git a/src/M365SecurityDashboard.Api/Endpoints/SetupEndpoints.cs b/src/M365SecurityDashboard.Api/Endpoints/SetupEndpoints.cs new file mode 100644 index 0000000..a50b80a --- /dev/null +++ b/src/M365SecurityDashboard.Api/Endpoints/SetupEndpoints.cs @@ -0,0 +1,173 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using M365SecurityDashboard.Api.Services; + +namespace M365SecurityDashboard.Api.Endpoints; + +/// First-run setup: onboarding checklist, Graph permission reference, and the admin-only Graph credential wizard. +public static class SetupEndpoints +{ + public static void MapSetupEndpoints(this WebApplication app) + { + // First-run / setup progress. Drives the onboarding checklist so a fresh install + // gets "do these things" instead of a dashboard full of empty cards. Analyst- + // readable; every signal comes from state the app already persists. + app.MapGet("/api/setup/status", async (AppDbContext db, IOptions options, CancellationToken ct) => + { + var graphConfigured = options.Value.IsConfigured(); + + var lastRun = await db.CollectionRuns.AsNoTracking() + .OrderByDescending(r => r.Id) + .FirstOrDefaultAsync(ct); + var hasCollected = lastRun is { Status: CollectionStatus.Completed }; + + // A permission gap shows up as a source failure on an otherwise-complete run. + var permissionGaps = lastRun?.SourceFailures ?? 0; + + var cfg = await db.NotificationSettings.AsNoTracking().FirstOrDefaultAsync(ct); + var notificationsConfigured = + (cfg?.EmailEnabled == true && !string.IsNullOrWhiteSpace(cfg.SmtpHost)) || + (cfg?.TeamsEnabled == true) || + (cfg?.WebhookEnabled == true); + + var policyCount = await db.AlertPolicies.CountAsync(p => p.Enabled, ct); + var userCount = await db.AppUsers.CountAsync(ct); + + var steps = new[] + { + new { key = "graph", label = "Connect Microsoft Graph", done = graphConfigured, + hint = "Complete the setup wizard with your tenant and app registration.", page = "setup" }, + new { key = "collection", label = "Run the first collection", done = hasCollected, + hint = "Collect alerts from your tenant so the dashboard has data.", page = "overview" }, + new { key = "permissions", label = "Grant all required permissions", done = graphConfigured && permissionGaps == 0, + hint = "One or more Graph sources were denied. Open Collection Runs to see which permission is missing.", page = "alertcenter" }, + new { key = "notifications", label = "Set up a notification channel", done = notificationsConfigured, + hint = "Add email (SMTP), Teams, or a webhook so alerts reach you.", page = "alertcenter" }, + new { key = "policies", label = "Enable alert policies", done = policyCount > 0, + hint = "Enable at least one alert policy so Vigil365 raises alerts.", page = "alertcenter" }, + new { key = "users", label = "Invite your team", done = userCount > 1, + hint = "Add analysts and viewers so you are not the only account.", page = "users" }, + }; + + return Results.Ok(new + { + complete = steps.All(s => s.done), + completedCount = steps.Count(s => s.done), + totalCount = steps.Length, + steps, + }); + }).RequireAuthorization("RequireAnalyst"); + + // Live permissions reference: every collector source, its required Graph + // application permission, and whether the last run could actually read it. + // Turns "which permission do I need?" into a page instead of a support ticket. + app.MapGet("/api/setup/permissions", async (AppDbContext db, CancellationToken ct) => + { + var lastRun = await db.CollectionRuns.AsNoTracking() + .OrderByDescending(r => r.Id) + .FirstOrDefaultAsync(ct); + + // Sources that failed on the most recent run — most commonly a permission gap. + var failedSources = new HashSet(StringComparer.OrdinalIgnoreCase); + if (lastRun?.SourceFailureDetails is { } details) + { + try + { + foreach (var f in JsonSerializer.Deserialize>>(details) ?? []) + if (f.TryGetValue("source", out var s)) failedSources.Add(s); + } + catch { /* malformed detail — treat as no known failures */ } + } + + var items = GraphErrorHint.AllRequirements() + .GroupBy(r => r.Permission) + .Select(g => new + { + permission = g.Key, + features = g.Select(r => r.Source).OrderBy(s => s).ToArray(), + // "granted" is a best-effort inference from the last run: a source that + // failed is almost certainly missing its permission; one that ran is fine. + // Null when there is no run yet to judge from. + status = lastRun is null ? "unknown" + : g.Any(r => failedSources.Contains(r.Source)) ? "missing" : "granted", + }) + .OrderBy(x => x.permission) + .ToList(); + + return Results.Ok(new { hasRun = lastRun is not null, permissions = items }); + }).RequireAuthorization("RequireAnalyst"); + + // ── First-run setup wizard (Admin only) ────────────────────────────────────────── + // Lets an Admin enter Graph credentials in the browser instead of editing JSON. + // Current config status + non-secret values (never returns the secret). + app.MapGet("/api/setup/graph", (IOptions opts) => + { + var o = opts.Value; + return Results.Ok(new + { + configured = o.IsConfigured(), + tenantId = o.IsConfigured() ? o.TenantId : "", + clientId = o.IsConfigured() ? o.ClientId : "", + hasSecret = o.HasSecret(), + hasCertificate = o.HasCertificate(), + loginInstance = o.LoginInstance, + baseUrl = o.BaseUrl, + // Certificate wins when both are present — mirrors GraphApiClient.BuildCredential. + authMode = o.HasCertificate() ? "certificate" : o.HasSecret() ? "secret" : "none", + }); + }).RequireAuthorization("RequireAdmin"); + + // Save + apply Graph credentials, then test the connection. Persists encrypted and + // mutates the live GraphOptions singleton so collection works without a restart. + app.MapPost("/api/setup/graph", async ( + GraphSetupRequest input, AppDbContext db, SecretProtector protector, AuditLogger audit, + IOptions opts, IServiceProvider services, CancellationToken ct) => + { + var tenantId = (input.TenantId ?? "").Trim(); + var clientId = (input.ClientId ?? "").Trim(); + var clientSecret = (input.ClientSecret ?? "").Trim(); + var loginInstance = (input.LoginInstance ?? "").Trim(); + var baseUrl = (input.BaseUrl ?? "").Trim(); + if (tenantId == "" || clientId == "") + return Results.BadRequest(new { error = "Tenant ID and Client ID are required." }); + + // Single-row table, so match on "the row" rather than on Id == 1: the + // Id is store-generated, and assigning it made EF send it in the + // INSERT, which an identity column rejects. + var row = await db.GraphConfig.OrderBy(g => g.Id).FirstOrDefaultAsync(ct); + if (row is null) { row = new GraphConfig(); db.GraphConfig.Add(row); } + row.TenantId = tenantId; + row.ClientId = clientId; + // Keep the existing secret if the field was left blank (e.g. editing tenant only). + if (clientSecret != "") row.ClientSecret = protector.Protect(clientSecret); + if (loginInstance != "") row.LoginInstance = loginInstance; + if (baseUrl != "") row.BaseUrl = baseUrl; + row.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + + // Apply over the live singleton so new GraphApiClient instances use it immediately. + var o = opts.Value; + o.TenantId = tenantId; + o.ClientId = clientId; + if (clientSecret != "") o.ClientSecret = clientSecret; + if (loginInstance != "") o.LoginInstance = loginInstance; + if (baseUrl != "") o.BaseUrl = baseUrl; + + await audit.WriteAsync("setup.graph", "settings", "graph", "Graph credentials updated", ct); + + // Test the connection with a fresh client (reads the just-mutated options). + string? testError = null; + try + { + var graph = services.GetRequiredService(); + await graph.GetSinglePageAsync("/v1.0/organization", ct); + } + catch (Exception ex) { testError = ex.Message; } + + return Results.Ok(new { saved = true, testOk = testError is null, testError }); + }).RequireAuthorization("RequireAdmin"); + } +} diff --git a/src/M365SecurityDashboard.Api/M365SecurityDashboard.Api.csproj b/src/M365SecurityDashboard.Api/M365SecurityDashboard.Api.csproj index 9ab196a..e5d4fc4 100644 --- a/src/M365SecurityDashboard.Api/M365SecurityDashboard.Api.csproj +++ b/src/M365SecurityDashboard.Api/M365SecurityDashboard.Api.csproj @@ -5,6 +5,19 @@ enable ..\m365-security-dashboard-client\ m365-security-dashboard-api + + 1.0.0 + + + vigil365.ico + Vigil365 API + Vigil365 + Vigil365 + Vigil365 — self-hosted Microsoft 365 security monitoring service. + Copyright © 2026 Vigil365 @@ -15,7 +28,20 @@ + + + + + + + + + diff --git a/src/M365SecurityDashboard.Api/Models/AlertBaselineRule.cs b/src/M365SecurityDashboard.Api/Models/AlertBaselineRule.cs new file mode 100644 index 0000000..cefea2d --- /dev/null +++ b/src/M365SecurityDashboard.Api/Models/AlertBaselineRule.cs @@ -0,0 +1,28 @@ +namespace M365SecurityDashboard.Api.Models; + +/// +/// Represents a rule in the 20-Rule Enterprise Alerting Baseline catalog. +/// Compares active tenant monitoring against best practices. +/// +public sealed class AlertBaselineRule +{ + public string Id { get; set; } = ""; + public string Title { get; set; } = ""; + public string Category { get; set; } = ""; // identity, devices, email, infrastructure + public string Severity { get; set; } = ""; // critical, high, medium, low + public string Description { get; set; } = ""; + public bool IsActive { get; set; } + public string RuleType { get; set; } = "Vigil365"; // "Vigil365" or "NativeM365" + public string Metric { get; set; } = ""; // For Vigil365 rules + public int DefaultThreshold { get; set; } = 1; // For Vigil365 rules + public string NativePortalBlade { get; set; } = ""; // For NativeM365 rules + public string NativePortalDeepLink { get; set; } = ""; // For NativeM365 rules +} + +public sealed class AlertCoverageScorecard +{ + public int TotalRules { get; set; } + public int ActiveRules { get; set; } + public int CoveragePercentage { get; set; } + public List Rules { get; set; } = new(); +} diff --git a/src/M365SecurityDashboard.Api/Models/AlertNote.cs b/src/M365SecurityDashboard.Api/Models/AlertNote.cs new file mode 100644 index 0000000..d627e58 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Models/AlertNote.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace M365SecurityDashboard.Api.Models; + +/// +/// An analyst note attached to an alert — the "what did we find" record the +/// triage loop needs. Covers both alert kinds via (TargetKind, TargetId): +/// "security" + SecurityAlert.Id, or "policy" + TriggeredAlert.Id. Append-only. +/// +public sealed class AlertNote +{ + public long Id { get; set; } + + /// "security" (collected M365 alert) or "policy" (triggered policy alert). + [MaxLength(20)] + public string TargetKind { get; set; } = ""; + + /// String form of the target's primary key (long or Guid). + [MaxLength(64)] + public string TargetId { get; set; } = ""; + + /// Email of the analyst who wrote the note (from the validated token). + [MaxLength(320)] + public string Author { get; set; } = ""; + + [MaxLength(2000)] + public string Text { get; set; } = ""; + + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/src/M365SecurityDashboard.Api/Models/AlertPolicy.cs b/src/M365SecurityDashboard.Api/Models/AlertPolicy.cs index 178897c..d58d93b 100644 --- a/src/M365SecurityDashboard.Api/Models/AlertPolicy.cs +++ b/src/M365SecurityDashboard.Api/Models/AlertPolicy.cs @@ -21,10 +21,34 @@ public sealed class AlertPolicy [MaxLength(300)] public string Condition { get; set; } = ""; - /// Metric key the engine watches, e.g. "criticalAlertCount". + /// + /// "metric" — fires when a computed metric crosses . + /// "activity" — fires when tenant audit events matching + /// occur ≥ Threshold times within + /// . Activity policies alert on WHAT HAPPENED + /// (role added, app consent granted), not on state counts. + /// + [MaxLength(20)] + public string Kind { get; set; } = "metric"; + + /// Metric key the engine watches, e.g. "criticalAlertCount". (Kind=metric) [MaxLength(60)] public string Metric { get; set; } = ""; + /// Activity name to match, * as wildcard — e.g. "Add member to role", + /// "*conditional access policy". (Kind=activity) + [MaxLength(200)] + public string? ActivityPattern { get; set; } + + /// Sliding window for activity matching. (Kind=activity) + public int WindowMinutes { get; set; } = 60; + + /// How many times above baseline counts as anomalous. (Kind=anomaly) + public double BaselineMultiplier { get; set; } = 3.0; + + /// Baseline lookback window in days. (Kind=anomaly) + public int BaselineDays { get; set; } = 30; + public int Threshold { get; set; } [MaxLength(20)] diff --git a/src/M365SecurityDashboard.Api/Models/ApiToken.cs b/src/M365SecurityDashboard.Api/Models/ApiToken.cs new file mode 100644 index 0000000..b77c96d --- /dev/null +++ b/src/M365SecurityDashboard.Api/Models/ApiToken.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; + +namespace M365SecurityDashboard.Api.Models; + +/// +/// Hashed bearer token for machine integrations such as SIEM collectors. +/// The raw token is returned once at creation time and is never stored. +/// +public sealed class ApiToken +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + [MaxLength(120)] + public string Name { get; set; } = ""; + + [MaxLength(20)] + public string Prefix { get; set; } = ""; + + [MaxLength(128)] + public string TokenHash { get; set; } = ""; + + [MaxLength(400)] + public string Scopes { get; set; } = "alerts:read,health:read"; + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + [MaxLength(320)] + public string? CreatedBy { get; set; } + + public DateTimeOffset? ExpiresAt { get; set; } + + public DateTimeOffset? LastUsedAt { get; set; } + + public DateTimeOffset? RevokedAt { get; set; } +} diff --git a/src/M365SecurityDashboard.Api/Models/AppUser.cs b/src/M365SecurityDashboard.Api/Models/AppUser.cs new file mode 100644 index 0000000..6b9c989 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Models/AppUser.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; + +namespace M365SecurityDashboard.Api.Models; + +/// +/// A user who has signed in to Vigil365, with their assigned role. Identity comes +/// from the validated Microsoft token (email); the role is managed in-app by an +/// Admin, not in Entra ID. This keeps role management self-contained — no Azure +/// App Roles setup and no high-privilege Graph write permission required. +/// +public sealed class AppUser +{ + /// The user's email / UPN from the token. Primary key, stored lower-cased. + [MaxLength(320)] + public required string Email { get; set; } + + /// Display name from the token, for the user-management UI. + [MaxLength(200)] + public string? DisplayName { get; set; } + + /// One of: Admin, Analyst, Viewer. + [MaxLength(20)] + public string Role { get; set; } = AppRoles.Viewer; + + public DateTimeOffset CreatedAt { get; set; } + + /// When the user most recently signed in. + public DateTimeOffset LastSeenAt { get; set; } +} + +/// The three role values. Kept as constants to avoid magic strings. +public static class AppRoles +{ + public const string Admin = "Admin"; + public const string Analyst = "Analyst"; + public const string Viewer = "Viewer"; + + public static readonly string[] All = [Admin, Analyst, Viewer]; + public static bool IsValid(string? role) => role is Admin or Analyst or Viewer; +} diff --git a/src/M365SecurityDashboard.Api/Models/AuditEntry.cs b/src/M365SecurityDashboard.Api/Models/AuditEntry.cs new file mode 100644 index 0000000..7fa379e --- /dev/null +++ b/src/M365SecurityDashboard.Api/Models/AuditEntry.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations; + +namespace M365SecurityDashboard.Api.Models; + +/// +/// An immutable record of a security-relevant action taken in Vigil365 — who did +/// what, to what, and when. Provides the admin/action audit trail expected by +/// SOC 2 / ISO 27001 logging controls. Append-only: rows are never updated. +/// +public sealed class AuditEntry +{ + public long Id { get; set; } + + public DateTimeOffset Timestamp { get; set; } + + /// Email/UPN of the user who performed the action (from the validated token). + [MaxLength(320)] + public string ActorEmail { get; set; } = ""; + + /// Short machine action code, e.g. "user.add", "user.role_change", "alert.resolve". + [MaxLength(60)] + public string Action { get; set; } = ""; + + /// The kind of thing acted on, e.g. "user", "alert", "settings". + [MaxLength(40)] + public string TargetType { get; set; } = ""; + + /// Identifier of the target (email, alert id, etc.). + [MaxLength(320)] + public string? TargetId { get; set; } + + /// Human-readable summary of what changed, e.g. "role Viewer -> Admin". + [MaxLength(500)] + public string? Details { get; set; } + + /// Client IP the request came from (first X-Forwarded-For hop behind a proxy). + [MaxLength(45)] + public string? IpAddress { get; set; } + + /// User-Agent header of the request, truncated. + [MaxLength(300)] + public string? UserAgent { get; set; } + + /// EntryHash of the previous audit row — forms a tamper-evident chain. + [MaxLength(64)] + public string? PrevHash { get; set; } + + /// SHA-256 over this row's fields + PrevHash. Editing or deleting any + /// historical row breaks every later hash, which /verify detects. + [MaxLength(64)] + public string? EntryHash { get; set; } +} diff --git a/src/M365SecurityDashboard.Api/Models/AuditEvent.cs b/src/M365SecurityDashboard.Api/Models/AuditEvent.cs new file mode 100644 index 0000000..edd67a2 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Models/AuditEvent.cs @@ -0,0 +1,53 @@ +using System.ComponentModel.DataAnnotations; + +namespace M365SecurityDashboard.Api.Models; + +/// +/// One tenant audit activity (Entra directory audit record), collected +/// incrementally each cycle. This is the raw material for activity-based +/// alert policies ("user added to privileged role", "app consent granted") — +/// alerting on WHAT HAPPENED rather than on metric counts. +/// +public sealed class AuditEvent +{ + public long Id { get; set; } + + /// Graph record id — dedupe key for incremental collection. + [MaxLength(256)] + public string ExternalId { get; set; } = ""; + + /// Feed the event came from. "directoryAudit" for now; the unified + /// audit log (Exchange/SharePoint activities) is a later source. + [MaxLength(40)] + public string Source { get; set; } = "directoryAudit"; + + /// Graph activityDisplayName, e.g. "Add member to role". + [MaxLength(200)] + public string Activity { get; set; } = ""; + + /// Graph category, e.g. "RoleManagement", "ApplicationManagement". + [MaxLength(80)] + public string? Category { get; set; } + + /// UPN of the initiating user, if a user initiated it. + [MaxLength(320)] + public string? ActorUpn { get; set; } + + /// Display name of the initiating app/service, when not a user. + [MaxLength(200)] + public string? ActorApp { get; set; } + + /// First target resource display name / UPN. + [MaxLength(320)] + public string? TargetName { get; set; } + + /// "success" / "failure" (Graph result). + [MaxLength(20)] + public string? Result { get; set; } + + public DateTimeOffset OccurredAt { get; set; } + public DateTimeOffset CollectedAt { get; set; } + + /// Full Graph record for the detail view. + public string RawJson { get; set; } = "{}"; +} diff --git a/src/M365SecurityDashboard.Api/Models/Enums.cs b/src/M365SecurityDashboard.Api/Models/Enums.cs index 3e76769..d866ded 100644 --- a/src/M365SecurityDashboard.Api/Models/Enums.cs +++ b/src/M365SecurityDashboard.Api/Models/Enums.cs @@ -6,7 +6,8 @@ public enum M365ServiceArea Intune = 2, DefenderXdr = 3, ExchangeOnline = 4, - ServiceHealth = 5 + ServiceHealth = 5, + SharePoint = 6 } public enum AlertSeverity diff --git a/src/M365SecurityDashboard.Api/Models/GraphConfig.cs b/src/M365SecurityDashboard.Api/Models/GraphConfig.cs new file mode 100644 index 0000000..b92d39b --- /dev/null +++ b/src/M365SecurityDashboard.Api/Models/GraphConfig.cs @@ -0,0 +1,19 @@ +namespace M365SecurityDashboard.Api.Models; + +/// +/// Graph credentials entered at runtime via the first-run setup wizard, so an +/// installer never has to hand-edit appsettings. Single row (Id = 1). The client +/// secret is stored DPAPI-encrypted at rest (via SecretProtector); when present, +/// these values are applied over the GraphOptions singleton at startup. +/// +public sealed class GraphConfig +{ + public int Id { get; set; } = 1; + public string TenantId { get; set; } = ""; + public string ClientId { get; set; } = ""; + /// DPAPI-encrypted client secret. Never returned by the API. + public string? ClientSecret { get; set; } + public string? LoginInstance { get; set; } + public string? BaseUrl { get; set; } + public DateTimeOffset UpdatedAt { get; set; } +} diff --git a/src/M365SecurityDashboard.Api/Models/GraphOptions.cs b/src/M365SecurityDashboard.Api/Models/GraphOptions.cs index 4de4d6f..30e5e5d 100644 --- a/src/M365SecurityDashboard.Api/Models/GraphOptions.cs +++ b/src/M365SecurityDashboard.Api/Models/GraphOptions.cs @@ -5,17 +5,36 @@ public sealed class GraphOptions public string TenantId { get; set; } = ""; public string ClientId { get; set; } = ""; public string ClientSecret { get; set; } = ""; + + // ── Certificate auth (preferred over the client secret when configured) ── + /// Thumbprint of a cert in the Windows certificate store + /// (CurrentUser\My, falls back to LocalMachine\My). + public string CertificateThumbprint { get; set; } = ""; + /// Path to a PFX file (alternative to the store thumbprint). + public string CertificatePath { get; set; } = ""; + /// Password for the PFX file, if any. + public string CertificatePassword { get; set; } = ""; + public string BaseUrl { get; set; } = "https://graph.microsoft.com"; + public string LoginInstance { get; set; } = "https://login.microsoftonline.com"; public int CollectionIntervalMinutes { get; set; } = 15; public int DevicesNotCheckedInDays { get; set; } = 7; public int SignInLookbackHours { get; set; } = 24; public string ExchangeQuarantinePath { get; set; } = ""; public string MailFlowIssuesPath { get; set; } = ""; + public bool HasCertificate() => + !string.IsNullOrWhiteSpace(CertificateThumbprint) || + !string.IsNullOrWhiteSpace(CertificatePath); + + public bool HasSecret() => IsRealValue(ClientSecret, "YOUR_APP_CLIENT_SECRET"); + + /// Configured = identity known and at least one credential + /// (certificate preferred, secret as fallback). public bool IsConfigured() => IsRealValue(TenantId, "YOUR_TENANT_ID") && IsRealValue(ClientId, "YOUR_APP_CLIENT_ID") && - IsRealValue(ClientSecret, "YOUR_APP_CLIENT_SECRET"); + (HasCertificate() || HasSecret()); private static bool IsRealValue(string? value, string placeholder) => !string.IsNullOrWhiteSpace(value) && diff --git a/src/M365SecurityDashboard.Api/Models/NotificationSettings.cs b/src/M365SecurityDashboard.Api/Models/NotificationSettings.cs index 62593b5..a7c59e9 100644 --- a/src/M365SecurityDashboard.Api/Models/NotificationSettings.cs +++ b/src/M365SecurityDashboard.Api/Models/NotificationSettings.cs @@ -42,7 +42,36 @@ public sealed class NotificationSettings [MaxLength(2048)] public string? WebhookUrl { get; set; } + /// Optional DPAPI-protected HMAC secret used to sign SIEM webhook payloads. + [MaxLength(512)] + public string? WebhookSigningSecret { get; set; } + /// Only send notifications at or above this severity (low|medium|high|critical). [MaxLength(20)] public string MinSeverity { get; set; } = "low"; + + // ── Digest mode (daily/weekly rollup) ── + // When a channel's digest flag is on, individual alerts are NOT sent instantly + // on that channel; instead they are batched into a single rollup sent at + // DigestHourUtc by the NotificationDigestWorker. + public bool TeamsDigest { get; set; } + public bool EmailDigest { get; set; } + public bool WebhookDigest { get; set; } + + /// Frequency of the digest: "daily" or "weekly" (sent on Monday). + [MaxLength(20)] + public string DigestFrequency { get; set; } = "daily"; + + /// Hour of day (UTC, 0–23) at which the digest rollup is sent. + public int DigestHourUtc { get; set; } = 8; + + /// Watermark: alerts triggered after this instant are pending inclusion in the next digest. + public DateTimeOffset? LastDigestAt { get; set; } + + // ── Delivery-failure alerting ── + /// Raise a delivery-failure alert once a channel reaches this many consecutive failed attempts. + public int FailureAlertThreshold { get; set; } = 3; + + /// When the last delivery-failure alert was raised (debounce so we don't re-alert every cycle). + public DateTimeOffset? LastFailureAlertAt { get; set; } } diff --git a/src/M365SecurityDashboard.Api/Models/ReportSchedule.cs b/src/M365SecurityDashboard.Api/Models/ReportSchedule.cs new file mode 100644 index 0000000..dc9dd78 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Models/ReportSchedule.cs @@ -0,0 +1,98 @@ +using System.ComponentModel.DataAnnotations; + +namespace M365SecurityDashboard.Api.Models; + +/// +/// A recurring scheduled report. Today the only report type is the weekly +/// executive digest (posture + trends + top alerts), delivered by email over +/// the existing SMTP configuration. The +/// checks every 15 minutes and dispatches any schedule whose next run is due. +/// +public sealed class ReportSchedule +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + [MaxLength(120)] + public string Name { get; set; } = "Weekly executive digest"; + + /// Report content type. Currently only "exec-digest". + [MaxLength(40)] + public string ReportType { get; set; } = "exec-digest"; + + /// daily | weekly | monthly + [MaxLength(20)] + public string Cadence { get; set; } = "weekly"; + + /// Day of week to send on for weekly cadence (0=Sunday … 6=Saturday). Ignored for daily. + public int DayOfWeek { get; set; } = 1; // Monday + + /// Day of month to send on for monthly cadence (1–28). Ignored otherwise. + public int DayOfMonth { get; set; } = 1; + + /// Hour of day (UTC, 0–23) at which the report should be sent. + public int HourUtc { get; set; } = 7; + + /// Comma/semicolon-separated recipient addresses. + [MaxLength(2000)] + public string Recipients { get; set; } = ""; + + /// Whether to attach a CSV summary alongside the HTML body. + public bool IncludeCsv { get; set; } = true; + + /// Whether to attach a PDF executive digest alongside the HTML body. + public bool IncludePdf { get; set; } = true; + + public bool Enabled { get; set; } = true; + + public DateTimeOffset? LastRunAt { get; set; } + + /// Outcome of the last dispatch: "sent", "failed: …", or null if never run. + [MaxLength(400)] + public string? LastRunStatus { get; set; } + + [MaxLength(120)] + public string? CreatedBy { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + /// + /// Computes the next UTC send time strictly after , + /// honoring cadence + configured day/hour. Pure function so it is unit-testable. + /// + public DateTimeOffset NextRunAfter(DateTimeOffset after) + { + var hour = Math.Clamp(HourUtc, 0, 23); + // Start from the candidate day at the target hour, then walk forward until + // it both matches the cadence's day constraint and is strictly after `after`. + var candidate = new DateTimeOffset(after.UtcDateTime.Year, after.UtcDateTime.Month, after.UtcDateTime.Day, hour, 0, 0, TimeSpan.Zero); + + for (var i = 0; i < 400; i++) + { + var day = candidate.AddDays(i); + if (day <= after) continue; + switch (Cadence) + { + case "daily": + return day; + case "monthly": + if (day.Day == Math.Clamp(DayOfMonth, 1, 28)) return day; + break; + default: // weekly + if ((int)day.DayOfWeek == ((DayOfWeek % 7) + 7) % 7) return day; + break; + } + } + // Unreachable for valid cadences, but keep a safe fallback. + return candidate.AddDays(1); + } + + /// Whether this schedule is due to run at , given its last run. + public bool IsDue(DateTimeOffset now) + { + if (!Enabled) return false; + // Anchor the "next run" calculation on the later of last-run or creation so a + // freshly created schedule doesn't immediately fire for a time earlier today. + var anchor = LastRunAt ?? CreatedAt.AddSeconds(-1); + return NextRunAfter(anchor) <= now; + } +} diff --git a/src/M365SecurityDashboard.Api/Models/RetentionOptions.cs b/src/M365SecurityDashboard.Api/Models/RetentionOptions.cs new file mode 100644 index 0000000..a302103 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Models/RetentionOptions.cs @@ -0,0 +1,32 @@ +namespace M365SecurityDashboard.Api.Models; + +/// +/// How long each kind of collected/derived data is kept before the nightly +/// pruning job deletes it. Bound from the "Retention" config section. +/// A value of 0 (or negative) disables pruning for that data set. +/// +public sealed class RetentionOptions +{ + /// Resolved security alerts (open alerts are never pruned). + public int ResolvedAlertsDays { get; set; } = 90; + + /// Resolved/auto-resolved triggered alerts (open ones are never pruned). + public int TriggeredAlertsDays { get; set; } = 180; + + /// Notification delivery log rows. + public int NotificationLogsDays { get; set; } = 90; + + /// Collection run history. + public int CollectionRunsDays { get; set; } = 90; + + /// Trend snapshots (drives the Trends page — keep long by default). + public int TrendSnapshotsDays { get; set; } = 365; + + /// Audit entries. Pruning removes the oldest rows; chain verification + /// starts from the first remaining hashed entry, so pruning never "breaks" it. + public int AuditEntriesDays { get; set; } = 365; + + /// Collected tenant audit events (directory audits feeding + /// activity-based alert policies). + public int TenantAuditEventsDays { get; set; } = 90; +} diff --git a/src/M365SecurityDashboard.Api/Models/SecurityAlert.cs b/src/M365SecurityDashboard.Api/Models/SecurityAlert.cs index 3dc362f..cff3f7d 100644 --- a/src/M365SecurityDashboard.Api/Models/SecurityAlert.cs +++ b/src/M365SecurityDashboard.Api/Models/SecurityAlert.cs @@ -34,4 +34,14 @@ public sealed class SecurityAlert public DateTimeOffset LastUpdatedAt { get; set; } public bool IsResolved { get; set; } public string RawJson { get; set; } = "{}"; + + // ── Workbench fields (local triage state — never written back to M365) ── + /// Vigil365 user (email) this alert is assigned to. + [MaxLength(320)] + public string? AssignedTo { get; set; } + + /// Local analyst disposition: reviewed | escalated | false_positive. + /// Null = untriaged. Purely informational; the source alert is untouched. + [MaxLength(30)] + public string? Disposition { get; set; } } diff --git a/src/M365SecurityDashboard.Api/Models/SecurityRecommendation.cs b/src/M365SecurityDashboard.Api/Models/SecurityRecommendation.cs new file mode 100644 index 0000000..c48fe82 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Models/SecurityRecommendation.cs @@ -0,0 +1,18 @@ +namespace M365SecurityDashboard.Api.Models; + +/// +/// Represents an actionable security recommendation derived from live tenant telemetry or posture gaps. +/// Strictly provides read-only guidance and deep links into native Microsoft 365 / Azure portals. +/// +public sealed class SecurityRecommendation +{ + public string Id { get; set; } = ""; + public string Category { get; set; } = ""; // Identity, Devices, Email & Collaboration, Data Protection, Infrastructure + public string Title { get; set; } = ""; + public string Severity { get; set; } = ""; // critical, high, medium, low + public int AffectedCount { get; set; } + public string WhyItMatters { get; set; } = ""; + public List RemediationSteps { get; set; } = new(); + public string PortalBladeName { get; set; } = ""; + public string PortalDeepLink { get; set; } = ""; +} diff --git a/src/M365SecurityDashboard.Api/Models/SuppressionRule.cs b/src/M365SecurityDashboard.Api/Models/SuppressionRule.cs new file mode 100644 index 0000000..ce8b5eb --- /dev/null +++ b/src/M365SecurityDashboard.Api/Models/SuppressionRule.cs @@ -0,0 +1,53 @@ +using System.ComponentModel.DataAnnotations; + +namespace M365SecurityDashboard.Api.Models; + +/// +/// A standing rule that stops an alert from being raised at all — the answer to +/// "this service account trips this policy every night and we know about it". +/// +/// Distinct from snooze, which silences one already-raised alert for a while. +/// A suppression is a durable statement about a class of alerts, so it is +/// audited, attributable (who and why), and optionally time-bounded. +/// +/// Scope is the intersection of the fields that are set: +/// PolicyId set, EntityPattern null -> the whole policy is suppressed +/// both set -> only that policy, for matching entities +/// PolicyId null, EntityPattern set -> that entity, across every policy +/// +public class SuppressionRule +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + /// Policy this rule applies to. Null = all policies. + public Guid? PolicyId { get; set; } + + /// + /// Case-insensitive match against an affected entity (UPN or device name). + /// Supports a single leading and/or trailing '*' wildcard, e.g. + /// "svc-*", "*@contractors.example.com". Null = no entity restriction. + /// + [MaxLength(320)] + public string? EntityPattern { get; set; } + + /// Why this suppression exists. Required — an unexplained + /// suppression is indistinguishable from a bug six months later. + [MaxLength(500)] + public string Reason { get; set; } = ""; + + /// When the rule stops applying. Null = indefinite. + public DateTimeOffset? ExpiresAt { get; set; } + + public bool Enabled { get; set; } = true; + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + [MaxLength(320)] + public string? CreatedBy { get; set; } + + /// Count of alerts this rule has prevented — makes an over-broad + /// rule visible instead of silently swallowing everything. + public int SuppressedCount { get; set; } + + public DateTimeOffset? LastSuppressedAt { get; set; } +} diff --git a/src/M365SecurityDashboard.Api/Models/TrendSnapshot.cs b/src/M365SecurityDashboard.Api/Models/TrendSnapshot.cs new file mode 100644 index 0000000..2bd690f --- /dev/null +++ b/src/M365SecurityDashboard.Api/Models/TrendSnapshot.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace M365SecurityDashboard.Api.Models; + +/// +/// A point-in-time snapshot of key security posture metrics. +/// Captured at the end of each collection cycle for historical trend analysis. +/// +public sealed class TrendSnapshot +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public DateTimeOffset CapturedAt { get; set; } = DateTimeOffset.UtcNow; + + public int RiskyUsersCount { get; set; } + + public double MfaCoveragePct { get; set; } + + public int NonCompliantDevicesCount { get; set; } + + public int CriticalAlertsCount { get; set; } + + public int HighAlertsCount { get; set; } + + public double SecureScorePct { get; set; } + + public int ComplianceIssuesCount { get; set; } +} diff --git a/src/M365SecurityDashboard.Api/Models/TriggeredAlert.cs b/src/M365SecurityDashboard.Api/Models/TriggeredAlert.cs index a23425c..1aa4fde 100644 --- a/src/M365SecurityDashboard.Api/Models/TriggeredAlert.cs +++ b/src/M365SecurityDashboard.Api/Models/TriggeredAlert.cs @@ -37,6 +37,14 @@ public sealed class TriggeredAlert [MaxLength(120)] public string? AcknowledgedBy { get; set; } + /// When the alert was resolved (manually or by auto-resolve). Enables + /// mean-time-to-resolve metrics; null while the alert is open. + public DateTimeOffset? ResolvedAt { get; set; } + + /// Identity of the actor who resolved this alert; "system" for auto-resolve. + [MaxLength(120)] + public string? ResolvedBy { get; set; } + /// Whether outbound notifications were dispatched for this alert. public bool Notified { get; set; } @@ -52,4 +60,11 @@ public sealed class TriggeredAlert /// When the evaluator last inspected this alert (used for diagnostics and the auto-resolve debounce). public DateTimeOffset? LastEvaluatedAt { get; set; } + + /// JSON serialized list of matching SecurityAlert entity rows at trigger time. + public string? AffectedEntities { get; set; } + + /// Vigil365 user (email) this alert is assigned to. + [MaxLength(320)] + public string? AssignedTo { get; set; } } diff --git a/src/M365SecurityDashboard.Api/Program.cs b/src/M365SecurityDashboard.Api/Program.cs index dec3942..559d334 100644 --- a/src/M365SecurityDashboard.Api/Program.cs +++ b/src/M365SecurityDashboard.Api/Program.cs @@ -1,1185 +1,400 @@ using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Endpoints; using M365SecurityDashboard.Api.Models; using M365SecurityDashboard.Api.Services; +using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using Microsoft.Identity.Web; +using Serilog; +using Serilog.Events; +using Serilog.Formatting.Compact; using System.Text.Json; using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); builder.Host.UseWindowsService(); + +// JSON logs preserve correlation IDs and structured fields for Docker, +// journald, Splunk, or Sentinel. Files roll daily and at a size limit so logs +// remain useful without consuming the host disk indefinitely. +var configuredLogPath = builder.Configuration["Logging:File:Path"] ?? "logs/vigil365-.json"; +var logPath = Path.GetFullPath(configuredLogPath, AppContext.BaseDirectory); +Directory.CreateDirectory(Path.GetDirectoryName(logPath)!); +var retainedLogFiles = Math.Max(1, builder.Configuration.GetValue("Logging:File:RetainedFileCountLimit", 14)); +var maxLogFileBytes = Math.Max(1_048_576, builder.Configuration.GetValue("Logging:File:FileSizeLimitBytes", 10 * 1024 * 1024)); + +builder.Host.UseSerilog((context, _, logger) => logger + .MinimumLevel.Information() + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning) + .Enrich.FromLogContext() + .Enrich.WithProperty("Application", "Vigil365") + .Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName) + .WriteTo.Console(new RenderedCompactJsonFormatter()) + .WriteTo.File(new RenderedCompactJsonFormatter(), logPath, + rollingInterval: RollingInterval.Day, + fileSizeLimitBytes: maxLogFileBytes, + rollOnFileSizeLimit: true, + retainedFileCountLimit: retainedLogFiles, + shared: true, + flushToDiskInterval: TimeSpan.FromSeconds(1))); builder.Services.Configure(builder.Configuration.GetSection("Graph")); builder.Services.Configure(builder.Configuration.GetSection("Alerting")); +builder.Services.Configure(builder.Configuration.GetSection("Retention")); + +// ── Authentication & Authorization ────────────────────────────────────────────── +// Validates Entra ID Bearer tokens. The SPA (MSAL) acquires a token for the +// scope api://{clientId}/access_as_user, so the token audience is api://{clientId}. +// AzureAd:Audience in config must match that, or validation fails with 401. +// Role claims ("Admin"/"Analyst"/"Viewer") come from Entra ID App Roles. +builder.Services.AddMicrosoftIdentityWebApiAuthentication(builder.Configuration, "AzureAd"); +// Attaches each user's in-app role (from AppUsers table) as a role claim after +// token validation. Scoped so it can use the request-scoped AppDbContext. +// Roles are memory-cached (short TTL) so hot paths skip the per-request DB lookup. +builder.Services.AddMemoryCache(); +builder.Services.AddScoped(); +builder.Services.AddAuthorization(options => +{ + // Role claims come from RoleClaimsTransformation (AppUsers table). Analyst + // actions are also allowed for Admins. Viewer needs no policy — the fallback + // (any authenticated user) covers read access. + options.AddPolicy("RequireAdmin", p => p.RequireAuthenticatedUser().RequireRole(AppRoles.Admin)); + options.AddPolicy("RequireAnalyst", p => p.RequireAuthenticatedUser().RequireRole(AppRoles.Admin, AppRoles.Analyst)); + // Deny-by-default: every endpoint requires a validated token unless it opts + // out with AllowAnonymous (/health, /api/auth/config, SPA fallback). + options.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); +}); builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); + +// Cross-platform secret encryption key ring. Persisted to disk so secrets survive +// restarts; in Docker, mount DataProtection:KeyPath as a volume. +var keyPath = builder.Configuration["DataProtection:KeyPath"] + ?? Path.Combine(AppContext.BaseDirectory, "keys"); +Directory.CreateDirectory(keyPath); +builder.Services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo(keyPath)) + .SetApplicationName("Vigil365"); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); builder.Services.AddEndpointsApiExplorer(); if (builder.Environment.IsDevelopment()) builder.Services.AddSwaggerGen(); +// CORS origins are config-driven so real deployments (custom hostnames, reverse +// proxies) work without a rebuild; localhost defaults cover dev out of the box. +var corsOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() + ?? ["http://localhost:5000", "http://localhost:5173"]; builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => - policy.WithOrigins("http://localhost:5000", "http://localhost:5173") + policy.WithOrigins(corsOrigins) .AllowAnyHeader() .WithMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")); }); -var app = builder.Build(); - -using (var scope = app.Services.CreateScope()) +// Basic abuse protection: per-client fixed-window limiter on the API. Generous +// enough for the SPA's parallel dashboard fan-out, tight enough to blunt scraping +// or brute-force attempts. 429s include Retry-After via the default handler. +builder.Services.AddRateLimiter(options => { - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.EnsureCreated(); - // EnsureCreated() does not add tables to a pre-existing database, so create - // the alerting tables idempotently for installs that predate this feature. - db.Database.ExecuteSqlRaw(AlertingSchema.EnsureTablesSql); - AlertingSchema.SeedDefaultPolicies(db); -} - -app.UseDefaultFiles(); -app.UseStaticFiles(); -app.UseCors(); - -// Security headers -app.Use(async (ctx, next) => -{ - ctx.Response.Headers["X-Frame-Options"] = "DENY"; - ctx.Response.Headers["X-Content-Type-Options"] = "nosniff"; - ctx.Response.Headers["Referrer-Policy"] = "no-referrer"; - await next(); -}); - -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.MapGet("/api/dashboard/overview", async (AppDbContext db, CancellationToken ct) => -{ - var since = DateTimeOffset.UtcNow.AddDays(-30); - var alerts = db.SecurityAlerts.AsNoTracking().Where(a => !a.IsResolved); - var totalActive = await alerts.CountAsync(ct); - var high = await alerts.CountAsync(a => a.Severity == AlertSeverity.High || a.Severity == AlertSeverity.Critical, ct); - var lastRun = await db.CollectionRuns.AsNoTracking().OrderByDescending(r => r.StartedAt).FirstOrDefaultAsync(ct); - - var byService = await alerts - .GroupBy(a => a.Service) - .Select(g => new { service = g.Key.ToString(), count = g.Count() }) - .OrderByDescending(x => x.count) - .ToListAsync(ct); - - var trends = await db.SecurityAlerts.AsNoTracking() - .Where(a => a.DetectedAt >= since) - .GroupBy(a => new { Date = a.DetectedAt.Date, a.Severity }) - .Select(g => new { date = g.Key.Date, severity = g.Key.Severity.ToString(), count = g.Count() }) - .OrderBy(x => x.date) - .ToListAsync(ct); - - return Results.Ok(new - { - totalActive, - highPriority = high, - lastRun, - byService, - trends, - generatedAt = DateTimeOffset.UtcNow - }); -}); - -app.MapGet("/api/alerts", async ( - AppDbContext db, - string? search, - AlertSeverity? severity, - M365ServiceArea? service, - bool? resolved, - int page, - int pageSize, - CancellationToken ct) => -{ - page = page < 1 ? 1 : page; - pageSize = pageSize is < 1 or > 200 ? 50 : pageSize; - - var query = db.SecurityAlerts.AsNoTracking().AsQueryable(); - if (!string.IsNullOrWhiteSpace(search)) - { - query = query.Where(a => - a.Title.Contains(search) || - (a.UserPrincipalName != null && a.UserPrincipalName.Contains(search)) || - (a.DeviceName != null && a.DeviceName.Contains(search)) || - (a.ExternalId != null && a.ExternalId.Contains(search))); - } - if (severity.HasValue) query = query.Where(a => a.Severity == severity.Value); - if (service.HasValue) query = query.Where(a => a.Service == service.Value); - if (resolved.HasValue) query = query.Where(a => a.IsResolved == resolved.Value); - - var total = await query.CountAsync(ct); - var items = await query.OrderByDescending(a => a.DetectedAt) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToListAsync(ct); - - return Results.Ok(new { total, page, pageSize, items }); -}); - -app.MapGet("/api/collector/runs", async (AppDbContext db, CancellationToken ct) => - await db.CollectionRuns.AsNoTracking().OrderByDescending(r => r.StartedAt).Take(20).ToListAsync(ct)); - -app.MapPost("/api/collector/run", async ( - IServiceProvider services, - Microsoft.Extensions.Options.IOptions options, - CancellationToken ct) => -{ - if (!options.Value.IsConfigured()) - { - return Results.BadRequest(new { error = "Graph credentials are not configured." }); - } - - var collector = services.GetRequiredService(); - var run = await collector.CollectAsync(ct); - return Results.Ok(run); -}); - -// ── New dashboard endpoints ──────────────────────────────────────────────── - -// Secure Score trend (direct Graph call) -app.MapGet("/api/dashboard/securescore", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => -{ - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, currentScore = 0.0, maxScore = 100.0, percentage = 0.0, trend = Array.Empty() }); - try - { - var graph = services.GetRequiredService(); - var items = await graph.GetCollectionAsync("/v1.0/security/secureScores?$top=30", ct); - if (items.Count == 0) - return Results.Ok(new { configured = true, currentScore = 0.0, maxScore = 100.0, percentage = 0.0, trend = Array.Empty() }); - - var latest = items[0]; - var currentScore = latest.TryGetProperty("currentScore", out var cs) && cs.ValueKind == JsonValueKind.Number ? cs.GetDouble() : 0; - var maxScore = latest.TryGetProperty("maxScore", out var ms) && ms.ValueKind == JsonValueKind.Number ? ms.GetDouble() : 100; - if (maxScore == 0) maxScore = 100; - var percentage = Math.Round(currentScore / maxScore * 100, 1); - - var trend = items.Select(s => - { - var sc = s.TryGetProperty("currentScore", out var sv) && sv.ValueKind == JsonValueKind.Number ? sv.GetDouble() : 0; - var mx = s.TryGetProperty("maxScore", out var mv) && mv.ValueKind == JsonValueKind.Number ? mv.GetDouble() : 100; - var dt = s.TryGetProperty("createdDateTime", out var dv) ? dv.GetString() : null; - return new { date = dt != null && dt.Length >= 10 ? dt[..10] : dt, score = sc, maxScore = mx == 0 ? 100 : mx }; - }).Where(x => x.date != null).OrderBy(x => x.date).ToList(); - - return Results.Ok(new { configured = true, currentScore, maxScore, percentage, trend }); - } - catch (Exception ex) - { - return Results.Ok(new { configured = true, error = ex.Message, currentScore = 0.0, maxScore = 100.0, percentage = 0.0, trend = Array.Empty() }); - } -}); - -// Identity summary: MFA from DB + guests & admin activity from Graph -app.MapGet("/api/dashboard/identity", async ( - AppDbContext db, IServiceProvider services, IOptions options, CancellationToken ct) => -{ - // MFA stats from already-collected alerts - var mfaAlerts = await db.SecurityAlerts.AsNoTracking() - .Where(a => a.AlertType == "MfaStatus").ToListAsync(ct); - var mfaRegistered = mfaAlerts.Count(a => a.IsResolved); - var mfaTotal = mfaAlerts.Count; - var mfaPct = mfaTotal > 0 ? Math.Round((double)mfaRegistered / mfaTotal * 100, 1) : 0.0; - - // Sign-in summary from DB - var since24h = DateTimeOffset.UtcNow.AddHours(-24); - var signInAlerts = await db.SecurityAlerts.AsNoTracking() - .Where(a => (a.AlertType == "RiskySignIn" || a.AlertType == "FailedSignIn") && a.DetectedAt >= since24h) - .ToListAsync(ct); - var foreignSignIns = signInAlerts.Where(a => a.AlertType == "RiskySignIn") - .OrderByDescending(a => a.DetectedAt).Take(5) - .Select(a => new { title = a.Title, userPrincipalName = a.UserPrincipalName, detectedAt = a.DetectedAt }) - .ToList(); - - // Risky users from DB - var riskyUsers = await db.SecurityAlerts.AsNoTracking() - .CountAsync(a => a.AlertType == "RiskyUser" && !a.IsResolved, ct); - - // Guest accounts and admin activity from Graph (best-effort, time-boxed). - // These are live Graph calls; under throttling they could otherwise stack - // up 15s retry backoffs and hang the whole request. Cap them so the page - // always returns the (fast) DB-backed data within a few seconds. - int guestTotal = 0; - object[] recentActivity = []; - if (options.Value.IsConfigured()) - { - var graph = services.GetRequiredService(); - using var budget = CancellationTokenSource.CreateLinkedTokenSource(ct); - budget.CancelAfter(TimeSpan.FromSeconds(10)); - var gct = budget.Token; - - try - { - var guests = await graph.GetCollectionAsync( - "/v1.0/users?$filter=userType eq 'Guest'&$select=id,displayName,userPrincipalName&$top=200", gct); - guestTotal = guests.Count; - } - catch { /* permission not granted, or budget elapsed – skip */ } - - try - { - // Single page only — we want the latest 10, not the entire audit - // history. GetCollectionAsync would follow @odata.nextLink through - // every page (thousands of records). - var audits = await graph.GetSinglePageAsync( - "/v1.0/auditLogs/directoryAudits?$top=10&$orderby=activityDateTime desc", gct); - recentActivity = audits.Select(a => (object)new + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.GlobalLimiter = System.Threading.RateLimiting.PartitionedRateLimiter.Create(ctx => + System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter( + ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions { - activityDateTime = a.TryGetProperty("activityDateTime", out var dt) ? dt.GetString() : null, - activityDisplayName = a.TryGetProperty("activityDisplayName", out var n) ? n.GetString() : null, - initiatedByUser = a.TryGetProperty("initiatedBy", out var ib) && - ib.TryGetProperty("user", out var u) && - u.TryGetProperty("userPrincipalName", out var upn) ? upn.GetString() : null, - result = a.TryGetProperty("result", out var r) ? r.GetString() : null - }).ToArray(); - } - catch { /* permission not granted, or budget elapsed – skip */ } - } - - return Results.Ok(new - { - configured = true, - mfa = new { registered = mfaRegistered, total = mfaTotal, percentage = mfaPct }, - guests = new { total = guestTotal, active = guestTotal }, - riskyUsers, - signIns = new - { - total = signInAlerts.Count, - failed = signInAlerts.Count(a => a.AlertType == "FailedSignIn"), - risky = signInAlerts.Count(a => a.AlertType == "RiskySignIn"), - foreign = foreignSignIns.Count - }, - foreignSignIns, - recentAdminActivity = recentActivity - }); + PermitLimit = 300, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + })); }); -// Device compliance summary from DB -app.MapGet("/api/dashboard/devices", async ( - AppDbContext db, IServiceProvider services, IOptions options, CancellationToken ct) => -{ - var deviceAlerts = await db.SecurityAlerts.AsNoTracking() - .Where(a => a.Service == M365ServiceArea.Intune && !a.IsResolved).ToListAsync(ct); - - var nonCompliant = deviceAlerts.Count(a => a.AlertType == "NonCompliantDevice"); - var notCheckedIn = deviceAlerts.Count(a => a.AlertType == "DeviceNotCheckedIn"); - - // Try to get total device count from Graph - int totalDevices = 0; - if (options.Value.IsConfigured()) - { - try - { - var graph = services.GetRequiredService(); - var all = await graph.GetCollectionAsync( - "/v1.0/deviceManagement/managedDevices?$select=id&$top=500", ct); - totalDevices = all.Count; - } - catch { /* skip */ } - } - - var nonCompliantDevices = deviceAlerts - .Where(a => a.AlertType == "NonCompliantDevice") - .OrderByDescending(a => a.LastUpdatedAt).Take(5) - .Select(a => new { a.DeviceName, a.UserPrincipalName, a.Description, a.LastUpdatedAt }) - .ToList(); - - double compliancePct = totalDevices > 0 && totalDevices > nonCompliant - ? Math.Round((double)(totalDevices - nonCompliant) / totalDevices * 100, 1) : 0; - - return Results.Ok(new { nonCompliant, notCheckedIn, totalDevices, compliancePct, nonCompliantDevices }); -}); - -// Service health summary from DB -app.MapGet("/api/dashboard/servicehealth", async (AppDbContext db, CancellationToken ct) => -{ - var issues = await db.SecurityAlerts.AsNoTracking() - .Where(a => a.Service == M365ServiceArea.ServiceHealth && !a.IsResolved) - .OrderByDescending(a => a.DetectedAt).ToListAsync(ct); - - return Results.Ok(new - { - total = issues.Count, - issues = issues.Select(i => new - { - title = i.Title, - description = i.Description, - severity = i.Severity.ToString(), - detectedAt = i.DetectedAt, - portalUrl = i.PortalUrl - }) - }); -}); - -// ── Enterprise feature endpoints ────────────────────────────────────────────── - -// License usage (subscribedSkus) -app.MapGet("/api/dashboard/licenses", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => -{ - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, skus = Array.Empty(), totalPurchased = 0, totalConsumed = 0 }); - try - { - var graph = services.GetRequiredService(); - var skus = await graph.GetCollectionAsync("/v1.0/subscribedSkus", ct); - var result = skus.Select(s => - { - var name = s.TryGetProperty("skuPartNumber", out var n) ? n.GetString() : "Unknown"; - var consumed = s.TryGetProperty("consumedUnits", out var c) && c.ValueKind == JsonValueKind.Number ? c.GetInt32() : 0; - var purchased = s.TryGetProperty("prepaidUnits", out var p) && - p.TryGetProperty("enabled", out var e) && e.ValueKind == JsonValueKind.Number ? e.GetInt32() : 0; - return new { name, consumed, purchased, available = Math.Max(0, purchased - consumed) }; - }).Where(s => s.purchased > 0).ToList(); - return Results.Ok(new { configured = true, skus = result, totalPurchased = result.Sum(s => s.purchased), totalConsumed = result.Sum(s => s.consumed) }); - } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", skus = Array.Empty(), totalPurchased = 0, totalConsumed = 0 }); } -}); - -// Inactive users (last sign-in > 90 days) -app.MapGet("/api/dashboard/inactive-users", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => -{ - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, inactive90Count = 0, neverSignedInCount = 0, totalUsers = 0, inactive90 = Array.Empty(), neverSignedIn = Array.Empty() }); - try - { - var graph = services.GetRequiredService(); - var users = await graph.GetCollectionAsync( - "/v1.0/users?$select=id,displayName,userPrincipalName,signInActivity,accountEnabled,assignedLicenses&$top=200", ct); - var threshold90 = DateTimeOffset.UtcNow.AddDays(-90); - var result = users.Select(u => - { - var upn = u.TryGetProperty("userPrincipalName", out var p) ? p.GetString() : null; - var name = u.TryGetProperty("displayName", out var d) ? d.GetString() : null; - var enabled = !u.TryGetProperty("accountEnabled", out var ae) || ae.GetBoolean(); - DateTimeOffset? lastSignIn = null; - if (u.TryGetProperty("signInActivity", out var sia) && sia.ValueKind == JsonValueKind.Object && - sia.TryGetProperty("lastSignInDateTime", out var lsd) && lsd.ValueKind == JsonValueKind.String && - DateTimeOffset.TryParse(lsd.GetString(), out var dt)) lastSignIn = dt; - var hasLicense = u.TryGetProperty("assignedLicenses", out var al) && al.ValueKind == JsonValueKind.Array && al.GetArrayLength() > 0; - var daysSince = lastSignIn.HasValue ? (int)(DateTimeOffset.UtcNow - lastSignIn.Value).TotalDays : -1; - return new { upn, name, enabled, lastSignIn, hasLicense, daysSince }; - }).Where(u => u.upn != null && !u.upn.Contains("#EXT#") && u.enabled).ToList(); - - var inactive90 = result.Where(u => u.lastSignIn == null || u.lastSignIn < threshold90).OrderBy(u => u.lastSignIn).Take(20).ToList(); - var neverSignedIn = result.Where(u => u.lastSignIn == null).Take(20).ToList(); - return Results.Ok(new { configured = true, inactive90Count = inactive90.Count, neverSignedInCount = neverSignedIn.Count, totalUsers = result.Count, inactive90, neverSignedIn }); - } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", inactive90Count = 0, neverSignedInCount = 0, totalUsers = 0, inactive90 = Array.Empty(), neverSignedIn = Array.Empty() }); } -}); +var app = builder.Build(); -// Password expiry -app.MapGet("/api/dashboard/password-expiry", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => +using (var scope = app.Services.CreateScope()) { - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, expiringSoonCount = 0, expiredCount = 0, neverExpiresCount = 0, totalUsers = 0, expiringSoon = Array.Empty(), expired = Array.Empty(), neverExpire = Array.Empty() }); - try - { - var graph = services.GetRequiredService(); - var users = await graph.GetCollectionAsync( - "/v1.0/users?$select=id,displayName,userPrincipalName,passwordPolicies,lastPasswordChangeDateTime,accountEnabled&$top=200", ct); - var now = DateTimeOffset.UtcNow; - var result = users.Select(u => - { - var upn = u.TryGetProperty("userPrincipalName", out var p) ? p.GetString() : null; - var name = u.TryGetProperty("displayName", out var d) ? d.GetString() : null; - var enabled = !u.TryGetProperty("accountEnabled", out var ae) || ae.GetBoolean(); - var policies = u.TryGetProperty("passwordPolicies", out var pp) ? pp.GetString() : null; - var neverExpires = policies != null && policies.Contains("DisablePasswordExpiration"); - DateTimeOffset? lastChanged = null; - if (u.TryGetProperty("lastPasswordChangeDateTime", out var lcd) && lcd.ValueKind == JsonValueKind.String && - DateTimeOffset.TryParse(lcd.GetString(), out var dt)) lastChanged = dt; - var daysSinceChange = lastChanged.HasValue ? (int)(now - lastChanged.Value).TotalDays : -1; - var daysUntilExpiry = neverExpires || daysSinceChange < 0 ? -1 : 90 - daysSinceChange; - return new { upn, name, enabled, neverExpires, lastChanged, daysSinceChange, daysUntilExpiry }; - }).Where(u => u.upn != null && !u.upn.Contains("#EXT#") && u.enabled).ToList(); - - var expiringSoon = result.Where(u => !u.neverExpires && u.daysUntilExpiry >= 0 && u.daysUntilExpiry <= 14).OrderBy(u => u.daysUntilExpiry).Take(20).ToList(); - var expired = result.Where(u => !u.neverExpires && u.daysUntilExpiry < 0 && u.lastChanged.HasValue).Take(20).ToList(); - var neverExpire = result.Where(u => u.neverExpires).Take(10).ToList(); - return Results.Ok(new { configured = true, expiringSoonCount = expiringSoon.Count, expiredCount = expired.Count, neverExpiresCount = neverExpire.Count, totalUsers = result.Count, expiringSoon, expired, neverExpire }); - } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", expiringSoonCount = 0, expiredCount = 0, neverExpiresCount = 0, totalUsers = 0, expiringSoon = Array.Empty(), expired = Array.Empty(), neverExpire = Array.Empty() }); } -}); + var db = scope.ServiceProvider.GetRequiredService(); -// Conditional Access policies -app.MapGet("/api/dashboard/conditional-access", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => -{ - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, enabled = 0, disabled = 0, reportOnly = 0, policies = Array.Empty() }); - try + // Versioned schema via EF migrations. Wait for the database server — in + // Docker the SQL container may still be starting. Retry for up to ~60s. + // + // Installs created before migrations existed (EnsureCreated + raw DDL) are + // BASELINED: their schema is first brought fully up to the current model by + // the idempotent legacy DDL, then InitialCreate is recorded as applied + // without running. Newer migrations then apply normally on every start. + var dbLog = app.Services.GetRequiredService>(); + for (var attempt = 1; ; attempt++) { - var graph = services.GetRequiredService(); - var policies = await graph.GetCollectionAsync("/v1.0/identity/conditionalAccess/policies", ct); - var result = policies.Select(p => + try { - var name = p.TryGetProperty("displayName", out var n) ? n.GetString() : "Unnamed"; - var state = p.TryGetProperty("state", out var s) ? s.GetString() : "unknown"; - var inclUsers = "All Users"; var exclUsers = "None"; var apps = "All Apps"; - if (p.TryGetProperty("conditions", out var cond)) + if (db.Database.CanConnect() && !db.Database.GetAppliedMigrations().Any()) { - if (cond.TryGetProperty("users", out var u)) + var isLegacyDb = db.Database + .SqlQueryRaw("SELECT CASE WHEN OBJECT_ID(N'[SecurityAlerts]', N'U') IS NOT NULL THEN 1 ELSE 0 END AS [Value]") + .AsEnumerable().First() == 1; + if (isLegacyDb) { - if (u.TryGetProperty("includeUsers", out var inc) && inc.ValueKind == JsonValueKind.Array) - inclUsers = inc.EnumerateArray().Select(x => x.GetString()).FirstOrDefault() == "All" ? "All Users" : $"{inc.GetArrayLength()} users"; - if (u.TryGetProperty("excludeUsers", out var exc) && exc.ValueKind == JsonValueKind.Array && exc.GetArrayLength() > 0) - exclUsers = $"{exc.GetArrayLength()} excluded"; - if (u.TryGetProperty("includeGroups", out var grp) && grp.ValueKind == JsonValueKind.Array && grp.GetArrayLength() > 0 && inclUsers == "All Users") - inclUsers = $"{grp.GetArrayLength()} groups"; + // Older installs may be missing later idempotent patches — + // apply them all so the DB matches the model we baseline to. + db.Database.ExecuteSqlRaw(AlertingSchema.EnsureTablesSql); + var baseline = db.Database.GetMigrations().First(); + db.Database.ExecuteSqlRaw(""" + IF OBJECT_ID(N'[__EFMigrationsHistory]', N'U') IS NULL + CREATE TABLE [__EFMigrationsHistory] ( + [MigrationId] nvarchar(150) NOT NULL CONSTRAINT [PK___EFMigrationsHistory] PRIMARY KEY, + [ProductVersion] nvarchar(32) NOT NULL); + """); + db.Database.ExecuteSql($""" + IF NOT EXISTS (SELECT 1 FROM [__EFMigrationsHistory] WHERE [MigrationId] = {baseline}) + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) VALUES ({baseline}, '8.0.11'); + """); + dbLog.LogInformation("Baselined pre-migration database at {Migration}.", baseline); } - if (cond.TryGetProperty("applications", out var ap) && ap.TryGetProperty("includeApplications", out var incA) && incA.ValueKind == JsonValueKind.Array) - apps = incA.EnumerateArray().Select(x => x.GetString()).FirstOrDefault() == "All" ? "All Apps" : $"{incA.GetArrayLength()} apps"; } - var controls = new List(); - if (p.TryGetProperty("grantControls", out var gc) && gc.ValueKind == JsonValueKind.Object && - gc.TryGetProperty("builtInControls", out var bic) && bic.ValueKind == JsonValueKind.Array) - controls.AddRange(bic.EnumerateArray().Select(x => x.GetString() ?? "").Where(x => x.Length > 0)); - return new { name, state, inclUsers, exclUsers, apps, controls = controls.ToArray() }; - }).ToList(); - return Results.Ok(new { configured = true, enabled = result.Count(p => p.state == "enabled"), disabled = result.Count(p => p.state == "disabled"), reportOnly = result.Count(p => p.state == "enabledForReportingButNotEnforced"), policies = result }); - } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", enabled = 0, disabled = 0, reportOnly = 0, policies = Array.Empty() }); } -}); - -// Admin audit log -app.MapGet("/api/dashboard/audit-log", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => -{ - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, total = 0, failures = 0, events = Array.Empty() }); - try - { - var graph = services.GetRequiredService(); - var audits = await graph.GetSinglePageAsync( - "/v1.0/auditLogs/directoryAudits?$top=50&$orderby=activityDateTime desc", ct); - var events = audits.Select(a => new + db.Database.Migrate(); + break; + } + catch (Exception ex) when (attempt < 30) { - activityDateTime = a.TryGetProperty("activityDateTime", out var dt) ? dt.GetString() : null, - activityDisplayName = a.TryGetProperty("activityDisplayName", out var n) ? n.GetString() : null, - category = a.TryGetProperty("category", out var cat) ? cat.GetString() : null, - result = a.TryGetProperty("result", out var r) ? r.GetString() : null, - resultReason = a.TryGetProperty("resultReason", out var rr) && rr.ValueKind == JsonValueKind.String ? rr.GetString() : null, - initiatedByUser = a.TryGetProperty("initiatedBy", out var ib) && ib.TryGetProperty("user", out var u) && u.ValueKind == JsonValueKind.Object && u.TryGetProperty("userPrincipalName", out var upn) ? upn.GetString() : null, - targetResources = a.TryGetProperty("targetResources", out var tr) && tr.ValueKind == JsonValueKind.Array - ? tr.EnumerateArray().Take(2).Select(t => t.TryGetProperty("displayName", out var dn) ? dn.GetString() : null).OfType().ToArray() - : Array.Empty() - }).ToList(); - return Results.Ok(new { configured = true, total = events.Count, failures = events.Count(e => e.result == "failure"), events }); + dbLog.LogWarning("Database not ready (attempt {Attempt}): {Message}. Retrying in 2s…", attempt, ex.Message); + Thread.Sleep(2000); + } } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", total = 0, failures = 0, events = Array.Empty() }); } -}); -// Sign-in locations -app.MapGet("/api/dashboard/signin-locations", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => -{ - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, total = 0, countries = 0, failures = 0, byCountry = Array.Empty(), recent = Array.Empty() }); - try - { - var graph = services.GetRequiredService(); - // Single page only — the latest 100 sign-ins for the location map. - // GetCollectionAsync would paginate through the entire sign-in history. - var signIns = await graph.GetSinglePageAsync( - "/v1.0/auditLogs/signIns?$top=100&$select=location,userPrincipalName,createdDateTime,status,appDisplayName&$orderby=createdDateTime desc", ct); - var result = signIns.Select(s => - { - var upn = s.TryGetProperty("userPrincipalName", out var p) ? p.GetString() : null; - var appName = s.TryGetProperty("appDisplayName", out var a) ? a.GetString() : null; - var created = s.TryGetProperty("createdDateTime", out var cd) ? cd.GetString() : null; - string? city = null, country = null; - if (s.TryGetProperty("location", out var loc) && loc.ValueKind == JsonValueKind.Object) - { - if (loc.TryGetProperty("city", out var cv)) city = cv.GetString(); - if (loc.TryGetProperty("countryOrRegion", out var cov)) country = cov.GetString(); - } - var success = s.TryGetProperty("status", out var st) && st.ValueKind == JsonValueKind.Object && - st.TryGetProperty("errorCode", out var ec) && ec.ValueKind == JsonValueKind.Number && ec.GetInt32() == 0; - return new { upn, app = appName, created, city, country, success }; - }).ToList(); - var byCountry = result.Where(s => s.country != null) - .GroupBy(s => s.country!) - .Select(g => new { country = g.Key, count = g.Count(), failures = g.Count(s => !s.success) }) - .OrderByDescending(g => g.count).Take(15).ToList(); - return Results.Ok(new { configured = true, total = result.Count, countries = byCountry.Count, failures = result.Count(s => !s.success), byCountry, recent = result.Take(20).ToList() }); - } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", total = 0, countries = 0, failures = 0, byCountry = Array.Empty(), recent = Array.Empty() }); } -}); + AlertingSchema.SeedDefaultPolicies(db); -// Unified Defender alerts (alerts_v2 — all products) -app.MapGet("/api/dashboard/defender-alerts", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => -{ - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, total = 0, alerts = Array.Empty() }); - try + // Apply Graph credentials saved via the setup wizard over the GraphOptions + // singleton. Because IOptions.Value is a singleton, mutating it + // here makes every consumer (and IsConfigured()) see the wizard-entered values + // without any config file. DB values win over appsettings when present. + // Loaded BEFORE any demo seeding so a configured install never gets sample data. + var graphOpts = scope.ServiceProvider.GetRequiredService>().Value; + var protector = scope.ServiceProvider.GetRequiredService(); + var saved = db.GraphConfig.OrderBy(g => g.Id).FirstOrDefault(); + if (saved is not null && !string.IsNullOrWhiteSpace(saved.TenantId)) { - var graph = services.GetRequiredService(); - var items = await graph.GetCollectionAsync( - "/v1.0/security/alerts_v2?$top=100&$filter=status ne 'resolved'&$orderby=createdDateTime desc", ct); - - var alerts = items.Select(a => new - { - id = a.TryGetProperty("id", out var id) ? id.GetString() : null, - title = a.TryGetProperty("title", out var t) ? t.GetString() : null, - description = a.TryGetProperty("description", out var d) ? d.GetString() : null, - severity = a.TryGetProperty("severity", out var s) ? s.GetString() : "unknown", - status = a.TryGetProperty("status", out var st) ? st.GetString() : "unknown", - classification = a.TryGetProperty("classification", out var cl) ? cl.GetString() : null, - serviceSource = a.TryGetProperty("serviceSource", out var ss) ? ss.GetString() : null, - detectionSource = a.TryGetProperty("detectionSource", out var ds) ? ds.GetString() : null, - category = a.TryGetProperty("category", out var cat) ? cat.GetString() : null, - createdDateTime = a.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null, - lastUpdateDateTime = a.TryGetProperty("lastUpdateDateTime", out var lu) ? lu.GetString() : null, - assignedTo = a.TryGetProperty("assignedTo", out var at) && at.ValueKind == JsonValueKind.String ? at.GetString() : null, - alertWebUrl = a.TryGetProperty("alertWebUrl", out var url) ? url.GetString() : null, - incidentId = a.TryGetProperty("incidentId", out var inc) ? inc.GetString() : null, - mitreTechniques = a.TryGetProperty("mitreTechniques", out var mt) && mt.ValueKind == JsonValueKind.Array - ? mt.EnumerateArray().Select(x => x.GetString()).OfType().ToArray() - : Array.Empty(), - recommendedActions = a.TryGetProperty("recommendedActions", out var ra) && ra.ValueKind == JsonValueKind.String ? ra.GetString() : null, - actorDisplayName = a.TryGetProperty("actorDisplayName", out var actor) && actor.ValueKind == JsonValueKind.String ? actor.GetString() : null, - threatDisplayName = a.TryGetProperty("threatDisplayName", out var threat) && threat.ValueKind == JsonValueKind.String ? threat.GetString() : null, - }).ToList(); - - var bySeverity = alerts.GroupBy(a => a.severity ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); - var bySource = alerts.GroupBy(a => a.serviceSource ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); - return Results.Ok(new { configured = true, total = alerts.Count, bySeverity, bySource, alerts }); + graphOpts.TenantId = saved.TenantId; + graphOpts.ClientId = saved.ClientId; + var secret = protector.Unprotect(saved.ClientSecret); + if (!string.IsNullOrWhiteSpace(secret)) graphOpts.ClientSecret = secret; } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", total = 0, alerts = Array.Empty() }); } -}); -// Security incidents (grouped correlated alerts) -app.MapGet("/api/dashboard/security-incidents", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => -{ - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, total = 0, incidents = Array.Empty() }); - try + if (graphOpts.IsConfigured()) { - var graph = services.GetRequiredService(); - var items = await graph.GetCollectionAsync( - "/v1.0/security/incidents?$top=50&$filter=status eq 'active'&$orderby=createdDateTime desc", ct); - - var incidents = items.Select(i => new - { - id = i.TryGetProperty("id", out var id) ? id.GetString() : null, - displayName = i.TryGetProperty("displayName", out var n) ? n.GetString() : null, - severity = i.TryGetProperty("severity", out var s) ? s.GetString() : "unknown", - status = i.TryGetProperty("status", out var st) ? st.GetString() : "unknown", - classification = i.TryGetProperty("classification", out var cl) ? cl.GetString() : null, - createdDateTime = i.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null, - lastUpdateDateTime = i.TryGetProperty("lastUpdateDateTime", out var lu) ? lu.GetString() : null, - assignedTo = i.TryGetProperty("assignedTo", out var at) && at.ValueKind == JsonValueKind.String ? at.GetString() : null, - incidentWebUrl = i.TryGetProperty("incidentWebUrl", out var url) ? url.GetString() : null, - customTags = i.TryGetProperty("customTags", out var tags) && tags.ValueKind == JsonValueKind.Array - ? tags.EnumerateArray().Select(x => x.GetString()).OfType().ToArray() - : Array.Empty(), - description = i.TryGetProperty("description", out var desc) && desc.ValueKind == JsonValueKind.String ? desc.GetString() : null, - recommendedActions = i.TryGetProperty("recommendedActions", out var ra) && ra.ValueKind == JsonValueKind.String ? ra.GetString() : null, - }).ToList(); - - var bySeverity = incidents.GroupBy(i => i.severity ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); - return Results.Ok(new { configured = true, total = incidents.Count, bySeverity, incidents }); + // One-time cleanup: purge demo/sample alerts (identified by the seed + // ExternalId prefixes) so they never commingle with real tenant data. + // Installs that seeded before configuring Graph carry these forever + // otherwise — the collector never matches their ExternalIds. + var purged = db.SecurityAlerts + .Where(a => a.ExternalId != null && ( + a.ExternalId.StartsWith("def-crit-") || a.ExternalId.StartsWith("def-high-") || + a.ExternalId.StartsWith("def-med-") || a.ExternalId.StartsWith("entra-risk-") || + a.ExternalId.StartsWith("entra-signin-") || a.ExternalId.StartsWith("intune-nc-") || + a.ExternalId.StartsWith("intune-nia-") || a.ExternalId.StartsWith("mfa-ok-") || + a.ExternalId.StartsWith("mfa-miss-"))) + .ExecuteDelete(); + if (purged > 0) + dbLog.LogInformation("Purged {Count} demo/sample alerts now that Graph is configured.", purged); } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", total = 0, incidents = Array.Empty() }); } -}); - -// Privileged roles -app.MapGet("/api/dashboard/privileged-roles", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => -{ - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, roles = Array.Empty(), totalPrivilegedUsers = 0 }); - try + else if (builder.Configuration.GetValue("Seed:DemoData", false) && !db.SecurityAlerts.Any()) { - var graph = services.GetRequiredService(); - var highPriv = new HashSet(StringComparer.OrdinalIgnoreCase) + db.CollectionRuns.Add(new CollectionRun { - "Global Administrator", "Security Administrator", "Compliance Administrator", - "SharePoint Administrator", "Exchange Administrator", "User Administrator", - "Privileged Role Administrator", "Global Reader", "Billing Administrator" - }; - var directoryRoles = await graph.GetCollectionAsync("/v1.0/directoryRoles", ct); - var roles = new List(); - var totalPrivilegedUsers = 0; - foreach (var role in directoryRoles) + StartedAt = DateTimeOffset.UtcNow.AddMinutes(-15), + CompletedAt = DateTimeOffset.UtcNow.AddMinutes(-14), + Status = CollectionStatus.Completed, + AlertsUpserted = 14 + }); + + // Defender XDR Alerts + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = "def-crit-1", AlertType = "ImpossibleTravel", Service = M365ServiceArea.DefenderXdr, Severity = AlertSeverity.Critical, Title = "Impossible travel detected for executive user", Description = "User signed in from two distant geographical locations within 45 minutes.", UserPrincipalName = "sarah.connor@vigil365.local", DetectedAt = DateTimeOffset.UtcNow.AddHours(-1), LastUpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-30) }); + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = "def-crit-2", AlertType = "SuspiciousExecution", Service = M365ServiceArea.DefenderXdr, Severity = AlertSeverity.Critical, Title = "Suspicious PowerShell command execution detected", Description = "Encoded command executed to dump process memory.", DeviceName = "SEC-WORKSTATION-04", DetectedAt = DateTimeOffset.UtcNow.AddHours(-2), LastUpdatedAt = DateTimeOffset.UtcNow.AddHours(-1) }); + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = "def-crit-3", AlertType = "DataExfiltration", Service = M365ServiceArea.DefenderXdr, Severity = AlertSeverity.Critical, Title = "Mass SharePoint file exfiltration observed", Description = "Over 1,500 sensitive files downloaded by user in 10 minutes.", UserPrincipalName = "alexw@vigil365.local", DetectedAt = DateTimeOffset.UtcNow.AddHours(-3), LastUpdatedAt = DateTimeOffset.UtcNow.AddHours(-2) }); + + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = "def-high-1", AlertType = "MailboxPersistence", Service = M365ServiceArea.DefenderXdr, Severity = AlertSeverity.High, Title = "Malicious inbox forwarding rule created", Description = "Rule created to forward incoming finance emails to external domain.", UserPrincipalName = "finance.lead@vigil365.local", DetectedAt = DateTimeOffset.UtcNow.AddHours(-4), LastUpdatedAt = DateTimeOffset.UtcNow.AddHours(-3) }); + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = "def-high-2", AlertType = "PhishingCampaign", Service = M365ServiceArea.DefenderXdr, Severity = AlertSeverity.High, Title = "Credential harvesting phishing campaign blocked", Description = "Multiple inbound phishing messages intercepted by Defender for Office 365.", DetectedAt = DateTimeOffset.UtcNow.AddHours(-5), LastUpdatedAt = DateTimeOffset.UtcNow.AddHours(-4) }); + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = "def-high-3", AlertType = "AnomalousGrant", Service = M365ServiceArea.DefenderXdr, Severity = AlertSeverity.High, Title = "Anomalous OAuth app consent grant", Description = "User granted Mail.Read permissions to unverified multi-tenant application.", UserPrincipalName = "john.doe@vigil365.local", DetectedAt = DateTimeOffset.UtcNow.AddHours(-6), LastUpdatedAt = DateTimeOffset.UtcNow.AddHours(-5) }); + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = "def-high-4", AlertType = "BruteForce", Service = M365ServiceArea.DefenderXdr, Severity = AlertSeverity.High, Title = "Potential password spray attack against tenant", Description = "Over 300 failed login attempts across 45 user accounts from single AS.", DetectedAt = DateTimeOffset.UtcNow.AddHours(-8), LastUpdatedAt = DateTimeOffset.UtcNow.AddHours(-7) }); + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = "def-high-5", AlertType = "UnfamiliarSignIn", Service = M365ServiceArea.DefenderXdr, Severity = AlertSeverity.High, Title = "Sign-in from unfamiliar properties", Description = "First time sign-in from new OS and ISP.", UserPrincipalName = "jane.smith@vigil365.local", DetectedAt = DateTimeOffset.UtcNow.AddHours(-9), LastUpdatedAt = DateTimeOffset.UtcNow.AddHours(-8) }); + + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = "def-med-1", AlertType = "SuspiciousExtension", Service = M365ServiceArea.DefenderXdr, Severity = AlertSeverity.Medium, Title = "Suspicious browser extension installed", DeviceName = "DEV-LAPTOP-12", DetectedAt = DateTimeOffset.UtcNow.AddHours(-10), LastUpdatedAt = DateTimeOffset.UtcNow.AddHours(-9) }); + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = "def-med-2", AlertType = "LegacyAuth", Service = M365ServiceArea.DefenderXdr, Severity = AlertSeverity.Medium, Title = "Legacy authentication protocol detected", UserPrincipalName = "old.svc@vigil365.local", DetectedAt = DateTimeOffset.UtcNow.AddHours(-11), LastUpdatedAt = DateTimeOffset.UtcNow.AddHours(-10) }); + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = "def-med-3", AlertType = "NetworkScan", Service = M365ServiceArea.DefenderXdr, Severity = AlertSeverity.Medium, Title = "Internal port scanning activity detected", DeviceName = "FIN-PC-09", DetectedAt = DateTimeOffset.UtcNow.AddHours(-12), LastUpdatedAt = DateTimeOffset.UtcNow.AddHours(-11) }); + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = "def-med-4", AlertType = "AutoInvestigate", Service = M365ServiceArea.DefenderXdr, Severity = AlertSeverity.Medium, Title = "Automated investigation pending approval", DeviceName = "HR-TABLET-03", DetectedAt = DateTimeOffset.UtcNow.AddHours(-14), LastUpdatedAt = DateTimeOffset.UtcNow.AddHours(-13) }); + + // Entra ID Alerts + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = "entra-risk-1", AlertType = "RiskyUser", Service = M365ServiceArea.EntraId, Severity = AlertSeverity.High, Title = "Risky user detected: Leaked credentials", UserPrincipalName = "sarah.connor@vigil365.local", DetectedAt = DateTimeOffset.UtcNow.AddHours(-2), LastUpdatedAt = DateTimeOffset.UtcNow.AddHours(-1) }); + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = "entra-signin-1", AlertType = "RiskySignIn", Service = M365ServiceArea.EntraId, Severity = AlertSeverity.Medium, Title = "Sign-in from anonymous VPN proxy", UserPrincipalName = "alexw@vigil365.local", DetectedAt = DateTimeOffset.UtcNow.AddHours(-3), LastUpdatedAt = DateTimeOffset.UtcNow.AddHours(-2) }); + + // Intune Alerts + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = "intune-nc-1", AlertType = "NonCompliantDevice", Service = M365ServiceArea.Intune, Severity = AlertSeverity.Medium, Title = "Non-compliant device: BitLocker encryption inactive", DeviceName = "DEV-LAPTOP-12", UserPrincipalName = "john.doe@vigil365.local", DetectedAt = DateTimeOffset.UtcNow.AddHours(-4), LastUpdatedAt = DateTimeOffset.UtcNow.AddHours(-3) }); + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = "intune-nc-2", AlertType = "NonCompliantDevice", Service = M365ServiceArea.Intune, Severity = AlertSeverity.Medium, Title = "Non-compliant device: Minimum OS build requirement failed", DeviceName = "HR-TABLET-03", UserPrincipalName = "jane.smith@vigil365.local", DetectedAt = DateTimeOffset.UtcNow.AddHours(-6), LastUpdatedAt = DateTimeOffset.UtcNow.AddHours(-5) }); + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = "intune-nc-3", AlertType = "NonCompliantDevice", Service = M365ServiceArea.Intune, Severity = AlertSeverity.Medium, Title = "Non-compliant device: Real-time protection disabled", DeviceName = "FIN-PC-09", UserPrincipalName = "finance.lead@vigil365.local", DetectedAt = DateTimeOffset.UtcNow.AddHours(-8), LastUpdatedAt = DateTimeOffset.UtcNow.AddHours(-7) }); + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = "intune-nia-1", AlertType = "DeviceNotCheckedIn", Service = M365ServiceArea.Intune, Severity = AlertSeverity.Low, Title = "Device not checked in for 14 days", DeviceName = "OLD-LAPTOP-01", DetectedAt = DateTimeOffset.UtcNow.AddDays(-2), LastUpdatedAt = DateTimeOffset.UtcNow.AddDays(-1) }); + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = "intune-nia-2", AlertType = "DeviceNotCheckedIn", Service = M365ServiceArea.Intune, Severity = AlertSeverity.Low, Title = "Device not checked in for 21 days", DeviceName = "TEMP-DESKTOP-02", DetectedAt = DateTimeOffset.UtcNow.AddDays(-3), LastUpdatedAt = DateTimeOffset.UtcNow.AddDays(-2) }); + + // MFA Status Alerts (242 registered, 15 missing) + for (int i = 0; i < 242; i++) { - var roleName = role.TryGetProperty("displayName", out var dn) ? dn.GetString() : null; - if (roleName == null || !highPriv.Contains(roleName)) continue; - var roleId = role.TryGetProperty("id", out var id) ? id.GetString() : null; - var members = new List(); - try - { - if (roleId != null) - { - var memberItems = await graph.GetCollectionAsync($"/v1.0/directoryRoles/{roleId}/members?$select=displayName,userPrincipalName", ct); - members = memberItems.Select(m => (object)new - { - displayName = m.TryGetProperty("displayName", out var md) ? md.GetString() : null, - userPrincipalName = m.TryGetProperty("userPrincipalName", out var mu) ? mu.GetString() : null - }).ToList(); - } - } - catch { /* 403 or per-role failure — leave members empty */ } - totalPrivilegedUsers += members.Count; - roles.Add(new { roleId, roleName, memberCount = members.Count, members }); + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = $"mfa-ok-{i}", AlertType = "MfaStatus", Service = M365ServiceArea.EntraId, Severity = AlertSeverity.Informational, Title = "MFA Registered", UserPrincipalName = $"user{i}@vigil365.local", DetectedAt = DateTimeOffset.UtcNow.AddDays(-1), LastUpdatedAt = DateTimeOffset.UtcNow, IsResolved = true }); } - return Results.Ok(new { configured = true, roles, totalPrivilegedUsers }); - } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", roles = Array.Empty(), totalPrivilegedUsers = 0 }); } -}); - -// DLP alerts -app.MapGet("/api/dashboard/dlp-alerts", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => -{ - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, total = 0, alerts = Array.Empty() }); - try - { - var graph = services.GetRequiredService(); - var items = await graph.GetCollectionAsync( - "/v1.0/security/alerts_v2?$top=50&$orderby=createdDateTime desc&$filter=category eq 'DataLossPrevention'", ct); - var alerts = items.Select(a => new - { - id = a.TryGetProperty("id", out var id) ? id.GetString() : null, - title = a.TryGetProperty("title", out var t) ? t.GetString() : null, - severity = a.TryGetProperty("severity", out var s) ? s.GetString() : "unknown", - status = a.TryGetProperty("status", out var st) ? st.GetString() : "unknown", - category = a.TryGetProperty("category", out var cat) ? cat.GetString() : null, - serviceSource = a.TryGetProperty("serviceSource", out var ss) ? ss.GetString() : null, - createdDateTime = a.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null, - description = a.TryGetProperty("description", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null, - alertWebUrl = a.TryGetProperty("alertWebUrl", out var url) ? url.GetString() : null, - }).ToList(); - var bySeverity = alerts.GroupBy(a => a.severity ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); - var bySource = alerts.GroupBy(a => a.serviceSource ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); - return Results.Ok(new { configured = true, total = alerts.Count, bySeverity, bySource, alerts }); - } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", total = 0, alerts = Array.Empty() }); } -}); - -// MDE vulnerabilities / endpoint alerts -app.MapGet("/api/dashboard/mde-vulnerabilities", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => -{ - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, total = 0, alerts = Array.Empty() }); - try - { - var graph = services.GetRequiredService(); - var items = await graph.GetCollectionAsync( - "/v1.0/security/alerts_v2?$top=50&$filter=serviceSource eq 'microsoftDefenderForEndpoint'&$orderby=createdDateTime desc", ct); - var alerts = items.Select(a => new + for (int i = 0; i < 15; i++) { - id = a.TryGetProperty("id", out var id) ? id.GetString() : null, - title = a.TryGetProperty("title", out var t) ? t.GetString() : null, - severity = a.TryGetProperty("severity", out var s) ? s.GetString() : "unknown", - status = a.TryGetProperty("status", out var st) ? st.GetString() : "unknown", - category = a.TryGetProperty("category", out var cat) ? cat.GetString() : null, - createdDateTime = a.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null, - description = a.TryGetProperty("description", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null, - alertWebUrl = a.TryGetProperty("alertWebUrl", out var url) ? url.GetString() : null, - mitreTechniques = a.TryGetProperty("mitreTechniques", out var mt) && mt.ValueKind == JsonValueKind.Array - ? mt.EnumerateArray().Select(x => x.GetString()).OfType().ToArray() - : Array.Empty(), - }).ToList(); - var bySeverity = alerts.GroupBy(a => a.severity ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); - var byCategory = alerts.GroupBy(a => a.category ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); - return Results.Ok(new { configured = true, total = alerts.Count, bySeverity, byCategory, alerts }); - } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", total = 0, alerts = Array.Empty() }); } -}); - -// PIM role activations -app.MapGet("/api/dashboard/pim", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => -{ - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, total = 0, activations = Array.Empty() }); - try - { - var graph = services.GetRequiredService(); - var items = await graph.GetCollectionAsync( - "/v1.0/roleManagement/directory/roleAssignments?$top=20&$expand=roleDefinition($select=displayName)", ct); - var activations = items.Select(a => - { - string? principalDisplayName = null, principalUpn = null, roleName = null; - if (a.TryGetProperty("principal", out var p) && p.ValueKind == JsonValueKind.Object) - { - if (p.TryGetProperty("displayName", out var pd)) principalDisplayName = pd.GetString(); - if (p.TryGetProperty("userPrincipalName", out var pu)) principalUpn = pu.GetString(); - } - if (a.TryGetProperty("roleDefinition", out var rd) && rd.ValueKind == JsonValueKind.Object && - rd.TryGetProperty("displayName", out var rdn)) roleName = rdn.GetString(); - return new - { - id = a.TryGetProperty("id", out var id) ? id.GetString() : null, - action = "Assigned", - status = "Active", - createdDateTime = (string?)null, - justification = (string?)null, - principalDisplayName, - principalUpn, - roleName - }; - }).ToList(); - return Results.Ok(new { configured = true, total = activations.Count, activations }); - } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", total = 0, activations = Array.Empty() }); } -}); + db.SecurityAlerts.Add(new SecurityAlert { ExternalId = $"mfa-miss-{i}", AlertType = "MfaStatus", Service = M365ServiceArea.EntraId, Severity = AlertSeverity.Medium, Title = "User missing MFA registration", UserPrincipalName = $"nomfa{i}@vigil365.local", DetectedAt = DateTimeOffset.UtcNow.AddDays(-1), LastUpdatedAt = DateTimeOffset.UtcNow, IsResolved = false }); + } -// Email protection (Defender for Office 365) -app.MapGet("/api/dashboard/email-protection", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => -{ - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, total = 0, alerts = Array.Empty() }); - try - { - var graph = services.GetRequiredService(); - var items = await graph.GetCollectionAsync( - "/v1.0/security/alerts_v2?$top=50&$filter=serviceSource eq 'microsoftDefenderForOffice365'&$orderby=createdDateTime desc", ct); - var alerts = items.Select(a => new - { - id = a.TryGetProperty("id", out var id) ? id.GetString() : null, - title = a.TryGetProperty("title", out var t) ? t.GetString() : null, - severity = a.TryGetProperty("severity", out var s) ? s.GetString() : "unknown", - status = a.TryGetProperty("status", out var st) ? st.GetString() : "unknown", - category = a.TryGetProperty("category", out var cat) ? cat.GetString() : null, - createdDateTime = a.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null, - description = a.TryGetProperty("description", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null, - alertWebUrl = a.TryGetProperty("alertWebUrl", out var url) ? url.GetString() : null, - }).ToList(); - var byCategory = alerts.GroupBy(a => a.category ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); - var bySeverity = alerts.GroupBy(a => a.severity ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); - return Results.Ok(new { configured = true, total = alerts.Count, byCategory, bySeverity, alerts }); + db.SaveChanges(); } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", total = 0, alerts = Array.Empty() }); } -}); +} -// Purview sensitivity labels -app.MapGet("/api/dashboard/purview", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => +// Enforce TLS outside Development. The app should be reached over HTTPS — either +// Kestrel with a certificate, or a reverse proxy terminating TLS. When a proxy +// (or Docker) handles TLS and forwards plain HTTP to the app, set +// Security:RequireHttps=false to avoid in-app redirect loops; the proxy enforces HTTPS. +var requireHttps = builder.Configuration.GetValue("Security:RequireHttps", !app.Environment.IsDevelopment()); +if (requireHttps) { - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, labelCount = 0, labels = Array.Empty() }); - try - { - var graph = services.GetRequiredService(); - var items = await graph.GetSinglePageAsync("https://graph.microsoft.com/beta/security/informationProtection/sensitivityLabels", ct); - var labels = items.Select(l => new - { - id = l.TryGetProperty("id", out var id) ? id.GetString() : null, - name = l.TryGetProperty("name", out var n) ? n.GetString() : null, - description = l.TryGetProperty("description", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null, - color = l.TryGetProperty("color", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() : null, - sensitivity = l.TryGetProperty("sensitivity", out var s) && s.ValueKind == JsonValueKind.Number ? s.GetInt32() : 0, - isActive = l.TryGetProperty("isActive", out var ia) && (ia.ValueKind == JsonValueKind.True || ia.ValueKind == JsonValueKind.False) && ia.GetBoolean(), - }).ToList(); - return Results.Ok(new { configured = true, labelCount = labels.Count, labels }); - } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", labelCount = 0, labels = Array.Empty() }); } -}); + app.UseHsts(); + app.UseHttpsRedirection(); +} -// MDI alerts (Defender for Identity — on-prem AD lateral movement, credential theft) -app.MapGet("/api/dashboard/mdi-alerts", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => +// Correlation id: honour an inbound X-Correlation-Id (from a proxy or caller), +// otherwise generate one. Echoed on the response and pushed as a logging scope +// so every log line for the request can be tied together across services. +app.Use(async (ctx, next) => { - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, total = 0, alerts = Array.Empty() }); - try - { - var graph = services.GetRequiredService(); - var items = await graph.GetCollectionAsync( - "/v1.0/security/alerts_v2?$top=50&$filter=serviceSource eq 'microsoftDefenderForIdentity'&$orderby=createdDateTime desc", ct); - var alerts = items.Select(a => new - { - id = a.TryGetProperty("id", out var id) ? id.GetString() : null, - title = a.TryGetProperty("title", out var t) ? t.GetString() : null, - severity = a.TryGetProperty("severity", out var s) ? s.GetString() : "unknown", - status = a.TryGetProperty("status", out var st) ? st.GetString() : "unknown", - category = a.TryGetProperty("category", out var cat) ? cat.GetString() : null, - createdDateTime = a.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null, - description = a.TryGetProperty("description", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null, - alertWebUrl = a.TryGetProperty("alertWebUrl", out var url) ? url.GetString() : null, - mitreTechniques = a.TryGetProperty("mitreTechniques", out var mt) && mt.ValueKind == JsonValueKind.Array - ? mt.EnumerateArray().Select(x => x.GetString()).OfType().ToArray() - : Array.Empty(), - }).ToList(); - var bySeverity = alerts.GroupBy(a => a.severity ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); - var byCategory = alerts.GroupBy(a => a.category ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); - return Results.Ok(new { configured = true, total = alerts.Count, bySeverity, byCategory, alerts }); - } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", total = 0, alerts = Array.Empty() }); } -}); + var correlationId = ctx.Request.Headers["X-Correlation-Id"].ToString(); + if (string.IsNullOrWhiteSpace(correlationId) || correlationId.Length > 64) + correlationId = Guid.NewGuid().ToString("N")[..16]; + ctx.TraceIdentifier = correlationId; + ctx.Response.Headers["X-Correlation-Id"] = correlationId; -// MCAS alerts (Defender for Cloud Apps — SaaS anomalies, impossible travel, mass download) -app.MapGet("/api/dashboard/mcas-alerts", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => -{ - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, total = 0, alerts = Array.Empty() }); - try + var loggerFactory = ctx.RequestServices.GetRequiredService(); + var reqLogger = loggerFactory.CreateLogger("Vigil365.Request"); + using (reqLogger.BeginScope(new Dictionary { ["CorrelationId"] = correlationId })) { - var graph = services.GetRequiredService(); - var items = await graph.GetCollectionAsync( - "/v1.0/security/alerts_v2?$top=50&$filter=serviceSource eq 'microsoftDefenderForCloudApps'&$orderby=createdDateTime desc", ct); - var alerts = items.Select(a => new - { - id = a.TryGetProperty("id", out var id) ? id.GetString() : null, - title = a.TryGetProperty("title", out var t) ? t.GetString() : null, - severity = a.TryGetProperty("severity", out var s) ? s.GetString() : "unknown", - status = a.TryGetProperty("status", out var st) ? st.GetString() : "unknown", - category = a.TryGetProperty("category", out var cat) ? cat.GetString() : null, - createdDateTime = a.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null, - description = a.TryGetProperty("description", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null, - alertWebUrl = a.TryGetProperty("alertWebUrl", out var url) ? url.GetString() : null, - }).ToList(); - var bySeverity = alerts.GroupBy(a => a.severity ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); - var byCategory = alerts.GroupBy(a => a.category ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); - return Results.Ok(new { configured = true, total = alerts.Count, bySeverity, byCategory, alerts }); + await next(); + // One structured line per API request; static assets and /health probes + // (which fire every few seconds from orchestrators) stay quiet. + if (ctx.Request.Path.StartsWithSegments("/api")) + reqLogger.LogInformation("{Method} {Path} => {StatusCode}", + ctx.Request.Method, ctx.Request.Path.Value, ctx.Response.StatusCode); } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", total = 0, alerts = Array.Empty() }); } }); -// Insider Risk Management (Purview IRM — data exfiltration, departing employees) -app.MapGet("/api/dashboard/insider-risk", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => +// Security headers must run before static files so the SPA shell and bundled +// assets receive the same browser protections as API responses. +var azureAdInstance = app.Configuration["AzureAd:Instance"]?.TrimEnd('/') ?? "https://login.microsoftonline.com"; +app.Use(async (ctx, next) => { - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, total = 0, alerts = Array.Empty() }); - try - { - var graph = services.GetRequiredService(); - var items = await graph.GetCollectionAsync( - "/v1.0/security/alerts_v2?$top=50&$filter=serviceSource eq 'microsoftPurviewInsiderRiskManagement'&$orderby=createdDateTime desc", ct); - var alerts = items.Select(a => new - { - id = a.TryGetProperty("id", out var id) ? id.GetString() : null, - title = a.TryGetProperty("title", out var t) ? t.GetString() : null, - severity = a.TryGetProperty("severity", out var s) ? s.GetString() : "unknown", - status = a.TryGetProperty("status", out var st) ? st.GetString() : "unknown", - category = a.TryGetProperty("category", out var cat) ? cat.GetString() : null, - createdDateTime = a.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null, - description = a.TryGetProperty("description", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null, - alertWebUrl = a.TryGetProperty("alertWebUrl", out var url) ? url.GetString() : null, - }).ToList(); - var bySeverity = alerts.GroupBy(a => a.severity ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); - return Results.Ok(new { configured = true, total = alerts.Count, bySeverity, alerts }); - } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", total = 0, alerts = Array.Empty() }); } + ctx.Response.Headers["X-Frame-Options"] = "DENY"; + ctx.Response.Headers["X-Content-Type-Options"] = "nosniff"; + ctx.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; + ctx.Response.Headers["Content-Security-Policy"] = $"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' {azureAdInstance} wss:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"; + ctx.Response.Headers["Permissions-Policy"] = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"; + await next(); }); -// Entra ID Risk Detections (25+ specific detection types: leaked creds, password spray, nation-state IPs) -app.MapGet("/api/dashboard/risk-detections", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => +app.UseDefaultFiles(); +app.UseStaticFiles(new StaticFileOptions { - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, total = 0, detections = Array.Empty() }); - try + OnPrepareResponse = ctx => { - var graph = services.GetRequiredService(); - var items = await graph.GetSinglePageAsync( - "/v1.0/identityProtection/riskDetections?$top=50", ct); - var detections = items.Select(d => + // Prevent caching of index.html so the SPA always loads the latest JS bundles. + if (ctx.File.Name.Equals("index.html", StringComparison.OrdinalIgnoreCase)) { - string? city = null, country = null; - if (d.TryGetProperty("location", out var loc) && loc.ValueKind == JsonValueKind.Object) - { - if (loc.TryGetProperty("city", out var cv)) city = cv.GetString(); - if (loc.TryGetProperty("countryOrRegion", out var cov)) country = cov.GetString(); - } - return new - { - id = d.TryGetProperty("id", out var id) ? id.GetString() : null, - riskEventType = d.TryGetProperty("riskEventType", out var ret) ? ret.GetString() : null, - riskLevel = d.TryGetProperty("riskLevel", out var rl) ? rl.GetString() : "unknown", - riskState = d.TryGetProperty("riskState", out var rs) ? rs.GetString() : "unknown", - userDisplayName = d.TryGetProperty("userDisplayName", out var udn) ? udn.GetString() : null, - userPrincipalName = d.TryGetProperty("userPrincipalName", out var upn) ? upn.GetString() : null, - lastUpdatedDateTime = d.TryGetProperty("lastUpdatedDateTime", out var lu) ? lu.GetString() : null, - activityDateTime = d.TryGetProperty("activityDateTime", out var ad) ? ad.GetString() : null, - ipAddress = d.TryGetProperty("ipAddress", out var ip) && ip.ValueKind == JsonValueKind.String ? ip.GetString() : null, - city, country - }; - }).ToList(); - var byType = detections.GroupBy(d => d.riskEventType ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); - var byLevel = detections.GroupBy(d => d.riskLevel ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); - return Results.Ok(new { configured = true, total = detections.Count, byType, byLevel, detections }); + ctx.Context.Response.Headers["Cache-Control"] = "no-cache, no-store, must-revalidate"; + ctx.Context.Response.Headers["Pragma"] = "no-cache"; + ctx.Context.Response.Headers["Expires"] = "-1"; + } } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", total = 0, detections = Array.Empty() }); } }); +app.UseCors(); +app.UseRateLimiter(); +app.UseAuthentication(); +app.UseAuthorization(); -// MDI Identity Sensor Health Issues (requires IdentityBaseline.Read.All) -app.MapGet("/api/dashboard/identity-health", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => +if (app.Environment.IsDevelopment()) { - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, total = 0, issues = Array.Empty() }); - try - { - var graph = services.GetRequiredService(); - var items = await graph.GetCollectionAsync("/v1.0/security/identities/healthIssues", ct); - var issues = items.Select(i => new - { - id = i.TryGetProperty("id", out var id) ? id.GetString() : null, - displayName = i.TryGetProperty("displayName", out var n) ? n.GetString() : null, - issueType = i.TryGetProperty("issueType", out var it) ? it.GetString() : null, - severity = i.TryGetProperty("severity", out var s) ? s.GetString() : "unknown", - status = i.TryGetProperty("status", out var st) ? st.GetString() : "unknown", - description = i.TryGetProperty("description", out var d) && d.ValueKind == JsonValueKind.String ? d.GetString() : null, - recommendations = i.TryGetProperty("recommendations", out var r) && r.ValueKind == JsonValueKind.String ? r.GetString() : null, - createdDateTime = i.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null, - domainNames = i.TryGetProperty("domainNames", out var dn) && dn.ValueKind == JsonValueKind.Array - ? dn.EnumerateArray().Select(x => x.GetString()).OfType().ToArray() - : Array.Empty(), - sensorDNSNames = i.TryGetProperty("sensorDNSNames", out var sdn) && sdn.ValueKind == JsonValueKind.Array - ? sdn.EnumerateArray().Select(x => x.GetString()).OfType().ToArray() - : Array.Empty(), - }).ToList(); - var bySeverity = issues.GroupBy(i => i.severity ?? "unknown").ToDictionary(g => g.Key, g => g.Count()); - return Results.Ok(new { configured = true, total = issues.Count, bySeverity, issues }); - } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", total = 0, issues = Array.Empty() }); } -}); + app.UseSwagger(); + app.UseSwaggerUI(); +} -// Attack Simulation & Training (requires AttackSimulation.ReadWrite.All) -app.MapGet("/api/dashboard/attack-simulation", async ( - IServiceProvider services, IOptions options, CancellationToken ct) => -{ - if (!options.Value.IsConfigured()) - return Results.Ok(new { configured = false, total = 0, simulations = Array.Empty() }); - try +// ── Endpoint modules ──────────────────────────────────────────────────────── +// Each module is a plain extension method over WebApplication, so the global +// deny-by-default FallbackPolicy still applies to everything it registers. +// These must all come before the /api catch-all and the SPA fallback below. +app.MapAuthHealthEndpoints(); +app.MapSetupEndpoints(); +app.MapDashboardEndpoints(); +app.MapAdminEndpoints(); +app.MapAlertsEndpoints(); +app.MapNotificationsEndpoints(); +app.MapReportsEndpoints(); +app.MapIntegrationsEndpoints(); +app.MapPlatformEndpoints(); + +app.Map("/api/{**rest}", (HttpContext ctx) => +{ + ctx.Response.StatusCode = StatusCodes.Status404NotFound; + return Results.Json(new { error = "No such API endpoint.", path = ctx.Request.Path.Value }, + statusCode: StatusCodes.Status404NotFound); +}).AllowAnonymous(); + +app.MapFallbackToFile("index.html", new StaticFileOptions +{ + OnPrepareResponse = ctx => { - var graph = services.GetRequiredService(); - var items = await graph.GetCollectionAsync( - "/v1.0/security/attackSimulation/simulations?$top=20", ct); - var simulations = items.Select(s => - { - int targeted = 0, clicked = 0, didNotClick = 0; double compromisedRate = 0; - if (s.TryGetProperty("report", out var rpt) && rpt.ValueKind == JsonValueKind.Object) - { - if (rpt.TryGetProperty("numberOfUsersTargeted", out var nut) && nut.ValueKind == JsonValueKind.Number) targeted = nut.GetInt32(); - if (rpt.TryGetProperty("simulationEventsContent", out var sec) && sec.ValueKind == JsonValueKind.Object) - { - if (sec.TryGetProperty("compromisedRate", out var cr2) && cr2.ValueKind == JsonValueKind.Number) compromisedRate = cr2.GetDouble(); - if (sec.TryGetProperty("clickedPhishingLinkCount", out var cpl) && cpl.ValueKind == JsonValueKind.Number) clicked = cpl.GetInt32(); - if (sec.TryGetProperty("didNotClickLinkCount", out var dnc) && dnc.ValueKind == JsonValueKind.Number) didNotClick = dnc.GetInt32(); - } - } - return new - { - id = s.TryGetProperty("id", out var id) ? id.GetString() : null, - displayName = s.TryGetProperty("displayName", out var n) ? n.GetString() : null, - attackType = s.TryGetProperty("attackType", out var at) ? at.GetString() : null, - status = s.TryGetProperty("status", out var st) ? st.GetString() : "unknown", - createdDateTime = s.TryGetProperty("createdDateTime", out var cr) ? cr.GetString() : null, - completionDateTime = s.TryGetProperty("completionDateTime", out var cd) && cd.ValueKind == JsonValueKind.String ? cd.GetString() : null, - numberOfUsersTargeted = targeted, - compromisedRate, - clickedPhishingLinkCount = clicked, - didNotClickLinkCount = didNotClick, - }; - }).ToList(); - var totalTargeted = simulations.Sum(s => s.numberOfUsersTargeted); - var avgCompromiseRate = simulations.Count > 0 - ? Math.Round(simulations.Average(s => s.compromisedRate), 1) : 0.0; - return Results.Ok(new { configured = true, total = simulations.Count, totalTargeted, avgCompromiseRate, simulations }); + ctx.Context.Response.Headers["Cache-Control"] = "no-cache, no-store, must-revalidate"; + ctx.Context.Response.Headers["Pragma"] = "no-cache"; + ctx.Context.Response.Headers["Expires"] = "-1"; } - catch (Exception ex) { app.Logger.LogError(ex, "Dashboard endpoint error"); return Results.Ok(new { configured = true, error = "An error occurred. Check server logs for details.", total = 0, simulations = Array.Empty() }); } -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Alert Center — server-side policies, triggered alerts, notifications -// ───────────────────────────────────────────────────────────────────────────── - -// Policies CRUD -app.MapGet("/api/alert-policies", async (AppDbContext db, CancellationToken ct) => - Results.Ok(await db.AlertPolicies.OrderByDescending(p => p.CreatedAt).ToListAsync(ct))); - -app.MapPost("/api/alert-policies", async (AppDbContext db, AlertPolicy input, CancellationToken ct) => -{ - input.Id = input.Id == Guid.Empty ? Guid.NewGuid() : input.Id; - input.CreatedAt = DateTimeOffset.UtcNow; - input.TriggerCount = 0; - if (input.SuppressionMinutes <= 0) input.SuppressionMinutes = 60; - db.AlertPolicies.Add(input); - await db.SaveChangesAsync(ct); - return Results.Ok(input); -}); - -app.MapPut("/api/alert-policies/{id:guid}", async (AppDbContext db, Guid id, AlertPolicy input, CancellationToken ct) => -{ - var p = await db.AlertPolicies.FindAsync([id], ct); - if (p is null) return Results.NotFound(); - p.Name = input.Name; - p.Enabled = input.Enabled; - p.Category = input.Category; - p.Condition = input.Condition; - p.Metric = input.Metric; - p.Threshold = input.Threshold; - p.Severity = input.Severity; - p.NotifyEmail = input.NotifyEmail; - p.SuppressionMinutes = input.SuppressionMinutes <= 0 ? 60 : input.SuppressionMinutes; - await db.SaveChangesAsync(ct); - return Results.Ok(p); -}); +}).AllowAnonymous(); -app.MapDelete("/api/alert-policies/{id:guid}", async (AppDbContext db, Guid id, CancellationToken ct) => -{ - var p = await db.AlertPolicies.FindAsync([id], ct); - if (p is null) return Results.NotFound(); - db.AlertPolicies.Remove(p); - await db.SaveChangesAsync(ct); - return Results.NoContent(); -}); - -// Triggered alerts -app.MapGet("/api/triggered-alerts", async (AppDbContext db, CancellationToken ct) => - Results.Ok(await db.TriggeredAlerts.OrderByDescending(t => t.TriggeredAt).Take(500).ToListAsync(ct))); - -app.MapPost("/api/triggered-alerts/{id:guid}/acknowledge", async (AppDbContext db, Guid id, CancellationToken ct) => -{ - var t = await db.TriggeredAlerts.FindAsync([id], ct); - if (t is null) return Results.NotFound(); - t.Status = "acknowledged"; - t.AcknowledgedAt = DateTimeOffset.UtcNow; - t.AcknowledgedBy = "dashboard"; - await db.SaveChangesAsync(ct); - return Results.Ok(t); -}); - -app.MapPost("/api/triggered-alerts/{id:guid}/resolve", async (AppDbContext db, Guid id, CancellationToken ct) => -{ - var t = await db.TriggeredAlerts.FindAsync([id], ct); - if (t is null) return Results.NotFound(); - t.Status = "resolved"; - await db.SaveChangesAsync(ct); - return Results.Ok(t); -}); - -// Per-alert snooze. Body: { "until": "2026-06-22T18:00:00Z" } or { "durationHours": 4|24|168 }. -// Until wins if both are supplied; durationHours defaults to 24 if neither is supplied. -app.MapPost("/api/triggered-alerts/{id:guid}/snooze", async ( - AppDbContext db, Guid id, SnoozeRequest input, CancellationToken ct) => -{ - var t = await db.TriggeredAlerts.FindAsync([id], ct); - if (t is null) return Results.NotFound(); - if (t.Status is "resolved" or "auto_resolved") - return Results.BadRequest(new { error = "Cannot snooze a terminal alert." }); - - var until = input.Until - ?? (input.DurationHours is { } h ? DateTimeOffset.UtcNow.AddHours(h) : DateTimeOffset.UtcNow.AddHours(24)); - t.SnoozedUntil = until; - t.SnoozedBy = "dashboard"; - await db.SaveChangesAsync(ct); - return Results.Ok(t); -}); - -app.MapPost("/api/triggered-alerts/{id:guid}/unsnooze", async ( - AppDbContext db, Guid id, CancellationToken ct) => -{ - var t = await db.TriggeredAlerts.FindAsync([id], ct); - if (t is null) return Results.NotFound(); - t.SnoozedUntil = null; - t.SnoozedBy = null; - await db.SaveChangesAsync(ct); - return Results.Ok(t); -}); +app.Run(); -// Manually run an evaluation pass (used by the dashboard "refresh" + on-demand check) -app.MapPost("/api/alert-policies/evaluate", async (AlertEvaluator evaluator, CancellationToken ct) => -{ - var fired = await evaluator.EvaluateAsync(ct); - return Results.Ok(new { fired }); -}); +/// Body shape for POST /api/triggered-alerts/{id}/snooze. +public sealed record SnoozeRequest(DateTimeOffset? Until, int? DurationHours); -// Notification settings (single row). Password is write-only — never returned. -app.MapGet("/api/notification-settings", async (AppDbContext db, SecretProtector protector, CancellationToken ct) => -{ - var s = await db.NotificationSettings.FirstOrDefaultAsync(ct) ?? new NotificationSettings { Id = 1 }; - return Results.Ok(new - { - s.TeamsEnabled, TeamsWebhookUrl = protector.Unprotect(s.TeamsWebhookUrl), - s.EmailEnabled, s.SmtpHost, s.SmtpPort, s.SmtpUseSsl, s.SmtpUsername, - hasSmtpPassword = !string.IsNullOrEmpty(s.SmtpPassword), - s.FromAddress, s.DefaultRecipient, - s.WebhookEnabled, WebhookUrl = protector.Unprotect(s.WebhookUrl), - s.MinSeverity, - }); -}); +/// Body shape for PUT /api/admin/users/{email}/role. +public sealed record RoleChangeRequest(string Role); -app.MapPut("/api/notification-settings", async (AppDbContext db, SecretProtector protector, NotificationSettings input, CancellationToken ct) => -{ - var s = await db.NotificationSettings.FirstOrDefaultAsync(ct); - if (s is null) { s = new NotificationSettings { Id = 1 }; db.NotificationSettings.Add(s); } - s.TeamsEnabled = input.TeamsEnabled; - s.TeamsWebhookUrl = protector.Protect(input.TeamsWebhookUrl); - s.EmailEnabled = input.EmailEnabled; - s.SmtpHost = input.SmtpHost; - s.SmtpPort = input.SmtpPort <= 0 ? 587 : input.SmtpPort; - s.SmtpUseSsl = input.SmtpUseSsl; - s.SmtpUsername = input.SmtpUsername; - if (!string.IsNullOrEmpty(input.SmtpPassword)) s.SmtpPassword = protector.Protect(input.SmtpPassword); // keep existing if blank - s.FromAddress = input.FromAddress; - s.DefaultRecipient = input.DefaultRecipient; - s.WebhookEnabled = input.WebhookEnabled; - s.WebhookUrl = protector.Protect(input.WebhookUrl); - s.MinSeverity = string.IsNullOrWhiteSpace(input.MinSeverity) ? "low" : input.MinSeverity; - await db.SaveChangesAsync(ct); - return Results.Ok(new { ok = true }); -}); +/// Body shape for POST/PUT /api/suppression-rules. +public sealed record SuppressionRuleRequest( + Guid? PolicyId, string? EntityPattern, string? Reason, + DateTimeOffset? ExpiresAt, bool? Enabled); -// Send a test notification through all enabled channels -app.MapPost("/api/notification-settings/test", async (AppDbContext db, NotificationSender sender, CancellationToken ct) => -{ - var cfg = await db.NotificationSettings.FirstOrDefaultAsync(ct); - if (cfg is null) return Results.Ok(new { ok = false, message = "No settings configured" }); - var test = new TriggeredAlert - { - Id = Guid.NewGuid(), - PolicyName = "Test Notification", - Severity = "high", - Category = "test", - Condition = "Manual test from Vigil365 settings", - MetricValue = 1, - Threshold = 1, - TriggeredAt = DateTimeOffset.UtcNow, - Status = "new", - }; - await sender.DispatchAsync(db, cfg, test, ct); - await db.SaveChangesAsync(ct); - var logs = await db.NotificationLogs.Where(l => l.TriggeredAlertId == test.Id).ToListAsync(ct); - return Results.Ok(new { ok = logs.Any(l => l.Success), results = logs.Select(l => new { l.Channel, l.Success, l.Error }) }); -}); +/// Body shape for POST /api/setup/graph (first-run wizard). +public sealed record GraphSetupRequest(string TenantId, string ClientId, string? ClientSecret, string? LoginInstance, string? BaseUrl); -// Notification delivery history -app.MapGet("/api/notification-log", async (AppDbContext db, CancellationToken ct) => - Results.Ok(await db.NotificationLogs.OrderByDescending(l => l.SentAt).Take(200).ToListAsync(ct))); +/// Body shape for POST /api/admin/users (pre-provision a user). +public sealed record AddUserRequest(string Email, string Role, string? DisplayName, bool SendInvite = false); -app.MapFallbackToFile("index.html"); +/// Body shape for POST /api/api-tokens. +public sealed record ApiTokenCreateRequest(string? Name, string? Scopes, DateTimeOffset? ExpiresAt); -app.Run(); +/// Body shape for the workbench endpoints (assign / disposition). +public sealed record WorkbenchRequest(string? AssignedTo, string? Disposition); -/// Body shape for POST /api/triggered-alerts/{id}/snooze. -public sealed record SnoozeRequest(DateTimeOffset? Until, int? DurationHours); +/// Body shape for POST /api/alert-notes/{kind}/{targetId}. +public sealed record NoteRequest(string Text); diff --git a/src/M365SecurityDashboard.Api/Properties/launchSettings.json b/src/M365SecurityDashboard.Api/Properties/launchSettings.json index c7a89bb..aa97266 100644 --- a/src/M365SecurityDashboard.Api/Properties/launchSettings.json +++ b/src/M365SecurityDashboard.Api/Properties/launchSettings.json @@ -4,7 +4,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://localhost:5000", + "applicationUrl": "https://vigil365.local:5001;https://localhost:5001;http://localhost:5000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/M365SecurityDashboard.Api/Services/AlertEvaluator.cs b/src/M365SecurityDashboard.Api/Services/AlertEvaluator.cs index c8c951e..e8af031 100644 --- a/src/M365SecurityDashboard.Api/Services/AlertEvaluator.cs +++ b/src/M365SecurityDashboard.Api/Services/AlertEvaluator.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using M365SecurityDashboard.Api.Data; using M365SecurityDashboard.Api.Models; using Microsoft.EntityFrameworkCore; @@ -29,20 +30,68 @@ public async Task EvaluateAsync(CancellationToken ct) var now = DateTimeOffset.UtcNow; var fired = 0; - // Map PolicyId -> Metric key so the auto-resolve loop below can look up - // each open alert's current metric without re-querying the policy table. - var policyMetricById = policies.ToDictionary(p => p.Id, p => p.Metric); + // Standing suppression rules, loaded once per cycle. Tracked (not + // AsNoTracking) so hit counters persist with the rest of this cycle's save. + var suppressions = await db.SuppressionRules + .Where(s => s.Enabled && (s.ExpiresAt == null || s.ExpiresAt > now)) + .ToListAsync(ct); + + // Map PolicyId -> policy so the auto-resolve loop below can recompute + // each open alert's current value without re-querying the policy table. + var policyById = policies.ToDictionary(p => p.Id); + var collapsed = 0; + var suppressed = 0; foreach (var policy in policies) { - var value = metrics.GetValueOrDefault(policy.Metric, 0); + var value = await ComputePolicyValueAsync(policy, metrics, now, ct); if (value < policy.Threshold) continue; - // Suppress re-notification while a recent "new" alert for this policy exists. - var window = now.AddMinutes(-Math.Max(1, policy.SuppressionMinutes)); - var recent = await db.TriggeredAlerts.AnyAsync( - t => t.PolicyId == policy.Id && t.Status == "new" && t.TriggeredAt >= window, ct); - if (recent) continue; + // One open alert per policy: while the breach persists, the existing + // open alert is updated in place (current value, affected entities, + // last-evaluated time) instead of stacking a new row every cycle. + // A new row — and a new notification — only happens after the previous + // alert reached a terminal state (resolved / auto-resolved). + var openForPolicy = await db.TriggeredAlerts + .Where(t => t.PolicyId == policy.Id && t.Status != "resolved" && t.Status != "auto_resolved") + .OrderByDescending(t => t.TriggeredAt) + .ToListAsync(ct); + if (openForPolicy.Count > 0) + { + var keeper = openForPolicy[0]; + keeper.MetricValue = value; + keeper.Threshold = policy.Threshold; + keeper.LastEvaluatedAt = now; + keeper.AffectedEntities = await GetAffectedEntitiesJsonAsync(policy, now, ct); + // Collapse duplicates accumulated by the old fire-every-cycle + // behaviour — keep the newest, retire the rest silently. + foreach (var dup in openForPolicy.Skip(1)) + { + dup.Status = "auto_resolved"; + dup.ResolvedAt ??= now; + dup.ResolvedBy ??= "system"; + collapsed++; + } + continue; + } + + var affectedEntitiesJson = await GetAffectedEntitiesJsonAsync(policy, now, ct); + + // Standing suppression: stop the alert being raised at all (no row, + // no notification). Checked here rather than at display time so a + // known-noisy condition costs nothing downstream. The counter makes + // an over-broad rule visible instead of silently swallowing alerts. + var suppressedBy = SuppressionMatcher.FindMatch(suppressions, policy.Id, affectedEntitiesJson, now); + if (suppressedBy is not null) + { + suppressedBy.SuppressedCount++; + suppressedBy.LastSuppressedAt = now; + suppressed++; + logger.LogInformation( + "Alert for policy {Policy} suppressed by rule {RuleId} ({Reason})", + policy.Name, suppressedBy.Id, suppressedBy.Reason); + continue; + } var alert = new TriggeredAlert { @@ -56,6 +105,7 @@ public async Task EvaluateAsync(CancellationToken ct) Threshold = policy.Threshold, TriggeredAt = now, Status = "new", + AffectedEntities = affectedEntitiesJson }; db.TriggeredAlerts.Add(alert); @@ -83,8 +133,8 @@ public async Task EvaluateAsync(CancellationToken ct) var autoResolved = 0; foreach (var alert in openAlerts) { - if (!policyMetricById.TryGetValue(alert.PolicyId, out var metricKey)) continue; - var current = metrics.GetValueOrDefault(metricKey, 0); + if (!policyById.TryGetValue(alert.PolicyId, out var alertPolicy)) continue; + var current = await ComputePolicyValueAsync(alertPolicy, metrics, now, ct); if (current < alert.Threshold) { @@ -92,6 +142,8 @@ public async Task EvaluateAsync(CancellationToken ct) if (alert.BelowThresholdStreakCount >= streakTarget) { alert.Status = "auto_resolved"; + alert.ResolvedAt = now; + alert.ResolvedBy = "system"; autoResolved++; } } @@ -102,14 +154,16 @@ public async Task EvaluateAsync(CancellationToken ct) alert.LastEvaluatedAt = now; } - if (fired > 0 || autoResolved > 0) - { - await db.SaveChangesAsync(ct); - if (fired > 0) - logger.LogInformation("Alert evaluation fired {Count} new alert(s)", fired); - if (autoResolved > 0) - logger.LogInformation("Auto-resolved {Count} alert(s) after metric recovery", autoResolved); - } + // Always save — in-place updates to open alerts happen even when nothing fired. + await db.SaveChangesAsync(ct); + if (fired > 0) + logger.LogInformation("Alert evaluation fired {Count} new alert(s)", fired); + if (autoResolved > 0) + logger.LogInformation("Auto-resolved {Count} alert(s) after metric recovery", autoResolved); + if (collapsed > 0) + logger.LogInformation("Collapsed {Count} duplicate open alert(s) into one per policy", collapsed); + if (suppressed > 0) + logger.LogInformation("Suppressed {Count} alert(s) via standing suppression rules", suppressed); return fired; } @@ -142,4 +196,163 @@ public async Task> ComputeMetricsAsync(CancellationToken ["alertCount"] = alertCount, }; } + + // The UI parses these camelCase — the default (PascalCase) serialization was + // why entity rows rendered as "System / N/A" regardless of real data. + private static readonly JsonSerializerOptions EntityJson = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + /// Current value for a policy: metric lookup, activity-window count, or anomaly spike value. + public async Task ComputePolicyValueAsync( + AlertPolicy policy, Dictionary metrics, DateTimeOffset now, CancellationToken ct) + { + if (policy.Kind.Equals("anomaly", StringComparison.OrdinalIgnoreCase)) + return await ComputeAnomalyPolicyValueAsync(policy, now, ct); + + if (!policy.Kind.Equals("activity", StringComparison.OrdinalIgnoreCase)) + return metrics.GetValueOrDefault(policy.Metric, 0); + + var pattern = (policy.ActivityPattern ?? "").Trim(); + if (pattern.Length == 0) return 0; + var like = pattern.Replace("*", "%"); + var since = now.AddMinutes(-Math.Max(1, policy.WindowMinutes)); + return await db.AuditEvents.CountAsync( + e => e.OccurredAt >= since && EF.Functions.Like(e.Activity, like), ct); + } + + private async Task GetAffectedEntitiesJsonAsync(AlertPolicy policy, DateTimeOffset now, CancellationToken ct) + { + if (policy.Kind.Equals("activity", StringComparison.OrdinalIgnoreCase)) + { + var pattern = (policy.ActivityPattern ?? "").Trim(); + if (pattern.Length == 0) return null; + var like = pattern.Replace("*", "%"); + var since = now.AddMinutes(-Math.Max(1, policy.WindowMinutes)); + var events = await db.AuditEvents + .Where(e => e.OccurredAt >= since && EF.Functions.Like(e.Activity, like)) + .OrderByDescending(e => e.OccurredAt) + .Take(50) + .Select(e => new + { + e.Id, + UserPrincipalName = e.ActorUpn ?? e.ActorApp, + DeviceName = (string?)null, + Title = e.TargetName != null ? e.Activity + " → " + e.TargetName : e.Activity, + PortalUrl = (string?)null, + DetectedAt = e.OccurredAt, + e.ExternalId, + }) + .ToListAsync(ct); + return events.Count == 0 ? null : JsonSerializer.Serialize(events, EntityJson); + } + if (policy.Kind.Equals("anomaly", StringComparison.OrdinalIgnoreCase)) + { + var details = await GetAnomalyDetailsAsync(policy, now, ct); + return details is null ? null : JsonSerializer.Serialize(new[] { details }, EntityJson); + } + return await GetMetricEntitiesJsonAsync(policy.Metric, ct); + } + + private async Task ComputeAnomalyPolicyValueAsync(AlertPolicy policy, DateTimeOffset now, CancellationToken ct) + { + var details = await GetAnomalyDetailsAsync(policy, now, ct); + return details?.CurrentValueRounded ?? 0; + } + + private async Task GetAnomalyDetailsAsync(AlertPolicy policy, DateTimeOffset now, CancellationToken ct) + { + var metric = (policy.Metric ?? "").Trim(); + if (metric.Length == 0) return null; + + var latest = await db.TrendSnapshots + .OrderByDescending(s => s.CapturedAt) + .FirstOrDefaultAsync(ct); + if (latest is null) return null; + + var baselineDays = Math.Max(1, policy.BaselineDays); + var baselineStart = now.AddDays(-baselineDays); + var baselineEnd = now.AddHours(-24); + if (baselineEnd <= baselineStart) return null; + + var baselineSnapshots = await db.TrendSnapshots + .Where(s => s.CapturedAt >= baselineStart && s.CapturedAt < baselineEnd) + .ToListAsync(ct); + if (baselineSnapshots.Count == 0) return null; + + var current = GetTrendValue(latest, metric); + var baselineAverage = baselineSnapshots.Average(s => GetTrendValue(s, metric)); + var baselineFloor = Math.Max(1.0, baselineAverage); + var multiplier = policy.BaselineMultiplier <= 0 ? 3.0 : policy.BaselineMultiplier; + var requiredByBaseline = baselineFloor * multiplier; + var absoluteThreshold = Math.Max(1, policy.Threshold); + + if (current < absoluteThreshold || current < requiredByBaseline) return null; + + return new AnomalyDetails( + metric, + Math.Round(current, 2), + (int)Math.Round(current, MidpointRounding.AwayFromZero), + Math.Round(baselineAverage, 2), + multiplier, + baselineDays, + latest.CapturedAt, + $"Anomalous {metric}: {current:0.##} vs {baselineAverage:0.##} baseline"); + } + + private static double GetTrendValue(TrendSnapshot snapshot, string metricKey) => + metricKey.ToLowerInvariant() switch + { + "riskyuserscount" => snapshot.RiskyUsersCount, + "mfacoveragepct" => snapshot.MfaCoveragePct, + "noncompliantcount" or "noncompliantdevicescount" => snapshot.NonCompliantDevicesCount, + "criticalalertcount" or "criticalalertscount" => snapshot.CriticalAlertsCount, + "highalertcount" or "highalertscount" => snapshot.HighAlertsCount, + "securescorepct" => snapshot.SecureScorePct, + "complianceissuescount" => snapshot.ComplianceIssuesCount, + _ => 0 + }; + + private sealed record AnomalyDetails( + string Metric, + double CurrentValue, + int CurrentValueRounded, + double BaselineAverage, + double BaselineMultiplier, + int BaselineDays, + DateTimeOffset DetectedAt, + string Title); + + private async Task GetMetricEntitiesJsonAsync(string metricKey, CancellationToken ct) + { + var open = db.SecurityAlerts.Where(a => !a.IsResolved); + IQueryable query = metricKey.ToLowerInvariant() switch + { + "criticalalertcount" => open.Where(a => a.Severity == AlertSeverity.Critical), + "highalertcount" => open.Where(a => a.Severity == AlertSeverity.High), + "riskyuserscount" => open.Where(a => a.AlertType == "RiskyUser"), + "mfamissingcount" => open.Where(a => a.AlertType == "MfaStatus"), + "noncompliantcount" => open.Where(a => a.AlertType == "NonCompliantDevice"), + "staledevicecount" => open.Where(a => a.AlertType == "DeviceNotCheckedIn"), + "failedsignincount" => open.Where(a => a.AlertType == "FailedSignIn"), + "serviceissuecount" => open.Where(a => a.Service == M365ServiceArea.ServiceHealth), + "alertcount" => open, + _ => null + } ?? open.Where(a => false); + + var entities = await query + .OrderByDescending(a => a.DetectedAt) + .Select(a => new + { + a.Id, + a.UserPrincipalName, + a.DeviceName, + a.Title, + a.PortalUrl, + a.DetectedAt, + a.ExternalId + }) + .ToListAsync(ct); + + if (entities.Count == 0) return null; + return JsonSerializer.Serialize(entities, EntityJson); + } } diff --git a/src/M365SecurityDashboard.Api/Services/AlertMetrics.cs b/src/M365SecurityDashboard.Api/Services/AlertMetrics.cs new file mode 100644 index 0000000..47cbbc4 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/AlertMetrics.cs @@ -0,0 +1,76 @@ +using M365SecurityDashboard.Api.Models; + +namespace M365SecurityDashboard.Api.Services; + +/// +/// Operational metrics over the triggered-alert workflow — MTTA, MTTR, +/// resolution rate, and per-assignee workload. Pure and static so the maths is +/// unit-testable without a database; the endpoint just supplies the rows. +/// +/// The data already existed (TriggeredAt / AcknowledgedAt / ResolvedAt); this +/// only reads it. Auto-resolved alerts count toward resolution but not toward +/// analyst workload, since no person acted on them. +/// +public static class AlertMetrics +{ + public sealed record AssigneeLoad(string Assignee, int Open, int Acknowledged, int Resolved); + + public sealed record Result( + int Total, + int Open, + int Resolved, + int AutoResolved, + double ResolutionRatePct, + double? MttaMinutes, + double? MttrMinutes, + int Acknowledged, + IReadOnlyList ByAssignee); + + /// Statuses that mean the alert is no longer demanding attention. + private static bool IsResolved(string status) => + status is "resolved" or "auto_resolved"; + + public static Result Compute(IReadOnlyList alerts) + { + var total = alerts.Count; + var resolved = alerts.Count(a => a.Status == "resolved"); + var autoResolved = alerts.Count(a => a.Status == "auto_resolved"); + var open = alerts.Count(a => !IsResolved(a.Status)); + var acknowledged = alerts.Count(a => a.AcknowledgedAt is not null); + + // Mean time to acknowledge: only alerts a person actually acknowledged. + var ttaSamples = alerts + .Where(a => a.AcknowledgedAt is not null && a.AcknowledgedAt >= a.TriggeredAt) + .Select(a => (a.AcknowledgedAt!.Value - a.TriggeredAt).TotalMinutes) + .ToList(); + + // Mean time to resolve: alerts with a resolve timestamp (manual or auto). + var ttrSamples = alerts + .Where(a => a.ResolvedAt is not null && a.ResolvedAt >= a.TriggeredAt) + .Select(a => (a.ResolvedAt!.Value - a.TriggeredAt).TotalMinutes) + .ToList(); + + var byAssignee = alerts + .Where(a => !string.IsNullOrWhiteSpace(a.AssignedTo)) + .GroupBy(a => a.AssignedTo!) + .Select(g => new AssigneeLoad( + g.Key, + Open: g.Count(a => !IsResolved(a.Status)), + Acknowledged: g.Count(a => a.AcknowledgedAt is not null && !IsResolved(a.Status)), + Resolved: g.Count(a => IsResolved(a.Status)))) + .OrderByDescending(x => x.Open) + .ThenByDescending(x => x.Resolved) + .ToList(); + + return new Result( + Total: total, + Open: open, + Resolved: resolved, + AutoResolved: autoResolved, + ResolutionRatePct: total == 0 ? 0 : Math.Round((resolved + autoResolved) * 100.0 / total, 1), + MttaMinutes: ttaSamples.Count == 0 ? null : Math.Round(ttaSamples.Average(), 1), + MttrMinutes: ttrSamples.Count == 0 ? null : Math.Round(ttrSamples.Average(), 1), + Acknowledged: acknowledged, + ByAssignee: byAssignee); + } +} diff --git a/src/M365SecurityDashboard.Api/Services/ApiTokenService.cs b/src/M365SecurityDashboard.Api/Services/ApiTokenService.cs new file mode 100644 index 0000000..bc8b703 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/ApiTokenService.cs @@ -0,0 +1,72 @@ +using System.Security.Cryptography; +using System.Text; +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace M365SecurityDashboard.Api.Services; + +public sealed class ApiTokenService(AppDbContext db) +{ + private const string TokenPrefix = "vig_"; + private const int TokenBytes = 32; + + public static (ApiToken row, string rawToken) Create(string name, string scopes, string? createdBy, DateTimeOffset? expiresAt) + { + var bytes = RandomNumberGenerator.GetBytes(TokenBytes); + var secret = Base64Url(bytes); + var raw = TokenPrefix + secret; + var prefix = raw[..Math.Min(12, raw.Length)]; + return (new ApiToken + { + Name = string.IsNullOrWhiteSpace(name) ? "SIEM integration" : name.Trim(), + Prefix = prefix, + TokenHash = Hash(raw), + Scopes = NormalizeScopes(scopes), + CreatedBy = createdBy, + ExpiresAt = expiresAt, + }, raw); + } + + public async Task ValidateAsync(string? rawToken, string requiredScope, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(rawToken) || !rawToken.StartsWith(TokenPrefix, StringComparison.Ordinal)) + return null; + + var hash = Hash(rawToken.Trim()); + var now = DateTimeOffset.UtcNow; + var token = await db.ApiTokens + .FirstOrDefaultAsync(t => t.TokenHash == hash && t.RevokedAt == null && (t.ExpiresAt == null || t.ExpiresAt > now), ct); + if (token is null || !HasScope(token.Scopes, requiredScope)) + return null; + + token.LastUsedAt = now; + await db.SaveChangesAsync(ct); + return token; + } + + public static bool HasScope(string? scopes, string requiredScope) + { + var set = (scopes ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return set.Any(s => s.Equals(requiredScope, StringComparison.OrdinalIgnoreCase) || s.Equals("*", StringComparison.Ordinal)); + } + + public static string Hash(string rawToken) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken)); + return Convert.ToHexString(bytes); + } + + public static string NormalizeScopes(string? scopes) + { + var normalized = (scopes ?? "alerts:read,health:read") + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(s => s.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + return normalized.Count == 0 ? "alerts:read,health:read" : string.Join(",", normalized); + } + + private static string Base64Url(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} diff --git a/src/M365SecurityDashboard.Api/Services/AuditLogger.cs b/src/M365SecurityDashboard.Api/Services/AuditLogger.cs new file mode 100644 index 0000000..f16ada8 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/AuditLogger.cs @@ -0,0 +1,110 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace M365SecurityDashboard.Api.Services; + +/// +/// Writes append-only audit entries for security-relevant actions. Resolves the +/// acting user from the current request's validated token, captures the client +/// IP + User-Agent, and chains each entry to the previous one with a SHA-256 +/// hash so tampering (edit or delete of any historical row) is detectable via +/// the /api/admin/audit-log/verify endpoint. Failures to write an audit row +/// must never block the underlying action, but are logged. +/// +public sealed class AuditLogger( + AppDbContext db, + IHttpContextAccessor httpContext, + ILogger logger) +{ + // Serialises hash-chain writes within this process so two concurrent actions + // don't both link to the same predecessor (which would fork the chain). + private static readonly SemaphoreSlim ChainLock = new(1, 1); + + /// + /// Record an action. Saves immediately so the entry survives even if the caller + /// does not call SaveChanges. Swallows persistence errors (logging them) so an + /// audit failure never breaks the user's action. + /// + public async Task WriteAsync(string action, string targetType, string? targetId, string? details, CancellationToken ct) + { + try + { + var ctx = httpContext.HttpContext; + var actor = ctx?.User is ClaimsPrincipal p ? AuthHelpers.GetEmail(p) : ""; + + var entry = new AuditEntry + { + Timestamp = DateTimeOffset.UtcNow, + ActorEmail = string.IsNullOrEmpty(actor) ? "system" : actor, + Action = action, + TargetType = targetType, + TargetId = targetId, + Details = details, + IpAddress = GetClientIp(ctx), + UserAgent = Truncate(ctx?.Request.Headers.UserAgent.ToString(), 300), + }; + + await ChainLock.WaitAsync(ct); + try + { + var prevHash = await db.AuditEntries.AsNoTracking() + .OrderByDescending(a => a.Id) + .Select(a => a.EntryHash) + .FirstOrDefaultAsync(ct); + entry.PrevHash = prevHash; + entry.EntryHash = ComputeHash(entry); + + db.AuditEntries.Add(entry); + await db.SaveChangesAsync(ct); + } + finally + { + ChainLock.Release(); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to write audit entry {Action} on {TargetType} {TargetId}", action, targetType, targetId); + } + } + + /// + /// Canonical hash of an entry's content + its predecessor's hash. Must stay + /// stable across releases — changing the format invalidates existing chains. + /// + public static string ComputeHash(AuditEntry e) + { + // Unit-separator delimiter so shifted field boundaries always change the hash. + var canonical = string.Join('\u001f', + e.PrevHash ?? "", + e.Timestamp.UtcDateTime.ToString("O"), + e.ActorEmail, + e.Action, + e.TargetType, + e.TargetId ?? "", + e.Details ?? "", + e.IpAddress ?? "", + e.UserAgent ?? ""); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))); + } + + /// First X-Forwarded-For hop when behind a proxy, else the socket address. + private static string? GetClientIp(HttpContext? ctx) + { + if (ctx is null) return null; + var forwarded = ctx.Request.Headers["X-Forwarded-For"].ToString(); + if (!string.IsNullOrWhiteSpace(forwarded)) + { + var first = forwarded.Split(',')[0].Trim(); + if (first.Length > 0) return Truncate(first, 45); + } + return ctx.Connection.RemoteIpAddress?.ToString(); + } + + private static string? Truncate(string? value, int max) => + string.IsNullOrEmpty(value) ? null : value.Length <= max ? value : value[..max]; +} diff --git a/src/M365SecurityDashboard.Api/Services/BacktestMath.cs b/src/M365SecurityDashboard.Api/Services/BacktestMath.cs new file mode 100644 index 0000000..6bd8f1a --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/BacktestMath.cs @@ -0,0 +1,114 @@ +namespace M365SecurityDashboard.Api.Services; + +/// +/// The counting maths behind policy backtesting, kept pure so it can be tested +/// without a database. +/// +/// The critical subtlety: the live evaluator keeps ONE open alert per policy and +/// only raises a new one after the previous reached a terminal state. So the +/// honest answer to "how often would this have fired" is the number of *episodes* +/// — transitions from below-threshold to at-or-above — not the number of +/// evaluation cycles that were above it. A single sustained 30-day breach is one +/// alert, not 2,880. +/// +public static class BacktestMath +{ + public sealed record Outcome(int Episodes, int MaxValue, IReadOnlyList FiredAt); + + /// Max firing timestamps returned; enough to be useful, bounded for payload size. + private const int MaxSamples = 50; + + /// + /// Replays a rolling-window count (activity policies) across a time range. + /// must be sorted ascending. + /// + public static Outcome CountEpisodes( + IReadOnlyList eventTimes, + DateTimeOffset from, DateTimeOffset to, + TimeSpan window, TimeSpan step, int threshold) + { + if (step <= TimeSpan.Zero) step = TimeSpan.FromMinutes(15); + if (threshold < 1) threshold = 1; + + var episodes = 0; + var maxValue = 0; + var firedAt = new List(); + var wasAbove = false; + + for (var t = from; t <= to; t += step) + { + var windowStart = t - window; + // eventTimes is sorted, so the window count is the gap between two + // binary-search boundaries rather than a rescan per step. + var lo = LowerBound(eventTimes, windowStart); + var hi = UpperBound(eventTimes, t); + var value = hi - lo; + + if (value > maxValue) maxValue = value; + + var isAbove = value >= threshold; + if (isAbove && !wasAbove) + { + episodes++; + if (firedAt.Count < MaxSamples) firedAt.Add(t); + } + wasAbove = isAbove; + } + + return new Outcome(episodes, maxValue, firedAt); + } + + /// + /// Replays a sampled series (metric policies backed by trend snapshots). + /// Each point is an observed value at a point in time; episodes are counted + /// the same rising-edge way. + /// + public static Outcome CountEpisodesFromSeries( + IReadOnlyList<(DateTimeOffset At, int Value)> series, int threshold) + { + if (threshold < 1) threshold = 1; + + var episodes = 0; + var maxValue = 0; + var firedAt = new List(); + var wasAbove = false; + + foreach (var (at, value) in series.OrderBy(p => p.At)) + { + if (value > maxValue) maxValue = value; + var isAbove = value >= threshold; + if (isAbove && !wasAbove) + { + episodes++; + if (firedAt.Count < MaxSamples) firedAt.Add(at); + } + wasAbove = isAbove; + } + + return new Outcome(episodes, maxValue, firedAt); + } + + /// First index with value >= target. + private static int LowerBound(IReadOnlyList sorted, DateTimeOffset target) + { + int lo = 0, hi = sorted.Count; + while (lo < hi) + { + var mid = lo + (hi - lo) / 2; + if (sorted[mid] < target) lo = mid + 1; else hi = mid; + } + return lo; + } + + /// First index with value > target. + private static int UpperBound(IReadOnlyList sorted, DateTimeOffset target) + { + int lo = 0, hi = sorted.Count; + while (lo < hi) + { + var mid = lo + (hi - lo) / 2; + if (sorted[mid] <= target) lo = mid + 1; else hi = mid; + } + return lo; + } +} diff --git a/src/M365SecurityDashboard.Api/Services/ConditionalAccessGapAnalyzer.cs b/src/M365SecurityDashboard.Api/Services/ConditionalAccessGapAnalyzer.cs new file mode 100644 index 0000000..ce7eb0e --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/ConditionalAccessGapAnalyzer.cs @@ -0,0 +1,138 @@ +using System.Text.Json; + +namespace M365SecurityDashboard.Api.Services; + +/// +/// Analyzes Conditional Access policies for common coverage gaps: no tenant-wide +/// MFA, legacy authentication left unblocked, unenforced (report-only/disabled) +/// policies, and MFA exemptions. Pure functions over a parsed policy view so the +/// findings logic is unit-testable without Graph. +/// +public static class ConditionalAccessGapAnalyzer +{ + /// Flattened view of the CA policy fields the analysis needs. + public sealed record CaPolicyView( + string Name, string State, bool RequiresMfa, bool Blocks, + bool IncludesAllUsers, bool IncludesAllApps, + int ExcludedUsers, int ExcludedGroups, IReadOnlyList ClientAppTypes); + + public sealed record Finding(string Severity, string Title, string Detail, string Recommendation); + + private static readonly string[] LegacyClientTypes = ["exchangeActiveSync", "other"]; + + private static bool IsEnabled(CaPolicyView p) => string.Equals(p.State, "enabled", StringComparison.OrdinalIgnoreCase); + private static bool IsReportOnly(CaPolicyView p) => string.Equals(p.State, "enabledForReportingButNotEnforced", StringComparison.OrdinalIgnoreCase); + private static bool IsDisabled(CaPolicyView p) => string.Equals(p.State, "disabled", StringComparison.OrdinalIgnoreCase); + + private static bool TargetsLegacyAuth(CaPolicyView p) => + p.ClientAppTypes.Any(c => LegacyClientTypes.Contains(c, StringComparer.OrdinalIgnoreCase)); + + /// Produces the gap findings, most severe first. + public static List Analyze(IReadOnlyList policies) + { + var findings = new List(); + + if (policies.Count == 0) + { + findings.Add(new Finding("critical", "No Conditional Access policies configured", + "The tenant has no Conditional Access policies at all.", + "Create a baseline that requires MFA for all users and blocks legacy authentication.")); + return findings; + } + + var enabled = policies.Where(IsEnabled).ToList(); + + // 1. Tenant-wide MFA baseline. + var hasBaselineMfa = enabled.Any(p => p.RequiresMfa && p.IncludesAllUsers && p.IncludesAllApps); + if (!hasBaselineMfa) + { + var partial = enabled.Any(p => p.RequiresMfa); + findings.Add(new Finding("critical", "No tenant-wide MFA policy", + partial + ? "MFA is required by some enabled policies, but none covers all users and all apps." + : "No enabled policy requires multi-factor authentication.", + "Add an enabled policy requiring MFA for All users and All cloud apps (exclude only break-glass accounts).")); + } + + // 2. Legacy authentication. + var blocksLegacy = enabled.Any(p => p.Blocks && TargetsLegacyAuth(p)); + if (!blocksLegacy) + { + findings.Add(new Finding("high", "Legacy authentication is not blocked", + "No enabled policy blocks legacy authentication clients (which cannot enforce MFA).", + "Add an enabled policy that blocks the 'Exchange ActiveSync' and 'Other clients' legacy client app types.")); + } + + // 3. MFA exemptions on the enforced MFA policies. + foreach (var p in enabled.Where(p => p.RequiresMfa && (p.ExcludedUsers > 0 || p.ExcludedGroups > 0))) + { + var bits = new List(); + if (p.ExcludedUsers > 0) bits.Add($"{p.ExcludedUsers} user{(p.ExcludedUsers == 1 ? "" : "s")}"); + if (p.ExcludedGroups > 0) bits.Add($"{p.ExcludedGroups} group{(p.ExcludedGroups == 1 ? "" : "s")}"); + findings.Add(new Finding("medium", $"MFA exemptions on \"{p.Name}\"", + $"This MFA policy exempts {string.Join(" and ", bits)} — those identities can sign in without MFA.", + "Confirm every exclusion is a documented break-glass account; remove the rest.")); + } + + // 4. Report-only policies (configured but not enforcing). + var reportOnly = policies.Where(IsReportOnly).ToList(); + if (reportOnly.Count > 0) + { + findings.Add(new Finding("medium", $"{reportOnly.Count} policy(ies) are report-only", + $"Report-only policies do not enforce controls: {string.Join(", ", reportOnly.Take(5).Select(p => $"\"{p.Name}\""))}{(reportOnly.Count > 5 ? "…" : "")}.", + "Review report-only impact, then switch to On to start enforcing.")); + } + + // 5. Disabled policies. + var disabled = policies.Where(IsDisabled).ToList(); + if (disabled.Count > 0) + { + findings.Add(new Finding("low", $"{disabled.Count} policy(ies) are disabled", + $"Disabled policies provide no protection: {string.Join(", ", disabled.Take(5).Select(p => $"\"{p.Name}\""))}{(disabled.Count > 5 ? "…" : "")}.", + "Enable them if intended, or delete stale policies to reduce confusion.")); + } + + var order = new Dictionary { ["critical"] = 0, ["high"] = 1, ["medium"] = 2, ["low"] = 3 }; + return findings.OrderBy(f => order.GetValueOrDefault(f.Severity, 4)).ToList(); + } + + /// Parses a raw Graph conditionalAccess policy element into the analysis view. + public static CaPolicyView Parse(JsonElement p) + { + var name = p.TryGetProperty("displayName", out var n) ? n.GetString() ?? "Unnamed" : "Unnamed"; + var state = p.TryGetProperty("state", out var s) ? s.GetString() ?? "unknown" : "unknown"; + + bool includesAllUsers = false, includesAllApps = false; + int exclUsers = 0, exclGroups = 0; + var clientAppTypes = new List(); + + if (p.TryGetProperty("conditions", out var cond) && cond.ValueKind == JsonValueKind.Object) + { + if (cond.TryGetProperty("users", out var u) && u.ValueKind == JsonValueKind.Object) + { + if (u.TryGetProperty("includeUsers", out var inc) && inc.ValueKind == JsonValueKind.Array) + includesAllUsers = inc.EnumerateArray().Any(x => string.Equals(x.GetString(), "All", StringComparison.OrdinalIgnoreCase)); + if (u.TryGetProperty("excludeUsers", out var exU) && exU.ValueKind == JsonValueKind.Array) + exclUsers = exU.GetArrayLength(); + if (u.TryGetProperty("excludeGroups", out var exG) && exG.ValueKind == JsonValueKind.Array) + exclGroups = exG.GetArrayLength(); + } + if (cond.TryGetProperty("applications", out var ap) && ap.ValueKind == JsonValueKind.Object && + ap.TryGetProperty("includeApplications", out var incA) && incA.ValueKind == JsonValueKind.Array) + includesAllApps = incA.EnumerateArray().Any(x => string.Equals(x.GetString(), "All", StringComparison.OrdinalIgnoreCase)); + if (cond.TryGetProperty("clientAppTypes", out var cat) && cat.ValueKind == JsonValueKind.Array) + clientAppTypes.AddRange(cat.EnumerateArray().Select(x => x.GetString() ?? "").Where(x => x.Length > 0)); + } + + bool requiresMfa = false, blocks = false; + if (p.TryGetProperty("grantControls", out var gc) && gc.ValueKind == JsonValueKind.Object && + gc.TryGetProperty("builtInControls", out var bic) && bic.ValueKind == JsonValueKind.Array) + { + var controls = bic.EnumerateArray().Select(x => x.GetString() ?? "").ToList(); + requiresMfa = controls.Contains("mfa", StringComparer.OrdinalIgnoreCase); + blocks = controls.Contains("block", StringComparer.OrdinalIgnoreCase); + } + + return new CaPolicyView(name, state, requiresMfa, blocks, includesAllUsers, includesAllApps, exclUsers, exclGroups, clientAppTypes); + } +} diff --git a/src/M365SecurityDashboard.Api/Services/CsvSanitizer.cs b/src/M365SecurityDashboard.Api/Services/CsvSanitizer.cs new file mode 100644 index 0000000..c258f45 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/CsvSanitizer.cs @@ -0,0 +1,33 @@ +namespace M365SecurityDashboard.Api.Services; + +/// +/// One place for CSV field encoding, shared by every export path. +/// +/// Two separate concerns, both required: +/// • RFC-4180 quoting so commas/quotes/newlines survive a round-trip. +/// • Formula-injection neutralisation. Alert titles, user display names and +/// audit actor names are tenant-controlled. A field beginning =, +, - or @ +/// is evaluated as a formula when the export is opened in Excel or Sheets +/// (e.g. =HYPERLINK(...) exfiltrating data on open). Prefixing an apostrophe +/// forces text and is invisible to the reader. +/// +public static class CsvSanitizer +{ + private static readonly char[] Dangerous = ['=', '+', '-', '@', '\t', '\r']; + + /// Neutralises a leading formula trigger. Does not quote. + public static string Neutralize(string? s) + { + if (string.IsNullOrEmpty(s)) return s ?? ""; + return Array.IndexOf(Dangerous, s[0]) >= 0 ? "'" + s : s; + } + + /// Full CSV field encoding: formula-safe, then RFC-4180 quoted. + public static string Field(string? s) + { + var v = Neutralize(s); + return v.Contains(',') || v.Contains('"') || v.Contains('\n') || v.Contains('\r') + ? "\"" + v.Replace("\"", "\"\"") + "\"" + : v; + } +} diff --git a/src/M365SecurityDashboard.Api/Services/DataRetentionWorker.cs b/src/M365SecurityDashboard.Api/Services/DataRetentionWorker.cs new file mode 100644 index 0000000..c78cb01 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/DataRetentionWorker.cs @@ -0,0 +1,156 @@ +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace M365SecurityDashboard.Api.Services; + +/// +/// Nightly data-retention job. Deletes rows older than the configured retention +/// windows (see ) so the database stays bounded on +/// long-running installs. Only terminal data is pruned — open alerts and +/// unresolved triggered alerts are always kept regardless of age. +/// +public sealed class DataRetentionWorker( + IServiceProvider services, + IOptions options, + ILogger logger) : BackgroundService +{ + private static readonly TimeSpan StartupDelay = TimeSpan.FromMinutes(2); + private static readonly TimeSpan Interval = TimeSpan.FromHours(24); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try { await Task.Delay(StartupDelay, stoppingToken); } + catch (OperationCanceledException) { return; } + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var summary = await PruneAsync(db, options.Value, stoppingToken); + if (summary.TotalDeleted > 0) + { + logger.LogInformation("Retention prune removed {Total} rows: {Summary}", + summary.TotalDeleted, summary.Describe()); + var audit = scope.ServiceProvider.GetRequiredService(); + await audit.WriteAsync("retention.prune", "database", null, summary.Describe(), stoppingToken); + } + else + { + logger.LogDebug("Retention prune: nothing to remove."); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } + catch (Exception ex) + { + logger.LogError(ex, "Data retention prune failed"); + } + + try { await Task.Delay(Interval, stoppingToken); } + catch (OperationCanceledException) { break; } + } + } + + /// + /// One prune pass. Batched RemoveRange (not ExecuteDelete) so it works on every + /// EF provider, including the in-memory one used by tests; volumes stay small + /// because the job runs daily. + /// + public static async Task PruneAsync(AppDbContext db, RetentionOptions o, CancellationToken ct) + { + var now = DateTimeOffset.UtcNow; + var summary = new PruneSummary(); + + if (o.ResolvedAlertsDays > 0) + { + var cutoff = now.AddDays(-o.ResolvedAlertsDays); + summary.ResolvedAlerts = await DeleteBatchedAsync(db, + db.SecurityAlerts.Where(a => a.IsResolved && a.LastUpdatedAt < cutoff), ct); + } + + if (o.TriggeredAlertsDays > 0) + { + var cutoff = now.AddDays(-o.TriggeredAlertsDays); + summary.TriggeredAlerts = await DeleteBatchedAsync(db, + db.TriggeredAlerts.Where(t => + (t.Status == "resolved" || t.Status == "auto_resolved") && t.TriggeredAt < cutoff), ct); + } + + if (o.NotificationLogsDays > 0) + { + var cutoff = now.AddDays(-o.NotificationLogsDays); + summary.NotificationLogs = await DeleteBatchedAsync(db, + db.NotificationLogs.Where(l => l.SentAt < cutoff), ct); + } + + if (o.CollectionRunsDays > 0) + { + var cutoff = now.AddDays(-o.CollectionRunsDays); + summary.CollectionRuns = await DeleteBatchedAsync(db, + db.CollectionRuns.Where(r => r.StartedAt < cutoff), ct); + } + + if (o.TrendSnapshotsDays > 0) + { + var cutoff = now.AddDays(-o.TrendSnapshotsDays); + summary.TrendSnapshots = await DeleteBatchedAsync(db, + db.TrendSnapshots.Where(t => t.CapturedAt < cutoff), ct); + } + + if (o.AuditEntriesDays > 0) + { + var cutoff = now.AddDays(-o.AuditEntriesDays); + summary.AuditEntries = await DeleteBatchedAsync(db, + db.AuditEntries.Where(a => a.Timestamp < cutoff), ct); + } + + if (o.TenantAuditEventsDays > 0) + { + var cutoff = now.AddDays(-o.TenantAuditEventsDays); + summary.TenantAuditEvents = await DeleteBatchedAsync(db, + db.AuditEvents.Where(e => e.OccurredAt < cutoff), ct); + } + + return summary; + } + + private static async Task DeleteBatchedAsync(AppDbContext db, IQueryable query, CancellationToken ct) + where T : class + { + const int batchSize = 5000; + var deleted = 0; + while (true) + { + var batch = await query.Take(batchSize).ToListAsync(ct); + if (batch.Count == 0) break; + db.RemoveRange(batch); + await db.SaveChangesAsync(ct); + deleted += batch.Count; + if (batch.Count < batchSize) break; + } + return deleted; + } + + public sealed class PruneSummary + { + public int ResolvedAlerts { get; set; } + public int TriggeredAlerts { get; set; } + public int NotificationLogs { get; set; } + public int CollectionRuns { get; set; } + public int TrendSnapshots { get; set; } + public int AuditEntries { get; set; } + public int TenantAuditEvents { get; set; } + + public int TotalDeleted => + ResolvedAlerts + TriggeredAlerts + NotificationLogs + CollectionRuns + TrendSnapshots + AuditEntries + TenantAuditEvents; + + public string Describe() => + $"resolved alerts {ResolvedAlerts}, triggered alerts {TriggeredAlerts}, " + + $"notification logs {NotificationLogs}, collection runs {CollectionRuns}, " + + $"trend snapshots {TrendSnapshots}, audit entries {AuditEntries}, " + + $"tenant audit events {TenantAuditEvents}"; + } +} diff --git a/src/M365SecurityDashboard.Api/Services/DigestBuilder.cs b/src/M365SecurityDashboard.Api/Services/DigestBuilder.cs new file mode 100644 index 0000000..6c3d0d5 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/DigestBuilder.cs @@ -0,0 +1,204 @@ +using System.Globalization; +using System.Net; +using System.Text; +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace M365SecurityDashboard.Api.Services; + +/// +/// Assembles the executive security digest — current posture, week-over-week +/// trend movement, and the top open alerts — into an HTML email body and an +/// optional CSV summary. Reads only local collected data; never calls Graph. +/// +public sealed class DigestBuilder(AppDbContext db) +{ + public sealed record Metric(string Label, string Value, double? Delta, string DeltaLabel, bool HigherIsWorse); + + public sealed record TopAlert(string PolicyName, string Severity, string Condition, int MetricValue, DateTimeOffset TriggeredAt, string Category, string Status, string? AssignedTo); + + public sealed record Digest( + string Subject, + string HtmlBody, + string? Csv, + DateTimeOffset GeneratedAt, + IReadOnlyList Metrics, + IReadOnlyList TopAlerts, + bool HasData); + + /// Builds the digest for the trailing (default 7). + public async Task BuildAsync(int windowDays, CancellationToken ct) + { + var now = DateTimeOffset.UtcNow; + var windowStart = now.AddDays(-Math.Max(1, windowDays)); + + // Posture "now" = the most recent snapshot; "prior" = the last snapshot at or + // before the window start, so deltas reflect movement across the period. + var latest = await db.TrendSnapshots.AsNoTracking() + .OrderByDescending(t => t.CapturedAt).FirstOrDefaultAsync(ct); + var prior = await db.TrendSnapshots.AsNoTracking() + .Where(t => t.CapturedAt <= windowStart) + .OrderByDescending(t => t.CapturedAt).FirstOrDefaultAsync(ct); + + var metrics = BuildMetrics(latest, prior); + + // Open alerts, excluding those currently snoozed — a snoozed alert has been + // deliberately silenced and shouldn't surface in an executive summary as if + // it were unhandled. + var openAlerts = (await db.TriggeredAlerts.AsNoTracking() + .Where(t => t.Status == "new" || t.Status == "acknowledged") + .ToListAsync(ct)) + .Where(t => t.SnoozedUntil == null || t.SnoozedUntil <= now) + .ToList(); + // Top open alerts by severity then recency, capped so the email stays scannable. + var topAlerts = openAlerts + .OrderByDescending(a => SeverityRank(a.Severity)) + .ThenByDescending(a => a.TriggeredAt) + .Take(10) + .Select(a => new TopAlert(a.PolicyName, a.Severity, a.Condition, a.MetricValue, a.TriggeredAt, a.Category, a.Status, a.AssignedTo)) + .ToList(); + + var newInWindow = openAlerts.Count(a => a.TriggeredAt >= windowStart); + var hasData = latest != null || openAlerts.Count > 0; + + var subject = $"[Vigil365] Weekly security digest — {now:yyyy-MM-dd}"; + var html = RenderHtml(now, windowDays, metrics, topAlerts, openAlerts.Count, newInWindow, latest == null); + var csv = RenderCsv(now, metrics, openAlerts); + + return new Digest(subject, html, csv, now, metrics, topAlerts, hasData); + } + + private static List BuildMetrics(TrendSnapshot? latest, TrendSnapshot? prior) + { + if (latest == null) return []; + double? D(Func sel) => prior == null ? null : sel(latest) - sel(prior); + static string Pct(double v) => v.ToString("0.#", CultureInfo.InvariantCulture) + "%"; + + return + [ + new("Secure Score", Pct(latest.SecureScorePct), D(t => t.SecureScorePct), "pts", HigherIsWorse: false), + new("MFA coverage", Pct(latest.MfaCoveragePct), D(t => t.MfaCoveragePct), "pts", HigherIsWorse: false), + new("Risky users", latest.RiskyUsersCount.ToString(), D(t => t.RiskyUsersCount), "", HigherIsWorse: true), + new("Non-compliant devices", latest.NonCompliantDevicesCount.ToString(), D(t => t.NonCompliantDevicesCount), "", HigherIsWorse: true), + new("Critical alerts", latest.CriticalAlertsCount.ToString(), D(t => t.CriticalAlertsCount), "", HigherIsWorse: true), + new("High alerts", latest.HighAlertsCount.ToString(), D(t => t.HighAlertsCount), "", HigherIsWorse: true), + new("Compliance issues", latest.ComplianceIssuesCount.ToString(), D(t => t.ComplianceIssuesCount), "", HigherIsWorse: true), + ]; + } + + private static int SeverityRank(string? sev) => (sev ?? "").ToLowerInvariant() switch + { + "critical" => 4, "high" => 3, "medium" => 2, "low" => 1, _ => 0, + }; + + private static string SevColor(string? sev) => (sev ?? "").ToLowerInvariant() switch + { + "critical" => "dc2626", "high" => "ea580c", "medium" => "d97706", "low" => "2563eb", _ => "6b7280", + }; + + /// Delta rendered as a colored ▲/▼ chip — green when moving the safe way. + private static string DeltaChip(Metric m) + { + if (m.Delta is not { } d || Math.Abs(d) < 0.05) + return ""; + var worse = m.HigherIsWorse ? d > 0 : d < 0; + var color = worse ? "#dc2626" : "#16a34a"; + var arrow = d > 0 ? "▲" : "▼"; + var mag = Math.Abs(d).ToString("0.#", CultureInfo.InvariantCulture); + var suffix = string.IsNullOrEmpty(m.DeltaLabel) ? "" : " " + m.DeltaLabel; + return $"{arrow} {mag}{suffix}"; + } + + private static string RenderHtml( + DateTimeOffset now, int windowDays, IReadOnlyList metrics, + IReadOnlyList topAlerts, int openCount, int newInWindow, bool noPosture) + { + var sb = new StringBuilder(); + sb.Append("
"); + sb.Append("

Vigil365 — Weekly Security Digest

"); + sb.Append($"

Posture as of {now:dddd, dd MMM yyyy HH:mm} UTC · trailing {windowDays} days

"); + + sb.Append($"
" + + $"{openCount} open alert{(openCount == 1 ? "" : "s")} · {newInWindow} new this period
"); + + if (noPosture) + { + sb.Append("

No posture snapshots have been captured yet. " + + "Trend metrics will appear once the collector has run at least once.

"); + } + else + { + sb.Append(""); + sb.Append("" + + "" + + "" + + ""); + foreach (var m in metrics) + { + sb.Append("" + + $"" + + $"" + + $""); + } + sb.Append("
MetricCurrentChange
{WebUtility.HtmlEncode(m.Label)}{WebUtility.HtmlEncode(m.Value)}{DeltaChip(m)}
"); + } + + sb.Append("

Top open alerts

"); + if (topAlerts.Count == 0) + { + sb.Append("

No open alerts. 🎉

"); + } + else + { + sb.Append(""); + foreach (var a in topAlerts) + { + var assigned = string.IsNullOrEmpty(a.AssignedTo) ? "Unassigned" : WebUtility.HtmlEncode(a.AssignedTo); + sb.Append("" + + $"" + + $"" + + $""); + } + sb.Append("
" + + $"
{WebUtility.HtmlEncode(a.Severity.ToUpperInvariant())}

" + + $"
{WebUtility.HtmlEncode(a.Category.ToUpperInvariant())}
" + + $"
" + + $"{WebUtility.HtmlEncode(a.PolicyName)}
" + + $"{WebUtility.HtmlEncode(a.Condition)}" + + $"
" + + $"{WebUtility.HtmlEncode(a.Status)}" + + $"👤 {assigned}" + + $"
" + + $"
{a.TriggeredAt:dd MMM}
"); + } + + sb.Append("

Generated by Vigil365 · read-only monitoring · no changes were made to your tenant.

"); + sb.Append("
"); + return sb.ToString(); + } + + private static string RenderCsv(DateTimeOffset now, IReadOnlyList metrics, IReadOnlyList allOpenAlerts) + { + var sb = new StringBuilder(); + sb.AppendLine($"Vigil365 Weekly Security Digest,{now:yyyy-MM-dd HH:mm} UTC"); + sb.AppendLine(); + sb.AppendLine("Metric,Current,Change"); + foreach (var m in metrics) + { + var delta = m.Delta is { } d ? d.ToString("+0.#;-0.#;0", CultureInfo.InvariantCulture) + (string.IsNullOrEmpty(m.DeltaLabel) ? "" : " " + m.DeltaLabel) : "n/a"; + sb.AppendLine($"{Csv(m.Label)},{Csv(m.Value)},{Csv(delta)}"); + } + sb.AppendLine(); + sb.AppendLine("Severity,Category,Policy,Condition,Status,Assigned To,Value,Triggered (UTC)"); + foreach (var a in allOpenAlerts) + sb.AppendLine($"{Csv(a.Severity)},{Csv(a.Category)},{Csv(a.PolicyName)},{Csv(a.Condition)},{Csv(a.Status)},{Csv(a.AssignedTo)},{a.MetricValue},{a.TriggeredAt:yyyy-MM-dd HH:mm}"); + return sb.ToString(); + } + + /// RFC-4180 field escaping + formula-injection guard. + private static string Csv(string? s) + { + return CsvSanitizer.Field(s); + } +} diff --git a/src/M365SecurityDashboard.Api/Services/DigestPdfRenderer.cs b/src/M365SecurityDashboard.Api/Services/DigestPdfRenderer.cs new file mode 100644 index 0000000..d65b8b8 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/DigestPdfRenderer.cs @@ -0,0 +1,106 @@ +using System.Globalization; +using System.Text; + +namespace M365SecurityDashboard.Api.Services; + +public sealed class DigestPdfRenderer +{ + public byte[] Render(DigestBuilder.Digest digest) + { + var lines = new List<(string Text, int Size, bool Bold)> + { + ("Vigil365 - Executive Security Digest", 18, true), + ($"Generated {digest.GeneratedAt:yyyy-MM-dd HH:mm} UTC", 10, false), + ("", 10, false), + ("Posture", 13, true), + }; + + if (digest.Metrics.Count == 0) + { + lines.Add(("No posture snapshot captured yet.", 10, false)); + } + else + { + foreach (var m in digest.Metrics) + { + var delta = m.Delta is { } d + ? d.ToString("+0.#;-0.#;0", CultureInfo.InvariantCulture) + (string.IsNullOrEmpty(m.DeltaLabel) ? "" : " " + m.DeltaLabel) + : "n/a"; + lines.Add(($" {m.Label}: {m.Value} ({delta})", 10, false)); + } + } + + lines.Add(("", 10, false)); + lines.Add(("Top open alerts", 13, true)); + if (digest.TopAlerts.Count == 0) + { + lines.Add(("No open alerts.", 10, false)); + } + else + { + foreach (var alert in digest.TopAlerts) + lines.Add(($" [{alert.Severity.ToUpperInvariant()}] {alert.PolicyName} - {alert.Condition}", 10, false)); + } + + lines.Add(("", 10, false)); + lines.Add(("Read-only monitoring: this report did not change the Microsoft 365 tenant.", 9, false)); + + return SimplePdf(lines); + } + + private static byte[] SimplePdf(IReadOnlyList<(string Text, int Size, bool Bold)> lines) + { + var objects = new List(); + objects.Add("<< /Type /Catalog /Pages 2 0 R >>"); + objects.Add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + objects.Add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>"); + objects.Add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + objects.Add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>"); + + var content = new StringBuilder(); + content.AppendLine("BT"); + var y = 750; + foreach (var line in lines) + { + if (y < 48) break; + var font = line.Bold ? "F2" : "F1"; + content.AppendLine($"/{font} {line.Size} Tf"); + content.AppendLine($"1 0 0 1 50 {y} Tm ({Escape(line.Text)}) Tj"); + y -= Math.Max(14, line.Size + 5); + } + content.AppendLine("ET"); + var stream = content.ToString(); + objects.Add($"<< /Length {Encoding.ASCII.GetByteCount(stream)} >>\nstream\n{stream}endstream"); + + var pdf = new StringBuilder(); + var offsets = new List { 0 }; + pdf.AppendLine("%PDF-1.4"); + for (var i = 0; i < objects.Count; i++) + { + offsets.Add(Encoding.ASCII.GetByteCount(pdf.ToString())); + pdf.AppendLine($"{i + 1} 0 obj"); + pdf.AppendLine(objects[i]); + pdf.AppendLine("endobj"); + } + + var xref = Encoding.ASCII.GetByteCount(pdf.ToString()); + pdf.AppendLine("xref"); + pdf.AppendLine($"0 {objects.Count + 1}"); + pdf.AppendLine("0000000000 65535 f "); + foreach (var offset in offsets.Skip(1)) + pdf.AppendLine($"{offset:0000000000} 00000 n "); + pdf.AppendLine("trailer"); + pdf.AppendLine($"<< /Size {objects.Count + 1} /Root 1 0 R >>"); + pdf.AppendLine("startxref"); + pdf.AppendLine(xref.ToString(CultureInfo.InvariantCulture)); + pdf.AppendLine("%%EOF"); + return Encoding.ASCII.GetBytes(pdf.ToString()); + } + + private static string Escape(string s) + { + var clean = s.Replace('\u2014', '-').Replace('\u2013', '-').Replace('\u2192', '>') + .Replace('\u25b2', '^').Replace('\u25bc', 'v'); + return clean.Replace("\\", "\\\\").Replace("(", "\\(").Replace(")", "\\)"); + } +} diff --git a/src/M365SecurityDashboard.Api/Services/EntityProfileBuilder.cs b/src/M365SecurityDashboard.Api/Services/EntityProfileBuilder.cs new file mode 100644 index 0000000..f198054 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/EntityProfileBuilder.cs @@ -0,0 +1,74 @@ +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace M365SecurityDashboard.Api.Services; + +/// +/// Assembles an investigation profile for a single entity (a user UPN or a device +/// name) from already-collected data: its security alerts and the tenant audit +/// activity it took part in, merged into one reverse-chronological timeline. This +/// is the dashboard → investigation-tool drill-down. Read-only; local data only. +/// +public sealed class EntityProfileBuilder(AppDbContext db) +{ + public sealed record TimelineItem( + DateTimeOffset At, string Type, string Severity, string Title, string Detail, long? AlertId); + + public sealed record Summary( + string Kind, string Id, int AlertCount, int OpenAlertCount, int ActivityCount, + DateTimeOffset? FirstSeen, DateTimeOffset? LastSeen, Dictionary AlertsBySeverity); + + public sealed record Profile(Summary Summary, IReadOnlyList Timeline, bool Found); + + /// Builds the profile. is "user" or "device". + public async Task BuildAsync(string kind, string id, int maxItems, CancellationToken ct) + { + kind = (kind ?? "").Trim().ToLowerInvariant(); + id = (id ?? "").Trim(); + var isUser = kind != "device"; + + var alerts = isUser + ? await db.SecurityAlerts.AsNoTracking().Where(a => a.UserPrincipalName == id).ToListAsync(ct) + : await db.SecurityAlerts.AsNoTracking().Where(a => a.DeviceName == id).ToListAsync(ct); + + var activities = isUser + ? await db.AuditEvents.AsNoTracking().Where(e => e.ActorUpn == id || e.TargetName == id).ToListAsync(ct) + : await db.AuditEvents.AsNoTracking().Where(e => e.TargetName == id).ToListAsync(ct); + + var timeline = new List(alerts.Count + activities.Count); + foreach (var a in alerts) + { + var detail = $"{a.Service} · {(a.IsResolved ? "resolved" : "active")}"; + timeline.Add(new TimelineItem(a.DetectedAt, "alert", a.Severity.ToString().ToLowerInvariant(), a.Title, detail, a.Id)); + } + foreach (var e in activities) + { + // Frame the activity from this entity's point of view: did they do it, or was it done to them? + var role = string.Equals(e.ActorUpn, id, StringComparison.OrdinalIgnoreCase) ? "by this user" + : string.Equals(e.TargetName, id, StringComparison.OrdinalIgnoreCase) ? "targeted this entity" + : ""; + var actor = e.ActorUpn ?? e.ActorApp; + var detailParts = new[] { e.Category, e.Result, role, actor is null ? null : $"actor: {actor}" } + .Where(s => !string.IsNullOrWhiteSpace(s)); + var severity = string.Equals(e.Result, "failure", StringComparison.OrdinalIgnoreCase) ? "medium" : "informational"; + timeline.Add(new TimelineItem(e.OccurredAt, "activity", severity, e.Activity, string.Join(" · ", detailParts), null)); + } + + timeline.Sort((x, y) => y.At.CompareTo(x.At)); + var capped = timeline.Count > maxItems ? timeline.Take(maxItems).ToList() : timeline; + + var summary = new Summary( + Kind: isUser ? "user" : "device", + Id: id, + AlertCount: alerts.Count, + OpenAlertCount: alerts.Count(a => !a.IsResolved), + ActivityCount: activities.Count, + FirstSeen: timeline.Count > 0 ? timeline.Min(t => t.At) : null, + LastSeen: timeline.Count > 0 ? timeline.Max(t => t.At) : null, + AlertsBySeverity: alerts.GroupBy(a => a.Severity.ToString().ToLowerInvariant()) + .ToDictionary(g => g.Key, g => g.Count())); + + return new Profile(summary, capped, alerts.Count > 0 || activities.Count > 0); + } +} diff --git a/src/M365SecurityDashboard.Api/Services/GraphApiClient.cs b/src/M365SecurityDashboard.Api/Services/GraphApiClient.cs index 3314f12..dfd22ad 100644 --- a/src/M365SecurityDashboard.Api/Services/GraphApiClient.cs +++ b/src/M365SecurityDashboard.Api/Services/GraphApiClient.cs @@ -1,4 +1,5 @@ using System.Net.Http.Headers; +using System.Security.Cryptography.X509Certificates; using System.Text.Json; using Azure.Core; using Azure.Identity; @@ -9,16 +10,78 @@ namespace M365SecurityDashboard.Api.Services; public sealed class GraphApiClient { - private static readonly string[] Scopes = ["https://graph.microsoft.com/.default"]; private readonly HttpClient _http; private readonly GraphOptions _options; - private readonly ClientSecretCredential _credential; + private readonly TokenCredential _credential; public GraphApiClient(HttpClient http, IOptions options) { _http = http; _options = options.Value; - _credential = new ClientSecretCredential(_options.TenantId, _options.ClientId, _options.ClientSecret); + _credential = BuildCredential(_options); + } + + /// + /// Certificate auth is preferred when configured (no long-lived secret to + /// store or rotate); the client secret remains the fallback so existing + /// installs keep working during migration. + /// + public static TokenCredential BuildCredential(GraphOptions o) + { + var authOptions = new ClientSecretCredentialOptions(); + var certOptions = new ClientCertificateCredentialOptions(); + + if (!string.IsNullOrWhiteSpace(o.LoginInstance)) + { + try + { + var uri = new Uri(o.LoginInstance); + authOptions.AuthorityHost = uri; + certOptions.AuthorityHost = uri; + } + catch { /* fallback to default if malformed */ } + } + + if (o.HasCertificate()) + return new ClientCertificateCredential(o.TenantId, o.ClientId, LoadCertificate(o), certOptions); + return new ClientSecretCredential(o.TenantId, o.ClientId, o.ClientSecret, authOptions); + } + + public static X509Certificate2 LoadCertificate(GraphOptions o) + { + if (!string.IsNullOrWhiteSpace(o.CertificateThumbprint)) + { + var thumb = o.CertificateThumbprint.Replace(" ", "").ToUpperInvariant(); + foreach (var location in new[] { StoreLocation.CurrentUser, StoreLocation.LocalMachine }) + { + // A store that cannot be opened is skipped, not fatal. On Linux + // (the Docker deployment) LocalMachine\My does not exist and + // Open() throws CryptographicException — which otherwise escapes + // as a cryptic error instead of the clear "not found" below, and + // masks a certificate that IS present in the other store. + try + { + using var store = new X509Store(StoreName.My, location); + store.Open(OpenFlags.ReadOnly); + var match = store.Certificates.Find(X509FindType.FindByThumbprint, thumb, validOnly: false); + if (match.Count > 0) return match[0]; + } + catch (Exception ex) when (ex is System.Security.Cryptography.CryptographicException + or PlatformNotSupportedException + or UnauthorizedAccessException) + { + // store unavailable on this platform/host — try the next one + } + } + throw new InvalidOperationException( + $"Certificate with thumbprint '{thumb}' was not found in CurrentUser\\My or LocalMachine\\My."); + } + + if (!File.Exists(o.CertificatePath)) + throw new InvalidOperationException($"Certificate file not found: '{o.CertificatePath}'."); + return string.IsNullOrEmpty(o.CertificatePassword) + ? new X509Certificate2(o.CertificatePath) + : new X509Certificate2(o.CertificatePath, o.CertificatePassword); } public async Task> GetCollectionAsync(string path, CancellationToken ct) @@ -29,6 +92,8 @@ public async Task> GetCollectionAsync(string path, Ca : $"{_options.BaseUrl.TrimEnd('/')}/{path.TrimStart('/')}"; var isFirstPage = true; + var throttleRetries = 0; + const int maxThrottleRetries = 3; // a persistently throttling tenant must fail, not hang forever while (!string.IsNullOrWhiteSpace(next)) { string? nextForIteration = null; @@ -36,12 +101,19 @@ public async Task> GetCollectionAsync(string path, Ca { using var request = new HttpRequestMessage(HttpMethod.Get, next); request.Headers.TryAddWithoutValidation("User-Agent", "M365SecurityDashboard/1.0"); - var token = await _credential.GetTokenAsync(new TokenRequestContext(Scopes), ct); + var token = await _credential.GetTokenAsync(new TokenRequestContext(new[] { $"{_options.BaseUrl.TrimEnd('/')}/.default" }), ct); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); using var response = await _http.SendAsync(request, ct); if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests) { + if (++throttleRetries > maxThrottleRetries) + { + if (!isFirstPage) break; // keep the pages we already have + throw new HttpRequestException( + $"Graph throttled the request {maxThrottleRetries} times in a row (429). Try again later.", + null, response.StatusCode); + } var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(15); await Task.Delay(retryAfter, ct); nextForIteration = next; // retry same URL @@ -55,6 +127,7 @@ public async Task> GetCollectionAsync(string path, Ca else { isFirstPage = false; + throttleRetries = 0; // budget is per page, not per collection await using var stream = await response.Content.ReadAsStreamAsync(ct); using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct); @@ -88,7 +161,7 @@ public async Task> GetSinglePageAsync(string path, Ca using var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.TryAddWithoutValidation("User-Agent", "M365SecurityDashboard/1.0"); - var token = await _credential.GetTokenAsync(new TokenRequestContext(Scopes), ct); + var token = await _credential.GetTokenAsync(new TokenRequestContext(new[] { $"{_options.BaseUrl.TrimEnd('/')}/.default" }), ct); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); using var response = await _http.SendAsync(request, ct); diff --git a/src/M365SecurityDashboard.Api/Services/GraphCollector.cs b/src/M365SecurityDashboard.Api/Services/GraphCollector.cs index bb8a6c2..8c83b5f 100644 --- a/src/M365SecurityDashboard.Api/Services/GraphCollector.cs +++ b/src/M365SecurityDashboard.Api/Services/GraphCollector.cs @@ -14,7 +14,25 @@ public sealed class GraphCollector( { private readonly GraphOptions _options = options.Value; + // One collection at a time per process: the manual endpoint and the background + // worker would otherwise race the (Service, AlertType, ExternalId) unique index. + private static readonly SemaphoreSlim CollectionGate = new(1, 1); + public async Task CollectAsync(CancellationToken ct) + { + if (!await CollectionGate.WaitAsync(TimeSpan.Zero, ct)) + throw new InvalidOperationException("A collection run is already in progress."); + try + { + return await CollectCoreAsync(ct); + } + finally + { + CollectionGate.Release(); + } + } + + private async Task CollectCoreAsync(CancellationToken ct) { var run = new CollectionRun { StartedAt = DateTimeOffset.UtcNow, Status = CollectionStatus.Started }; db.CollectionRuns.Add(run); @@ -38,14 +56,35 @@ public async Task CollectAsync(CancellationToken ct) catch (Exception ex) { run.SourceFailures++; - failures.Add(new { source = source.Name, error = Trim(ex.Message, 300) }); + // Store an actionable sentence, not the raw Graph JSON — this + // string is what the Collection Health card and Runs view show. + failures.Add(new { source = source.Name, error = GraphErrorHint.Describe(ex.Message, source.Name) }); logger.LogWarning(ex, "Graph source {SourceName} failed", source.Name); } } + // Tenant audit events feed the activity-based alert policies. + // Failures here count as a source failure but never sink the run. + try + { + await CollectAuditEventsAsync(ct); + } + catch (Exception ex) + { + run.SourceFailures++; + failures.Add(new { source = "Tenant audit events", error = GraphErrorHint.Describe(ex.Message, "Tenant audit events") }); + logger.LogWarning(ex, "Tenant audit event collection failed"); + } + run.SourceFailureDetails = failures.Count > 0 ? JsonSerializer.Serialize(failures) : null; run.Status = run.SourceFailures == sources.Count ? CollectionStatus.Failed : CollectionStatus.Completed; run.CompletedAt = DateTimeOffset.UtcNow; + + if (run.Status != CollectionStatus.Failed) + { + await CaptureTrendSnapshotAsync(ct); + } + await db.SaveChangesAsync(ct); return run; } @@ -59,6 +98,69 @@ public async Task CollectAsync(CancellationToken ct) } } + /// + /// Incrementally pull Entra directory-audit records into the AuditEvents + /// store — the raw material for activity-based alert policies. Watermarked + /// on the newest stored event (with a 10-minute overlap for late arrivals); + /// first run looks back 24h. Records are deduped on (Source, ExternalId). + /// + private async Task CollectAuditEventsAsync(CancellationToken ct) + { + var newest = await db.AuditEvents + .Where(e => e.Source == "directoryAudit") + .MaxAsync(e => (DateTimeOffset?)e.OccurredAt, ct); + var since = (newest?.AddMinutes(-10) ?? DateTimeOffset.UtcNow.AddHours(-24)) + .UtcDateTime.ToString("O"); + + var rows = await graph.GetCollectionAsync( + WithFilter("/v1.0/auditLogs/directoryAudits?$top=200", $"activityDateTime ge {since}"), ct); + if (rows.Count == 0) return; + + var incomingIds = rows.Select(r => Get(r, "id")).Where(id => id != null).Cast().ToList(); + var known = (await db.AuditEvents + .Where(e => e.Source == "directoryAudit" && incomingIds.Contains(e.ExternalId)) + .Select(e => e.ExternalId) + .ToListAsync(ct)).ToHashSet(StringComparer.OrdinalIgnoreCase); + + var added = 0; + foreach (var r in rows) + { + var id = Get(r, "id"); + if (id is null || !known.Add(id)) continue; // dedupe within batch + store + + string? targetName = null; + if (r.TryGetProperty("targetResources", out var targets) && targets.ValueKind == JsonValueKind.Array) + { + foreach (var t in targets.EnumerateArray()) + { + targetName = Get(t, "displayName") ?? Get(t, "userPrincipalName"); + if (targetName != null) break; + } + } + + db.AuditEvents.Add(new AuditEvent + { + ExternalId = id, + Source = "directoryAudit", + Activity = Trim(Get(r, "activityDisplayName") ?? "Unknown activity", 200), + Category = TrimOrNull(Get(r, "category"), 80), + ActorUpn = TrimOrNull(Get(r, "initiatedBy", "user", "userPrincipalName"), 320), + ActorApp = TrimOrNull(Get(r, "initiatedBy", "app", "displayName"), 200), + TargetName = TrimOrNull(targetName, 320), + Result = TrimOrNull(Get(r, "result"), 20), + OccurredAt = GetDate(r, "activityDateTime"), + CollectedAt = DateTimeOffset.UtcNow, + RawJson = r.GetRawText(), + }); + added++; + } + if (added > 0) + { + await db.SaveChangesAsync(ct); + logger.LogInformation("Collected {Count} new tenant audit events", added); + } + } + private List BuildSources() { var signInCutoff = DateTimeOffset.UtcNow.AddHours(-_options.SignInLookbackHours).UtcDateTime.ToString("O"); @@ -77,7 +179,10 @@ private List BuildSources() new("Malware detections", WithFilter("/v1.0/security/alerts_v2?$top=50", "category eq 'Malware'"), MapMalwareDetection), new("Quarantined messages", _options.ExchangeQuarantinePath, MapQuarantinedMessage), new("Mail flow issues", _options.MailFlowIssuesPath, MapMailFlowIssue), - new("Service health issues", WithFilter("/v1.0/admin/serviceAnnouncement/issues?$top=50", "isResolved eq false"), MapServiceHealth) + new("Service health issues", WithFilter("/v1.0/admin/serviceAnnouncement/issues?$top=50", "isResolved eq false"), MapServiceHealth), + // Single-object endpoint: GetCollectionAsync yields the settings object once. + // Requires SharePointTenantSettings.Read.All (counts as a source failure if missing). + new("SharePoint sharing posture", "/v1.0/admin/sharepoint/settings", MapSharingPosture) ]; } @@ -157,6 +262,25 @@ private static SecurityAlert MapMailFlowIssue(JsonElement e) => Alert(e, M365Ser Get(e, "id"), AlertSeverity.High, $"Exchange issue: {Get(e, "title") ?? Get(e, "id")}", Get(e, "impactDescription"), null, null, GetDate(e, "startDateTime"), Get(e, "details", "url"), GetBool(e, "isResolved")); + /// + /// One tenant-wide posture alert (fixed ExternalId so it upserts in place): + /// open at the worst finding's severity while the sharing posture is risky, + /// auto-resolved once the findings clear. + /// + private static SecurityAlert MapSharingPosture(JsonElement e) + { + var findings = SharingPostureAnalyzer.Analyze(SharingPostureAnalyzer.Parse(e)); + var worst = findings.FirstOrDefault(); + var clean = worst is null; + return Alert(e, M365ServiceArea.SharePoint, "SharingPosture", "tenant-sharing-settings", + clean ? AlertSeverity.Informational : SeverityFromString(worst!.Severity), + clean ? "SharePoint/OneDrive sharing posture is healthy" + : $"Risky sharing posture: {worst!.Title}", + clean ? "No risky external-sharing settings detected." + : string.Join(" | ", findings.Select(f => f.Title)), + null, null, DateTimeOffset.UtcNow, null, isResolved: clean); + } + private static SecurityAlert MapServiceHealth(JsonElement e) => Alert(e, M365ServiceArea.ServiceHealth, "ServiceHealthIssue", Get(e, "id"), SeverityFromClassification(Get(e, "classification")), $"{Get(e, "service")}: {Get(e, "title") ?? Get(e, "id")}", Get(e, "impactDescription"), null, null, GetDate(e, "startDateTime"), null, GetBool(e, "isResolved")); @@ -237,6 +361,75 @@ private static bool IsClosed(string? value) => value?.Equals("closed", StringComparison.OrdinalIgnoreCase) == true; private static string Trim(string s, int max) => s.Length <= max ? s : s[..max]; + private static string? TrimOrNull(string? s, int max) => s is null ? null : Trim(s, max); + + private async Task CaptureTrendSnapshotAsync(CancellationToken ct) + { + var open = db.SecurityAlerts.Where(a => !a.IsResolved); + var riskyUsersCount = await open.CountAsync(a => a.AlertType == "RiskyUser", ct); + var mfaMissingCount = await open.CountAsync(a => a.AlertType == "MfaStatus", ct); + var nonCompliantCount = await open.CountAsync(a => a.AlertType == "NonCompliantDevice", ct); + + // Track open Critical & High alerts across all services (matching AlertEvaluator and Overview KPIs) + var criticalAlertsCount = await open.CountAsync(a => a.Severity == AlertSeverity.Critical, ct); + var highAlertsCount = await open.CountAsync(a => a.Severity == AlertSeverity.High, ct); + + // Microsoft Purview best practice: Track compliance operations findings (Quarantine, Mail flow, DLP) + var complianceIssuesCount = await open.CountAsync(a => a.Service == M365ServiceArea.ExchangeOnline, ct); + + // Calculate Secure Score + double secureScorePct = 0; + if (_options.IsConfigured()) + { + try + { + // Single page only — GetCollectionAsync would follow @odata.nextLink + // through the ENTIRE score history one item at a time. + var items = await graph.GetSinglePageAsync("/v1.0/security/secureScores?$top=1", ct); + if (items.Count > 0) + { + var latest = items[0]; + var cs = latest.TryGetProperty("currentScore", out var cVal) && cVal.ValueKind == JsonValueKind.Number ? cVal.GetDouble() : 0; + var ms = latest.TryGetProperty("maxScore", out var mVal) && mVal.ValueKind == JsonValueKind.Number ? mVal.GetDouble() : 100; + if (ms == 0) ms = 100; + secureScorePct = Math.Round(cs / ms * 100, 1); + } + } + catch { /* ignore */ } + } + + // Calculate MFA Coverage Pct + double mfaCoveragePct = 0; + if (_options.IsConfigured()) + { + try + { + var reg = await graph.GetCollectionAsync("/v1.0/reports/authenticationMethods/userRegistrationDetails", ct); + var mfaTotal = reg.Count; + if (mfaTotal > 0) + { + var mfaRegistered = reg.Count(r => r.TryGetProperty("isMfaRegistered", out var p) && p.ValueKind == JsonValueKind.True); + mfaCoveragePct = Math.Round((double)mfaRegistered / mfaTotal * 100, 1); + } + } + catch { /* ignore */ } + } + + var snapshot = new TrendSnapshot + { + Id = Guid.NewGuid(), + CapturedAt = DateTimeOffset.UtcNow, + RiskyUsersCount = riskyUsersCount, + MfaCoveragePct = mfaCoveragePct, + NonCompliantDevicesCount = nonCompliantCount, + CriticalAlertsCount = criticalAlertsCount, + HighAlertsCount = highAlertsCount, + SecureScorePct = secureScorePct, + ComplianceIssuesCount = complianceIssuesCount + }; + + db.TrendSnapshots.Add(snapshot); + } private sealed record GraphSource(string Name, string Path, Func Map); } diff --git a/src/M365SecurityDashboard.Api/Services/GraphErrorHint.cs b/src/M365SecurityDashboard.Api/Services/GraphErrorHint.cs new file mode 100644 index 0000000..1b6634d --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/GraphErrorHint.cs @@ -0,0 +1,107 @@ +namespace M365SecurityDashboard.Api.Services; + +/// +/// Turns a raw Graph failure into something an administrator can act on. +/// +/// The default rendering of a denied call is a wall of JSON — +/// 403 Forbidden: {"error":{"code":"accessDenied","message":"Caller does not +/// have required permissions for this API",...}} — which tells the reader +/// neither which permission is missing nor where to grant it. Nearly every +/// collector failure in practice is one un-consented application permission. +/// +public static class GraphErrorHint +{ + /// + /// Application permission required by each collector source / Graph path + /// fragment. Keyed by the source name used in GraphCollector.BuildSources + /// so the hint survives Graph changing its error text. + /// + private static readonly Dictionary PermissionBySource = new(StringComparer.OrdinalIgnoreCase) + { + ["Risky users"] = "IdentityRiskyUser.Read.All", + ["Risky sign-ins"] = "AuditLog.Read.All", + ["Failed sign-ins"] = "AuditLog.Read.All", + ["MFA registration"] = "AuditLog.Read.All and Reports.Read.All", + ["Non-compliant devices"] = "DeviceManagementManagedDevices.Read.All", + ["Devices not checked in"] = "DeviceManagementManagedDevices.Read.All", + ["Defender incidents"] = "SecurityIncident.Read.All", + ["Defender alerts"] = "SecurityAlert.Read.All", + ["Malware detections"] = "SecurityAlert.Read.All", + ["Quarantined messages"] = "SecurityEvents.Read.All", + ["Mail flow issues"] = "ServiceHealth.Read.All", + ["Service health issues"] = "ServiceHealth.Read.All", + ["SharePoint sharing posture"] = "SharePointTenantSettings.Read.All", + ["Tenant audit events"] = "AuditLog.Read.All", + }; + + /// Required permission for a named collector source, if known. + public static string? PermissionFor(string? sourceName) + => sourceName is not null && PermissionBySource.TryGetValue(sourceName, out var p) ? p : null; + + /// Every (source, required permission) pair — the authoritative list + /// behind the in-app permissions reference. + public static IReadOnlyList<(string Source, string Permission)> AllRequirements() + => PermissionBySource.Select(kv => (kv.Key, kv.Value)).ToList(); + + /// + /// Rewrites an exception message into an actionable sentence. Falls back to + /// the trimmed original when the failure is not one we recognise — never + /// hide detail we cannot improve on. + /// + public static string Describe(string? rawMessage, string? sourceName = null, int maxLength = 300) + { + var raw = rawMessage ?? ""; + + if (IsPermissionDenied(raw)) + { + var perm = PermissionFor(sourceName); + return perm is null + ? "Permission denied by Microsoft Graph. The app registration is missing an application permission for this data; check the required permissions and grant admin consent." + : $"Permission denied: grant the {perm} application permission to the Vigil365 app registration and click 'Grant admin consent' in Entra."; + } + + if (raw.Contains("429") || raw.Contains("TooManyRequests", StringComparison.OrdinalIgnoreCase)) + return "Microsoft Graph throttled this request (429). Vigil365 backs off and retries automatically; no action needed unless it persists."; + + if (raw.Contains("401") || raw.Contains("Unauthorized", StringComparison.OrdinalIgnoreCase) + || raw.Contains("invalid_client", StringComparison.OrdinalIgnoreCase)) + return "Microsoft Graph rejected the credentials (401). The client secret or certificate is expired or incorrect — re-check the Graph configuration in Setup."; + + if (raw.Contains("404") || raw.Contains("NotFound", StringComparison.OrdinalIgnoreCase)) + return "Microsoft Graph returned 404 for this data. The feature may not be licensed or enabled in this tenant."; + + return Trim(raw, maxLength); + } + + /// + /// Endpoint-facing variant: returns a hint only for failures we recognise, + /// otherwise null so the caller keeps its own generic message. Never echoes + /// the raw exception, which could carry internal detail into an HTTP response. + /// + public static string? DescribeOrNull(string? rawMessage, string? requiredPermission = null) + { + var raw = rawMessage ?? ""; + + if (IsPermissionDenied(raw)) + return requiredPermission is null + ? "Permission denied by Microsoft Graph. The Vigil365 app registration is missing an application permission for this data — check the required permissions and grant admin consent in Entra." + : $"Permission denied: grant the {requiredPermission} application permission to the Vigil365 app registration and click 'Grant admin consent' in Entra."; + + if (raw.Contains("429") || raw.Contains("TooManyRequests", StringComparison.OrdinalIgnoreCase)) + return "Microsoft Graph is throttling requests (429). This usually clears on its own — try again shortly."; + + if (raw.Contains("401") || raw.Contains("Unauthorized", StringComparison.OrdinalIgnoreCase) + || raw.Contains("invalid_client", StringComparison.OrdinalIgnoreCase)) + return "Microsoft Graph rejected the credentials (401). The client secret or certificate may be expired — re-check the Graph configuration in Setup."; + + return null; + } + + private static bool IsPermissionDenied(string raw) + => raw.Contains("403") + || raw.Contains("accessDenied", StringComparison.OrdinalIgnoreCase) + || raw.Contains("Forbidden", StringComparison.OrdinalIgnoreCase) + || raw.Contains("Authorization_RequestDenied", StringComparison.OrdinalIgnoreCase); + + private static string Trim(string s, int max) => s.Length <= max ? s : s[..max]; +} diff --git a/src/M365SecurityDashboard.Api/Services/NotificationDigestWorker.cs b/src/M365SecurityDashboard.Api/Services/NotificationDigestWorker.cs new file mode 100644 index 0000000..7368314 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/NotificationDigestWorker.cs @@ -0,0 +1,140 @@ +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace M365SecurityDashboard.Api.Services; + +/// +/// Hourly maintenance for outbound notifications: +/// 1. Digest mode — once per day at DigestHourUtc, batches the alerts triggered +/// since the last digest into a single rollup per digest-enabled channel. +/// 2. Delivery-failure alerting — when a channel accumulates enough consecutive +/// failed attempts, logs a warning and (debounced) notifies via any healthy +/// channel so a silently-broken webhook doesn't go unnoticed. +/// +public sealed class NotificationDigestWorker( + IServiceProvider services, + ILogger logger) : BackgroundService +{ + private static readonly TimeSpan StartupDelay = TimeSpan.FromMinutes(4); + private static readonly TimeSpan Interval = TimeSpan.FromHours(1); + private static readonly TimeSpan FailureAlertDebounce = TimeSpan.FromHours(6); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try { await Task.Delay(StartupDelay, stoppingToken); } + catch (OperationCanceledException) { return; } + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var sender = scope.ServiceProvider.GetRequiredService(); + await TickAsync(db, sender, DateTimeOffset.UtcNow, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } + catch (Exception ex) + { + logger.LogError(ex, "Notification digest tick failed"); + } + + try { await Task.Delay(Interval, stoppingToken); } + catch (OperationCanceledException) { break; } + } + } + + /// One maintenance pass. Public + parameterized on `now` for testability. + public async Task TickAsync(AppDbContext db, NotificationSender sender, DateTimeOffset now, CancellationToken ct) + { + var cfg = await db.NotificationSettings.FirstOrDefaultAsync(ct); + if (cfg is null) return; + + await MaybeSendDigestAsync(db, sender, cfg, now, ct); + await MaybeAlertOnFailuresAsync(db, sender, cfg, now, ct); + await db.SaveChangesAsync(ct); + } + + private async Task MaybeSendDigestAsync(AppDbContext db, NotificationSender sender, NotificationSettings cfg, DateTimeOffset now, CancellationToken ct) + { + if (!ShouldSendDigest(cfg, now)) return; + + var since = cfg.LastDigestAt ?? now.AddDays(string.Equals(cfg.DigestFrequency, "weekly", StringComparison.OrdinalIgnoreCase) ? -7 : -1); + var candidates = await db.TriggeredAlerts.AsNoTracking() + .Where(a => a.TriggeredAt > since) + .ToListAsync(ct); + var pending = PendingForDigest(candidates, cfg.MinSeverity); + + cfg.LastDigestAt = now; + if (pending.Count == 0) + { + logger.LogDebug("Digest hour reached but no pending alerts to roll up."); + return; + } + var sent = await sender.SendDigestRollupAsync(db, cfg, pending, ct); + logger.LogInformation("Sent {Frequency} digest of {Count} alerts to {Channels} channel(s).", cfg.DigestFrequency, pending.Count, sent); + } + + private async Task MaybeAlertOnFailuresAsync(AppDbContext db, NotificationSender sender, NotificationSettings cfg, DateTimeOffset now, CancellationToken ct) + { + // Inspect a bounded recent window so a long-ago failure streak can't linger. + var recent = await db.NotificationLogs.AsNoTracking() + .OrderByDescending(l => l.SentAt).Take(200).ToListAsync(ct); + var failing = NotificationHealth.FailingChannels(recent, cfg.FailureAlertThreshold); + if (failing.Count == 0) return; + + if (cfg.LastFailureAlertAt is { } lastAlert && now - lastAlert < FailureAlertDebounce) return; + + var summary = string.Join("; ", failing.Select(f => $"{f.Channel} ({f.ConsecutiveFailures} consecutive failures)")); + logger.LogWarning("Notification delivery failing: {Summary}", summary); + cfg.LastFailureAlertAt = now; + + // Best-effort heads-up through a channel that is still healthy. If email is the + // broken one, this simply no-ops on that channel. + var notice = new TriggeredAlert + { + Id = Guid.NewGuid(), + PolicyName = "Notification delivery failure", + Severity = "high", + Category = "system", + Condition = $"Delivery failing on: {summary}", + MetricValue = failing.Sum(f => f.ConsecutiveFailures), + Threshold = Math.Max(1, cfg.FailureAlertThreshold), + TriggeredAt = now, + Status = "new", + }; + var failingChannels = failing.Select(f => f.Channel).ToHashSet(StringComparer.OrdinalIgnoreCase); + await sender.DispatchDeliveryFailureAsync(db, cfg, notice, failingChannels, ct); + } + + /// + /// Whether a digest is due at : at least one channel is in + /// digest mode, the hour matches, and no digest has yet been sent today. + /// + public static bool ShouldSendDigest(NotificationSettings cfg, DateTimeOffset now) + { + if (!(cfg.TeamsDigest || cfg.EmailDigest || cfg.WebhookDigest)) return false; + if (now.Hour != Math.Clamp(cfg.DigestHourUtc, 0, 23)) return false; + if (string.Equals(cfg.DigestFrequency, "weekly", StringComparison.OrdinalIgnoreCase) && now.DayOfWeek != DayOfWeek.Monday) return false; + // Only one digest per calendar day, even though the worker ticks hourly. + if (cfg.LastDigestAt is { } last && last.UtcDateTime.Date == now.UtcDateTime.Date) return false; + return true; + } + + /// Filters candidate alerts to those at or above the configured minimum severity, most-severe first. + public static List PendingForDigest(IEnumerable candidates, string minSeverity) + { + var minRank = Rank(minSeverity); + return candidates + .Where(a => Rank(a.Severity) >= minRank) + .OrderByDescending(a => Rank(a.Severity)).ThenByDescending(a => a.TriggeredAt) + .ToList(); + } + + private static readonly Dictionary SeverityRank = new(StringComparer.OrdinalIgnoreCase) + { + ["informational"] = 0, ["low"] = 1, ["medium"] = 2, ["high"] = 3, ["critical"] = 4, + }; + private static int Rank(string? sev) => SeverityRank.TryGetValue(sev ?? "low", out var r) ? r : 1; +} diff --git a/src/M365SecurityDashboard.Api/Services/NotificationHealth.cs b/src/M365SecurityDashboard.Api/Services/NotificationHealth.cs new file mode 100644 index 0000000..26e56e6 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/NotificationHealth.cs @@ -0,0 +1,54 @@ +using M365SecurityDashboard.Api.Models; + +namespace M365SecurityDashboard.Api.Services; + +/// +/// Derives per-channel delivery health from notification log rows. Pure functions +/// so the failure-detection logic is unit-testable without a database. +/// +public static class NotificationHealth +{ + public sealed record ChannelHealth( + string Channel, + int ConsecutiveFailures, + bool Healthy, + DateTimeOffset? LastAttemptAt, + DateTimeOffset? LastSuccessAt, + string? LastError); + + /// + /// Computes health per channel. "Consecutive failures" counts the most-recent + /// unbroken run of failed attempts (reset by any success). A channel with no + /// attempts at all is reported healthy. + /// + public static IReadOnlyList Compute(IEnumerable logs) + { + return logs + .GroupBy(l => l.Channel) + .Select(g => + { + var recent = g.OrderByDescending(l => l.SentAt).ToList(); + var consecutive = 0; + foreach (var row in recent) + { + if (row.Success) break; + consecutive++; + } + var lastSuccess = recent.FirstOrDefault(l => l.Success); + var lastFail = recent.FirstOrDefault(l => !l.Success); + return new ChannelHealth( + g.Key, + consecutive, + Healthy: consecutive == 0, + LastAttemptAt: recent.FirstOrDefault()?.SentAt, + LastSuccessAt: lastSuccess?.SentAt, + LastError: consecutive > 0 ? lastFail?.Error : null); + }) + .OrderBy(h => h.Channel) + .ToList(); + } + + /// Channels whose consecutive-failure count is at or above the threshold. + public static IReadOnlyList FailingChannels(IEnumerable logs, int threshold) => + Compute(logs).Where(h => h.ConsecutiveFailures >= Math.Max(1, threshold)).ToList(); +} diff --git a/src/M365SecurityDashboard.Api/Services/NotificationSender.cs b/src/M365SecurityDashboard.Api/Services/NotificationSender.cs index d8d2b47..ed7f73e 100644 --- a/src/M365SecurityDashboard.Api/Services/NotificationSender.cs +++ b/src/M365SecurityDashboard.Api/Services/NotificationSender.cs @@ -1,5 +1,7 @@ using System.Net; +using System.Net.Http.Headers; using System.Net.Mail; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using M365SecurityDashboard.Api.Data; @@ -14,8 +16,11 @@ namespace M365SecurityDashboard.Api.Services; public sealed class NotificationSender( IHttpClientFactory httpFactory, SecretProtector protector, - ILogger logger) + ILogger logger, + IConfiguration? config = null) { + public sealed record ReportAttachment(string FileName, string ContentType, byte[] Bytes); + private static readonly Dictionary SeverityRank = new(StringComparer.OrdinalIgnoreCase) { ["informational"] = 0, ["low"] = 1, ["medium"] = 2, ["high"] = 3, ["critical"] = 4, @@ -23,6 +28,14 @@ public sealed class NotificationSender( private static int Rank(string? sev) => SeverityRank.TryGetValue(sev ?? "low", out var r) ? r : 1; + /// Deep link to this alert in Vigil365 — one click from a Teams + /// message or email straight to the alert. Null when no base URL is configured. + private string? AlertLink(TriggeredAlert a) + { + var baseUrl = config?["Auth:RedirectUri"]; + return string.IsNullOrWhiteSpace(baseUrl) ? null : $"{baseUrl.TrimEnd('/')}/#/alertcenter?alert={a.Id}"; + } + /// Dispatch all configured channels for a single triggered alert. public async Task DispatchAsync(AppDbContext db, NotificationSettings cfg, TriggeredAlert alert, CancellationToken ct) { @@ -32,15 +45,18 @@ public async Task DispatchAsync(AppDbContext db, NotificationSettings cfg, Trigg // Sensitive fields are stored DPAPI-encrypted at rest — decrypt for use only. var teamsUrl = protector.Unprotect(cfg.TeamsWebhookUrl); var webhookUrl = protector.Unprotect(cfg.WebhookUrl); + var webhookSecret = protector.Unprotect(cfg.WebhookSigningSecret); var smtpPassword = protector.Unprotect(cfg.SmtpPassword); - if (cfg.TeamsEnabled && !string.IsNullOrWhiteSpace(teamsUrl)) + // Channels in digest mode are skipped here — the NotificationDigestWorker + // batches their alerts into a single daily rollup instead. + if (cfg.TeamsEnabled && !cfg.TeamsDigest && !string.IsNullOrWhiteSpace(teamsUrl)) await SendTeamsAsync(db, teamsUrl!, alert, ct); - if (cfg.WebhookEnabled && !string.IsNullOrWhiteSpace(webhookUrl)) - await SendWebhookAsync(db, webhookUrl!, alert, ct); + if (cfg.WebhookEnabled && !cfg.WebhookDigest && !string.IsNullOrWhiteSpace(webhookUrl)) + await SendWebhookAsync(db, webhookUrl!, webhookSecret, alert, ct); - if (cfg.EmailEnabled && !string.IsNullOrWhiteSpace(cfg.SmtpHost)) + if (cfg.EmailEnabled && !cfg.EmailDigest && !string.IsNullOrWhiteSpace(cfg.SmtpHost)) { var to = alert.Status == "new" ? (FirstNonEmpty(cfg.DefaultRecipient) ?? cfg.FromAddress) @@ -53,6 +69,265 @@ public async Task DispatchAsync(AppDbContext db, NotificationSettings cfg, Trigg private static string? FirstNonEmpty(params string?[] vals) => vals.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)); + /// + /// Dispatches a delivery-failure heads-up, deliberately skipping the channels that + /// are themselves failing (no point retrying a broken webhook to announce it is + /// broken). Digest mode is ignored — this is an operational alert, always instant. + /// + public async Task DispatchDeliveryFailureAsync( + AppDbContext db, NotificationSettings cfg, TriggeredAlert notice, ISet failingChannels, CancellationToken ct) + { + var teamsUrl = protector.Unprotect(cfg.TeamsWebhookUrl); + var webhookUrl = protector.Unprotect(cfg.WebhookUrl); + var webhookSecret = protector.Unprotect(cfg.WebhookSigningSecret); + var smtpPassword = protector.Unprotect(cfg.SmtpPassword); + + if (cfg.TeamsEnabled && !failingChannels.Contains("teams") && !string.IsNullOrWhiteSpace(teamsUrl)) + await SendTeamsAsync(db, teamsUrl!, notice, ct); + if (cfg.WebhookEnabled && !failingChannels.Contains("webhook") && !string.IsNullOrWhiteSpace(webhookUrl)) + await SendWebhookAsync(db, webhookUrl!, webhookSecret, notice, ct); + if (cfg.EmailEnabled && !failingChannels.Contains("email") && !string.IsNullOrWhiteSpace(cfg.SmtpHost)) + { + var to = FirstNonEmpty(cfg.DefaultRecipient, cfg.FromAddress); + if (!string.IsNullOrWhiteSpace(to)) + await SendEmailAsync(db, cfg, smtpPassword, to!, notice, ct); + } + } + + /// + /// Sends a one-off access-notification ("invite") email to a pre-provisioned user, + /// reusing the configured SMTP settings. These are internal tenant users who already + /// have Microsoft accounts — this is a courtesy notice + sign-in link, not an account + /// creation. Returns (ok, error) rather than writing a NotificationLog row (those are + /// keyed to triggered alerts). + /// + public async Task<(bool ok, string? error)> SendInviteEmailAsync( + NotificationSettings cfg, string toEmail, string role, string dashboardUrl, CancellationToken ct) + { + if (!cfg.EmailEnabled || string.IsNullOrWhiteSpace(cfg.SmtpHost)) + return (false, "SMTP email is not configured. Set it up in Settings → Notifications first."); + + var smtpPassword = protector.Unprotect(cfg.SmtpPassword); + try + { + using var msg = new MailMessage + { + From = new MailAddress(cfg.FromAddress ?? cfg.SmtpUsername ?? "vigil365@localhost"), + Subject = "You've been granted access to Vigil365", + IsBodyHtml = true, + Body = $""" +
+

Vigil365 — Access Granted

+

+ You've been granted {WebUtility.HtmlEncode(role)} access to the + Vigil365 Microsoft 365 security dashboard. +

+

+ Sign in with your Microsoft 365 account to get started: +

+

+ Open Vigil365 +

+

+ Access is restricted to your organisation. If you didn't expect this, you can ignore this email. +

+
+ """, + }; + msg.To.Add(toEmail); + + using var client = new SmtpClient(cfg.SmtpHost, cfg.SmtpPort) + { + EnableSsl = cfg.SmtpUseSsl, + Credentials = string.IsNullOrWhiteSpace(cfg.SmtpUsername) + ? CredentialCache.DefaultNetworkCredentials + : new NetworkCredential(cfg.SmtpUsername, smtpPassword), + }; + await client.SendMailAsync(msg, ct); + return (true, null); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Invite email to {Email} failed", toEmail); + return (false, ex.Message); + } + } + + /// + /// Sends a scheduled/report email (e.g. the executive digest) over the configured + /// SMTP settings, with an optional CSV attachment. Returns (ok, error) rather than + /// writing a NotificationLog row (those are keyed to triggered alerts). + /// + public async Task<(bool ok, string? error)> SendReportEmailAsync( + NotificationSettings cfg, IEnumerable recipients, string subject, + string htmlBody, IEnumerable attachments, CancellationToken ct) + { + if (!cfg.EmailEnabled || string.IsNullOrWhiteSpace(cfg.SmtpHost)) + return (false, "SMTP email is not configured. Set it up in Settings → Notifications first."); + + var to = recipients.Where(r => !string.IsNullOrWhiteSpace(r)).Select(r => r.Trim()).Distinct().ToList(); + if (to.Count == 0) return (false, "No recipients configured for this report."); + + var smtpPassword = protector.Unprotect(cfg.SmtpPassword); + var attachmentStreams = new List(); + try + { + using var msg = new MailMessage + { + From = new MailAddress(cfg.FromAddress ?? cfg.SmtpUsername ?? "vigil365@localhost"), + Subject = subject, + IsBodyHtml = true, + Body = htmlBody, + }; + foreach (var r in to) msg.To.Add(r); + + foreach (var attachment in attachments) + { + if (attachment.Bytes.Length == 0) continue; + var stream = new System.IO.MemoryStream(attachment.Bytes); + attachmentStreams.Add(stream); + msg.Attachments.Add(new Attachment(stream, attachment.FileName, attachment.ContentType)); + } + + using var client = new SmtpClient(cfg.SmtpHost, cfg.SmtpPort) + { + EnableSsl = cfg.SmtpUseSsl, + Credentials = string.IsNullOrWhiteSpace(cfg.SmtpUsername) + ? CredentialCache.DefaultNetworkCredentials + : new NetworkCredential(cfg.SmtpUsername, smtpPassword), + }; + await client.SendMailAsync(msg, ct); + return (true, null); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Report email '{Subject}' failed", subject); + return (false, ex.Message); + } + finally + { + foreach (var stream in attachmentStreams) stream.Dispose(); + } + } + + /// + /// Sends a batched daily digest of triggered alerts to whichever channels have + /// digest mode enabled. Each channel attempt is logged (TriggeredAlertId = empty + /// so digest rows are distinguishable from per-alert rows). Returns the number of + /// channel sends that succeeded. + /// + public async Task SendDigestRollupAsync(AppDbContext db, NotificationSettings cfg, IReadOnlyList alerts, CancellationToken ct) + { + if (alerts.Count == 0) return 0; + var teamsUrl = protector.Unprotect(cfg.TeamsWebhookUrl); + var webhookUrl = protector.Unprotect(cfg.WebhookUrl); + var webhookSecret = protector.Unprotect(cfg.WebhookSigningSecret); + var smtpPassword = protector.Unprotect(cfg.SmtpPassword); + var sent = 0; + + var ordered = alerts.OrderByDescending(a => Rank(a.Severity)).ThenByDescending(a => a.TriggeredAt).ToList(); + var title = $"Vigil365 daily digest — {alerts.Count} alert{(alerts.Count == 1 ? "" : "s")}"; + + if (cfg.TeamsEnabled && cfg.TeamsDigest && !string.IsNullOrWhiteSpace(teamsUrl)) + { + var facts = ordered.Take(20).Select(a => new { title = a.Severity.ToUpperInvariant(), value = a.PolicyName }).ToArray(); + var card = new + { + type = "message", + attachments = new[] { new { + contentType = "application/vnd.microsoft.card.adaptive", + content = new { + type = "AdaptiveCard", version = "1.4", + body = new object[] { + new { type = "TextBlock", text = title, weight = "Bolder", size = "Medium" }, + new { type = "FactSet", facts }, + }, + }, + } }, + }; + if (await PostDigestAsync(db, "teams", teamsUrl!, JsonSerializer.Serialize(card), alerts.Count, ct)) sent++; + } + + if (cfg.WebhookEnabled && cfg.WebhookDigest && !string.IsNullOrWhiteSpace(webhookUrl)) + { + var payload = JsonSerializer.Serialize(new + { + source = "Vigil365", kind = "digest", count = alerts.Count, + alerts = ordered.Select(a => new { a.PolicyName, a.Severity, a.Category, a.MetricValue, a.Threshold, a.TriggeredAt }), + }); + if (await PostDigestAsync(db, "webhook", webhookUrl!, payload, alerts.Count, ct, webhookSecret)) sent++; + } + + if (cfg.EmailEnabled && cfg.EmailDigest && !string.IsNullOrWhiteSpace(cfg.SmtpHost)) + { + var to = FirstNonEmpty(cfg.DefaultRecipient, cfg.FromAddress); + if (!string.IsNullOrWhiteSpace(to) && await SendDigestEmailAsync(db, cfg, smtpPassword, to!, title, ordered, ct)) sent++; + } + return sent; + } + + private async Task PostDigestAsync(AppDbContext db, string channel, string url, string json, int count, CancellationToken ct, string? signingSecret = null) + { + var log = new NotificationLog { TriggeredAlertId = Guid.Empty, PolicyName = $"Daily digest ({count})", Channel = channel, Target = Truncate(url, 120) }; + try + { + var http = httpFactory.CreateClient(); + http.Timeout = TimeSpan.FromSeconds(15); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + AddWebhookSignature(content.Headers, json, signingSecret); + using var resp = await http.PostAsync(url, content, ct); + log.Success = resp.IsSuccessStatusCode; + if (!resp.IsSuccessStatusCode) log.Error = Truncate($"{(int)resp.StatusCode} {resp.ReasonPhrase}", 1000); + } + catch (Exception ex) + { + log.Success = false; + log.Error = Truncate(ex.Message, 1000); + logger.LogWarning(ex, "Digest {Channel} send failed", channel); + } + db.NotificationLogs.Add(log); + return log.Success; + } + + private async Task SendDigestEmailAsync(AppDbContext db, NotificationSettings cfg, string? smtpPassword, string to, string title, IReadOnlyList alerts, CancellationToken ct) + { + var log = new NotificationLog { TriggeredAlertId = Guid.Empty, PolicyName = $"Daily digest ({alerts.Count})", Channel = "email", Target = Truncate(to, 120) }; + try + { + var rows = new StringBuilder(); + foreach (var a in alerts.Take(50)) + rows.Append($"{a.Severity.ToUpperInvariant()}" + + $"{WebUtility.HtmlEncode(a.PolicyName)}" + + $"{a.TriggeredAt:dd MMM HH:mm}"); + using var msg = new MailMessage + { + From = new MailAddress(cfg.FromAddress ?? cfg.SmtpUsername ?? "vigil365@localhost"), + Subject = $"[Vigil365] {title}", + IsBodyHtml = true, + Body = $"

{WebUtility.HtmlEncode(title)}

" + + $"{rows}
", + }; + msg.To.Add(to); + using var client = new SmtpClient(cfg.SmtpHost, cfg.SmtpPort) + { + EnableSsl = cfg.SmtpUseSsl, + Credentials = string.IsNullOrWhiteSpace(cfg.SmtpUsername) ? CredentialCache.DefaultNetworkCredentials : new NetworkCredential(cfg.SmtpUsername, smtpPassword), + }; + await client.SendMailAsync(msg, ct); + log.Success = true; + } + catch (Exception ex) + { + log.Success = false; + log.Error = Truncate(ex.Message, 1000); + logger.LogWarning(ex, "Digest email send failed"); + } + db.NotificationLogs.Add(log); + return log.Success; + } + private static string SevColor(string sev) => sev?.ToLowerInvariant() switch { "critical" => "dc2626", @@ -64,35 +339,63 @@ public async Task DispatchAsync(AppDbContext db, NotificationSettings cfg, Trigg private async Task SendTeamsAsync(AppDbContext db, string url, TriggeredAlert a, CancellationToken ct) { - // MessageCard format — works for both Teams incoming webhooks and (loosely) Slack. - var card = new - { - @type = "MessageCard", - @context = "http://schema.org/extensions", - themeColor = SevColor(a.Severity), - summary = $"Vigil365 alert: {a.PolicyName}", - title = $"🛡️ {a.PolicyName}", - sections = new[] + var cardColor = a.Severity.ToLowerInvariant() switch + { + "critical" or "high" => "Attention", + "medium" => "Warning", + "low" => "Accent", + _ => "Default" + }; + + // Modern Adaptive Card structure compatible with Teams Workflows / Incoming Webhooks + var cardPayload = new + { + type = "message", + attachments = new[] { new { - activityTitle = $"Severity: {a.Severity.ToUpperInvariant()}", - facts = new[] + contentType = "application/vnd.microsoft.card.adaptive", + content = new { - new { name = "Condition", value = a.Condition }, - new { name = "Observed value", value = a.MetricValue.ToString() }, - new { name = "Threshold", value = a.Threshold.ToString() }, - new { name = "Category", value = a.Category }, - new { name = "Triggered", value = a.TriggeredAt.ToString("u") }, - }, - markdown = true, - }, - }, + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new + { + type = "TextBlock", + text = $"Vigil365: {a.PolicyName}", + weight = "Bolder", + size = "Medium", + color = cardColor + }, + new + { + type = "FactSet", + facts = new[] + { + new { title = "Severity", value = a.Severity.ToUpperInvariant() }, + new { title = "Condition", value = a.Condition }, + new { title = "Observed Value", value = a.MetricValue.ToString() }, + new { title = "Threshold", value = a.Threshold.ToString() }, + new { title = "Category", value = a.Category }, + new { title = "Triggered At", value = a.TriggeredAt.ToString("u") } + } + } + }, + actions = AlertLink(a) is { } link + ? new object[] { new { type = "Action.OpenUrl", title = "Open in Vigil365", url = link } } + : [], + } + } + } }; - await PostJsonAsync(db, "teams", url, JsonSerializer.Serialize(card), a, ct); + + await PostJsonAsync(db, "teams", url, JsonSerializer.Serialize(cardPayload), a, ct); } - private async Task SendWebhookAsync(AppDbContext db, string url, TriggeredAlert a, CancellationToken ct) + private async Task SendWebhookAsync(AppDbContext db, string url, string? signingSecret, TriggeredAlert a, CancellationToken ct) { var payload = JsonSerializer.Serialize(new { @@ -106,11 +409,12 @@ private async Task SendWebhookAsync(AppDbContext db, string url, TriggeredAlert threshold = a.Threshold, triggeredAt = a.TriggeredAt, status = a.Status, + link = AlertLink(a), }); - await PostJsonAsync(db, "webhook", url, payload, a, ct); + await PostJsonAsync(db, "webhook", url, payload, a, ct, signingSecret); } - private async Task PostJsonAsync(AppDbContext db, string channel, string url, string json, TriggeredAlert a, CancellationToken ct) + private async Task PostJsonAsync(AppDbContext db, string channel, string url, string json, TriggeredAlert a, CancellationToken ct, string? signingSecret = null) { var log = new NotificationLog { TriggeredAlertId = a.Id, PolicyName = a.PolicyName, Channel = channel, Target = Truncate(url, 120) }; try @@ -118,6 +422,7 @@ private async Task PostJsonAsync(AppDbContext db, string channel, string url, st var http = httpFactory.CreateClient(); http.Timeout = TimeSpan.FromSeconds(15); using var content = new StringContent(json, Encoding.UTF8, "application/json"); + AddWebhookSignature(content.Headers, json, signingSecret); using var resp = await http.PostAsync(url, content, ct); log.Success = resp.IsSuccessStatusCode; if (!resp.IsSuccessStatusCode) @@ -132,6 +437,17 @@ private async Task PostJsonAsync(AppDbContext db, string channel, string url, st db.NotificationLogs.Add(log); } + private static void AddWebhookSignature(HttpContentHeaders headers, string json, string? signingSecret) + { + if (string.IsNullOrWhiteSpace(signingSecret)) return; + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + var body = $"{timestamp}.{json}"; + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(signingSecret)); + var signature = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(body))).ToLowerInvariant(); + headers.Add("X-Vigil365-Timestamp", timestamp); + headers.Add("X-Vigil365-Signature", $"sha256={signature}"); + } + private async Task SendEmailAsync(AppDbContext db, NotificationSettings cfg, string? smtpPassword, string to, TriggeredAlert a, CancellationToken ct) { var log = new NotificationLog { TriggeredAlertId = a.Id, PolicyName = a.PolicyName, Channel = "email", Target = Truncate(to, 120) }; @@ -153,6 +469,9 @@ private async Task SendEmailAsync(AppDbContext db, NotificationSettings cfg, str Category{a.Category} Triggered{a.TriggeredAt:u} + {(AlertLink(a) is { } link + ? $"""

Open in Vigil365

""" + : "")} """, }; diff --git a/src/M365SecurityDashboard.Api/Services/PolicyBacktester.cs b/src/M365SecurityDashboard.Api/Services/PolicyBacktester.cs new file mode 100644 index 0000000..33fdf48 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/PolicyBacktester.cs @@ -0,0 +1,141 @@ +using Microsoft.EntityFrameworkCore; +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; + +namespace M365SecurityDashboard.Api.Services; + +/// +/// Answers "if this policy had been enabled, how often would it have fired?" +/// against stored history, before an analyst commits to a threshold. +/// +/// Honesty rule: only claim a result where real history exists. Activity +/// policies replay exactly (audit events are timestamped). Metric policies can +/// only be replayed where a trend-snapshot field records that metric over time. +/// Anything else returns Supported=false with the reason — reporting "0 times" +/// for something we cannot measure would make an untested policy look safe. +/// +public sealed class PolicyBacktester(AppDbContext db) +{ + public sealed record Result( + bool Supported, + string? UnsupportedReason, + int WindowDays, + int Threshold, + int WouldFireCount, + int MaxObservedValue, + int SamplesEvaluated, + string Basis, + IReadOnlyList FiredAt); + + /// Metric names that a TrendSnapshot actually records over time. + private static readonly Dictionary> SnapshotMetrics = + new(StringComparer.OrdinalIgnoreCase) + { + ["riskyUsersCount"] = s => s.RiskyUsersCount, + ["nonCompliantCount"] = s => s.NonCompliantDevicesCount, + ["criticalAlertCount"] = s => s.CriticalAlertsCount, + ["highAlertCount"] = s => s.HighAlertsCount, + }; + + public async Task RunAsync(AlertPolicy policy, int days, TimeSpan evalInterval, CancellationToken ct) + { + var windowDays = Math.Clamp(days, 1, 90); + var to = DateTimeOffset.UtcNow; + var from = to.AddDays(-windowDays); + var threshold = Math.Max(1, policy.Threshold); + + if (policy.Kind.Equals("anomaly", StringComparison.OrdinalIgnoreCase)) + { + return Unsupported(windowDays, threshold, + "Anomaly policies compare against a rolling baseline that is itself derived from history, so replaying them would not reflect how they will behave going forward."); + } + + if (policy.Kind.Equals("activity", StringComparison.OrdinalIgnoreCase)) + return await BacktestActivityAsync(policy, from, to, windowDays, threshold, evalInterval, ct); + + return await BacktestMetricAsync(policy, from, windowDays, threshold, ct); + } + + private async Task BacktestActivityAsync( + AlertPolicy policy, DateTimeOffset from, DateTimeOffset to, + int windowDays, int threshold, TimeSpan evalInterval, CancellationToken ct) + { + var pattern = (policy.ActivityPattern ?? "").Trim(); + if (pattern.Length == 0) + return Unsupported(windowDays, threshold, "This activity policy has no activity pattern set."); + + var like = pattern.Replace("*", "%"); + var window = TimeSpan.FromMinutes(Math.Max(1, policy.WindowMinutes)); + + // Load once and count in memory — the alternative is one query per step. + // Reach back a full window before the range so the first steps are correct. + var times = await db.AuditEvents.AsNoTracking() + .Where(e => e.OccurredAt >= from - window && e.OccurredAt <= to + && EF.Functions.Like(e.Activity, like)) + .OrderBy(e => e.OccurredAt) + .Select(e => e.OccurredAt) + .ToListAsync(ct); + + var earliest = await db.AuditEvents.AsNoTracking() + .OrderBy(e => e.OccurredAt).Select(e => (DateTimeOffset?)e.OccurredAt).FirstOrDefaultAsync(ct); + if (earliest is null) + return Unsupported(windowDays, threshold, "No tenant audit events have been collected yet, so there is nothing to replay."); + + // Never imply coverage older than the data. Retention trims audit events. + var effectiveFrom = earliest > from ? earliest.Value : from; + var effectiveDays = (int)Math.Ceiling((to - effectiveFrom).TotalDays); + + var step = evalInterval <= TimeSpan.Zero ? TimeSpan.FromMinutes(15) : evalInterval; + var outcome = BacktestMath.CountEpisodes(times, effectiveFrom, to, window, step, threshold); + var steps = (int)Math.Max(1, (to - effectiveFrom).Ticks / step.Ticks); + + return new Result( + Supported: true, + UnsupportedReason: null, + WindowDays: Math.Max(1, effectiveDays), + Threshold: threshold, + WouldFireCount: outcome.Episodes, + MaxObservedValue: outcome.MaxValue, + SamplesEvaluated: steps, + Basis: $"Replayed {times.Count} matching audit event(s) over a {policy.WindowMinutes}-minute rolling window.", + FiredAt: outcome.FiredAt); + } + + private async Task BacktestMetricAsync( + AlertPolicy policy, DateTimeOffset from, int windowDays, int threshold, CancellationToken ct) + { + if (!SnapshotMetrics.TryGetValue(policy.Metric ?? "", out var selector)) + { + return Unsupported(windowDays, threshold, + $"No historical record exists for '{policy.Metric}'. Only metrics captured in trend snapshots " + + "(risky users, non-compliant devices, critical and high alert counts) can be replayed."); + } + + var snapshots = await db.TrendSnapshots.AsNoTracking() + .Where(s => s.CapturedAt >= from) + .OrderBy(s => s.CapturedAt) + .ToListAsync(ct); + + if (snapshots.Count < 2) + return Unsupported(windowDays, threshold, + "Not enough trend snapshots in this period to replay the policy. Snapshots accumulate as collections run."); + + var series = snapshots.Select(s => (s.CapturedAt, selector(s))).ToList(); + var outcome = BacktestMath.CountEpisodesFromSeries(series, threshold); + var covered = (int)Math.Ceiling((DateTimeOffset.UtcNow - snapshots[0].CapturedAt).TotalDays); + + return new Result( + Supported: true, + UnsupportedReason: null, + WindowDays: Math.Max(1, covered), + Threshold: threshold, + WouldFireCount: outcome.Episodes, + MaxObservedValue: outcome.MaxValue, + SamplesEvaluated: snapshots.Count, + Basis: $"Replayed {snapshots.Count} trend snapshot(s) of '{policy.Metric}'. Accurate to snapshot granularity, not every evaluation cycle.", + FiredAt: outcome.FiredAt); + } + + private static Result Unsupported(int windowDays, int threshold, string reason) => + new(false, reason, windowDays, threshold, 0, 0, 0, "", []); +} diff --git a/src/M365SecurityDashboard.Api/Services/PolicyPack.cs b/src/M365SecurityDashboard.Api/Services/PolicyPack.cs new file mode 100644 index 0000000..7d25c86 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/PolicyPack.cs @@ -0,0 +1,163 @@ +using M365SecurityDashboard.Api.Models; + +namespace M365SecurityDashboard.Api.Services; + +/// +/// Portable alert-policy packs: share a tuned policy set between installs, keep +/// it in version control, or restore it after a rebuild. +/// +/// Two rules shape the format: +/// • Runtime state never travels. Id, CreatedAt, LastTriggered and TriggerCount +/// describe *this* install's history; carrying them to another tenant would +/// show fabricated trigger counts for policies that never ran there. +/// • Recipients are stripped by default. NotifyEmail holds an internal address, +/// and packs are meant to be shared (a repo, a colleague). Opt in with +/// includeRecipients when exporting a backup for the same organisation. +/// +/// Import matches on Name because ids differ across installs — the name is the +/// only stable natural key a human curates. +/// +public static class PolicyPack +{ + /// Bump only for a breaking shape change; importers reject unknown majors. + public const int CurrentVersion = 1; + + private static readonly string[] ValidKinds = ["metric", "activity", "anomaly"]; + private static readonly string[] ValidSeverities = ["critical", "high", "medium", "low", "informational"]; + + public sealed record PackPolicy( + string Name, + bool Enabled, + string Category, + string Condition, + string Kind, + string Metric, + string? ActivityPattern, + int WindowMinutes, + double BaselineMultiplier, + int BaselineDays, + int Threshold, + string Severity, + int SuppressionMinutes, + string? NotifyEmail); + + public sealed record Pack( + int PackVersion, + DateTimeOffset ExportedAt, + string ExportedFrom, + bool IncludesRecipients, + IReadOnlyList Policies); + + public static PackPolicy ToPack(AlertPolicy p, bool includeRecipients) => new( + Name: p.Name, + Enabled: p.Enabled, + Category: p.Category, + Condition: p.Condition, + Kind: p.Kind, + Metric: p.Metric, + ActivityPattern: p.ActivityPattern, + WindowMinutes: p.WindowMinutes, + BaselineMultiplier: p.BaselineMultiplier, + BaselineDays: p.BaselineDays, + Threshold: p.Threshold, + Severity: p.Severity, + SuppressionMinutes: p.SuppressionMinutes, + NotifyEmail: includeRecipients ? p.NotifyEmail : null); + + /// + /// An empty Kind means "metric" — that is how the evaluator reads it, and + /// policies created before the Kind column existed still carry it. + /// + public static string NormaliseKind(string? kind) + { + var k = (kind ?? "").Trim().ToLowerInvariant(); + return k.Length == 0 ? "metric" : k; + } + + /// + /// Validates one packed policy against what the evaluator actually requires. + /// + /// Rejects only what would make a policy silently useless — no name, no + /// threshold, an activity policy with no pattern. Tuning fields that do not + /// apply to the policy's kind (window on a metric policy, baseline on + /// anything but anomaly) are coerced to defaults on import instead, matching + /// how POST /api/alert-policies already treats them. Rejecting those would + /// fail most real-world policies, since the columns default to zero for + /// kinds that never read them. + /// + public static string? Validate(PackPolicy? p) + { + if (p is null) return "Entry is empty."; + if (string.IsNullOrWhiteSpace(p.Name)) return "Name is required."; + if (p.Name.Length > 200) return "Name exceeds 200 characters."; + if (string.IsNullOrWhiteSpace(p.Category)) return "Category is required."; + + var kind = NormaliseKind(p.Kind); + if (!ValidKinds.Contains(kind)) + return $"Kind must be one of: {string.Join(", ", ValidKinds)}."; + + if (!ValidSeverities.Contains((p.Severity ?? "").Trim().ToLowerInvariant())) + return $"Severity must be one of: {string.Join(", ", ValidSeverities)}."; + + if (p.Threshold < 1) return "Threshold must be at least 1."; + + if (kind == "activity" && string.IsNullOrWhiteSpace(p.ActivityPattern)) + return "Activity policies require an activity pattern."; + + if (kind != "activity" && string.IsNullOrWhiteSpace(p.Metric)) + return "Metric and anomaly policies require a metric."; + + return null; + } + + // Defaults mirror POST /api/alert-policies so an imported policy behaves + // identically to one created through the UI. + private static int Window(int v) => v > 0 ? v : 60; + private static int BaselineDaysOf(int v) => v > 0 ? v : 30; + private static double MultiplierOf(double v) => v > 0 ? v : 3.0; + private static int Suppression(int v) => v >= 0 ? v : 60; + + /// Materialises a validated packed policy as a new entity for this install. + public static AlertPolicy ToEntity(PackPolicy p) => new() + { + Id = Guid.NewGuid(), + Name = p.Name.Trim(), + Enabled = p.Enabled, + Category = p.Category.Trim(), + Condition = p.Condition ?? "", + Kind = NormaliseKind(p.Kind), + Metric = p.Metric ?? "", + ActivityPattern = string.IsNullOrWhiteSpace(p.ActivityPattern) ? null : p.ActivityPattern.Trim(), + WindowMinutes = Window(p.WindowMinutes), + BaselineMultiplier = MultiplierOf(p.BaselineMultiplier), + BaselineDays = BaselineDaysOf(p.BaselineDays), + Threshold = p.Threshold, + Severity = p.Severity.Trim().ToLowerInvariant(), + SuppressionMinutes = Suppression(p.SuppressionMinutes), + NotifyEmail = p.NotifyEmail, + // Fresh runtime state — this policy has never fired *here*. + CreatedAt = DateTimeOffset.UtcNow, + LastTriggered = null, + TriggerCount = 0, + }; + + /// Copies pack fields onto an existing policy, preserving its identity and history. + public static void ApplyTo(AlertPolicy target, PackPolicy p) + { + target.Enabled = p.Enabled; + target.Category = p.Category.Trim(); + target.Condition = p.Condition ?? ""; + target.Kind = NormaliseKind(p.Kind); + target.Metric = p.Metric ?? ""; + target.ActivityPattern = string.IsNullOrWhiteSpace(p.ActivityPattern) ? null : p.ActivityPattern.Trim(); + target.WindowMinutes = Window(p.WindowMinutes); + target.BaselineMultiplier = MultiplierOf(p.BaselineMultiplier); + target.BaselineDays = BaselineDaysOf(p.BaselineDays); + target.Threshold = p.Threshold; + target.Severity = p.Severity.Trim().ToLowerInvariant(); + target.SuppressionMinutes = Suppression(p.SuppressionMinutes); + // NotifyEmail only overwritten when the pack actually carries one, so + // importing a shared (stripped) pack never silently clears local routing. + if (!string.IsNullOrWhiteSpace(p.NotifyEmail)) target.NotifyEmail = p.NotifyEmail; + } +} diff --git a/src/M365SecurityDashboard.Api/Services/RecommendationsEngine.cs b/src/M365SecurityDashboard.Api/Services/RecommendationsEngine.cs new file mode 100644 index 0000000..1aa114c --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/RecommendationsEngine.cs @@ -0,0 +1,234 @@ +using Microsoft.EntityFrameworkCore; +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; + +namespace M365SecurityDashboard.Api.Services; + +public static class RecommendationsEngine +{ + public static async Task> GetRecommendationsAsync(AppDbContext db, CancellationToken ct = default) + { + var list = new List(); + + var mfaMissing = await db.SecurityAlerts.AsNoTracking().CountAsync(a => a.AlertType == "MfaStatus" && !a.IsResolved, ct); + list.Add(new SecurityRecommendation + { + Id = "rec-mfa-registration", + Category = "Identity", + Title = "Enforce Multi-Factor Authentication Registration", + Severity = mfaMissing > 10 ? "critical" : mfaMissing > 0 ? "high" : "low", + AffectedCount = mfaMissing, + WhyItMatters = "Accounts without MFA are 99.9% more susceptible to automated password spray, credential stuffing, and phishing attacks.", + RemediationSteps = new List + { + "Navigate to Microsoft Entra ID -> Authentication methods -> Registration campaign.", + "Enable Microsoft Authenticator push notifications as the default method.", + "Target non-compliant user accounts and enforce a 14-day grace period for enrollment." + }, + PortalBladeName = "Microsoft Entra ID — Authentication Methods", + PortalDeepLink = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/AuthenticationMethodsMenuBlade/~/AdminAuthMethods" + }); + + var riskyUsers = await db.SecurityAlerts.AsNoTracking().CountAsync(a => a.AlertType == "RiskyUser" && !a.IsResolved, ct); + list.Add(new SecurityRecommendation + { + Id = "rec-risky-users", + Category = "Identity", + Title = "Investigate & Remediate High-Risk Accounts", + Severity = riskyUsers > 0 ? "critical" : "low", + AffectedCount = riskyUsers, + WhyItMatters = "Identity Protection has detected anomalous behavior indicating active credential compromise or impossible travel.", + RemediationSteps = new List + { + "Open the user profile in Entra ID Risky Users and review detection triggers.", + "Trigger 'Confirm user compromised' to immediately revoke active refresh tokens.", + "Require secure self-service password reset (SSPR) with MFA verification." + }, + PortalBladeName = "Microsoft Entra ID — Risky Users", + PortalDeepLink = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/RiskyUsersBlade" + }); + + var nonCompliantDevices = await db.SecurityAlerts.AsNoTracking().CountAsync(a => a.AlertType == "NonCompliantDevice" && !a.IsResolved, ct); + list.Add(new SecurityRecommendation + { + Id = "rec-device-compliance", + Category = "Devices", + Title = "Quarantine Non-Compliant & Unpatched Endpoints", + Severity = nonCompliantDevices > 5 ? "high" : nonCompliantDevices > 0 ? "medium" : "low", + AffectedCount = nonCompliantDevices, + WhyItMatters = "Endpoints failing compliance check-ins may lack critical OS security patches, BitLocker encryption, or active EDR agents.", + RemediationSteps = new List + { + "Filter Intune Device Compliance list for non-compliant hardware.", + "Verify device encryption status and Antimalware signature updates.", + "Link Conditional Access device policies to block access from non-compliant devices." + }, + PortalBladeName = "Microsoft Intune — Device Compliance", + PortalDeepLink = "https://intune.microsoft.com/#view/Microsoft_Intune_DeviceSettings/DevicesMenu/~/compliance" + }); + + var emailMalware = await db.SecurityAlerts.AsNoTracking().CountAsync(a => a.Service == M365ServiceArea.ExchangeOnline && !a.IsResolved, ct); + list.Add(new SecurityRecommendation + { + Id = "rec-email-quarantine", + Category = "Email & Collaboration", + Title = "Review Malicious Email & Quarantine Detections", + Severity = emailMalware > 0 ? "high" : "low", + AffectedCount = emailMalware, + WhyItMatters = "Phishing and malware payloads targeting employee mailboxes can lead to ransomware execution and business email compromise (BEC).", + RemediationSteps = new List + { + "Access the Microsoft Defender Quarantine portal.", + "Inspect header details and sender domain reputation for quarantined payloads.", + "Submit false-negatives or malicious attachments to Microsoft Threat Explorer." + }, + PortalBladeName = "Microsoft Defender Portal — Quarantine", + PortalDeepLink = "https://security.microsoft.com/quarantine" + }); + + var staleDevices = await db.SecurityAlerts.AsNoTracking().CountAsync(a => a.AlertType == "StaleDevice" && !a.IsResolved, ct); + list.Add(new SecurityRecommendation + { + Id = "rec-stale-devices", + Category = "Devices", + Title = "Prune Stale Endpoints (> 7 Days Inactive)", + Severity = staleDevices > 10 ? "medium" : "low", + AffectedCount = staleDevices, + WhyItMatters = "Orphaned computer accounts and stale endpoints inflate licensing costs and present unmanaged attack surface.", + RemediationSteps = new List + { + "Identify devices with last check-in timestamp older than 7 days.", + "Retire or wipe inactive corporate endpoints no longer in active employee custody.", + "Purge stale hardware records from Entra ID device directory." + }, + PortalBladeName = "Microsoft Intune — All Devices", + PortalDeepLink = "https://intune.microsoft.com/#view/Microsoft_Intune_DeviceSettings/DevicesMenu/~/allDevices" + }); + + var highCriticalAlerts = await db.SecurityAlerts.AsNoTracking().CountAsync(a => !a.IsResolved && (a.Severity == AlertSeverity.High || a.Severity == AlertSeverity.Critical), ct); + list.Add(new SecurityRecommendation + { + Id = "rec-high-severity-incidents", + Category = "Infrastructure", + Title = "Triage Unresolved High & Critical Incidents", + Severity = highCriticalAlerts > 0 ? "critical" : "low", + AffectedCount = highCriticalAlerts, + WhyItMatters = "Unattended high and critical severity alerts indicate potential active intrusion or lateral movement across M365 services.", + RemediationSteps = new List + { + "Sort active alerts by severity descending in Incident Command.", + "Assign primary incident handler to investigate root cause.", + "Acknowledge alert status to initiate automated containment workflows." + }, + PortalBladeName = "Microsoft Defender Portal — Incidents", + PortalDeepLink = "https://security.microsoft.com/incidents" + }); + + return list; + } + + private static readonly AlertBaselineRule[] BaselineCatalog = + [ + new() { Id = "base-01", Title = "Critical Security Alerts", Category = "identity", Severity = "critical", RuleType = "Vigil365", Metric = "criticalAlertCount", DefaultThreshold = 1, Description = "Fires when open critical security alerts reach or exceed 1." }, + new() { Id = "base-02", Title = "MFA Not Registered", Category = "identity", Severity = "high", RuleType = "Vigil365", Metric = "mfaMissingCount", DefaultThreshold = 5, Description = "Fires when users missing MFA registration reach or exceed 5." }, + new() { Id = "base-03", Title = "Risky Users Detected", Category = "identity", Severity = "high", RuleType = "Vigil365", Metric = "riskyUsersCount", DefaultThreshold = 1, Description = "Fires when active high-risk users are detected in Entra ID." }, + new() { Id = "base-04", Title = "Non-Compliant Devices", Category = "devices", Severity = "medium", RuleType = "Vigil365", Metric = "nonCompliantCount", DefaultThreshold = 1, Description = "Fires when endpoints fail Intune compliance checks." }, + new() { Id = "base-05", Title = "Stale Devices", Category = "devices", Severity = "low", RuleType = "Vigil365", Metric = "staleDeviceCount", DefaultThreshold = 1, Description = "Fires when devices have not checked in for more than 7 days." }, + new() { Id = "base-06", Title = "High Priority Alerts", Category = "identity", Severity = "high", RuleType = "Vigil365", Metric = "highAlertCount", DefaultThreshold = 3, Description = "Fires when open high-severity alerts exceed threshold." }, + new() { Id = "base-07", Title = "Service Health Advisory", Category = "infrastructure", Severity = "medium", RuleType = "Vigil365", Metric = "serviceIssueCount", DefaultThreshold = 1, Description = "Fires when Microsoft 365 service degrades or outages occur." }, + new() { Id = "base-08", Title = "Mass File Deletion Spike", Category = "data protection", Severity = "high", RuleType = "Vigil365", Metric = "massDeletionCount", DefaultThreshold = 10, Description = "Fires when bulk file removal detected across SharePoint/OneDrive." }, + new() { Id = "base-09", Title = "Sudden Risky Sign-In Spike", Category = "identity", Severity = "high", RuleType = "Vigil365", Metric = "riskySignInCount", DefaultThreshold = 5, Description = "Fires when anomalous sign-in failures surge within 24 hours." }, + new() { Id = "base-10", Title = "Email Malware Quarantine Surge", Category = "email", Severity = "high", RuleType = "Vigil365", Metric = "malwareQuarantineCount", DefaultThreshold = 3, Description = "Fires when inbound malicious attachments exceed normal volume." }, + + new() { Id = "base-11", Title = "Privileged Role Assignment Elevation", Category = "identity", Severity = "critical", RuleType = "NativeM365", Description = "Detects whenever Global Admin or Security Admin role is assigned outside PIM workflow.", NativePortalBlade = "Microsoft Entra ID — Roles & Admins", NativePortalDeepLink = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/RolesManagementMenuBlade/~/AllRoles" }, + new() { Id = "base-12", Title = "Suspicious Mailbox Forwarding Rule", Category = "email", Severity = "high", RuleType = "NativeM365", Description = "Detects creation of inbox rules forwarding mail to external personal domains.", NativePortalBlade = "Microsoft Purview — Alert Policies", NativePortalDeepLink = "https://purview.microsoft.com/alertpolicies" }, + new() { Id = "base-13", Title = "Impossible Travel Sign-In Activity", Category = "identity", Severity = "high", RuleType = "NativeM365", Description = "Detects successful authentications from geographically distant IPs in physically impossible time window.", NativePortalBlade = "Microsoft Entra ID — Identity Protection", NativePortalDeepLink = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/IdentityProtectionMenuBlade/~/RiskyUsers" }, + new() { Id = "base-14", Title = "New OAuth Application Consent Grant", Category = "identity", Severity = "medium", RuleType = "NativeM365", Description = "Alerts when third-party applications request delegated mailbox or directory permissions.", NativePortalBlade = "Microsoft Entra ID — Enterprise Apps", NativePortalDeepLink = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/~/AppAppsPreview" }, + new() { Id = "base-15", Title = "Admin MFA Disabled or Modified", Category = "identity", Severity = "critical", RuleType = "NativeM365", Description = "Detects any modification or bypass exception added to Conditional Access MFA rules.", NativePortalBlade = "Microsoft Entra ID — Audit Logs", NativePortalDeepLink = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/AuditLogs" }, + new() { Id = "base-16", Title = "Emergency Break-Glass Account Sign-In", Category = "identity", Severity = "critical", RuleType = "NativeM365", Description = "Alerts immediately if designated break-glass emergency administrative account authenticates.", NativePortalBlade = "Microsoft Entra ID — Sign-in Logs", NativePortalDeepLink = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/SignIns" }, + new() { Id = "base-17", Title = "Bulk DLP Policy Violation", Category = "data protection", Severity = "high", RuleType = "NativeM365", Description = "Triggers when sensitive credit card or PII data exfiltration attempts exceed threshold.", NativePortalBlade = "Microsoft Purview — DLP", NativePortalDeepLink = "https://purview.microsoft.com/dataloss-prevention" }, + new() { Id = "base-18", Title = "Unusual External OneDrive Oversharing", Category = "data protection", Severity = "medium", RuleType = "NativeM365", Description = "Monitors spikes in anonymous guest links generated across SharePoint document libraries.", NativePortalBlade = "SharePoint Admin Center", NativePortalDeepLink = "https://admin.microsoft.com/sharepoint" }, + new() { Id = "base-19", Title = "Intune Compliance Policy Modifications", Category = "devices", Severity = "high", RuleType = "NativeM365", Description = "Alerts on unauthorized weakening of device password or BitLocker encryption requirements.", NativePortalBlade = "Microsoft Intune — Audit Logs", NativePortalDeepLink = "https://intune.microsoft.com/#view/Microsoft_Intune_DeviceSettings/TenantAdminMenu/~/auditLogs" }, + new() { Id = "base-20", Title = "Exchange Online High-Volume Outbound Spam", Category = "email", Severity = "high", RuleType = "NativeM365", Description = "Detects compromised internal employee mailboxes sending outbound bulk spam.", NativePortalBlade = "Microsoft Defender Portal — Antispam", NativePortalDeepLink = "https://security.microsoft.com/antispam" } + ]; + + public static async Task GetAlertCoverageAsync(AppDbContext db, CancellationToken ct = default) + { + var existingPolicies = await db.AlertPolicies.AsNoTracking().ToListAsync(ct); + var rules = new List(); + + foreach (var rule in BaselineCatalog) + { + var clone = new AlertBaselineRule + { + Id = rule.Id, + Title = rule.Title, + Category = rule.Category, + Severity = rule.Severity, + Description = rule.Description, + RuleType = rule.RuleType, + Metric = rule.Metric, + DefaultThreshold = rule.DefaultThreshold, + NativePortalBlade = rule.NativePortalBlade, + NativePortalDeepLink = rule.NativePortalDeepLink + }; + + if (clone.RuleType == "Vigil365") + { + clone.IsActive = existingPolicies.Any(p => p.Name.Equals(clone.Title, StringComparison.OrdinalIgnoreCase) && p.Enabled); + } + else + { + // Native rules default to monitored assuming tenant has baseline Defender enabled + clone.IsActive = true; + } + + rules.Add(clone); + } + + var activeCount = rules.Count(r => r.IsActive); + var total = rules.Count; + var pct = total > 0 ? (int)Math.Round((double)activeCount / total * 100) : 0; + + return new AlertCoverageScorecard + { + TotalRules = total, + ActiveRules = activeCount, + CoveragePercentage = pct, + Rules = rules + }; + } + + public static async Task EnableCoverageRuleAsync(AppDbContext db, string ruleId, CancellationToken ct = default) + { + var target = BaselineCatalog.FirstOrDefault(r => r.Id.Equals(ruleId, StringComparison.OrdinalIgnoreCase)); + if (target == null || target.RuleType != "Vigil365") return null; + + var existing = await db.AlertPolicies.FirstOrDefaultAsync(p => p.Name.Equals(target.Title, StringComparison.OrdinalIgnoreCase), ct); + if (existing != null) + { + existing.Enabled = true; + await db.SaveChangesAsync(ct); + return existing; + } + + var newPolicy = new AlertPolicy + { + Id = Guid.NewGuid(), + Name = target.Title, + Enabled = true, + Category = target.Category, + Metric = target.Metric, + Threshold = target.DefaultThreshold, + Severity = target.Severity, + Condition = target.Description, + SuppressionMinutes = 60, + CreatedAt = DateTimeOffset.UtcNow, + TriggerCount = 0 + }; + + db.AlertPolicies.Add(newPolicy); + await db.SaveChangesAsync(ct); + return newPolicy; + } +} diff --git a/src/M365SecurityDashboard.Api/Services/ReportScheduleWorker.cs b/src/M365SecurityDashboard.Api/Services/ReportScheduleWorker.cs new file mode 100644 index 0000000..b9a868e --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/ReportScheduleWorker.cs @@ -0,0 +1,102 @@ +using M365SecurityDashboard.Api.Data; +using M365SecurityDashboard.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace M365SecurityDashboard.Api.Services; + +/// +/// Checks every 15 minutes and dispatches any enabled whose next +/// run is due. Delivery reuses the SMTP configuration in NotificationSettings. +/// +public sealed class ReportScheduleWorker( + IServiceProvider services, + ILogger logger) : BackgroundService +{ + private static readonly TimeSpan StartupDelay = TimeSpan.FromMinutes(3); + // A scheduled 07:00 UTC executive digest should not arrive close to 08:00. + // This remains inexpensive because only due schedules build a digest. + private static readonly TimeSpan Interval = TimeSpan.FromMinutes(15); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try { await Task.Delay(StartupDelay, stoppingToken); } + catch (OperationCanceledException) { return; } + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var now = DateTimeOffset.UtcNow; + var due = (await db.ReportSchedules.ToListAsync(stoppingToken)).Where(s => s.IsDue(now)).ToList(); + foreach (var schedule in due) + { + var (ok, status) = await DispatchAsync(scope.ServiceProvider, db, schedule, stoppingToken); + schedule.LastRunAt = now; + schedule.LastRunStatus = status; + logger.Log(ok ? LogLevel.Information : LogLevel.Warning, + "Report '{Name}' dispatch: {Status}", schedule.Name, status); + } + if (due.Count > 0) await db.SaveChangesAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } + catch (Exception ex) + { + logger.LogError(ex, "Report schedule tick failed"); + } + + try { await Task.Delay(Interval, stoppingToken); } + catch (OperationCanceledException) { break; } + } + } + + /// + /// Builds and sends one report. Shared by the worker and the manual "run now" + /// endpoint. Does not persist the schedule — the caller records LastRun*. + /// + public static async Task<(bool ok, string status)> DispatchAsync( + IServiceProvider sp, AppDbContext db, ReportSchedule schedule, CancellationToken ct) + { + var cfg = await db.NotificationSettings.FirstOrDefaultAsync(ct); + if (cfg == null || !cfg.EmailEnabled || string.IsNullOrWhiteSpace(cfg.SmtpHost)) + return (false, "failed: SMTP email is not configured"); + + var recipients = SplitRecipients(schedule.Recipients); + if (recipients.Count == 0) + return (false, "failed: no recipients"); + + var window = schedule.Cadence switch { "daily" => 1, "monthly" => 30, _ => 7 }; + var builder = sp.GetRequiredService(); + var digest = await builder.BuildAsync(window, ct); + var attachments = new List(); + if (schedule.IncludeCsv && !string.IsNullOrEmpty(digest.Csv)) + attachments.Add(new NotificationSender.ReportAttachment( + $"vigil365-digest-{digest.GeneratedAt:yyyyMMdd}.csv", + "text/csv", + System.Text.Encoding.UTF8.GetBytes(digest.Csv))); + if (schedule.IncludePdf) + { + var pdf = sp.GetRequiredService().Render(digest); + attachments.Add(new NotificationSender.ReportAttachment( + $"vigil365-exec-digest-{digest.GeneratedAt:yyyyMMdd}.pdf", + "application/pdf", + pdf)); + } + + var sender = sp.GetRequiredService(); + var (ok, error) = await sender.SendReportEmailAsync( + cfg, recipients, digest.Subject, digest.HtmlBody, + attachments, ct); + + return ok + ? (true, $"sent to {recipients.Count} recipient{(recipients.Count == 1 ? "" : "s")}") + : (false, $"failed: {error}"); + } + + public static List SplitRecipients(string? raw) => + (raw ?? "") + .Split([',', ';', '\n', '\r', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); +} diff --git a/src/M365SecurityDashboard.Api/Services/RoleClaimsTransformation.cs b/src/M365SecurityDashboard.Api/Services/RoleClaimsTransformation.cs new file mode 100644 index 0000000..b69d353 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/RoleClaimsTransformation.cs @@ -0,0 +1,69 @@ +using System.Security.Claims; +using M365SecurityDashboard.Api.Data; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace M365SecurityDashboard.Api.Services; + +/// +/// After the Microsoft token is validated (which proves WHO the user is), this +/// attaches the user's Vigil365 role (WHAT they can do) as a role claim, read from +/// our own AppUsers table. The role policies (RequireAdmin / RequireAnalyst) then +/// work unchanged. Roles are managed in-app by an Admin — never in Entra ID. +/// +/// Read-only: user creation and bootstrap happen once in GET /api/auth/me. If a +/// user has no row yet, they get Viewer (least privilege) so reads still work. +/// +/// Roles are cached for a short TTL to avoid a DB round-trip on every request. +/// Role-change/removal endpoints evict the entry so changes apply immediately; +/// the TTL only bounds staleness across multiple app instances. +/// +public sealed class RoleClaimsTransformation(AppDbContext db, IMemoryCache cache) : IClaimsTransformation +{ + public static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(60); + + /// Cache key for a user's role; also used by endpoints to evict on change. + public static string RoleCacheKey(string email) => $"approle:{email}"; + + public async Task TransformAsync(ClaimsPrincipal principal) + { + if (principal.Identity is not { IsAuthenticated: true }) return principal; + + // Avoid re-adding on repeated invocations within a request. + if (principal.HasClaim(c => c.Type == ClaimTypes.Role)) return principal; + + var email = AuthHelpers.GetEmail(principal); + if (string.IsNullOrEmpty(email)) return principal; + + var role = await cache.GetOrCreateAsync(RoleCacheKey(email), async entry => + { + entry.AbsoluteExpirationRelativeToNow = CacheTtl; + return await db.AppUsers + .Where(u => u.Email == email) + .Select(u => u.Role) + .FirstOrDefaultAsync() ?? Models.AppRoles.Viewer; + }) ?? Models.AppRoles.Viewer; + + var identity = new ClaimsIdentity(); + identity.AddClaim(new Claim(ClaimTypes.Role, role)); + principal.AddIdentity(identity); + return principal; + } +} + +/// Shared helpers for pulling identity out of a validated principal. +public static class AuthHelpers +{ + /// The signed-in user's email / UPN, lower-cased, or empty string. + public static string GetEmail(ClaimsPrincipal principal) => + (principal.FindFirst("preferred_username")?.Value + ?? principal.FindFirst(ClaimTypes.Upn)?.Value + ?? principal.FindFirst(ClaimTypes.Email)?.Value + ?? "").Trim().ToLowerInvariant(); + + public static string GetDisplayName(ClaimsPrincipal principal) => + principal.FindFirst("name")?.Value + ?? principal.FindFirst(ClaimTypes.Name)?.Value + ?? ""; +} diff --git a/src/M365SecurityDashboard.Api/Services/SecretProtector.cs b/src/M365SecurityDashboard.Api/Services/SecretProtector.cs index f0d6af0..6d7c9a7 100644 --- a/src/M365SecurityDashboard.Api/Services/SecretProtector.cs +++ b/src/M365SecurityDashboard.Api/Services/SecretProtector.cs @@ -1,35 +1,42 @@ -using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; +using Microsoft.AspNetCore.DataProtection; namespace M365SecurityDashboard.Api.Services; /// -/// Encrypts sensitive values (SMTP password, webhook URLs) at rest using the -/// Windows Data Protection API (DPAPI), machine scope. Ciphertext is bound to -/// this host — a leaked database row cannot be decrypted on another machine. -/// On non-Windows hosts it falls back to returning values unchanged (with a -/// marker) so the app still runs in dev containers; production target is Windows. +/// Encrypts sensitive values (SMTP password, webhook URLs, Graph client secret) at +/// rest. Uses ASP.NET Core Data Protection, which works on Windows, Linux, and in +/// containers; the key ring is persisted to disk (mount it as a volume in Docker) +/// so secrets survive restarts. Legacy Windows DPAPI values (prefix "dpapi:") are +/// still readable on Windows for backward compatibility; new values are written +/// with the cross-platform "dp:" prefix. /// -public sealed class SecretProtector(ILogger logger) +public sealed class SecretProtector { - private const string Prefix = "dpapi:"; // marks a value as DPAPI-encrypted + private const string DpPrefix = "dp:"; // cross-platform Data Protection + private const string DpapiPrefix = "dpapi:"; // legacy Windows DPAPI + private readonly IDataProtector _protector; + private readonly ILogger _logger; + + public SecretProtector(IDataProtectionProvider provider, ILogger logger) + { + _protector = provider.CreateProtector("Vigil365.Secrets.v1"); + _logger = logger; + } public string? Protect(string? plaintext) { if (string.IsNullOrEmpty(plaintext)) return plaintext; - if (plaintext.StartsWith(Prefix, StringComparison.Ordinal)) return plaintext; // already protected - if (!OperatingSystem.IsWindows()) return plaintext; - + if (plaintext.StartsWith(DpPrefix, StringComparison.Ordinal) || + plaintext.StartsWith(DpapiPrefix, StringComparison.Ordinal)) return plaintext; // already protected try { - var bytes = Encoding.UTF8.GetBytes(plaintext); - var cipher = ProtectedData.Protect(bytes, optionalEntropy: null, scope: DataProtectionScope.LocalMachine); - return Prefix + Convert.ToBase64String(cipher); + return DpPrefix + _protector.Protect(plaintext); } catch (Exception ex) { - logger.LogWarning(ex, "DPAPI Protect failed; storing value unprotected"); + _logger.LogWarning(ex, "Protect failed; storing value unprotected"); return plaintext; } } @@ -37,19 +44,26 @@ public sealed class SecretProtector(ILogger logger) public string? Unprotect(string? stored) { if (string.IsNullOrEmpty(stored)) return stored; - if (!stored.StartsWith(Prefix, StringComparison.Ordinal)) return stored; // legacy plaintext — return as-is - if (!OperatingSystem.IsWindows()) return null; // can't decrypt off Windows - try + if (stored.StartsWith(DpPrefix, StringComparison.Ordinal)) { - var cipher = Convert.FromBase64String(stored[Prefix.Length..]); - var bytes = ProtectedData.Unprotect(cipher, optionalEntropy: null, scope: DataProtectionScope.LocalMachine); - return Encoding.UTF8.GetString(bytes); + try { return _protector.Unprotect(stored[DpPrefix.Length..]); } + catch (Exception ex) { _logger.LogWarning(ex, "Unprotect failed; returning empty"); return null; } } - catch (Exception ex) + + // Legacy Windows DPAPI value — only decryptable on the original Windows host. + if (stored.StartsWith(DpapiPrefix, StringComparison.Ordinal)) { - logger.LogWarning(ex, "DPAPI Unprotect failed; returning empty"); - return null; + if (!OperatingSystem.IsWindows()) return null; + try + { + var cipher = Convert.FromBase64String(stored[DpapiPrefix.Length..]); + var bytes = System.Security.Cryptography.ProtectedData.Unprotect(cipher, null, DataProtectionScope.LocalMachine); + return Encoding.UTF8.GetString(bytes); + } + catch (Exception ex) { _logger.LogWarning(ex, "Legacy DPAPI Unprotect failed; returning empty"); return null; } } + + return stored; // legacy plaintext } } diff --git a/src/M365SecurityDashboard.Api/Services/SharingPostureAnalyzer.cs b/src/M365SecurityDashboard.Api/Services/SharingPostureAnalyzer.cs new file mode 100644 index 0000000..b8f4c8e --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/SharingPostureAnalyzer.cs @@ -0,0 +1,114 @@ +using System.Text.Json; + +namespace M365SecurityDashboard.Api.Services; + +/// +/// Analyzes tenant SharePoint/OneDrive sharing settings (Graph +/// /v1.0/admin/sharepoint/settings) for risky external-sharing posture. +/// Pure functions over a flattened view so the findings logic is unit-testable +/// without Graph. Requires SharePointTenantSettings.Read.All. +/// +public static class SharingPostureAnalyzer +{ + /// Flattened view of the sharing-relevant tenant settings. + public sealed record SharingView( + string? SharingCapability, // disabled | externalUserSharingOnly | externalUserAndGuestSharing | existingExternalUserSharingOnly + string? OneDriveSharingCapability, + string? DefaultSharingLinkType, // none | direct | internal | anonymousAccess + int? AnonymousLinkExpirationDays, // null/0 = links never expire + bool ResharingByExternalUsersEnabled, + IReadOnlyList AllowedDomains, // sharing allow-list (empty = unrestricted) + IReadOnlyList BlockedDomains); + + public sealed record Finding(string Severity, string Title, string Detail, string Recommendation); + + private static bool IsAnyoneSharing(string? cap) => + string.Equals(cap, "externalUserAndGuestSharing", StringComparison.OrdinalIgnoreCase); + + private static bool IsExternalSharing(string? cap) => + IsAnyoneSharing(cap) || string.Equals(cap, "externalUserSharingOnly", StringComparison.OrdinalIgnoreCase) + || string.Equals(cap, "existingExternalUserSharingOnly", StringComparison.OrdinalIgnoreCase); + + /// Produces the posture findings, most severe first. + public static List Analyze(SharingView v) + { + var findings = new List(); + + // 1. "Anyone" links tenant-wide (anonymous, unauthenticated access). + if (IsAnyoneSharing(v.SharingCapability)) + { + findings.Add(new Finding("high", "\"Anyone\" links are enabled tenant-wide", + "SharePoint allows anonymous sharing links — files can be opened by anyone with the URL, no sign-in, no audit trail of who accessed them.", + "Restrict sharing to 'New and existing guests' unless anonymous links are a documented business need.")); + + // 1b. Anonymous links that never expire — only meaningful when Anyone links exist. + if (v.AnonymousLinkExpirationDays is null or <= 0) + { + findings.Add(new Finding("medium", "Anonymous links never expire", + "No expiration is enforced on 'Anyone' links — a link shared once remains valid forever.", + "Set an anonymous-link expiration (30 days or less is typical).")); + } + } + + // 2. Default link type is anonymous — every casual share becomes an Anyone link. + if (string.Equals(v.DefaultSharingLinkType, "anonymousAccess", StringComparison.OrdinalIgnoreCase)) + { + findings.Add(new Finding("high", "Default sharing link is \"Anyone\"", + "The default link type users get when sharing is an anonymous link, so the riskiest option is also the path of least resistance.", + "Set the default sharing link to 'Only people in your organization' or 'Specific people'.")); + } + + // 3. External resharing. + if (v.ResharingByExternalUsersEnabled && IsExternalSharing(v.SharingCapability)) + { + findings.Add(new Finding("medium", "External users can re-share content", + "Guests can share items onward to other external users, extending access beyond the original recipient.", + "Disable resharing by external users so shares stay limited to who your users chose.")); + } + + // 4. No domain restrictions while external sharing is on. + if (IsExternalSharing(v.SharingCapability) && v.AllowedDomains.Count == 0 && v.BlockedDomains.Count == 0) + { + findings.Add(new Finding("low", "External sharing has no domain restrictions", + "Sharing is open to any external domain — including consumer and competitor domains.", + "Consider an allow-list of partner domains (or a block-list) to bound external collaboration.")); + } + + // 5. OneDrive looser than SharePoint (per-service drift). + if (IsAnyoneSharing(v.OneDriveSharingCapability) && !IsAnyoneSharing(v.SharingCapability)) + { + findings.Add(new Finding("medium", "OneDrive sharing is looser than SharePoint", + "OneDrive allows 'Anyone' links while SharePoint does not — personal storage is the least-governed surface.", + "Align OneDrive's sharing capability with (or make it stricter than) SharePoint's.")); + } + + var order = new Dictionary { ["critical"] = 0, ["high"] = 1, ["medium"] = 2, ["low"] = 3 }; + return findings.OrderBy(f => order.GetValueOrDefault(f.Severity, 4)).ToList(); + } + + /// Parses the raw Graph sharepoint settings object into the analysis view. + public static SharingView Parse(JsonElement e) + { + static string? Str(JsonElement el, string name) => + el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + static List Arr(JsonElement el, string name) => + el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Array + ? v.EnumerateArray().Select(x => x.GetString() ?? "").Where(s => s.Length > 0).ToList() + : []; + + int? expiry = null; + if (e.TryGetProperty("sharingLinkExpirationInDays", out var exp) && exp.ValueKind == JsonValueKind.Number) + expiry = exp.GetInt32(); + + var reshare = e.TryGetProperty("isResharingByExternalUsersEnabled", out var rs) && rs.ValueKind == JsonValueKind.True; + + return new SharingView( + SharingCapability: Str(e, "sharingCapability"), + OneDriveSharingCapability: Str(e, "oneDriveSharingCapability") ?? Str(e, "sharingCapability"), + DefaultSharingLinkType: Str(e, "sharingDefaultLinkType") ?? Str(e, "defaultSharingLinkType"), + AnonymousLinkExpirationDays: expiry, + ResharingByExternalUsersEnabled: reshare, + AllowedDomains: Arr(e, "sharingAllowedDomainList"), + BlockedDomains: Arr(e, "sharingBlockedDomainList")); + } +} diff --git a/src/M365SecurityDashboard.Api/Services/SuppressionMatcher.cs b/src/M365SecurityDashboard.Api/Services/SuppressionMatcher.cs new file mode 100644 index 0000000..80f3596 --- /dev/null +++ b/src/M365SecurityDashboard.Api/Services/SuppressionMatcher.cs @@ -0,0 +1,99 @@ +using System.Text.Json; +using M365SecurityDashboard.Api.Models; + +namespace M365SecurityDashboard.Api.Services; + +/// +/// Decides whether a would-be alert is covered by a standing suppression rule. +/// Pure and static so the matching semantics — the part that can silently hide +/// real alerts if it is wrong — are unit-testable without a database. +/// +public static class SuppressionMatcher +{ + /// + /// Matches an entity against a pattern supporting a single leading and/or + /// trailing '*'. Case-insensitive. "*" alone matches anything non-empty. + /// + public static bool EntityMatches(string? pattern, string? entity) + { + if (string.IsNullOrWhiteSpace(pattern)) return true; // no restriction + if (string.IsNullOrWhiteSpace(entity)) return false; // pattern set, nothing to match + + var p = pattern.Trim(); + var e = entity.Trim(); + const StringComparison ci = StringComparison.OrdinalIgnoreCase; + + var starts = p.StartsWith('*'); + var ends = p.EndsWith('*'); + var core = p.Trim('*'); + + if (core.Length == 0) return true; // "*" or "**" + if (starts && ends) return e.Contains(core, ci); + if (starts) return e.EndsWith(core, ci); + if (ends) return e.StartsWith(core, ci); + return string.Equals(e, core, ci); + } + + /// Entity identifiers from an alert's AffectedEntities JSON. + /// Tolerates malformed JSON by returning nothing rather than throwing. + public static IReadOnlyList ExtractEntities(string? affectedEntitiesJson) + { + if (string.IsNullOrWhiteSpace(affectedEntitiesJson)) return []; + try + { + using var doc = JsonDocument.Parse(affectedEntitiesJson); + if (doc.RootElement.ValueKind != JsonValueKind.Array) return []; + + var result = new List(); + foreach (var el in doc.RootElement.EnumerateArray()) + { + if (el.ValueKind != JsonValueKind.Object) continue; + // Entity JSON is camelCase by contract (locked by test after the + // PascalCase bug that produced "System / N/A" rows). + foreach (var key in new[] { "userPrincipalName", "deviceName", "targetName" }) + { + if (el.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String) + { + var s = v.GetString(); + if (!string.IsNullOrWhiteSpace(s)) result.Add(s!); + } + } + } + return result; + } + catch (JsonException) { return []; } + } + + /// + /// Returns the first rule that suppresses this alert, or null. A rule applies + /// when it is enabled, unexpired, scoped to this policy (or all policies), and + /// — if it names an entity pattern — at least one affected entity matches. + /// + public static SuppressionRule? FindMatch( + IEnumerable rules, + Guid policyId, + string? affectedEntitiesJson, + DateTimeOffset now) + { + var entities = ExtractEntities(affectedEntitiesJson); + + foreach (var rule in rules) + { + if (!rule.Enabled) continue; + if (rule.ExpiresAt is not null && rule.ExpiresAt <= now) continue; + if (rule.PolicyId is not null && rule.PolicyId != policyId) continue; + + if (string.IsNullOrWhiteSpace(rule.EntityPattern)) + { + // Policy-wide suppression. Requires an explicit policy scope — + // a rule with neither policy nor entity would mute everything, + // which is never what someone means. + if (rule.PolicyId is null) continue; + return rule; + } + + if (entities.Any(e => EntityMatches(rule.EntityPattern, e))) return rule; + } + return null; + } +} diff --git a/src/M365SecurityDashboard.Api/appsettings.json b/src/M365SecurityDashboard.Api/appsettings.json index 24cf5eb..873f34f 100644 --- a/src/M365SecurityDashboard.Api/appsettings.json +++ b/src/M365SecurityDashboard.Api/appsettings.json @@ -1,4 +1,11 @@ { + "Logging": { + "File": { + "Path": "logs/vigil365-.json", + "RetainedFileCountLimit": 14, + "FileSizeLimitBytes": 10485760 + } + }, "ConnectionStrings": { "DefaultConnection": "Server=.\\SQLEXPRESS;Database=M365SecurityDashboard;Trusted_Connection=True;Encrypt=True;TrustServerCertificate=True;MultipleActiveResultSets=true" }, @@ -6,6 +13,10 @@ "TenantId": "YOUR_TENANT_ID", "ClientId": "YOUR_APP_CLIENT_ID", "ClientSecret": "YOUR_APP_CLIENT_SECRET", + "CertificateThumbprint": "", + "CertificatePath": "", + "CertificatePassword": "", + // For sovereign clouds (GCC High, DoD), update BaseUrl (e.g. https://graph.microsoft.us) "BaseUrl": "https://graph.microsoft.com", "CollectionIntervalMinutes": 15, "DevicesNotCheckedInDays": 7, @@ -16,5 +27,26 @@ "Alerting": { "AutoResolveDebounceCycles": 2 }, + "Seed": { + "DemoData": false + }, + "Retention": { + "ResolvedAlertsDays": 90, + "TriggeredAlertsDays": 180, + "NotificationLogsDays": 90, + "CollectionRunsDays": 90, + "TrendSnapshotsDays": 365, + "AuditEntriesDays": 365, + "TenantAuditEventsDays": 90 + }, + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "YOUR_TENANT_ID", + "ClientId": "YOUR_APP_CLIENT_ID", + "Audience": "api://YOUR_APP_CLIENT_ID" + }, + "Auth": { + "RedirectUri": "https://vigil365.local:5001" + }, "AllowedHosts": "*" } diff --git a/src/M365SecurityDashboard.Api/vigil365.ico b/src/M365SecurityDashboard.Api/vigil365.ico new file mode 100644 index 0000000..ddbd074 Binary files /dev/null and b/src/M365SecurityDashboard.Api/vigil365.ico differ diff --git a/src/M365SecurityDashboard.GuiInstaller/App.xaml b/src/M365SecurityDashboard.GuiInstaller/App.xaml new file mode 100644 index 0000000..8677f82 --- /dev/null +++ b/src/M365SecurityDashboard.GuiInstaller/App.xaml @@ -0,0 +1,9 @@ + + + + + diff --git a/src/M365SecurityDashboard.GuiInstaller/App.xaml.cs b/src/M365SecurityDashboard.GuiInstaller/App.xaml.cs new file mode 100644 index 0000000..2b52ac5 --- /dev/null +++ b/src/M365SecurityDashboard.GuiInstaller/App.xaml.cs @@ -0,0 +1,13 @@ +using System.Configuration; +using System.Data; +using System.Windows; + +namespace M365SecurityDashboard.GuiInstaller; + +/// +/// Interaction logic for App.xaml +/// +public partial class App : System.Windows.Application +{ +} + diff --git a/src/M365SecurityDashboard.GuiInstaller/AssemblyInfo.cs b/src/M365SecurityDashboard.GuiInstaller/AssemblyInfo.cs new file mode 100644 index 0000000..cc29e7f --- /dev/null +++ b/src/M365SecurityDashboard.GuiInstaller/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly:ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/src/M365SecurityDashboard.GuiInstaller/CertificateSetup.cs b/src/M365SecurityDashboard.GuiInstaller/CertificateSetup.cs new file mode 100644 index 0000000..3fecfeb --- /dev/null +++ b/src/M365SecurityDashboard.GuiInstaller/CertificateSetup.cs @@ -0,0 +1,329 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace M365SecurityDashboard.GuiInstaller +{ + /// + /// A certificate already present in LocalMachine\My, as offered in the wizard. + /// + internal sealed class StoreCertificate + { + public string Subject { get; set; } = ""; + public string Thumbprint { get; set; } = ""; + public string Display { get; set; } = ""; + + /// Whether this certificate actually covers the hostname being installed. + public bool MatchesHost { get; set; } + } + + /// + /// The Kestrel "Certificate" node to write into appsettings.Production.json, + /// plus a line for the install log. + /// + internal sealed class CertificateBinding + { + public string Json { get; set; } = ""; + public string Description { get; set; } = ""; + + /// Set only for store-based certificates, where the private key needs an ACL grant. + public string? Thumbprint { get; set; } + + /// Set only for file-based certificates, which the service account must be able to read. + public string? PfxPath { get; set; } + } + + /// + /// Certificate acquisition for the installer. + /// + /// Sign-in goes through Entra, and Entra refuses plain HTTP redirect URIs for + /// anything except localhost. So an install that is reachable by hostname has + /// to serve HTTPS or sign-in simply cannot work — a certificate is not an + /// optional extra here, which is why the wizard always ends up with one. + /// + /// The three sources map onto how organisations actually hold certificates: + /// most already have one issued by an internal CA (store), some keep a + /// wildcard as a file (pfx), and anyone with neither needs something that + /// works today without learning ACME (self-signed). + /// + internal static class CertificateSetup + { + private const string ServerAuthOid = "1.3.6.1.5.5.7.3.1"; + + /// + /// Certificates in LocalMachine\My that could plausibly serve a site: + /// usable private key, not expired, and either a server-auth EKU or no + /// EKU at all (an absent EKU means "no restriction"). + /// + public static List ListUsable(string hostname) + { + var result = new List(); + using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine); + try { store.Open(OpenFlags.ReadOnly); } + catch { return result; } + + foreach (var cert in store.Certificates) + { + if (!cert.HasPrivateKey) continue; + if (cert.NotAfter < DateTime.Now) continue; + + // Server Authentication must be present, not merely unprohibited. + // Treating an absent EKU as "unrestricted" pulled in CA and + // identity certificates that can never serve a site. + var eku = cert.Extensions.OfType().FirstOrDefault(); + if (eku == null || !eku.EnhancedKeyUsages.Cast().Any(o => o.Value == ServerAuthOid)) + continue; + + if (IsMachineIdentity(cert)) continue; + + var cn = CommonName(cert.Subject); + // Flagging the match is worth more than filtering on it: wildcard + // and SAN certs are common, and silently hiding a certificate the + // admin knows is correct is worse than showing an extra one. + var matches = !string.IsNullOrWhiteSpace(hostname) && Matches(cert, hostname); + result.Add(new StoreCertificate + { + Subject = cn, + Thumbprint = cert.Thumbprint ?? "", + MatchesHost = matches, + Display = $"{(matches ? "✓ " : "")}{cn} — expires {cert.NotAfter:yyyy-MM-dd}" + + (matches ? "" : " (does not cover this address)") + }); + } + + store.Close(); + return result.OrderByDescending(c => c.Display.StartsWith("✓")).ThenBy(c => c.Subject).ToList(); + } + + /// + /// Rejects certificates that identify the machine rather than a website. + /// + /// An Entra-joined or Intune-managed Windows box issues itself several of + /// these automatically, and MS-Organization-P2P-Access certificates + /// genuinely carry Server Authentication with a private key — so they pass + /// every structural test and are still useless. Their subject is a device + /// GUID, no browser trusts the issuer, and they rotate about daily. Offered + /// in a list of "certificates on this server" they look like a valid + /// choice, and picking one yields a site nobody can open. + /// + private static bool IsMachineIdentity(X509Certificate2 cert) + { + var issuer = cert.Issuer ?? ""; + if (issuer.Contains("MS-Organization", StringComparison.OrdinalIgnoreCase)) return true; + if (issuer.Contains("Microsoft Intune", StringComparison.OrdinalIgnoreCase)) return true; + if (issuer.Contains("MS-Device", StringComparison.OrdinalIgnoreCase)) return true; + + // A bare GUID for a common name is a device identifier, never a host. + return Guid.TryParse(CommonName(cert.Subject), out _); + } + + private static bool Matches(X509Certificate2 cert, string hostname) + { + bool Match(string candidate) + { + if (string.IsNullOrWhiteSpace(candidate)) return false; + candidate = candidate.Trim(); + if (candidate.StartsWith("*.")) + return hostname.EndsWith(candidate[1..], StringComparison.OrdinalIgnoreCase) + && hostname.Count(c => c == '.') == candidate.Count(c => c == '.'); + return string.Equals(candidate, hostname, StringComparison.OrdinalIgnoreCase); + } + + if (Match(CommonName(cert.Subject))) return true; + + // SAN is the field browsers actually honour; CN alone has been + // ignored by Chrome since 58. + var san = cert.Extensions.FirstOrDefault(e => e.Oid?.Value == "2.5.29.17"); + if (san == null) return false; + var text = san.Format(false); + return text.Split(',') + .Select(p => p.Contains('=') ? p[(p.IndexOf('=') + 1)..] : p) + .Any(Match); + } + + private static string CommonName(string subject) + { + foreach (var part in subject.Split(',')) + { + var t = part.Trim(); + if (t.StartsWith("CN=", StringComparison.OrdinalIgnoreCase)) return t[3..]; + } + return subject; + } + + /// + /// Generates a self-signed certificate for , + /// writes it beside the application, and trusts it on THIS machine. + /// + /// Trusting it locally is what stops the server's own browser warning. + /// It does nothing for anyone else — every other machine still warns, + /// which on a security product teaches people to click through TLS + /// warnings. That is why the wizard labels this a starting point. + /// + public static CertificateBinding CreateSelfSigned(string hostname, string installDir, Action log) + { + var pfxPath = Path.Combine(installDir, "vigil365-selfsigned.pfx"); + var password = Guid.NewGuid().ToString("N"); + + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + $"CN={hostname}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + var san = new SubjectAlternativeNameBuilder(); + san.AddDnsName(hostname); + request.CertificateExtensions.Add(san.Build()); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + new OidCollection { new Oid(ServerAuthOid) }, false)); + + // Backdated a day so a server whose clock is slightly behind does not + // reject a certificate that was valid the moment it was created. + using var cert = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(2)); + + Directory.CreateDirectory(installDir); + File.WriteAllBytes(pfxPath, cert.Export(X509ContentType.Pfx, password)); + log($"Generated a certificate for {hostname} (valid until {cert.NotAfter:yyyy-MM-dd})."); + + try + { + // Public half only. The Root store exists to answer "do I trust + // this identity"; the private key has no business being there. + using var publicOnly = new X509Certificate2(cert.Export(X509ContentType.Cert)); + using var root = new X509Store(StoreName.Root, StoreLocation.LocalMachine); + root.Open(OpenFlags.ReadWrite); + root.Add(publicOnly); + root.Close(); + log("Trusted it on this server, so this machine's browser will not warn."); + } + catch (Exception ex) + { + log($"Could not add it to the trusted store ({ex.Message}). The site still works; this machine will show a warning."); + } + + return new CertificateBinding + { + Json = $$""" + "Path": "{{Path.GetFileName(pfxPath)}}", + "Password": "{{password}}" + """, + Description = $"self-signed certificate for {hostname}", + PfxPath = pfxPath + }; + } + + /// Points Kestrel at a certificate the admin supplied as a file. + public static CertificateBinding FromPfx(string pfxPath, string password) + { + if (!File.Exists(pfxPath)) + throw new FileNotFoundException($"Certificate file not found: {pfxPath}"); + + // Fail here rather than after the service is registered and refuses + // to start with nothing but an event-log entry to show for it. + using var probe = new X509Certificate2(pfxPath, password); + if (!probe.HasPrivateKey) + throw new InvalidOperationException("That .pfx has no private key, so it cannot be used to serve HTTPS."); + + return new CertificateBinding + { + Json = $$""" + "Path": "{{pfxPath.Replace("\\", "\\\\")}}", + "Password": "{{password.Replace("\\", "\\\\").Replace("\"", "\\\"")}}" + """, + Description = $"{CommonName(probe.Subject)} (from file, expires {probe.NotAfter:yyyy-MM-dd})", + PfxPath = pfxPath + }; + } + + /// + /// Points Kestrel at a certificate already in LocalMachine\My. Kestrel + /// resolves these by subject, not thumbprint, so the subject is what gets + /// written. + /// + public static CertificateBinding FromStore(StoreCertificate chosen) + { + return new CertificateBinding + { + Json = $$""" + "Subject": "{{chosen.Subject}}", + "Store": "My", + "Location": "LocalMachine", + "AllowInvalid": false + """, + Description = $"{chosen.Subject} (from the Windows certificate store)", + Thumbprint = chosen.Thumbprint + }; + } + + /// + /// Lets the service account read the certificate's private key. + /// + /// Windows stores private keys as files with their own ACL, separate from + /// the certificate. An admin importing a certificate grants themselves + /// access, not LOCAL SERVICE — so without this the service installs + /// cleanly, then fails to start with nothing but a Kestrel error in the + /// event log. This is the single most common way a working certificate + /// still produces a dead site. + /// + public static void GrantKeyAccess(string thumbprint, string account, Action log) + { + try + { + using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine); + store.Open(OpenFlags.ReadOnly); + var cert = store.Certificates.Cast() + .FirstOrDefault(c => c.Thumbprint == thumbprint); + store.Close(); + if (cert == null) return; + + string? keyFile = null; + using (var rsa = cert.GetRSAPrivateKey()) + { + if (rsa is System.Security.Cryptography.RSACng cng) + { + keyFile = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Microsoft", "Crypto", "Keys", cng.Key.UniqueName ?? ""); + } + else if (rsa is System.Security.Cryptography.RSACryptoServiceProvider csp) + { + keyFile = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Microsoft", "Crypto", "RSA", "MachineKeys", + csp.CspKeyContainerInfo.UniqueKeyContainerName); + } + } + + if (keyFile == null || !File.Exists(keyFile)) + { + log($"Could not locate the private key file for {cert.Subject}; if the service fails to start, grant '{account}' read access to it manually."); + return; + } + + GrantRead(keyFile, account); + log($"Granted {account} read access to the certificate's private key."); + } + catch (Exception ex) + { + log($"Could not grant private-key access ({ex.Message}). If the service fails to start, this is why."); + } + } + + /// Adds a read ACE for without disturbing existing rights. + public static void GrantRead(string path, string account) + { + var info = new FileInfo(path); + var security = info.GetAccessControl(); + security.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule( + account, + System.Security.AccessControl.FileSystemRights.Read, + System.Security.AccessControl.AccessControlType.Allow)); + info.SetAccessControl(security); + } + } +} diff --git a/src/M365SecurityDashboard.GuiInstaller/DatabaseSetup.cs b/src/M365SecurityDashboard.GuiInstaller/DatabaseSetup.cs new file mode 100644 index 0000000..5164f7f --- /dev/null +++ b/src/M365SecurityDashboard.GuiInstaller/DatabaseSetup.cs @@ -0,0 +1,107 @@ +using System; +using Microsoft.Data.SqlClient; + +namespace M365SecurityDashboard.GuiInstaller +{ + /// + /// Gives the Windows service account access to the database. + /// + /// This is not optional plumbing. The service runs as LOCAL SERVICE and its + /// connection string uses Trusted_Connection, so SQL sees a login named + /// "NT AUTHORITY\LOCAL SERVICE". A fresh SQL Express install grants sysadmin + /// to BUILTIN\ADMINISTRATORS and nothing else, so that login does not exist — + /// the service starts, fails to open a connection, and the whole install looks + /// broken for a reason that never appears in the installer's own log. + /// + /// The installer itself runs elevated, so it connects as a local administrator + /// (already sysadmin) and creates the login the service will need. + /// + internal static class DatabaseSetup + { + // The statements live here as constants rather than inline so their syntax + // can be verified against a real server without executing them (SET + // PARSEONLY). An earlier version used EXEC('...' + QUOTENAME(@account)), + // which is a syntax error — EXEC() concatenates only literals and + // variables, never function calls — and nothing caught it until an install + // failed in front of a user. + internal const string SqlCreateLogin = """ + IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name = @account) + BEGIN + DECLARE @sql nvarchar(max) = N'CREATE LOGIN ' + QUOTENAME(@account) + N' FROM WINDOWS'; + EXEC sp_executesql @sql; + END + """; + + internal const string SqlCreateDatabase = """ + IF DB_ID(@db) IS NULL + BEGIN + DECLARE @sql nvarchar(max) = N'CREATE DATABASE ' + QUOTENAME(@db); + EXEC sp_executesql @sql; + END + """; + + internal const string SqlGrantDbOwner = """ + DECLARE @sql nvarchar(max); + IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = @account) + BEGIN + SET @sql = N'CREATE USER ' + QUOTENAME(@account) + N' FOR LOGIN ' + QUOTENAME(@account); + EXEC sp_executesql @sql; + END + SET @sql = N'ALTER ROLE db_owner ADD MEMBER ' + QUOTENAME(@account); + EXEC sp_executesql @sql; + """; + + public static void GrantServiceAccess(string connectionString, string account, Action log) + { + var builder = new SqlConnectionStringBuilder(connectionString); + var database = builder.InitialCatalog; + if (string.IsNullOrWhiteSpace(database)) + throw new InvalidOperationException("The connection string does not name a database."); + + // Connect to master: the application database may not exist yet, and + // creating logins is a server-level operation regardless. + var adminConnection = new SqlConnectionStringBuilder(connectionString) + { + InitialCatalog = "master", + IntegratedSecurity = true, + TrustServerCertificate = true, + ConnectTimeout = 15 + }.ConnectionString; + + using var conn = new SqlConnection(adminConnection); + conn.Open(); + + // QUOTENAME rather than raw concatenation, and sp_executesql rather + // than EXEC(): EXEC() accepts only string literals and variables + // concatenated together, so a function call inside it is a syntax + // error ("Incorrect syntax near 'QUOTENAME'"). Building the statement + // into a variable first is what makes the two combine. + Execute(conn, SqlCreateLogin, account); + log($"SQL login for {account} is present."); + + // EF applies migrations on startup, which needs the database to exist. + // Creating it here rather than granting the service dbcreator keeps the + // service account's rights scoped to this one database. + Execute(conn, SqlCreateDatabase, account, database); + log($"Database {database} is present."); + + var dbConnection = new SqlConnectionStringBuilder(adminConnection) { InitialCatalog = database }.ConnectionString; + using var dbConn = new SqlConnection(dbConnection); + dbConn.Open(); + + // db_owner because migrations create and alter tables. Narrower roles + // cannot apply a schema change, and this install owns the database + // outright. + Execute(dbConn, SqlGrantDbOwner, account); + log($"{account} can now read and write {database}."); + } + + private static void Execute(SqlConnection conn, string sql, string account, string? database = null) + { + using var cmd = new SqlCommand(sql, conn); + cmd.Parameters.AddWithValue("@account", account); + if (database != null) cmd.Parameters.AddWithValue("@db", database); + cmd.ExecuteNonQuery(); + } + } +} diff --git a/src/M365SecurityDashboard.GuiInstaller/GraphPermissions.cs b/src/M365SecurityDashboard.GuiInstaller/GraphPermissions.cs new file mode 100644 index 0000000..2d90506 --- /dev/null +++ b/src/M365SecurityDashboard.GuiInstaller/GraphPermissions.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; + +namespace M365SecurityDashboard.GuiInstaller +{ + /// + /// The Microsoft Graph application permissions Vigil365 needs in order to + /// collect anything. + /// + /// The installer used to register only the sign-in application, so a new + /// install could authenticate people and then show them nothing: every + /// collector failed on authorization until an administrator went to the + /// portal and added these fifteen permissions by hand. The README pointed at + /// a register-app.ps1 to do it, and that script does not exist. + /// + internal static class GraphPermissions + { + /// The Microsoft Graph service principal, the same id in every tenant. + public const string GraphAppId = "00000003-0000-0000-c000-000000000000"; + + /// + /// Application (not delegated) permissions, matched to the table in the + /// README. Kept as names rather than GUIDs and resolved against the + /// tenant's own Graph service principal, because a wrong hard-coded GUID + /// fails as an opaque "invalid value" with nothing naming the permission. + /// + public static readonly string[] Required = + [ + "SecurityAlert.Read.All", // Defender XDR alerts + "SecurityIncident.Read.All", // Defender XDR incidents + "IdentityRiskyUser.Read.All", // Entra ID risky users + "IdentityRiskEvent.Read.All", // Risk detections + "AuditLog.Read.All", // Sign-in and audit logs + "Reports.Read.All", // MFA registration, auth methods + "DeviceManagementManagedDevices.Read.All", // Intune devices + "ServiceHealth.Read.All", // M365 service health + "Policy.Read.All", // Conditional Access policies + "Directory.Read.All", // Users, groups, PIM + "PrivilegedAccess.Read.AzureAD", // PIM assignments + "ThreatHunting.Read.All", // Advanced hunting / MDI + "UserAuthenticationMethod.Read.All", // MFA method detail + "SharePointTenantSettings.Read.All", // Sharing posture + ]; + + /// + /// Permissions that are genuinely optional — the feature degrades to a + /// permission-error card rather than the install being broken. Graph has + /// no read-only variant of the attack-simulation permission, so a tenant + /// may reasonably refuse it. + /// + public static readonly string[] Optional = + [ + "AttackSimulation.ReadWrite.All", + ]; + + /// + /// Maps permission names to the role ids this tenant uses, from the Graph + /// service principal's own appRoles. Names not offered by the tenant are + /// reported rather than silently dropped. + /// + public static (string Json, List Missing) BuildRequiredResourceAccess( + string appRolesJson, IEnumerable wanted) + { + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var role in JsonSerializer.Deserialize(appRolesJson).EnumerateArray()) + { + var value = role.TryGetProperty("value", out var v) ? v.GetString() : null; + var id = role.TryGetProperty("id", out var i) ? i.GetString() : null; + if (!string.IsNullOrEmpty(value) && !string.IsNullOrEmpty(id)) byName[value] = id; + } + + var missing = new List(); + var entries = new List(); + foreach (var name in wanted) + { + if (byName.TryGetValue(name, out var id)) + entries.Add($$"""{"id":"{{id}}","type":"Role"}"""); + else + missing.Add(name); + } + + var sb = new StringBuilder(); + sb.Append($$"""[{"resourceAppId":"{{GraphAppId}}","resourceAccess":["""); + sb.Append(string.Join(",", entries)); + sb.Append("]}]"); + return (sb.ToString(), missing); + } + } +} diff --git a/src/M365SecurityDashboard.GuiInstaller/M365SecurityDashboard.GuiInstaller.csproj b/src/M365SecurityDashboard.GuiInstaller/M365SecurityDashboard.GuiInstaller.csproj new file mode 100644 index 0000000..c6cc6f6 --- /dev/null +++ b/src/M365SecurityDashboard.GuiInstaller/M365SecurityDashboard.GuiInstaller.csproj @@ -0,0 +1,51 @@ + + + + WinExe + net8.0-windows + enable + enable + true + + true + + + vigil365.ico + + + 1.0.0 + Vigil365 Setup + Vigil365 + Vigil365 + Vigil365 setup wizard — installs the self-hosted Microsoft 365 security dashboard. + Copyright © 2026 Vigil365 + + + + + + + + + + + + + + + diff --git a/src/M365SecurityDashboard.GuiInstaller/MainWindow.xaml b/src/M365SecurityDashboard.GuiInstaller/MainWindow.xaml new file mode 100644 index 0000000..bcbdd24 --- /dev/null +++ b/src/M365SecurityDashboard.GuiInstaller/MainWindow.xaml @@ -0,0 +1,192 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +