Commit Graph

5 Commits

Author SHA1 Message Date
Oliver Kieselbach fe9ffc3f67 fix(security): backend-review Lows — SignalR revoke enforcement, revoke audit integrity, casing
Revoke enforcement (L4): a revoked delegated (MSP) user kept receiving pushes on
already-joined SignalR groups until the connection dropped (join-time-only
authorization). Negotiate now goes through the Management SDK and binds the
client token to a SignalR user id (lowercased UPN); new DisconnectUserAsync
strips the user from all groups server-side and pushes "accessRevoked", on
which the web client restarts its connection so every re-join re-runs
authorization against the fresh scope. Wired into delegated revoke/disable and
tenant-group unassign / remove-tenant / delete-group. Residual (documented): a
tampered client ignoring the push, and connections negotiated before this
deploy (no user id), keep streams until the connection drops.

Revoke audit integrity (L5): POST /global/delegated-admins now requires a GUID
tenantId (mirrors the tenant-group path), and DELETE reports 404 for an
assignment that never existed instead of returning 200 and writing a false
customer-visible "access removed" audit row (repo delete now checks the 404
response of the idempotent DeleteEntityAsync).

Casing (L6): the SignalR group join/leave cross-tenant check now compares
tenant IDs OrdinalIgnoreCase, aligned with the middleware and the delegated
scope dictionary, so a casing difference can never flip which branch
authorizes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:06:39 +02:00
Oliver Kieselbach 9e3a2e3708 fix(backend): bound SignalR sends + skip degenerate Azure Monitor range
Two hardening fixes surfaced by 48h backend log review:

- AzureMonitorMetricsReader.GetTotalAsync: skip the round-trip when the query range is shorter than 1 minute. The SignalR-quota watcher and the health check both anchor the start of the range to start-of-day, so a call landing on the 00:00 UTC boundary sent from==to and absorbed a daily 400 BadRequest.

- SignalRNotificationService: cap every imperative send (hub-context resolution + SendCoreAsync) at 5s via CancellationTokenSource. Without it a slow/unreachable SignalR endpoint blocks for the SDK HttpClient default of 100s, pinning the calling queue-worker/Task.Run thread. These pushes are observability nudges, never correctness, so fail fast and swallow as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:57:18 +02:00
Oliver Kieselbach 5dbc44c4ec feat(backend): cascade-delete worker + restore + codex-fixes (PR4/7)
PR4 ships the cascade-delete executor for the safe-cascade-delete plan: the
SessionDeletionWorker (BackgroundService queue poll-loop) plus the
SessionDeletionHandler (per-step execution, live verification, tombstone).
Worker is NOT registered in Program.cs — that lights up in PR5.

Three logical phases land together as one commit per Oliver's call:

PR4 — Worker + Handler + CascadeVerificationService
- SessionDeletionWorker: BatchSize=1, kill-switch entry guard, heartbeat task
  extending visibility every 60s during long handler runs, poison after
  max-dequeue
- SessionDeletionHandler: download manifest+progress, CAS Queued→Running
  (with Running→Running resume), per-step dispatch by Class, AGGREGATE
  decrement, live verification, ordered tombstone (SessionsIndex first,
  Sessions last)
- CascadeVerificationService: per-Class live residual scans (CveIndex is
  manifest-key-only per §12-Q8; AGGREGATE+FINAL skipped)
- NotifySessionDeletedAsync added to ISignalRNotificationService
- BlobStorageService: DownloadDeletionProgressAsync + ETag-CAS
  UpdateDeletionProgressAsync

PR4b — Restore endpoint
- POST /api/admin/sessions/{sessionId}/restore (GA-only via Policy Catalog)
- SessionRestoreService: dispatch table per §13.4, full + partial-poisoned-
  recovery modes
- TableStorageService.Restore.cs: RestoreRowsByExactKeysInBatchesAsync +
  ConvertFromPropValue + ConvertDumpToEntity (Sessions-row override forces
  DeletionState=None on restore so the row isn't permanently locked)
- RestoreSoftwareInventoryEntryByKeyAsync helper
- Worker MoveToPoisonAsync now CAS-transitions Running→Poisoned (E1 fix)
  so restore can dispatch into partial-restore mode

PR4c — Codex review fixes (6 findings)
- F1: AGGREGATE per-key idempotency via DeletionProgress.AggregateDecrementsApplied
  (persist-first/decrement-second bounds drift to +1 per crash vs unbounded -N)
- F2: TombstoneStarted flag closes the gap where sessions=null+completedAt=null
  blocked restore after a tombstone crash
- F3a: Sessions row inserted LAST in full restore (was first → trapped retry
  with RejectAlreadyAtOriginalState)
- F3b: RestoreMode.Full now 409-ignores (was throw-on-409); converges with
  Partial mode, makes retry-after-partial-failure idempotent
- F4: Partial-restore per-key re-increment idempotency via
  DeletionProgress.RestoreReIncrementsApplied
- F5: Poison transition pre-checks PendingDeletionManifestId so stale
  envelopes can't flip a fresh active cascade to Poisoned
- F6: DownloadDeletionManifestWithShaAsync exposes snapshot SHA; Handler +
  Restore both verify against progress.SnapshotSha256 (snapshot↔progress
  binding per plan §3)

DeletionProgress schema gains 3 additive optional fields (AggregateDecrementsApplied,
RestoreReIncrementsApplied, TombstoneStarted) with safe defaults so PR1-PR4
progress blobs deserialize cleanly.

Tests: 1457/1457 backend xUnit facts green (1391 pre-PR4 + 66 new across PR4,
PR4b, PR4c). Full solution build green. Program.cs has zero
SessionDeletionWorker references (PR5 territory stays untouched).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:44:20 +02:00
Oliver Kieselbach 0b80f09a74 refactor(notifications): bell on pure SignalR push, kill 60s polling
The two notification-bell React contexts polled GET /api/notifications and
GET /api/global/notifications every 60s regardless of activity, generating
3017+618 = 3635 calls/24h with near-zero hit rate (notifications are emitted
sparsely). SignalR was already used app-wide for live session updates; the
bell was the lone hold-out on a pull model.

Switch to the same push model. Steady-state traffic drops ~86% and the bell
updates sub-second instead of within 60s.

Architecture (Option C+):
  - Mount  -> 1x HTTP fetch (cold-start hydration via existing GET endpoint)
  - SignalR onreconnected -> re-fetch (covers the disconnect window)
  - SignalR push -> live append/dismiss
  - No periodic polling, no setInterval

Audience-tier security: TenantNotificationAudienceCatalog still drives
visibility (Member vs Admin), but enforcement moves into SignalR group
membership: tenant-{id}-notify-member is joined by every tenant member,
tenant-{id}-notify-admin only by Tenant Admins / Global Admins. The
AddToGroup function fail-closes with 403 on Member-tier joins to the
admin group, so Admin-tier notifications cannot leak via push.

Backend
  - ISignalRNotificationService extracted; SignalRNotificationService
    implements it. Resolves the long-standing test-mocking blocker
    (the real service's constructor validates AzureSignalRConnectionString
    and BuildServiceManager(); previously you could not unit-test
    consumers). Tenant + Global NotificationService consume the
    interface; the three legacy callers (Analyze, Ingest, VulnCorrelate)
    still bind to the concrete class -- migrate per test need.
  - Six new push methods: SendTenantNotification{,Dismissed,DismissedAll}
    and SendGlobalNotification{,Dismissed,DismissedAll}. Tenant variant
    routes to the audience-correct group.
  - Tenant + Global service push after a successful repo write
    (fire-and-forget; never throws into the caller).
  - SignalRGroupHelper: parsing handles the two new -notify-{member,admin}
    suffixes; new IsTenantNotifyAdminGroup predicate; group-name builders.
  - SignalRAddToGroupFunction: explicit 403 when a non-admin attempts
    to join the admin-tier group.

Frontend
  - TenantNotificationContext: setInterval gone. useSignalR() to
    join member-group always, admin-group iff isTenantAdmin || isGlobalAdmin.
    Listens for tenantNotification (append + dedupe), tenantNotificationDismissed
    (filter), tenantNotificationsDismissedAll (clear). onreconnected
    triggers re-fetch.
  - GlobalNotificationContext: same pattern against global-admins group.
  - app/layout.tsx: SignalRProvider hoisted above the notification
    providers so useSignalR() resolves inside them.

Tests
  - SignalRGroupHelperTests (8): parsing for all four group formats
    incl. new -notify suffixes, IsTenantNotifyAdminGroup predicate.
  - TenantNotificationServiceSignalRPushTests (8): audience-correct push
    on Create (Admin / Member / Unknown -> Admin fail-closed), no-push
    on repo failure, no-push on DismissAll(count=0), no-push on empty
    tenantId.
  - FakeSignalRNotificationService: in-memory spy implementing the
    new interface; replaces the unmockable real service in tests.
  - AuthFunctionSideEffectTests + TenantNotificationServiceTests:
    constructor signatures updated for the new SignalR dep.

Suites
  Backend xUnit: 1161/1161 (was 1146, +15 net).
  Web vitest:    259/259 (unchanged; existing notification-API +
                 gate tests still cover).
  Solution build: 0 warnings, 0 errors.

Expected post-deploy reduction (24h):
  GET GetTenantNotifications:  3017 -> ~50
  GET GetGlobalNotifications:   618 -> ~10
  POST AddToGroup:              ~246 -> ~350-450 (two joins per tab)
  Net bell load:               -86%, sub-second live UX.

Disconnect tradeoff (accepted): if SignalR drops without reconnect
(rare -- withAutomaticReconnect retries 1/2/4/8/16/30s ... forever),
the bell shows the snapshot from the last fetch but no live updates
until next reload. The initial-3-retry give-up path of the SignalR
client still gets the user a hydrated bell because the HTTP fetch
runs independently on mount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:32:22 +02:00
Oliver Kieselbach 26e5925672 feat: Software Inventory Analyzer with Vulnerability Correlation 2026-03-20 14:43:58 +01:00