mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
fe9ffc3f67b5bbaffe72f666eaf556e1b74109ef
122 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
0f82577606 |
fix(backend): address review mediums M6-M9 (orphan cleanup, retention starvation, ingest observability, counter idempotency)
M6: DeleteByFilterInBatchesAsync is fail-loud and counts only confirmed batch submits; the orphan cleanup keeps the EventSessionIndex entry when the event delete fails, so surviving rows stay discoverable and retry. M7: GetSessionsOlderThanAsync gains excludeInFlightDeletions - sessions locked in deletion states no longer occupy the capped read head, so a stuck head (Poisoned / stranded Queued) cannot starve the retention fanout tail. Warnings surface a fully-locked backlog and zero-progress runs. M8: ingest logs a per-batch warning with per-kind counts and the first TelemetryItemId when unparseable telemetry items are dropped (agent clears its spool on success, so drops were silent data loss). M9: ReconcileSessionRebootCountAsync -> ReconcileSessionCountersAsync; one partition scan reconciles EventCount and RebootCount at terminal transitions, self-correcting at-least-once retry double-counts. Script counters stay increment-only with the drift documented. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9f7b070e80 |
chore: remove legacy V1 agent and its backend ingest path
The V1 agent line is retired: no new V1 sessions since 2026-05-09, only orphaned straggler traffic remained on /api/agent/ingest (last seen 2026-06-29, events for long-cleaned-up sessions). Bootstrap/Intune/CI have deployed V2-only since the v2 cutover. Agent: - Delete AutopilotMonitor.Agent, .Agent.Core, .Agent.Core.Tests (projects + sln entries). V2 trio, SummaryDialog, DecisionCore.Tests and V2Isolation.targets stay. Backend: - Relocate the shared statics/DTOs V2 reused from the V1 partials (StampServerFields, SanitizeEventTimestamps, IsMaxLifetimeAgentShutdown, RecalculateAppDurations, EventClassification, AppInstallAggregationState) into EventIngestProcessor; IngestEventsOutput moves next to IngestTelemetryFunction (same namespace, zero ripple). - Delete IngestEventsFunction (route agent/ingest), BootstrapIngestEvents (route bootstrap/ingest), NdjsonParser and their DI registration. EventDataNormalizer stays (used by TelemetryPayloadParser). - Drop both routes from EndpointAccessPolicyCatalog and RequestTelemetryMiddleware.TenantHeaderTrustedPaths. - Retire the V1 update-hash oracle: LatestAgentV1*/LatestBootstrapV1* fields + GetAgentLine case 1 (default arm returns empty hashes, so stragglers gracefully skip the integrity check; ParseAgentMajor keeps its default of 1 on purpose — defaulting to 2 would feed V2 hashes to V1 binaries). TableConfigRepository Store+Map updated in lockstep. - Remove ApiEndpoints.IngestEvents/.BootstrapIngestEvents constants. Tests: NdjsonParserTests deleted; timestamp/critical-path/max-lifetime tests repointed to EventIngestProcessor; the two NDJSON pipeline tests removed (coverage retained by direct-list siblings); catalog/no-store InlineData rows updated. Docs: architecture.md (endpoint tables, ingest flow diagram, solution layout, test matrix), web SectionAgentInternals endpoint tables, build.ps1 help text. Azure: no config change needed — /api/agent is not in the client-cert exclusion paths (agent/ingest was always cert-enforced) and the /api/bootstrap prefix exclusion remains for the surviving bootstrap routes (verified via ARM on autopilotmonitor-api). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0abccf1fe0 |
refactor(delegated): rename TenantTemplate -> TenantGroup across the full stack
Completes the Tenant Templates -> Tenant Groups rename down to identifiers,
tables, routes and DTOs (the earlier commit renamed only the UI labels). Done
now while the table is effectively empty; the new TenantGroups /
TenantGroupAssignments tables are auto-created on deploy, the operator re-enters
their group, and the old TenantTemplates table is dropped in Azure manually.
- Tables: TenantTemplates -> TenantGroups, TenantTemplateAssignments ->
TenantGroupAssignments (Constants + All).
- Entities/DTOs: TenantTemplate(Entity)/TenantTemplateAssignment(Entity) ->
TenantGroup*, property TemplateId -> GroupId.
- Repo/service/API: all *Template* methods -> *Group*; routes
/api/global/tenant-templates -> /api/global/tenant-groups, {templateId} ->
{groupId}; function class + file renamed; audit entity DelegatedTemplateAccess
-> DelegatedGroupAccess; NormalizeTemplateId -> NormalizeGroupId.
- Offboarding wipe list + tests updated.
- Web: api.tenantGroups, SectionTenantGroups (file renamed), response key groups.
Scoped strictly to the feature: unrelated 'template' usages (analyze-rule
templates, EmailTemplates, interpolate-rule-template) left untouched. Backend
2538 tests green; web tsc clean + vitest 494 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
3e827fbb92 |
fix(delegated): tenant-template membership write failed (Edm.DateTime out of range)
AddTenantToTemplate returned 500 on the first add: the membership-row write threw because TenantTemplateEntity.CreatedDate was left at default(DateTime) (0001-01-01), which is below Azure Table Storage's Edm.DateTime minimum (1601). The meta row wrote fine only because Create stamps CreatedDate=UtcNow. Make CreatedDate nullable so membership rows omit it (null is valid); meta rows still carry it. Read-back maps null -> default. Confirmed via storage: the affected partition had only the meta row, no membership row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
86f8fd5ace |
feat(delegated): tenant templates — app-internal tenant bundles for MSP delegated admins
Let a GlobalAdmin define a named Tenant Template (a bundle of tenants) and assign a delegated-admin UPN to the template instead of to each tenant. A UPN's effective scope = union of direct grants + every tenant in every assigned template, resolved in DelegatedAdminService.GetScopeAsync — so middleware enforcement, /auth/me delegatedTenantIds and MCP all inherit it unchanged. Adding a tenant to a template grants it to all assignees at once (membership- driven MSP management). Fully app-internal: no Entra group claim / Graph. - Storage: TenantTemplates (PK=templateId; meta + membership rows) and TenantTemplateAssignments (PK=upn) tables; meta-backed existence invariant in both management reads AND the auth hot path (no ghost templates, no scope from stray rows). - Offboarding hygiene: property-only wipe now purges TenantTemplates membership AND DelegatedAdmins rows for the offboarded tenant (closes a pre-existing orphaned-grant gap). - Management API: GlobalAdminOnly /api/global/tenant-templates/* (8 routes); per-affected-tenant audit; all mutations route through the service so the delegated-scope cache is invalidated in lockstep; existence/membership invariant owned by the service (no ghost create, no false revoke audits); templateId normalized case-insensitively at the service boundary. - Web: SectionTenantTemplates management UI (create/rename/delete, add/remove tenants, assign/unassign), triple-registered + api.ts block. Backend 2538 tests green; web tsc clean + vitest 488 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8dd45b20fb |
feat(delegated): scoped-global reader — dashboard + analytics + fleet overview
Make a delegated ("MSP") admin a "scoped Global Reader": the same pages a Global
Admin sees, but every tenant switcher/filter is bounded to the managed allow-list.
Reader path only (readWrite deferred). MCP stays read-only (Phase 6 already covers
delegated reads).
Backend (bounded aggregate — reuses the GA per-tenant fan-out):
- global/sessions + global/stats/sessions: GlobalReadOrAdmin -> GlobalReadOrDelegatedSubset
(keep QueryParam). A delegated caller WITHOUT ?tenantId= is admitted as a bounded
aggregate; the handler restricts the cross-tenant fan-out to RequestContext.AllowedTenantIds.
With ?tenantId=X it drills into one managed tenant (middleware validates X in scope).
- TableStorageService: GetAllSessions{,Page,Stats}Async gain an optional allowedTenantIds
set; FetchAllSessionsPageInternalAsync swaps its Step-1 tenant source to that subset
(everything downstream — per-tenant query, inverted-tick merge, cursor — is reused).
- IsDelegatedReadTier += GlobalReadOrDelegatedSubset so the single-tenant drill rescue fires.
- SignalR AddToGroup/RemoveFromGroup: admit a delegated caller to a managed tenant-{id}
group (in scope); global-admins stays platform-scope-only.
Web:
- useAggregatedAdminScope / useGlobalAdminScope: cross-tenant mode is now on for a delegated
admin too (isGlobalAdmin = crossTenant; new isDelegatedScope). Tenant list bounded to the
managed subset; always a concrete tenant (no "All tenants" aggregate on analytics pages).
- TenantScopeSelector hides the aggregate option for delegated; GlobalAdminBanner gains a
blue "Delegated Admin View" variant (wired on all analytics pages + dashboard).
- Nav: Fleet group exposes Dashboard + Fleet Health + Software + Geographic + SLA + Usage;
showDashboard now includes delegated.
- Dashboard delegated-ready: crossTenant drives the /global endpoints + filter UI; empty
filter = bounded aggregate over managed tenants, a selected tenant = drill-in; global-admins
SignalR join stays real-GA-only; read-only (mutations already GA-gated). `?tenant=` deep-link
seeds the filter.
- Fleet is now a pure stats card grid; the per-tenant session browser /fleet/[tenantId] is
removed and cards link to /dashboard?tenant=<id>.
Deferred follow-ups: Audit Log + Geographic session drill-in for delegated (both diverge from
the scope-hook pattern / need tenantId threading).
Tests: backend 2484 green (catalog/middleware contract updated for the new subset tier),
web 481 green, MCP 292 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
b2abe0e74a |
feat(web+backend): delegated-admin (MSP) grant/revoke management [Phase 4]
Close the write path for MSP mode: a GlobalAdmin can now grant/revoke/enable/
disable delegated cross-tenant read assignments from the portal, instead of
hand-editing the DelegatedAdmins table (no more Boolean/lowercase/5-min-cache
foot-guns). Mirrors the MCP-user management surface.
Backend:
- DelegatedAdminManagementFunction at /api/global/delegated-admins: GET list
(GlobalReadOrAdmin), POST grant, DELETE {upn}/{tenantId} revoke,
PATCH .../enable|disable (all GlobalAdminOnly). Grant defaults role
DelegatedReader, stamps status=Active + source=OperatorGranted + grantedBy;
rejects an unrecognized role (fail-closed). Service.UpsertAsync invalidates
the caller's scope cache, so a grant takes effect on the next request.
- IAdminRepository.GetAllDelegatedAdminsAsync + TableAdminRepository impl
(small full-table scan, off the hot path) + DelegatedAdminService.GetAllAsync.
- Catalog: 5 routes. The {tenantId}-bearing mutation routes use
TenantScoping.RouteParam (fail-closed convention; sets TargetTenantId to the
managed tenant). Policy stays GlobalAdminOnly so a delegated caller cannot
manage delegations (GlobalAdminOnly is not a read tier).
- Mutations are audited under the TARGET managed tenant's trail (CREATE/DELETE/
UPDATE, entityType DelegatedAdmin), so the customer sees who was granted
access to their tenant.
Frontend:
- SectionDelegatedAdmins settings section: grant form (UPN + tenant dropdown +
role), assignments grouped by admin, enable/disable/revoke. Triple-registered
(globalNavConfig ga-settings + settingsNavSections + [section] map).
- api.delegatedAdmins family (list/grant/revoke/enable/disable).
Backend build clean; 2473/2473 backend tests; web tsc clean; 481/481 web tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
05a591e1d4 |
feat(backend): delegated-admin (MSP mode) data model + scope service [Phase 0]
Foundation for "MSP mode" — a scoped-global authorization tier between a single-tenant member and a platform GlobalAdmin. A delegated admin may access a SUBSET of tenants (its assignment rows), resolved UPN-based and tid-agnostic, exactly like GlobalAdmins. Phase 0 is purely additive: the service is registered but no auth path consults it yet (the cross-tenant gate change lands in Phase 1), so behavior is unchanged. - Constants: DelegatedRoles/DelegatedStatus/DelegatedSource; new DelegatedAdmins table added to TableNames.All (auto-create), CriticalBackupTables.All and AuthTablesFullRestoreForbidden (identity table — full-table restore blocked). - IAdminRepository + TableAdminRepository: DelegatedAdminEntry DTO + CRUD (PK=UPN, RK=TenantId). Hot-path GetDelegatedTenantsAsync (partition scan) and reverse GetDelegatedAssigneesAsync. Queries use the typed predicate overload (no string-interpolated OData filter — avoids filter injection). - DelegatedAdminService: resolves DelegatedScope (Covers/RoleFor/CanWrite) with a 5-min cache + write-through invalidation. Fail-closed: only Active+enabled rows with a recognized role confer scope; empty role -> DelegatedReader; unknown -> dropped. - 16 unit tests (fake repo); full Functions suite 2434/2434 green. Backup catalog-count assertion bumped 15 -> 16. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d0486de21e |
perf(backend): push app-install-summary time window server-side + parallelize session join
The app dashboard and metrics endpoints loaded entire AppInstallSummaries partitions (tenant scope) or the full table (global scope) and filtered StartedAt >= cutoff in memory, even for days=30. The device-model join then issued one serial GetSessionAsync point-read per distinct session. - Add optional DateTime? sinceUtc to GetAppInstallSummariesByTenantAsync / GetAllAppInstallSummariesAsync: when set, a server-side StartedAt ge filter scopes the scan so only in-window rows are deserialized/transferred. Default null preserves the legacy full scan for callers that need all history. - AppsAnalyticsHelper.LoadSummariesAsync derives sinceUtc from days; all six /api/apps/* functions pass it. The four metrics/app + metrics/geographic functions pass their existing cutoff. The in-memory Where stays as the exact sub-second trim (the OData filter is second-granular). MCP get_app_install_metrics benefits automatically (days already flows through; response shape unchanged). - Replace the serial session point-read loop with a bounded-concurrency (SemaphoreSlim=10) LoadSessionLookupAsync, shared by the analytics and sessions builders. AppInstallSummaries is already bounded by the session-retention cascade (DeletionManifestBuilder) + tenant offboarding, so no separate table retention is added. Full Backend suite: 2406 passed / 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
27ef527e80 |
fix(backend): server-bound retention fanout read to per-run cap
The retention fanout materialized the entire eligible-session backlog every run (GetSessionsOlderThanAsync returned a full List<SessionSummary>) but only advanced MaxEnqueuesPerTenantPerRun=100 per tenant. A large backlog was reread and rematerialized each run, so maintenance got slower as the backlog grew while only 100 sessions progressed. Cap the read at the per-run dispatch limit: GetSessionsOlderThanAsync gains a maxResults parameter that caps maxPerPage and stops enumerating once the cap is reached. The fanout fetches MaxEnqueuesPerTenantPerRun+1 (the +1 is a cheap "more remaining" probe) instead of the whole backlog. Read cost is now proportional to the cap, not the backlog; per-run progress is unchanged. The exact eligible count is no longer known without a full scan, so the audit now records EligibleThisRun + a MoreRemaining flag instead of the exact total. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5ba4fb7dc9 |
feat(backend): retention/cleanup for previously-unbounded append-only tables
Several append-only tables had no purge mechanism and grew unbounded. Add age-based retention to the 2h MaintenanceFunction (and the manual path) via a new CleanupUnboundedTablesAsync orchestrator (per-table try/catch, fixed const windows, mirroring the DistressReports pattern): - GlobalNotifications + TenantNotifications: hybrid retention — dismissed rows pruned 30d after DismissedAt, any row pruned 180d after CreatedAt. Unread (still-actionable) admin warnings survive the dismiss window so they are never silently lost; the 180d catch-all bounds the table. Shared pure predicate NotificationRetentionFilter.BuildPredicate (unit-tested). - HardwareRejectionNotificationTracker: 30d (FirstNotifiedAt). Pruning re-arms the lifetime dedup, so a model rejected again >30d later re-bells (intended). - AuditLogs: 180d (system Timestamp, cross-partition). - UsageMetrics: 180d (PartitionKey date-string range). - BackupJobs: 365d (QueuedAtUtc). PlatformStats intentionally excluded — it is a single upserted row, not append-only. Bootstrap sessions left as-is (feature currently unused). Each repo delete mirrors DeleteUserActivityOlderThanAsync: server-side "X lt cutoff" filter + select PK/RK + delete loop. Deletes are resumable across runs, so a partial run self-heals on the next cycle. Observability: end-of-run MaintenanceLongRunning Warning OpsEvent if RunAllAsync exceeds 10min, dual-registered in OpsAlertRulesSection.tsx, to surface a run climbing toward the 60min functionTimeout before a future hard-abort (which emits nothing). Inert until backend redeploy; first post-deploy run bulk-prunes the backlog. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7c1f14c66b |
feat(backend+web): GA "Active Users" live presence dashboard + UserActivity retention
Global-Admin cross-tenant view of web users active "now" (made an authenticated request within a window, default 5min), backed by a new self-maintaining UserPresence table. Not App Insights (sampling) / not SignalR. Backend: - UserPresence table (one row/user, PK=tenantId, RK=SHA-256(lowercase UPN)); upserted fire-and-forget by UserPresenceMiddleware (after PolicyEnforcement, 60s per-process throttle, UPN-gated so agent cert calls are skipped). - GET /api/global/presence (GlobalReadOrAdmin) via GetActiveUsersFunction; read path propagates storage errors (5xx) instead of masking them as "0 active". - 1-day presence retention + 90-day UserActivity retention in maintenance; PlatformStats.TotalUsers clamped monotonic so the public "Users Seen" figure cannot regress after a prune. Web: - /admin/presence page (live counter + table), nav entry under GA > Metrics. - "Total Users" relabeled "Unique Users (90d)" to match the windowed metric. Backend+web only, no ConfigVersion bump, inert until redeploy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
2d3853c38f |
feat(rbac): add read-only GlobalReader platform role (backend + MCP + web)
GlobalReader = cross-tenant READ visibility like a Global Admin, but no mutating operations and no platform settings. Provisioned exactly like a GA: a GlobalAdmins table row with Role=GlobalReader (empty/legacy Role = GlobalAdmin, back-compat). Semantics are ADDITIVE — a user who is also their own tenant's Admin keeps edit rights on that tenant; a pure GlobalReader is read-only everywhere. Backend: - GlobalAdmins.Role column + GlobalAdminService.GetGlobalRoleAsync; IsGlobalAdminAsync (incl. TableAdminRepository) delegates so a GlobalReader row never reads back as admin - RequestContext.IsGlobalReader + HasGlobalScope - Split GlobalAdminOnly into GlobalReadOrAdmin (cross-tenant reads) vs GlobalAdminOnly (writes/settings) + TenantAdminOrGlobalReader for config-read - PolicyEnforcementMiddleware: extracted testable DecideAsync seam; cross-tenant gate keys off HasGlobalScope; additive own-tenant role carried so IsTenantAdmin stays correct - Config secret redaction for readers (RedactedCopyForReader) + save-time placeholder restore (RestoreRedactedSecretsFrom) so a redacted view can't blank real secrets - raw table/log tools stay GlobalAdminOnly; SignalR global-admins read group admits HasGlobalScope - /auth/me emits isGlobalReader (+ preview-gate bypass, no auto-admin); /auth/mcp emits globalRole MCP: hasGlobalScope (GA or Reader) drives routing + tool catalog; raw secret-bearing tools (query_table/list_tables/query_backend_logs) gated strict-GA only. Web: AuthContext isGlobalReader + hasGlobalScope; reader reaches /dashboard + cross-tenant views; platform Settings/Ops/Backups/Software GA-only (nav-hidden + requireGlobalAdmin route layouts); mutating controls on mixed data pages disabled via useCanMutatePlatform; rules pages read-only on foreign tenants; additive own-tenant edit preserved. Tests: backend 2369, MCP 252, web 469 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2d1db6c897 |
feat(backend+web): generic JSON webhook provider + custom headers
Adds WebhookProviderType.GenericJson (20) so a tenant can POST a stable, channel-agnostic JSON payload to any HTTPS endpoint - ticketing systems, automation, or an SMTP gateway like Postal - instead of only Teams/Slack. Backend: - GenericJsonRenderer emits schemaVersion 1.0 with eventType, severity, title/summary/themeColor, primaryUrl (first openUrl action), facts[], sections[], actions[]; registered in WebhookNotificationService. - NotificationAlert.EventType set on all 8 builders (enrollment_succeeded/ _failed/_started, preprovisioning_*, hardware_rejected, sla_breach/_resolved, consecutive_failures, test) for consumer-side routing. - Per-tenant WebhookCustomHeadersJson + GetGenericWebhookHeaders() (generic-only, restricted-header denylist, fail-soft parse) for API-key auth; dispatcher gained optional customHeaders applied via HttpRequestMessage; threaded into all 12 tenant call sites (Ops/admin-config sites unchanged). - TableConfigRepository persists the field; UpdateTenantConfigurationFunction validates it (object, string values, RFC-7230 token names, CRLF-injection guard, 8 KB / 25-header caps). Web: - Generic provider option + key->value WebhookHeaderEditor (rendered only for provider 20); leaving the generic provider clears local headers so a later switch back cannot revive stale auth headers; context/types wiring sends headers only when provider===20; report shows Generic JSON provider name. ConfigVersion unchanged (backend-only, no agent payload). No new Constants.EventTypes, so no MCP catalog sync. Still fire-and-forget (no SMTP client / retry by design). New GenericWebhookTests cover renderer, header parsing/denylist, and save-time validation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2e3c0b8db6 |
feat(agent+backend+web): OOBE-console / Shift+F10 detection (default-on opt-out)
Detects a SYSTEM console opened during enrollment — the classic Shift+F10 OOBE bypass (winlogon.exe spawning cmd.exe as SYSTEM). Two complementary Warning signals, one per-tenant toggle EnableConsoleBypassDetection (default ON / opt-out), ConfigVersion 31 -> 32: - oobe_console_spawned (live): ConsoleBypassWatcher + ConsoleBypassHost. WMI Win32_ProcessStartTrace on cmd.exe + startup probe; flags only a cmd.exe whose parent is winlogon.exe. PID dedup happens ONLY after a confirmed winlogon match, so an ignored cmd cannot suppress a later real one via PID reuse. - console_prefetch_detected (startup forensic): ConsolePrefetchScanner (IAgentAnalyzer). A CMD.EXE-*.pf prefetch artifact whose last-run is after boot, covering the pre-agent OOBE window the live watcher misses. stat-only; corroborating artifacts sorted for a stable gate fingerprint. Both events carry coverageComplete:false + an honest coverageNote (and the prefetch event attribution:"coarse") — detection is best-effort, not gapless, and only reports, never blocks. Plumbing: Constants (2 event types) + MCP resource-catalog.ts + Web eventTypes.ts; AnalyzerConfiguration/TenantConfiguration + GetAgentConfig (default true) + TableConfigRepository; DefaultComponentFactory host gate + AgentAnalyzerManager registration; full Web opt-out toggle. Tests: watcher classification (9) + prefetch scanner (7) + analyzer-manager on/off. Also fix(web): unrestricted-mode toggle persisted the stale pre-toggle value because onSave() read async React state. saveConfiguration now takes an explicit unrestrictedMode override that the toggle passes through, so the saved value matches the UI state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
879bd0c59b |
feat(backend): optional per-tenant Entra app-role RBAC (Admin/Operator)
Grant tenant member roles via Entra ID app-role assignments on the Enterprise App (the token's "roles" claim), in addition to the TenantAdmins table. Per-tenant opt-in via the new GA-only EntraAppRolesEnabled flag (backend-only, no ConfigVersion bump). Resolution is table-first and tri-state (NotPresent/Disabled/Enabled): an enabled table row wins, a disabled row is an explicit deny that suppresses the claim entirely (a disabled member can never be re-authorized through their app-role assignment), and only a missing row falls back to the claim. Only Admin and Operator are claim-mappable (Admin outranks Operator); Viewer and the platform GlobalAdmin role are never claim-derived. All effective-role consumers share one pure decision authority (EntraAppRoleResolver.Resolve): PolicyEnforcementMiddleware, auth/me, and feedback/status — the latter previously used a table-only check, so Entra-only members did not see the feedback bubble. GA toggle wired through the BootstrapToken pattern: TableConfigRepository persistence, UpdateTenantConfigurationFunction GA-only protection, and a checkbox in the admin tenant editor. Tests: EntraAppRoleResolverTests (incl. disabled-row deny) + an auto-promote regression guard; 2308/2308 backend green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e84ac248f9 |
feat(agent-v2): opt-in keep-awake during User-ESP phase (per-tenant, default off)
Keeps the device awake (system + display via SetThreadExecutionState) during the User-ESP / AccountSetup phase so idle standby/sleep can't stall app installs or account provisioning. Reboots are unaffected; the OS auto-clears the hold on process exit, so it can never leak. - UserEspKeepAwakeHost observes the signal rail: engage on AccountSetup phase, release on AccountSetupProvisioningComplete / host stop / safety cap. - Safety cap is derived from the tenant's Intune ESP SyncFailureTimeout (+30 min margin), read at engage time; falls back to 90 min when unavailable. Resolved cap + source are reported on keep_awake_engaged for fleet observability. - KeepAwakeController owns the hold on a dedicated thread (ES_CONTINUOUS bound to the thread's lifetime). Dispose guard on the engage-ready timeout path. - Per-tenant portal toggle KeepAwakeDuringUserEsp (ConfigVersion 30 -> 31) with full config plumbing (Tenant/Analyzer config, table storage, agent delivery) and Web settings UI. - New events keep_awake_engaged / keep_awake_released across Constants, the MCP resource catalog, and the Web gather-rule on_event autocomplete. - Tests: KeepAwakeController late-confirm timeout edge, host engage/release/cap, and pure cap-resolution policy (V2.Core 1467 green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
b956de1377 |
feat(realmjoin): make RealmJoin watcher portal-configurable per tenant (default off)
RealmJoinWatcher was always-on, gated only by a compile-time const. Most tenants do not deploy via RealmJoin, so the watcher and its 60-min completion gate were pure noise. Add a per-tenant portal toggle (Agent Analyzers -> RealmJoin Watcher), default off, delivered to the agent via remote config. Mirrors the EnableIntegrityBypassAnalyzer flow end-to-end, with one divergence: RealmJoin is a collector host (not an IAgentAnalyzer), so the agent-side gate lives in DefaultComponentFactory.CreateCollectorHosts, not AgentAnalyzerManager. When disabled the host is not created; the existing null-guard in the DesktopArrival callback handles the absent HKU arming. The compile-time const RealmJoinTrackingEnabled is kept as a build-time master kill-switch (effective enable = remote flag AND const). - Shared: TenantConfiguration.EnableRealmJoinWatcher (bool?, null=agent default false) + AnalyzerConfiguration.EnableRealmJoinWatcher (bool=false) - Backend: TableConfigRepository write/read; GetAgentConfigFunction maps ?? false into Analyzers; ConfigVersion 29 -> 30 - Web: types + TenantConfigContext (state/load/save/reset/deps/context value) + SectionAgentAnalyzers + new "RealmJoin Watcher" subsection toggle Inert until agent redeploy: old agents ignore the flag and stay always-on. Verified: solution build 0 errors, tsc --noEmit clean, 76 backend config + 99 V2.Core tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f4d51ea499 |
perf(backend): cut App Insights cost + ingest hot-path reads (review follow-through)
Telemetry cost (L4): worker-side adaptive sampling now excludes Request;Event so the enriched RequestTelemetryMiddleware copy is never thinned; host.json Host.Results=Error drops the duplicate unenriched host request item. One complete request row per call instead of two (one unsampled+unenriched, one sampled+enriched). Ingest hot path (H2/H3): BlockedDeviceService caches negative answers (30s revalidation, symmetric with positive entries) so healthy devices no longer pay a BlockedDevices point-read per request; SessionDeletionGuard returns its already-loaded row and IncrementSessionEventCountAsync returns the post-merge snapshot, collapsing ~4 Sessions reads/batch to ~2. Queue-worker correctness (O1/O2): new GetSessionEventsStrictAsync propagates storage failures on the analyze + vulnerability-correlate paths so a transient read fault retries/poisons instead of silently deleting the message after "analyzing" an empty stream. Fail-soft variant kept for UI/metrics surfaces. Review fixes: parse prefetched Status as string (the actual wire format, was int -> optimization never fired); session-aware block over a cached negative entry keeps its session scope instead of widening to whole-device. 2274/2274 backend tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ad345f5869 |
fix(ingest): dedup duplicate RowKeys before batch transaction in V2 ingest repos
A telemetry batch containing two rows with the same RowKey made Azure Table Storage reject the entire entity-group-transaction with HTTP 400 / InvalidDuplicateRow, surfacing as HTTP 500 on POST IngestTelemetry. Observed as a 23-minute spike (36x 500) from a single session replaying an overlapping SessionSignalOrdinal; self-resolved when the agent stopped retrying. The three V2 ingest write paths (Signals, DecisionTransitions, Index) built the transaction without deduping duplicate RowKeys and had no fallback (unlike the Events path). Add a shared TableBatchDedup.ByRowKey helper that collapses duplicate (PartitionKey, RowKey) entities last-write-wins (idempotent UpsertReplace) and logs a WARN with the dropped count, so bad agents become visible in logs instead of failing the batch. SubmitTransaction stays fail-loud for any other batch error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6344a8b061 |
feat(sessions): per-session RebootCount, stored + MCP-queryable
Surface "how many reboots did this enrollment have" as a stored, filterable per-session metric so admins can ask e.g. "machines with many reboots" via MCP search_sessions. No agent change: V2 already emits one system_reboot_detected per reboot (EventLog 6009 boot-time vs session start). The backend counts and stores it; V1 stays 0 (never emits it). Hybrid counting: - Incremental per ingest batch in ClassifyEvents (V2 + legacy V1, shared EventClassification) -> rebootIncrement on IncrementSessionEventCountAsync, for a live in-flight value (passed 0 on terminal batches). - Authoritative reconcile (ReconcileSessionRebootCountAsync): distinct count of system_reboot_detected from the Events table, idempotent + fail-soft. Runs centrally in TableSessionRepository.UpdateSessionStatusAsync after any successful terminal transition (ingest, admin mark, maintenance timeout, rule-engine fail), and again post-increment in the ingest path to cover already-terminal batch replays. Self-corrects at-least-once batch double-counts. Storage/correctness: - RebootCount preserved across re-registration in StoreSessionAsync (a reboot IS the restart trigger) and projected in BuildSessionIndexEntity (superset rule). Search/MCP: - rebootCountMin/Max pushed to OData in BuildSearchScanFilter (sparse-subset), decoded in SearchSessionsFunction + QueryRawSessionsFunction, added to the continuation fingerprint and the fields= projection. Client-side MatchesRebootCountBounds backstops the device-snapshot + legacy scan paths. - search_sessions MCP tool gets rebootCountMin/Max + rebootCount projection. Tests: SessionIndexProjection (superset), StoreSessionReregistrationPreserve, RebootCountFilterBounds. Suite 2228/2228 green; MCP 220 green. Inert until backend redeploy; legacy index rows lack the column until next upsert (excluded by min-filter, intended). Web UI badge deferred. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
841f1ae04c |
feat(mcp): add action/performedBy/entityType/entityId filters to get_audit_logs
The get_audit_logs MCP tool could only narrow by tenant + date window, even though the audit entity already stores Action/PerformedBy/EntityType/EntityId. Add exact-match server-side filtering across all three layers so a filtered query never falls back to in-memory scanning. - Shared: AuditLogQueryFilters carrier + extended IMaintenanceRepository - Storage: shared AppendAuditFieldFilters (OData-escaped eq clauses) in both the tenant and global fan-out filter builders; threaded through page + legacy methods - Functions: new shared AuditLogFilterRequest parser folds filters into the pagination fingerprint AND echoes them on nextLink (cross-filter token replay rejected, follow-up pages round-trip), identical for tenant + global - MCP: four optional params on get_audit_logs + pass-through - Tests: AuditLogFieldFilterTests (eq clauses, escaping, coexistence with performer suppression + deletion exclusion, extras ordering) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
55f5fb883a |
fix(audit): make "excl. deletions" a server-side filter that fills the page
The "All (excl. deletions)" audit view filtered deletions client-side AFTER a fixed 20-row backend page. During/after a cleanup sweep the table fills with deletion_started/deletion_completed rows (written with the operator UPN, so the existing System.Maintenance exclusion misses them); a 20-row page came back all-deletions and the client stripped it to an empty list while real entries sat on later pages. Push the exclusion into the OData query (mirroring the System.Maintenance clause) and back-fill the page: a server-side `Action ne ...` exclusion can make a single Azure page return far fewer than pageSize matches, so follow the opaque continuation until pageSize matches or the window is exhausted (accumulate whole Azure pages to keep the token gap-free; cap at 20 round-trips). The flag is bound into the continuation fingerprint scope (audit:tenant:nodel) so a token cannot be replayed across views. Threaded through interface -> repo -> storage with a defaulted param; global GA fan-out path covered too. Frontend sends excludeDeletions=true when the filter is DEFAULT and refetches page 1 on toggle. Tests: 2172/2172 backend green + 4 new AuditLogDeletionExclusionTests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d7b42ad6e1 |
feat(mcp-raw): make query_raw_sessions/events return literal table rows
The "raw" tools were oversold: query_raw_sessions returned curated SessionSummary DTOs and its fields= ran through a hard-coded whitelist (ProjectFields) that silently dropped ~20 real SessionsIndex columns (OsEdition, ImeAgentVersion, GeoRegion/City/Loc, FailureSource, CurrentPhaseDetail, LastEventAt, ResumedAt, StalledAt, DeletionState, ...), and it accepted fewer filters than search_sessions -> a strict subset. The raw endpoints are the deliberate "fall back to the real stored bytes" escape hatch; the enriched views already live on search_sessions/get_session and search_events/get_session_events. So both raw tools now return the LITERAL stored rows: every column verbatim, PascalCase, incl. PartitionKey/RowKey/Timestamp, with fields= a pure pass-through (narrows, never silently drops a real column; always keeps PartitionKey/RowKey). - New RawEntityProjection helper: ToDictionary(TableEntity) + Project(rows, fields) - 3 new ISessionRepository methods returning IReadOnlyDictionary<string,object?> (no Azure types leak into Shared); reuse BuildSearchScanFilter + MatchesScanClientFilters for the predicate, emit the raw entity - query_raw_sessions: delete ProjectFields; add filter parity (manufacturer, model, enrollmentType, deviceName, osBuild, geoCountry, isPreProvisioned, isHybridJoin) -- already covered by the continuation fingerprint - query_raw_events: literal rows, drop ErrorCodeEnricher + EventFieldProjection (DataJson raw string, Severity/Phase raw ints); severity/source/date filters reimplemented on raw dicts preserving prior match semantics - EventFieldProjection kept for the enriched get_session_events; doc-comment fixed - MCP admin.ts: rewrite both tool descriptions, add the new query_raw_sessions inputs, PascalCase pass-through fields= docs; prompts.ts hint updated Note: contract change for these two tools (camelCase curated keys -> PascalCase literal columns). deviceProperties join stays out of raw (owned by search_sessions); query_table remains the generic literal escape hatch. Tests: new RawEntityProjectionTests (8); full backend suite 2134 passed; MCP tsc clean + vitest 149 passed/41 skipped; tools-raw.test.ts updated to the PascalCase contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d72a79b156 |
chore(backend): remove dead /raw/events/search endpoint + orphaned repo methods
After the MCP search tools moved to the paginated /api/raw/events fan-out (prev commit), /api/raw/events/search had zero callers anywhere in the repo (no MCP tool, no web). Remove the now-dead surface end to end: - Delete SearchEventsFunction (raw/events/search + global/raw/events/search). - Drop both routes from EndpointAccessPolicyCatalog and the no-store cache middleware allowlist (+ its test InlineData). EndpointPolicyCatalog completeness test stays green (route and entry removed together). - Remove the now-orphaned repo method SearchEventsByTypesAsync (its only caller was the deleted endpoint) from ISessionRepository + TableSessionRepository delegation + TableStorageService impl. - Remove the non-paginated SearchSessionsByEventAsync: its only runtime caller was SearchEventsByTypesAsync, and it is strictly redundant with the resumable SearchSessionsByEventPageAsync (used by /api/raw/events + search/sessions-by-event). Reword the surviving paged method's xmldoc/comment that referenced it. No capability lost: multi-type cross-session event search still runs via the MCP fan-out over the paginated single-type endpoint; single-type primitives remain exposed via query_raw_events + search_sessions_by_event. Full solution builds clean (0 errors); catalog-completeness + cache-middleware tests 77/77. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c434eae179 |
feat(device-block): auto block/kill on excessive session events
Adds a second, higher threshold to the runaway-session detector that auto-blocks or auto-kills the device (admin-selectable mode), so the existing 2000-event warn keeps observing while the harder action runs unattended at e.g. 2500 events. Both tiers stay independently idempotent per session and the maintenance batch picks them up in a single query pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
b855b2b927 |
fix(rules): retry-safe sunset GC + idempotent point-deletes
Codex review of the sunset orphan-GC introduced in
|
||
|
|
dff5ba65f4 |
fix(rules): sunset = delete, with cross-tenant orphan RuleState GC
Session 080edee9 follow-up — the customer screenshots showed ANALYZE-CORR-001
("Slow Network") and ANALYZE-APP-009 ("App Download Stalled") still firing
on a clean re-analyze, despite my previous fix shipping them as
enabled:false in the JSON. Investigation via direct DB query:
- AnalyzeRules/global rows for both: Enabled=false ✓ (version-bump landed)
- RuleStates/{tenant} rows for both: Enabled=true ✗ (orphan overrides
from the user toggling them in the portal UI on 2026-03-09)
[AnalyzeRuleService.cs:60](src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs#L60)
overlays per-tenant RuleState on top of the global Enabled, so the orphans
kept resurrecting the rules. No reseed path was cleaning them up.
The right semantic is "sunset = remove from catalog", not "ship as
enabled:false and hope nobody overrides". Three coordinated changes:
1. Both sunset rules deleted from the rules/analyze/ source tree (they
never had a working condition shape — CORR-001 didn't actually check
download speeds, APP-009 matched the V2 termination handler's
post-mortem-synthetic download_progress:failed event).
2. New IRuleRepository.DeleteRuleStatesForRuleIdAcrossTenantsAsync
cross-partition cleanup primitive — scans RuleStates by RowKey, deletes
matching rows across every tenant partition.
3. Both reseed paths now do orphan-state GC:
- AnalyzeRuleService.EnsureBuiltInRulesSeededAsync (on-startup):
detects rules that exist in DB as built-in but aren't in
BuiltInAnalyzeRules.GetAll() anymore → deletes the global row +
fans out the per-tenant RuleState GC.
- AnalyzeRuleService.ReseedBuiltInRulesAsync (delete+reimport): same
diff-based detection on the pre-delete snapshot.
- ReseedFromGitHubFunction.ReseedAnalyzeAsync (admin "Reseed from
GitHub" button — the path the user actually clicks): mirrored
identical logic so every reseed flavor leaves the same DB invariant.
4. ReseedBuiltInRulesAsync return shape extended to
(deleted, written, orphanStatesGcd) so the admin UI can surface the
cleanup count to operators.
Coverage:
- RuleEngineDetectionFailureTests: the two version-bump assertions for
CORR-001/APP-009 are replaced with `DoesNotContain` against the
built-in catalog (sunset semantic).
- AnalyzeRuleServiceSunsetTests (new, 3 tests): pins EnsureSeed orphan-GC
invariant, asserts live-rule states are NOT touched, and pins the
ReseedBuiltIn (deleted, written, orphanStatesGcd) tuple shape.
After deploy + a single GetAllRulesForTenantAsync call (any analyze run
triggers it), the orphan RuleStates for sunset rules are automatically
cleaned. The user's tenant 5ca2b350-… will see CORR-001 + APP-009
disappear from the Rules UI on the next analyze cycle.
Backend 2073/2073.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
f9d22216b6 |
feat(backend,mcp): opt-in pagination on config/all to fix list_tenants size-cap
The MCP list_tenants tool fetched GET /api/config/all, which returns all
tenant configs in one unpaginated array. With ~198 tenants the projected
response exceeded the tool response cap and was offloaded to a file, making
the tool unusable for the model.
Add backward-compatible opt-in pagination to config/all (same dual-mode
pattern as GetSessionReportsFunction):
- No pageSize -> unchanged legacy bare full-config array (web + maintenance
aggregation depend on this shape; zero regression).
- pageSize -> secret-stripped { count, tenants, nextLink } envelope using
the existing ContinuationToken HMAC (scope "config-all", caller-bound),
TenantId-ordered pages. Secrets never leave the backend.
Backend: GetTenantConfigurationsPageAsync (repo + service), TenantConfigPagination
helper, dual-mode GetAllTenantConfigurationsFunction. Route/policy unchanged.
MCP: list_tenants now follows nextLink against config/all (extractTenantList as
defense-in-depth + optional fields= trim); client-side selectTenantPage removed.
Tests: TenantConfigPaginationTests 13/13; MCP security-guards 56/56.
No /api/global/ relocation here - that convention cleanup is deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
0734484f0e |
feat(mcp): harden MCP server + add list_tenants & fleet vulnerability summary
MCP server review follow-ups (PR A-D):
- Remove stale agents/query.ts dev script + drop unused @anthropic-ai/sdk dep
- Add 256kb body limit on /mcp; server-level instructions + 3 diagnostic prompts
(investigate-failed-session, cve-exposure-audit, compare-agent-versions)
- New list_tenants tool over /api/config/all with a strict keep-list projection
so tenant secrets (webhook/SAS URLs) never reach the model (+ 6 unit tests)
- New get_vulnerability_summary tool + backend GET /api/{global/}metrics/vulnerability
aggregating the CveIndex (top CVEs, severity breakdown, KEV, affected devices;
truncated flag on scan cap) + 6 aggregator unit tests
- Test hygiene: exclude tests from tsc output, pin vitest to src/ (no dist double-run)
- Bump MCP server version 1.2.0 -> 1.3.0
Backend solution builds clean; MCP 52 unit tests + 6 backend aggregator tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
31958e7ba8 | Security hardening query filters | ||
|
|
d4034842ee |
feat(backend/backup): PR1 — critical-table backup feature (daily timer + manual GA trigger)
Adds the full backup pipeline that snapshots 15 critical tables (Admin/Tenant config, rules, identities, audit, preview) into a logically-isolated blob container, daily at 04:00 UTC and on operator demand. Restore endpoints follow in PR2/PR3. Architecture: * Daily TimerTrigger + manual POST /api/global/backups/trigger (GA-only, fail-hard producer). HTTP returns 202 + jobId on enqueue, 500 if the queue write fails (and the BackupJobs row is rolled to Failed). * BackgroundService queue worker (not QueueTrigger — matches the Analyze/ IndexReconcile pattern that avoids the Functions-host connection-binding app-setting). Worker holds the maintenance lease for the whole run; service is intentionally lease-unaware so it stays unit-testable. * BlobBackupStore wraps the new 'critical-table-backups' container with a shared maintenance-lease sentinel at '_lock/maintenance.lock' that serialises every backup or restore op across worker/timer/single-row paths. * CriticalTableBackupService streams each table through CryptoStream(SHA256) into per-table NDJSON blobs, then writes manifest.json LAST as the durability anchor. Per-table failures degrade Outcome to Partial without aborting; per-run budget (50 min) → Skipped status. * BackupJobsRepository persists the six-state machine (Queued/Running/Completed/Failed/Skipped/BlockedTerminal) with ETag-CAS updates. Enum→string mapping rejects unknown values on read so refactors surface loudly instead of silently shifting state. * BackupJobWatchdog (30 min timer) heals stuck rows via a probe lease on the shared sentinel: Queued > 60 min → Failed, Running with stale heartbeat AND free lease → Failed (lease-busy means a live worker — leave alone). * OpsEvents: distinct CriticalTableBackupPartial (Warning) vs Failed (Error) so operators can wire softer Telegram rules for partial captures. * MaintenanceService.MonitoredPoisonQueues + OPS_EVENT_TYPES catalog registered (dual-register memory). Test coverage (15 new tests, 1952/1952 backend xUnit green): * BackupManifestJsonTests: enum-as-string (allowIntegerValues=false rejects numeric values), DictionaryKeyPolicy unset (Azure column names preserved case-faithfully), manifest field round-trip. * BackupJobsRepositoryMappingTests: full entity↔domain round-trip, unknown Kind/State/BackupOutcome rejected, legacy BackupOutcome="Failed" rejected. * CriticalTableBackupServiceTests: happy/partial/empty/budget-exceeded paths exercise the per-table loop with in-memory table + blob fakes. Setup: scripts/Backup/setup-backup-container.ps1 — idempotent az-CLI bootstrap for container + lifecycle rule (90 d delete on prefix). Soft-Delete and Versioning are NOT applied automatically (storage-account-wide blast radius — operator must validate per separate doc note). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
a36bf8bf0c | fix onboarding bug, onboardedBy and auto promote admin | ||
|
|
feff123f11 |
feat(backend,agent,web): hosted diagnostics destination + cascade-delete cleanup
Adds an opt-in second destination for agent diagnostics uploads:
- CustomerSas (default, unchanged): tenant's own SAS-backed container
- Hosted (new, opt-in via explicit admin click): backend's storage account
under {tenantId}/AgentDiagnostics-...zip, with per-upload 15-min
blob-scoped Write+Create SAS minted via the Function App's MI
Closes the long-standing gap where Session.DiagnosticsBlobName was captured
into the deletion-manifest but the blob was never actually cleaned up by
the cascade. Hosted blobs are always deleted (we own the storage); customer
blobs are deleted only when the SAS includes the 'd' permission, otherwise
logged + skipped.
Existing tenants change nothing: the new TenantConfiguration.DiagnosticsUploadDestination
field defaults to CustomerSas, no silent flip. Hosted requires an explicit
admin click + Save with a clearly marked "data leaves your tenant" disclosure.
Tests: Backend 1844, V2 1076, V1 538, vitest 380 (+78 new across all suites).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
c26b25b81b |
feat(backend,web): offboarding farewell-email capture + dedicated feedback table + reports tabs
- Persist tenant Preview-Notification-Email on OffboardingHistory row at Phase 1 so it
survives Phase 2 wipe; ResendEmailService.SendAsync wired but disarmed by a const-false
arm-flag with a [DRAFT] placeholder template — explicit flip required to ship.
- Move user feedback storage out of PreviewConfig discriminator into a dedicated Feedback
table (PK=InApp / Offboarding) that is NOT in any tenant-offboarding wipe list — feedback
from departing tenants is exactly the signal we want to keep. New IFeedbackRepository +
TableFeedbackRepository; FeedbackFunction migrated; (PreviewConfig, "Feedback")
removed from TenantOffboardingHandler.DiscriminatorTables.
- POST /api/tenants/{tenantId}/offboard/feedback collects a free-form farewell comment
during the drain-barrier countdown (marker-gated, fail-soft OpsEvent emit). Reports >
User Feedback page gains an Offboarding-Feedback sub-tab next to In-App Feedback.
- OffboardingFeedbackReceived OpsEventType registered (info-severity) + catalog entry
in OpsAlertRulesSection so admins can wire Telegram in two clicks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
e4c4dd57db |
feat(backend,web): optional Graph add-on permissions for Intune script display names
Adds opt-in resolution of Intune Platform Script + Remediation display names in
session timelines via a tenant-side appRoleAssignment grant. No app manifest
change, no impact on customers who don't enable it. Architecture is reusable for
future optional Graph permissions: catalog -> detector -> resolver -> endpoint.
Backend
- GraphFeatureDetector parses the `roles` claim from the SP access token and
caches the (token, roles) bundle per tenant. GetSnapshotAsync surfaces a
transient flag so admin-UI callers can render "unknown" instead of a
misleading "0 of 0 granted" when token acquire times out.
- GraphTokenService now accepts a CancellationToken so optional-feature callers
can apply tight budgets instead of inheriting the 5+15+30 s retry chain.
- ScriptDisplayNameResolver: cache-first, list-pull-on-cold-tenant, per-ID
fallback. Throttle-aware abort: skip per-ID for kinds whose full-pull hit a
transient failure; break the per-ID loop on the first HttpRequestException.
Total budget 5 s, with a tight 1.5 s sub-budget on the token acquire.
- Azure Table `ScriptNameCache` + direct-TableClient fail-loud repository
(mirrors TableOffboardingAuditRepository pattern). Daily
ScriptNameCacheCleanupFunction: 7 d positive / 1 h NotFound TTL.
- POST /api/tenants/{id}/scripts/display-names (body-carried refs to dodge URL
length limits; backend cap 200 refs/request).
- GET /api/tenants/{id}/graph-permissions/status (4 s budget; includes app
ClientId so the UI can render an exact grant command).
- POST /api/tenants/{id}/graph-permissions/refresh (invalidates token cache).
- All three routes registered in EndpointAccessPolicyCatalog (MemberRead for the
resolve, TenantAdminOrGA for the admin-UI surfaces).
Web
- `useScriptDisplayNames` hook with stable refset fingerprint (no spurious
refetches on parent re-renders); clears stale map on tenant/refset change so
a session switch never leaks names across tenants.
- Chunked POST loop: 200-ref batches with partial-success semantics. Preserves
earlier chunks when later ones fail (non-2xx OR transport throw); only
TokenExpiredError propagates so auth flow can react.
- ScriptExecutions renders " . {DisplayName}" alongside existing label/short-id,
keyed by Kind + policyId so Platform/Remediation under the same id never
collide.
- New Settings section "Optional Graph capabilities": status table with granted
/ not granted / unknown, copy-paste PS one-liner with ClientId baked in,
refresh button gated on response.ok.
Customer-facing
- scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1: idempotent
grant / verify / revoke script. -Features ScriptDisplayNames (high level) or
-Permissions ... (admin-UI generated). Pure ASCII for PS 5.1 compat.
- docs/customer-graph-add-on-permissions.md: end-to-end customer guide
covering prerequisites, the appRoleAssignment pattern, and revocation.
Tests
- 1718 backend xUnit / 364 web vitest. Highlights: JWT roles parsing edge
cases; full-pull-429 suppresses per-ID; per-ID-429 breaks loop; transient
detection via GetSnapshotAsync; chunked partial success (HTTP failure + thrown
transport); TokenExpiredError propagation; EndpointPolicyCatalogCompleteness
covers the three new routes by reflection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
206388da99 |
feat(backend,web): tenant offboarding async cascade with customs archive
Replace synchronous DeleteAllTenantDataAsync with a queued async cascade
that wipes all 24 tenant-scoped tables safely, archives custom rules,
and survives crashes at every step.
Phase 1 (TenantOffboardFunction, ~200ms sync):
- Write History / ByTenantPointer / Marker rows (OffboardingAudit table)
- Flip TenantConfiguration.Disabled=true (the active auth gate)
- Enqueue with 6 min visibility-delay (cache-drain barrier =
TenantConfigurationService.CacheDuration 5 min + 1 min safety)
- Return 202 with EarliestProcessingAt for the UI countdown
Phase 2 (TenantOffboardingWorker + Handler, async after drain):
- Enumerate sessions, per-session cascade-enqueue, persist
Expectations blob (separate offboarding-state container)
- Drain probe against per-session progress blobs; self re-enqueue
up to 60x at 2 min visibility-delay
- 2.D-archive: GatherRules / AnalyzeRules / ImeLogPatterns moved
to TenantOffboardingCustomsArchive table (3-iter cap + verify)
- 2.D-wipe: SafeWipe over all tenant tables (4 variants: exact-PK,
composite-PK range, discriminator+TenantId, property-only) with
fetch+verify+delete pattern + SafeWipeVerificationException
- 2.E: blob-prefix wipe deletion-manifests/{tenantId}/
- 2.D-pass-2: re-wipe AuditLogs (defense-in-depth)
- 2.G: side-effects FIRST (Marker, Pointer, blob, audit, OpsEvent)
then History=Completed LAST (DrainCompletedAt as resume anchor)
- 2.F-final: delete TenantConfiguration row LAST so self-service
re-onboarding (next /api/auth/me auto-creates default) works
Defense-in-depth:
- OffboardingMarkerCleanupFunction (2h timer): sweeps Completed
markers after 15 min grace, conditional TenantConfiguration sweep
(only when row is still the offboarding tombstone — preserves
user-driven self-service re-onboards)
- Worker poison path: durable Failed state MUST land before queue
moves; transient failure leaves message visible for retry
- SessionDeletionHandler reorder: Audit + SignalR notify BEFORE
CompletedAt write so the drain predicate is correct
Web UI:
- /settings danger zone: typed "OFFBOARD" confirmation dialog,
post-202 drain-barrier banner with minute-based countdown, hint
"you can sign out and close this tab — deletion runs in
background", silent auto-logout when countdown elapses
- /admin/customs-archive (GA only): list runs + inspect entries +
typed "DELETE" confirmation modal for run/entry deletion
- OpsAlertRulesSection: TenantOffboardingFailed event registered
- lib/api.ts: shared CustomsArchive* response types
Tests: 100+ new tests covering drain contract, handler ordering,
worker poison path, ETag-CAS, SafeWipe variants, OffboardingFilters
prefix-bleed safety, MarkerCleanup conditional sweep, Bootstrap
auth-disabled-tenant gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
0a8a6eabd7 |
feat(backend,agent): cert-reject visibility — structured backend warnings + V2 distress cert-context
Backend emits AgentCertRejected (with reason enum) at every cert-validation failure path and AgentRequestRejected (with request-context) at every 401/403 return so operators can diagnose why mTLS auth was denied. V2 agent now attaches a Thumbprint/Subject/Issuer/NotBefore/NotAfter snapshot plus a DistressCertSourceState classifier to each distress report; correlates 1:1 with the backend warnings via tenant + serial + thumbprint. Distress endpoint stays unauthenticated and security-restrictive: rate-limit (3-layer) + sanitize + truncate + enum-validation unchanged; new fields are hex-regex / IsDefined / hard-capped at 96 chars; MaxContentLength bumped from 1024 to 1536 to fit the cert-context payload. V1 agent untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
2211399f23 |
chore(backend,web): remove legacy session-delete path (PR7)
Cascade-delete V2 is the only path now. Drops the per-tenant EnableCascadeDeleteV2 flag, the legacy ETag-CAS-guarded DeleteSessionAsync helper, the AppInstallSummaries cleanup helper (only legacy caller), and the SessionsLegacyDeleted counter chain (fanout → maintenance → OpsEvent). DeleteSessionFunction collapses to a single kill-switch gate; the producer owns 404 / 409 / Poisoned mapping AND the intentional Queued/Preparing+Snapshot → 202 recovery resume — gating those at HTTP would strand crashed admin cascades. Fanout now aborts both the inner loop AND outer loop on producer KillSwitchActive instead of leaking calls into the next tenant. Test coverage: - DeleteSessionCasGuardTests removed (helper gone) - AdminConfigurationSessionDeletionKillSwitchTests trimmed to kill-switch only - DeleteSessionFunctionTests pinned to the kill-switch short-circuit + null - SessionRetentionFanoutServiceTests: legacy path tests dropped, two new abort regressions for producer KillSwitchActive (inner + outer loop) Build: 0 warnings, 0 errors. Backend: 1526 passed / 0 failed. Web: tsc clean, vitest 346 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
ef530fb890 |
feat(agent/v2): DAD liveness + agent_shutting_down gap closure
DAD liveness (3 new state-change-only events, NOT periodic, max 3 per detector lifetime): desktop_detector_started / _first_poll / _no_candidate. Distinguishes "agent dead post-reboot" from "DAD wiring broken" from "user never logged in" in sessions missing desktop_arrived (the ca9e3c59 timeout pattern). Threshold via global AdminConfiguration .DesktopDetectorNoCandidateTimeoutMinutes (default 10, 0 = disabled), round-trip wired through TableConfigRepository, GetAgentConfigFunction (ConfigVersion 27 -> 28), CollectorConfiguration, DefaultComponentFactory, DesktopArrivalHost + admin UI. Also fixes a peripheral-loss bug: V2's DesktopArrivalDetector.OnTraceEvent callback was never wired, so the existing desktop_excluded_user and desktop_real_user_detected events were dead code in V2. Now routed via InformationalEventPost in the host. Shutdown gap closure (4 new agent_shutting_down emit paths): ctrl_c, process_exit, unhandled_exception (with exception type + 500-char- truncated message), runtime_host_exit (defensive finally fallback). Cross-path Interlocked idempotency gate shared with EnrollmentTermination Handler via new tryClaimShutdownEvent ctor param prevents double-emit when Ctrl+C races a real Terminated transition. Event-type unification (V2 only): CreateAuthThresholdHandler now emits agent_shutting_down with data.reason=auth_failure (was agent_shutdown). EnrollmentTerminationHandler reason vocabulary unified to lowercased tags (decision_terminal, max_lifetime). V1 keeps legacy agent_shutdown (phasing out). Web/MCP consumers (eventHelpers, whiteGloveSplit test fixtures, gather-rules eventTypes catalog, MCP semantic-event list, docs) treat both as synonyms. Tests: 3411 green (1538 backend + 1065 V2 core + 530 V1 core + 294 DecisionCore + 347 vitest). TypeScript noEmit clean. 5 new DAD-liveness tests, 5 new gap-path tests, 2 new idempotency tests, 3 reason-vocab updates. Not in this PR: cross-process death detection for the actual ca9e3c59 silent-post-reboot-death pattern (Logon-Trigger / external watchdog) - separate follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
257c5c4354 |
feat(backend,web): cascade-delete state-machine + guard + producer + preview (PR3/7)
PR3 of the safe cascade-delete plan
(.claude/plans/session-deletion-cascade-safe.md). Substantial PR per
plan §16 — adds the concurrency state-machine, the single-chokepoint
SessionDeletionGuard, the producer that builds manifest + uploads
blobs + enqueues the worker envelope, and the read-only preview
endpoint Global Admins use to verify a cascade plan before flipping
EnableCascadeDeleteV2 on. Wiring the guard at the 9 writer sites is
intentionally deferred to PR5 — no cascade actually fires until PR5
calls the producer from DeleteSessionFunction, so PR3 ships as
infrastructure-only.
== State machine + guard infrastructure ==
SessionDeletionState (Shared, Plan §1 P7 / §16-R2):
None | Preparing | Queued | Running | Poisoned. Completed deliberately
omitted — the cascade FINAL step removes the row, so "completed" is
observable via row-absence + progress.completedAt, not via a stable
state value. IsValidTransition(from, to) enforces the legal edges
(None→Preparing, Preparing→{Queued,None,Poisoned}, Queued→{Running,
Poisoned}, Running→Poisoned, Poisoned→None) and treats null/empty
legacy rows as None.
SessionDeletionLockedException carries (TenantId, SessionId,
CallerContext, CurrentState, ManifestId) for audit + log lines.
SessionDeletionGuard (Plan §1 P7 + §16-R13):
- ThrowIfLocked(TableEntity sessionRow, callerContext) — piggyback
variant for hot paths that already loaded the Sessions row; zero
added I/O. Caller must include DeletionState +
PendingDeletionManifestId in its select projection.
- EnsureWritableAsync(tenantId, sessionId, callerContext) —
standalone variant for paths with no existing read; one Get
round-trip via the PR1 ISessionDeletionInventoryReader seam.
Both paths throw the same exception. Per §16-R13 framing the guard
is "best-effort fast-fail, not safety" — correctness lives in §1 P4
live verification + poison + §13 restore-from-poisoned (PR4).
== Sessions row state-machine columns ==
SessionSummary.DeletionState (string) +
SessionSummary.PendingDeletionManifestId (string?) — added to the DTO
+ MapToSessionSummary read path. Empty/null means no cascade in flight
(legacy rows; treated as None per the guard contract). The producer
writes both columns via direct CAS via the new helper; existing
session-write paths don't need to know about them in PR3.
TableStorageService.CasSetSessionDeletionStateAsync — new partial
helper. Read-then-CAS on Sessions row, returns a structured
SessionDeletionStateCasResult (Updated | SessionMissing | WrongState |
EtagConflict) so the producer can branch correctly. Stamps newManifestId
on transition to Preparing; clears it on transition back to None
(poisoned-recovery restore).
== Producer + preview endpoint ==
SessionDeletionProducer.EnqueueAsync(tenantId, sessionId, reason,
actor) — Plan §5 PR3 step sequence: kill-switch check → CAS None→
Preparing with new ManifestId → DeletionManifestBuilder.BuildAsync →
BlobStorageService.UploadDeletionManifestAsync → UploadInitial-
DeletionProgressAsync (new helper for the mutable .progress.json
sibling) → audit deletion_started → CAS Preparing→Queued → enqueue
SessionDeletionEnvelope to Constants.QueueNames.SessionDeletion.
Returns SessionDeletionEnqueueOutcome enum so PR5/PR6 callers
translate per their UX (admin → 409 on AlreadyInFlight, maintenance
→ skip+audit). Crash recovery contract: row stuck in Preparing with
no progress blob → maintenance GC clears after 1h (PR6); Queued/
Running/Poisoned NEVER auto-cleared.
GET /api/admin/sessions/{sessionId}/delete/preview?mode={summary|full|
download} — GlobalAdminOnly per the EndpointAccessPolicyCatalog (per
memory feedback_route_policy_catalog). Three modes:
- summary: preflightCounts + sample (PK, RK) per step + estimated
snapshot bytes + builderDurationMs + schemaHash.
- full: entire manifest JSON inline (every row dump, every decrement
key) — byte-level review of "what would be deleted".
- download: gzipped *.snapshot.json.gz with Content-Disposition
attachment, identical bytes to what the real cascade would upload.
NEVER mutates state, NEVER uploads a blob, NEVER enqueues. Calls the
guard only as a hint (in-flight cascades surface in the response, no
lock acquired). Audits deletion_preview per Plan §7.
== Configuration changes (with web round-trip) ==
AdminConfiguration.SessionDeletionKillSwitch (bool, default false,
Plan §1 P8 / §9) — global emergency stop. When true, V2 producer +
legacy direct-delete return 503; PR4 worker pauses on entry. Round-
tripped through TableConfigRepository Store + Map AND the 4-file web
chain (AdminConfigContext.tsx, AdminConfigSettingsSection.tsx,
SectionGlobalSettings.tsx, types/adminConfig.ts) per memory
feedback_admin_config_ui_roundtrip — without it, every admin Save
silently clobbers the field back to false. UI surfaces a red checkbox
with explanatory copy.
TenantConfiguration.EnableCascadeDeleteV2 (bool, default false) —
per-tenant rollout flag. When true, PR5 admin-delete + PR6 retention
fanout build a manifest + enqueue; when false, legacy direct-delete
runs. Per-tenant flag selects PATH; global kill-switch halts BOTH.
Constants.QueueNames.SessionDeletion = "session-deletion" — worker
queue name.
Shared envelope SessionDeletionEnvelope { TenantId, SessionId,
ManifestId, Reason, EnqueuedAt } — wire shape between producer and
PR4 worker.
== DI ==
Program.cs registers SessionDeletionGuard + SessionDeletionProducer
as singletons next to the existing DeletionManifestBuilder.
== Tests (50 new, backend suite 1340/1340 green) ==
- SessionDeletionStateTests (10) — IsLocked over every state value
including legacy null/empty, IsValidTransition over every legal
edge, explicit rejection of illegal edges, "Completed is not a
state" guarantee.
- SessionDeletionGuardTests (8) — null row no-op, every lock state
throws with correct exception fields, missing-session no-op,
context propagation in the exception.
- SessionDeletionProducerTests (7) — kill-switch returns
KillSwitchActive without downstream calls; SessionMissing /
AlreadyInFlight / Poisoned outcomes from CAS; happy path verifies
all 7 steps in order via Mock.Verify; CAS-2 failure leaves snapshot
uploaded but no queue message; producer's pre-allocated ManifestId
flows to both the snapshot blob and the CAS marker (consistency
invariant the worker depends on).
- AdminConfigurationSessionDeletionKillSwitchTests (6) — both flags
default false on new config + on CreateDefault, true persists.
Wiring 9 sites with the guard (telemetry V1+V2 ingest, mark-success/
failed, server actions, maintenance marker, re-analyze handler,
vulnerability handler, manual rescan, index reconcile) deferred to
PR5 — no cascade fires in PR3, so the guard is dormant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
66fc23b609 |
feat(backend): SoftwareInventory contributions side-row + cascade delete helpers (PR2/7)
PR2 of the safe cascade-delete plan
(.claude/plans/session-deletion-cascade-safe.md). Adds the per-table
delete primitives the cascade worker will use in PR4 plus the §17
SessionInventoryContributions side-row that makes per-session inventory
counter increments at-most-once and exactly reversible. No production
caller invokes the cascade primitives yet; the inventory rewrite IS a
call-site change in VulnerabilityCorrelationService and ships in this PR.
== Cascade delete helpers (TableStorageService.Deletion.cs) ==
DeleteByExactKeysInBatchesAsync(tableName, keys) is the load-bearing
helper: groups by PartitionKey (Azure batch transactions are
partition-scoped), chunks at 100 actions per batch, falls back to
per-row DeleteEntityAsync with 404-ignore on whole-batch rollback.
Returns (Attempted, DeletedNow, AlreadyMissing) for accurate progress
accounting on idempotent re-runs of partial cascades. Plus 9 thin
wrappers per the §3 taxonomy: 7 keyed (EventTypeIndex, CveIndex,
SessionsByTerminal, SessionsByStage, DeadEndsByReason,
ClassifierVerdictsByIdLevel, SignalsByKind), 1 single-row
(DeviceSnapshot), and 2 enumerate-then-delete (Signals,
DecisionTransitions for the PK_BY_SESSION class). All fail-loud per
memory feedback_storage_helpers_fail_soft.
== SoftwareInventory side-row (TableStorageService.Inventory.cs) ==
Plan §17 design: each inventory-correlated session contributes ONE
SessionInventoryContributions row (PK=tenantId, RK=sessionId) with the
normalized software keys it added. Increments to SoftwareInventory
counters are now driven by deltas against this row, so:
- WG sessions that fire software_inventory_analysis 4× no longer
over-count by 4× per software per session
- Cascade decrement is exact: the side-row is the source of truth
for which counters this session contributed to
- Pre-side-row sessions (created before this deploy) have no
contributions row → cascade skips inventory entirely for them
(drift accepted on the historical backlog, ages out via 90d
retention).
Helpers (all fail-loud, all bounded retry per §12-Q10 = 10 attempts /
60s wall-clock cap, all ETag-CAS):
- GetSessionInventoryContributionsAsync: returns prior key list or
null on 404
- UpsertSessionInventoryContributionsAsync: side-row CAS that
computes (added, removed) delta against the new key set
- DeleteSessionInventoryContributionsAsync: 404-ignore tombstone
- IncrementSoftwareInventoryEntryAsync: ETag-CAS +1 with first-time
AddEntity that writes full metadata
- DecrementSoftwareInventoryEntryAsync: ETag-CAS -1, clamp >= 0,
deletes the row when count hits zero
Codec: SoftwareKeysJsonCodec encodes the key list as raw JSON below
30 KB, gzip+Base64 above. Same shape DeletionManifestBuilder (PR1)
already reads at preflight, so the cascade decrement reads back what
the increment wrote with no schema gap. Plus deterministic ComputeDelta
(case-insensitive composite key) so concurrent correlations on the
same session collapse to a single counter update.
== VulnerabilityCorrelationService rewrite ==
The Task.Run fire-and-forget at the old line 246 now calls the new
ApplyInventoryContributionsAsync helper instead of
UpsertSoftwareInventoryAsync. Each Increment / Decrement is wrapped
in its OWN try/catch so one counter failure does NOT abort the rest
of the loop (Plan §17.3 + §17.8): without that, the side-row would
already promise the new key set while subsequent runs see an empty
delta and never retry the under-counted keys. Each individual failure
emits an `inventory_counter_write_failed` audit entry with operation
+ vendor + name + version + exception type/message via the existing
LogAuditEntryAsync. IMaintenanceRepository was added as an OPTIONAL
constructor param (positional default null) so existing 8-arg test
factories keep compiling unchanged.
ParseSoftwareInventoryItems exposed as internal static for testability;
preserves the same dedup + cpe-mapping behaviour as the old
UpsertSoftwareInventoryAsync path. The legacy method is left in place
on TableStorageService.Rules.cs for now (no remaining callers; PR7
cleanup can remove if desired).
== Tests (39 new) ==
- SoftwareKeysJsonCodecTests (12): raw vs gzip+Base64 paths,
determinism, malformed-input rejection, delta cases (added /
removed / identity / case-insensitive), composite-key shape.
- TableStorageDeletionBatchTests (8, SDK-mocked via internal test
ctor): empty input, PK grouping (one batch per PK), chunking at
100, batch 404 + all per-row 404, mixed batch 404 (2 succeed +
3 missing) with accurate accounting, non-404 propagates, no
over-deletion, ETag.All on every action.
- TableStorageInventoryCasTests (9): Increment first-create with
full metadata, CAS +1 on existing row, 412 retry, 409 first-create
race fall back to Update, empty-triple skip; Decrement 404 no-op,
clamp-at-zero deletes the row, count>1 subtract, 0-count clamp,
412 retry.
- VulnerabilityCorrelationServiceContributionsTests (10): Parser
correctness, end-to-end add/remove flow with Mock.Verify counts,
empty-input no-op, side-row exhaustion swallowed, per-counter
failure does not abort + audits the failed key (both increment
and decrement variants).
Backend suite: 1290/1290 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
7020a2e5a6 |
fix(backend): propagate device/version block+kill across Function App instances
BlockedDeviceService and BlockedVersionService kept their cache per Function App
instance. Once an instance had lazy-loaded a tenant's block list, a cache miss
short-circuited to "not blocked" without ever re-consulting storage — so a Kill
(or Block) added on instance A stayed invisible to instance B for the lifetime
of the process. Live repro: device was killed at 13:11:46Z and kept posting
telemetry for 11+ minutes afterwards on every cadence tick.
BlockedDeviceService.IsBlockedAsync now has two safety nets to bridge other
instances to storage:
- Cache miss after tenant was loaded -> storage point-read on the spot, then
promote the result into the cache. Lets a freshly-set block from another
instance reach this instance immediately.
- Positive cache hit older than EntryRevalidateAfter (30s) -> storage
point-read to re-confirm. Lets manual Unblock, Action upgrade (Block->Kill),
and UnblockAt changes from another instance propagate within seconds.
BlockedVersionService CacheRefreshInterval lowered from 5min to 30s for the same
reason — a version-Kill should not take up to five minutes per instance.
IDeviceSecurityRepository.IsDeviceBlockedAsync gains a BlockedSessionIds tuple
slot so the cache-miss fallback preserves session-aware semantics instead of
silently degrading session-scoped blocks to whole-device.
Tests cover the cache-miss fallback for both Block and Kill actions, the
stale-positive Block->Kill upgrade path, and the stale-positive Unblock path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
eacef9c38a |
feat(notifications): opt-in "enrollment started" webhook on session registration
Fires Teams/Slack alert at backend session registration. Default off so existing tenants don't get a surprise notification storm. Triggers on fresh session OR WhiteGlove Part 2 resume; agent restart and re-registration of terminal sessions are skipped via pre-read of the session row before StoreSessionAsync. NotifyOnWhiteGloveResume constant in RegisterSessionFunction lets us disable the resume signal with a 1-line patch if it lands badly with end users, without removing the fresh-session signal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
fce4a03d23 |
feat(sla): persistent SlaTenantStatus row replaces in-memory throttle, fixes 2h breach-notification spam
Background
----------
The in-memory SlaNotificationThrottleService kept its 4h cooldown in a process-
local ConcurrentDictionary. On Flex Consumption the Function host recycles
between 2h timer fires, wiping the dictionary, so the throttle was effectively
a no-op — observed in production as tenant b54dc1af receiving an SLA breach
notification every 2 hours for days on end (1 failed of 11-13 sessions, target
95%, success rate 91-92%).
Design
------
One row per tenant in a new SlaTenantStatus table (PartitionKey = tenantId,
RowKey = "status"). Flat schema with {Type}_* property prefixes for the four
breach kinds (SuccessRate, Duration, AppInstall, ConsecutiveFailures); one row,
one Upsert per evaluation cycle. Rationale documented inline: per-type rows
would be over-normalised for the access pattern (always all-types-per-tenant),
and ~50 tenants x 4 types = max 200 rows ever.
Throttle correctness
--------------------
The throttle decision (now - LastNotifiedAt >= cooldown) and the LastNotifiedAt
write happen in the same TryUpsertAsync(status, ifMatchETag) call. Two parallel
inline calls cannot both succeed in sending the same notification — the loser
gets a 412 from Table Storage, refetches, sees the updated watermark, and
either skips or schedules a new claim attempt. Up to 4 retries; on exhausted
retries we drop the notification rather than risk double-fire (at-most-once).
Cooldown is configurable: AdminConfiguration.SlaNotificationCooldownHours
(default 24, range 1-168). UI round-trip wired through the four required
files (AdminConfigContext, AdminConfigSettingsSection, types/adminConfig.ts,
SectionGlobalSettings).
Resolve path
------------
When a previously-active breach is no longer detected, the row transitions to
IsActive=false + ResolvedAt=now and a single sla_resolved notification fires.
Orphan sweep: tenants whose toggles get turned off while a breach is active
fall out of the qualifying loop, so the timer now also scans every status row
and silently clears any IsActive that no longer has a toggle backing it
(no resolved notification — the tenant disabled SLA, they don't care).
Per-type notes
--------------
- SuccessRate breach notifies with the configured warn-threshold, not the SLA
target — a 94% rate against target=99% / threshold=95% reads "below 95%",
not "below 99%".
- AppInstall now actually evaluates (the toggle was wired but evaluation was
missing). Aligned to the same ISO-week window the dashboard uses
(SlaMetricsService.GetIsoWeekKey), so notifications cannot diverge from the
/sla page. Sample-size floor of 5 apps per week, same as the dashboard.
- AppInstall has its own notification template (own title/summary/facts plus
TopFailingApp fact) — the builder used to silently fall through to the
Duration template for everything not SuccessRate.
- Telemetry Period tag derived from breachType: CurrentMonth for SuccessRate
and Duration, CurrentWeek for AppInstall — so AppInsights / ops dashboards
don't group AppInstall under the wrong period.
- AppInstall ops events now carry the actual installer totals (10 evaluated,
4 failed) instead of 0/0.
- ConsecutiveFailures: inline (ingest) path detects breaches, timer path
detects resolve. Both paths write to the same row through the same CAS
retry loop.
Clock injection
---------------
SlaBreachEvaluationService takes an optional Func<DateTime> nowProvider
(default () => DateTime.UtcNow) so the week-window regression test runs
deterministically on every test day instead of silently skipping when today
happens to fall in the first ISO week of the month.
Removed
-------
- SlaNotificationThrottleService and its tests. The in-memory model is gone;
there is no scenario where it would be wanted alongside the persistent row.
Tests
-----
- 21 new lifecycle + mapping + telemetry tests (CAS conflict + retry, CAS
exhausted-retries drops notification, threshold-not-target in notification,
orphan clearing, AppInstall template / week-window / totals, ConsecutiveFailures
inline persistence, multi-type race-probe, period-mapping theory).
- Backend xUnit: 1218 passed.
- Web vitest: 266 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
b76a6ccaf0 |
fix(dashboard/stats): server-aggregate cards so they stop drifting with pagination
The five dashboard cards (Active Sessions, Success Rate, Avg. Duration, Total Today, Failed Today) were derived from whatever sessions the client had paginated into memory. With default pageSize=10 and SignalR-driven appends, "Total Today" would jump 40 -> 22 -> 20 within a single visit as the list grew/shrank. "Last 7 days" was a printed label only - the client compute had no 7-day filter at all. Now: - GET /api/sessions/stats?days=7 (MemberRead, JWT tenant) - GET /api/global/sessions/stats?days=7&tenantId= (GlobalAdminOnly, optional tenant filter; absent = cross-tenant sum) - TableStorageService.AggregateSessionStats drains SessionsIndex once over the window: UTC-midnight boundary for today, success rate over terminal sessions only (InProgress no longer drags it down), avg duration over Succeeded with DurationSeconds > 0 only. - useDashboardStats hook owns the cards: initial fetch + debounced refetch on SignalR newSession/newevents in the active scope + forced refetch on reconnect to recover messages missed during an outage. - Client compute removed from useDashboardFilters. New submittedTenantIdFilter state isolates typing-in-filter from submitted-filter so keystrokes never trigger backend round-trips. Tests: 17 xUnit (aggregation invariants + days parsing) + 7 vitest (URL builder). Suites: backend 1177/1178 (unrelated RawTablePaginationTests flake), web 266/266. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d51bde6017 |
refactor(release): parametric per-line agent build pipeline
Replaces V1/V2-hardcoded build + bootstrap with a single parametric
pipeline that scales to V3+ without rewrites. Full architecture in
.claude/plans/v2-cutover.md.
Stable namespace (Bootstrap-side, never renamed): generic Bootstrap
script, agent owns its --install defaults so it works for any line.
Versioned namespace (per-line, SelfUpdater-side): isolated *-v{N}.*
blobs; SelfUpdater never crosses lines or touches stable.
Backend GetAgentConfig dispatches per X-Agent-Version-Major to
AdminConfiguration.GetAgentLine(int) -> LatestAgentV{N}* fields.
Future V3 = add field set + switch arm. TableConfigRepository.Map
falls back to legacy unsuffixed columns for transparent migration.
V1 SelfUpdater now reads version-v1.json (would have been clobbered
by the V2 cutover otherwise). V2 agent owns its own 600s TenantId-wait
default so the Bootstrap calls --install plain.
Tests: 3289 across 6 suites, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
089c0123e6 |
feat(reports): add Submit Logs page for session-less diag-files reports
Tenant Admins can now ship log files, JSON state snapshots (.log/.txt/.zip/ .json/.jsonl/.ndjson), and screenshots to the Autopilot Monitor team from /settings/tenant/support without needing a session context — no more OneDrive round-trips for ad-hoc client-log requests. Backend - New POST /api/diag-files-reports (TenantAdminOrGA) -> SessionReports table + session-reports container, distinguished by ReportType="diagFiles". - ReportType discriminator added to SessionReportMetadata + Store/Map (legacy rows default to "session"). - Telegram + Global-Admin bell notifications mirror the session-report flow. Frontend - New "Submit Logs" tenant settings tab with comment / email / log+state files (5 MB cap) / screenshot pickers; multi-file auto-zip via fflate. - Admin SessionReportsSection now shows a Type column (Session / Diag Files) and hides Session ID in the detail modal for diag-files rows. Verification: dotnet build clean, 1058 backend xUnit tests pass, tsc --noEmit clean, 259 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
840ac9f21f |
feat(backend,mcp): paginate the last three legacy MCP diagnostic tools
query_raw_sessions, search_sessions_by_cve, and query_table were still on the legacy `?limit=` contract (capped at 200/500/100 with no nextLink). On large deployments this made "all devices affected by CVE-X" or "every row in TenantConfiguration" structurally un-answerable. Migrate to the established pageSize/continuation/nextLink contract: - query_raw_sessions reuses SearchSessionsPagination; field projection logic unchanged. - search_sessions_by_cve gets a new SearchSessionsByCvePageAsync repo method (analog to by-event) plus SearchSessionsByCvePagination helper with cveId+CVSS/risk fingerprint binding. - query_table gets a generic RawTablePagination helper covering tableName + PK/RK/filter scope. Sweep all pageable MCP tools to use followNextLink so backend-echoed query params round-trip correctly. Raise session-list defaults 50 to 200 and unify tool descriptions on a "pagination-first" message: pageSize is the batch size, not a truncation point. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |