mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
bcd307d8a494585e03348661e66ccc5e1f523ce2
136 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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>
|
||
|
|
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> |
||
|
|
9f367cea0c |
fix(delegated): exclude platform-operational reads from delegated scope (least-privilege)
The delegated rescue treats every GlobalReadOrAdmin + QueryParam/RouteParam route
as delegated-reachable, which had silently opened several platform-OPERATIONAL
global reads to delegated ("MSP") admins. Those should be Global Admin + read-only
Global Reader only - the platform operator's view, not the managed tenant's data.
- global/ops-events -> TenantScoping.None (Consent/Maintenance/Security/Tenant/
Agent/SLA - operator data). Its in-memory FilterByTenant is reframed as a
GA/Reader ?tenantId= drill (correctness, NOT a delegated-isolation boundary)
and extracted internal-static for direct test coverage.
- New EndpointPolicyEntry.ExcludeDelegated flag: the None-equivalent for routes
whose {tenantId} template forces RouteParam (the {tenantId}-convention test
forbids None there). The middleware ANDs !ExcludeDelegated into the delegated
rescue, so it can only RESTRICT access, never grant it. Applied to:
- global/customs-archive (+ the two drill reads)
- global/tenants/{tenantId}/deletion-manifests
- preview/notification-email/{tenantId}
GA + read-only Global Reader keep all of these.
Full reclassification of every delegated-reachable global/* route: customer
telemetry (sessions / metrics / apps / rules / audit-logs / mcp-usage / ...) stays
delegated-reachable; platform-operational + offboarding reads are now excluded.
Tests: Delegated_{OpsEvents,CustomsArchive,DeletionManifests,PreviewNotificationEmail}_*
+ GlobalReader_*_Allowed + catalog Theory PlatformOperationalReads_ExcludeDelegated.
Backend filtered tests 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>
|
||
|
|
a5075b2236 |
feat(mcp): delegated-admin (MSP) support over MCP — tenant-bounded cross-tenant reads
Delegated (scoped-global / MSP) callers get cross-tenant ROUTING bounded to their managed tenant set: every tool call must name a managed tenantId (no aggregate), platform-only tools stay hidden, and enforceDelegatedTenant(ForPage) validates the effective tenant client-side as defense-in-depth. Backend: McpUserService resolves delegated scope and auto-grants MCP access under WhitelistOnly; McpUserFunction emits delegatedTenantIds/delegatedRole. Recatalog global/metrics/summary to QueryParam so delegated readers reach it only on their managed ?tenantId= path (aggregate stays blocked). Authoritative enforcement is the PolicyEnforcementMiddleware delegated rescue. 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>
|
||
|
|
87711e1261 |
feat(backend): bounded tenant list for delegated admins (MSP fleet) [Phase 2b]
Introduces the GlobalReadOrDelegatedSubset policy tier — a cross-tenant READ that
is BOUNDABLE to a subset — and applies it to config/all (the tenant list). A
delegated ("MSP") admin can now list ITS managed tenants in one call, bounded to
its allowed set; a Global Admin / Reader still gets all tenants (unbounded).
This is the server-side linchpin for the MSP fleet view + tenant switcher: the
frontend gets the managed-tenant list (id + domain) here, then builds the
per-tenant fleet grid by fanning out over that subset using the Phase-2a
single-tenant /api/global/*?tenantId= endpoints (subset fan-out, client-orchestrated
over already-bounded per-tenant reads — no all-tenants code path for delegated).
- Middleware: new EvaluateGlobalReadOrDelegatedSubsetAsync — GA/Reader admitted
unbounded; a delegated caller with a non-empty scope admitted with its managed
set carried on the decision and published as RequestContext.AllowedTenantIds.
DecideAsync merges this into the delegated flags + AllowedTenantIds.
- GetAllTenantConfigurationsFunction: when AllowedTenantIds is present, BIND the
response to that subset (both the bare-array and paginated shapes) and always
redact secrets (a delegated caller is never a Global Admin). The subset is small,
so it is returned in one shot.
- Catalog: config/all -> GlobalReadOrDelegatedSubset. Aggregates that cannot be
bounded stay GlobalReadOrAdmin + None (delegated-unreachable).
- Tests: delegated config/all admitted with bounded AllowedTenantIds; GA unbounded
(null bound); non-delegated non-global denied; tier-assignment guard. Full suite green (2464).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
d44dbe7ec0 |
feat(backend): delegated single-tenant access to /api/global/* reads (MSP switcher) [Phase 2a]
Lets a delegated ("MSP") admin read its managed tenants through the cross-tenant
/api/global/* endpoints — but ONLY on the single-tenant ?tenantId=X path, bounded
by the middleware to a tenant in its allowed set. This is what makes the tenant
SWITCHER work end-to-end (drill into a managed tenant, see its normal dashboard).
The dangerous no-tenantId AGGREGATE path (fan-out over ALL tenants) stays
GA/Reader-only and is untouched here; bounded fleet aggregation is a separate step.
- Middleware: GlobalReadOrAdmin added to the delegated read tier. The existing
`isScopedRoute && crossTenant` guard means a delegated grant still fires ONLY on
a tenant-scoped route for an in-scope target — so a delegated caller reaches a
global endpoint only via its single-tenant path, never the aggregate path.
- Catalog: the 27 GlobalReadOrAdmin routes whose handlers strictly restrict to the
named ?tenantId= (verified by an exhaustive per-endpoint audit) recataloged
None -> QueryParam. QueryParam/RouteParam on a GlobalReadOrAdmin route now
formally means "handler restricts to the named tenant => delegated-reachable";
aggregate-only routes (presence, platform metrics, distress-reports, config/all,
…) stay None and remain unreachable by delegated callers. Documented inline.
- Tests: delegated single-tenant global read allowed in-scope / denied out-of-scope
/ denied on the no-tenantId aggregate path; an aggregate-only route stays blocked
even with an in-scope ?tenantId=; Global Reader aggregate path regression; and a
catalog-contract theory pinning the SAFE(QueryParam) vs AGGREGATE-ONLY(None) split.
Updated GlobalAppsRoutes completeness assertion to QueryParam. Full suite green (2461).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
f37ee3e6b1 |
perf(backend): cache Graph app token + stream-parse V2 ingest
Ingest hot path + Graph auth latency. IngestTelemetry is the #1 backend endpoint (~35.6k/day); its p99 tail was dominated not by parsing but by an uncached Graph token: GraphTokenService minted a fresh client-credentials token (500-1900ms POST) on every device-validation cache miss and threw it away. Cache it per tenant. GraphTokenService: - Per-tenant token cache keyed on the validated tenantId; TTL from the token's own expires_in minus a 5min skew margin, clamped to [60s, 55min] (expires_in was previously ignored). Only successful tokens are cached. - Per-tenant SemaphoreSlim stampede gate (bounded wait) so an enrollment wave does not fan a burst of identical token POSTs at AAD. - InvalidateTenant() drops the token + consent-status entries so the consent/Graph-permission fresh-read contract still holds. - Per-tenant invalidation generation closes the write-after-invalidate race: a POST in flight when InvalidateTenant runs tags its entry with the pre-invalidation generation, which readers reject. GraphFeatureDetector: - InvalidateTenant() now also invalidates the underlying token cache, and applies the same generation guard to its own parsed-roles cache. - Cross-layer order is load-bearing: invalidate the token layer FIRST, then bump the detector generation, so a concurrent acquire cannot pair a new detector generation with a stale token read. IngestTelemetryFunction: - Deserialize the request body directly from the buffered MemoryStream via JsonTextReader instead of materializing a full UTF-16 string + ToArray() copy (peak ~4N -> ~1N). Cap still applies before parsing; same parse semantics. Pure allocation hygiene on the hottest endpoint. Tests: token caching, cross-tenant isolation, failure-not-cached, stampede coalescing (barrier-gated), in-flight write-after-invalidate, cross-layer invalidation, and cross-layer ordering -- each verified red without its fix. Parse hygiene: PayloadJson stays a raw string, malformed -> 400. 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> |
||
|
|
12d67380e9 |
feat(mcp+backend): session_debug — client-side diag-ZIP analysis via HMAC ticket
Lets tenant-admins and GAs run end-to-end session debugging over MCP: backend
telemetry plus the agent diagnostics ZIP, downloaded and analyzed locally by the
AI client. The backend never unzips/parses the archive (no parse CPU); it only
mints a short-lived signed download ticket and proxy-streams bytes.
Backend (no ConfigVersion bump, no agent change):
- DiagnosticsDownloadTicket: HMAC codec (reuses PaginationTokenSigningKey,
purpose-tagged diag-dl-v1, 10-min TTL) so a no-JWT client can download.
- DiagnosticsBlobStreamer: shared proxy-stream engine; DiagnosticsDownloadFunction
(web/JWT) refactored onto it. ClassifyBlobName/BlobDestination stay put (xUnit).
- POST diagnostics/download-ticket (MemberRead, QueryParam, GA-bypass) +
GET diagnostics/download?t= (anonymous; tenantId/blobName read only from the
signed ticket -> no retargeting). Both added to NoStoreCacheMiddleware.
MCP:
- get_session_diagnostics tool (all roles) -> absolute downloadUrl + static zipMap
+ instructions; unwraps the {success,session} envelope.
- diag_zip_layout catalog (get_resource + protocol resource).
- debug-session prompt (tenant + GA variants).
- Tool descriptions flag the client requirement (needs local file/shell tools;
pure chat clients cannot unzip).
Tests: backend 2380/2380 (ticket codec, NoStore, classify, catalog completeness);
MCP 261 passed (incl. envelope-unwrap regression). Inert until redeploy.
Co-Authored-By: Claude Opus 4.8 (1M context) <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> |
||
|
|
8862d73eed |
feat(backend+web): detect pre-approved consent for rights-less admins
In larger tenants the multi-tenant app is often pre-approved by someone
with consent rights, while the admin operating our UI lacks them. The
/adminconsent redirect then fails, so ValidateAutopilotDevice/
ValidateCorporateIdentifier never persist and the tenant stays "Disabled"
even though the app + DeviceManagementServiceConfig.Read.All effectively
exist. That bool is also the hard gate in SecurityValidator, so a UI-only
badge would leave agent traffic 403'd.
Add a read-only access-check probe that verifies the concrete role in the
SP token's roles claim (via GraphFeatureDetector, not mere token
acquirability) and, when present, persists the gate bool through the
normal config PUT — opening the UI badge AND the agent gate together.
Backend:
- GraphAppPermissions.DeviceManagementServiceConfigReadAll constant.
- GET /api/config/{tenantId}/autopilot-device-validation/access-check
(TenantAdminOrGA): InvalidateTenant + fresh GetSnapshotAsync (4s budget),
pure ResolveAccessPresent (transient => never present), emits
ConsentAccessDetected telemetry. Always-fresh so a propagation-time
empty-roles snapshot can't wedge the admin out for the token TTL.
Web:
- lib/accessCheck.ts classifyAccessCheck pure fn (transient/absent never
reconcile) + tests.
- Reconcile on consent-redirect failure (silently enables) and a per-toggle
"Detect existing access" button for Autopilot, Corporate, and DevPrep
Device Association (all share the same role).
- Successful consent callback now verifies the concrete role too: persists
ONLY on authoritative "reconciled"; transient => retry/propagation
message, absent => "permission not granted" — neither opens the gate.
- saveConfiguration returns boolean so success is shown only after a
confirmed persist.
No ConfigVersion bump (backend + web only). Backend 2326/2326, web 464/464.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
5775c7c176 |
fix(web+backend): Progress Portal live updates for end users + sidebar Dashboard gate
Two related fixes for the end-user Progress Portal experience:
Live updates / permissions: realtime/groups/join+leave were elevated to
MemberRead (regression
|
||
|
|
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> |
||
|
|
2a5f9e133e |
feat(backend): enrich request telemetry (real client IP, tenant, 429, distress IP)
Makes the requests table actually usable for diagnosing agent traffic and rate
limits, without adding any new telemetry items — only properties on the already-
emitted, sampling-excluded Request telemetry (host.json excludedTypes "Request;
Event"), consistent with StorageDependencyFilterProcessor.
RequestTelemetryMiddleware:
- client_IP: the isolated worker behind Front Door/App Service left it 0.0.0.0.
Set Context.Location.Ip from X-Azure-ClientIP (Front Door's true client egress
IP) else trusted rightmost X-Forwarded-For. Header values are joined via
StringValues.ToString() so the rightmost trusted hop wins (not a spoofable
left entry). Diagnostic only — the rate limiter independently uses the trusted
hop, so a spoofed value here cannot affect throttling.
- TenantId: agent ingest skips PolicyEnforcementMiddleware so reqCtx.TenantId was
empty. Fall back to X-Tenant-Id, but ONLY on an exact allowlist of device
endpoints that actually validate it (agent telemetry/ingest/error/distress,
bootstrap ingest/error) and only when it is a well-formed GUID — a prefix match
would let anonymous/management routes (bootstrap/validate, bootstrap/sessions,
agent/config, …) be tagged with a spoofed tenant.
RateLimitService: 429 warning is now structured ({RequestsInWindow}/{MaxRequests}
/{WindowSeconds}/{RateLimitKeyPrefix}) so it's queryable; privacy-safe (8-char
prefix only — the key may be a cert thumbprint, bootstrap token, or UPN).
ReportDistressFunction: store the real device egress IP in SourceIp (table entry
+ AgentDistressReport event) via new ClientIpExtractor.GetClientEgressIp; the
rate-limit key keeps the un-spoofable trusted hop.
Full backend suite 2249/2249 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
5586963bcc |
feat(autopilot): show "not registered" devices (14-day window) under enrollment validation
Adds a tenant-scoped, informational overview of devices that failed enrollment with HTTP 403 because they were not in the tenant's Autopilot / Corporate Identifier registry. Mirrors the Hardware Whitelist rejection insights but aggregates per device (serial number), matching how Autopilot registers individual devices rather than models. Read-only, no webhook. Backend - GetDeviceNotRegisteredFunction: route audit/device-not-registered, MemberRead, filters distress reports on ErrorType == "DeviceNotRegistered", aggregates by serial (case-insensitive; empty serial -> single bucket); 14-day horizon is the existing distress-report retention. - Register route in EndpointAccessPolicyCatalog (fail-closed). - 8 unit tests for BuildAggregatedResult. Frontend - api.distress.deviceNotRegistered(). - NotRegisteredDevicesInsights component (serial/mfr/model/attempts/first/last seen, green empty-state, unverified-data notice). - Wired below AutopilotValidationSection. DeviceNotRegistered (403) covers both Autopilot and Corporate Identifier validation; the agent cannot distinguish which failed, so copy is neutral. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
dbb4b3f5a7 |
feat(metrics): server-side Fleet Health aggregation endpoints
Fleet Health previously drained up to 200k raw sessions into the browser (MAX_EAGER_PAGES=200 x 1000) over as many HTTP round-trips, then computed 7 aggregates in useMemo on the main thread. Move that aggregation to the backend, mirroring the dashboard-stats server-aggregation path. - MetricsMath.BuildFleetHealthPayload: single pass over the windowed session list producing stats, daily timeline, top failure reasons, and model health/slowest/top-failing breakdowns. Semantics ported verbatim from the client (success rate over ALL sessions; avg duration over non-in-progress sessions incl. failures), so displayed numbers are unchanged. Typed FleetHealthMetrics DTO in Shared for a typed contract. - GET /api/metrics/fleet-health (MemberRead) and GET /api/global/metrics/fleet-health (GlobalAdminOnly, optional ?tenantId=) reuse the existing ISessionRepository window readers. - Registered both routes in EndpointAccessPolicyCatalog (fail-closed). - 9 xUnit tests covering the aggregation semantics. Frontend swap to these endpoints lands in a follow-up commit. App-metrics (slowest/failing apps) is already backend-aggregated and untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
fed162946e |
feat(metrics+mcp): app-install & software-inventory MCP tools + DO rollup
Backend: - Extract MetricsMath.BuildAppMetricsPayload as the single source for the app-metrics aggregation (was duplicated verbatim across the tenant and global functions) and add a Delivery Optimization rollup: per-app peer/MCC offload bytes + overall deliveryOptimization summary (peers and MCC counted separately, peerOffloadPercent credits both as not-from-CDN). - New tenant-scoped GetTenantSoftwareInventoryFunction (GET metrics/software- inventory, MemberRead, JWT tenant) + fail-closed policy-catalog entry. MCP server: - get_app_install_metrics: role-aware routing (global/metrics/app vs metrics/app), surfaces the DO rollup. - get_software_inventory: one role-aware tool; non-GA sees only its own tenant (no tenantId/scope in schema), GA additionally gets tenantId + scope=unmatched. New exported paginateInventory client-side pager. Tests: MetricsMathAppPayloadTests (DO rollup/offload/sample-floor) + vitest paginateInventory. Backend 2202/2202, MCP 220 passed, tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
0090790350 |
feat(health): non-blocking MCP card with wake-aware cold-start handling
The MCP server health probe previously sat inside PerformAllChecksAsync's
Task.WhenAll, so /health/detailed blocked on the MCP timeout before returning
any card, and a scaled-to-zero cold start was reported as an immediate warning.
Backend:
- Move the MCP probe out of the blocking all-checks batch onto its own
GET /api/health/mcp endpoint (AuthenticatedUser; visible to all, not GA-only).
- CheckMcpServerAsync now uses a wake-capable timeout (McpServerHealthTimeoutSeconds,
default 30s). Azure Container Apps holds the request during cold-start activation,
so the probe doubles as the wake signal: a successful wake -> healthy ("woke from
idle" when >2.5s), only a failed wake -> warning, a reachable-but-erroring server
(non-2xx) -> unhealthy.
- Register health/mcp in EndpointAccessPolicyCatalog (fail-closed).
Frontend:
- Fetch the MCP check on its own track, concurrently with and independent of the
fast checks. The card renders a blue "checking..." state immediately and updates
in place when the probe resolves; the other cards never wait on it.
- The MCP result folds into the overall banner/counts only once resolved, so a slow
cold start no longer flips the page to "Warnings detected".
Test: warning-path assertion updated for the new wording (10 HealthCheckService
tests green; 61 policy/endpoint tests green).
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> |
||
|
|
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>
|
||
|
|
a5ce72fbf5 | added vulnerability counters | ||
|
|
9e689dc9d8 |
feat(backend/backup): PR2 — single-row restore + admin UI
POST /api/global/backups/{backupId}/restore-row (GA-only) with preview|commit
modes. Preview scans the ETag-pinned NDJSON blob for the requested (pk, rk),
diffs against the live row, and returns rowSha256 + currentETag for echo.
Commit re-validates everything under a short maintenance lease and writes via
UpdateEntity(IfMatch=etag, Replace) or AddEntity; 412/404/409 → CurrentRowChanged.
Backend:
- RestoreTablePreflightValidator (no I/O) + BackupRestoreInputValidator (manifest
parse, entry check, BlobName canonical, ETag-pin + SHA in one stream read)
- CriticalTableRestoreService with PreviewRowAsync (no lease) + CommitRowAsync
(MaintenanceLeaseHolder, 45 s renewal, deterministic finally release)
- BlobBackupStore.OpenNdjsonReadAsync — GetProperties + OpenRead(IfMatch=etag)
- BackupTerminalException carries a stable Code for HTTP envelope mapping
- OpsEventService.RecordBackupRowRestoredAsync (Warning; payload is
container + blobName only, never a SAS URL)
- 27 new xUnit tests; full backend suite 1979/1979 green
Web:
- /admin/backups list page with newest-first list + "Run backup now" trigger
and async job-status poller
- /admin/backups/{backupId} detail page with per-table breakdown + single-row
restore trigger
- RestoreRowDiffModal with Added/Removed/Changed/Unchanged pills and
auth-table security banner (GlobalAdmins/TenantAdmins/McpUsers)
- api.backups namespace + matching DTO types
- globalNavConfig: ga-backups under Ops
- BackupRowRestored registered in OPS_EVENT_TYPES (dual-register policy)
- 6 new vitest cases; full web suite 390/390 green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
b2e17d39f6 | security hardnings - nosniff, x-forwaredded-for, ensure-tenantid | ||
|
|
7a05268250 | security hardenings | ||
|
|
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>
|
||
|
|
bd9fc6f6f4 | feat(backend): ScriptNameCache cache negative verdict | ||
|
|
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> |
||
|
|
1041d44831 |
feat(backend,web): restore-browser tenant filter — only tenants with snapshot blobs
Adds a "Only tenants with restore data" checkbox to the Restore Browser tenant dropdown so a Global Admin can hide tenants that have nothing to recover. Default off (full AdminConfig list); flipping it on fetches a cheap hierarchy listing once (cached for page lifetime) and intersects the dropdown with the tenants that own ≥1 snapshot blob. Backend: new GlobalAdminOnly endpoint GET /api/global/tenants-with-deletion-manifests backed by BlobStorageService.ListTenantsWithDeletionManifestsAsync, which uses GetBlobsByHierarchyAsync with delimiter="/" so Azure returns just the top-level prefixes (= tenant IDs) without enumerating blobs underneath. Cost scales with tenant count, not manifest count. Web: checkbox under the tenant dropdown shows a "(X of Y)" counter when active, dropdown placeholder reflects the loading state, and the currently-selected tenant clears if the filter excludes it. Fetch failures snap the checkbox back to off so the operator never gets stranded with an empty list. Build: 0 warnings/errors. Backend 1526/1526 pass. tsc clean. Vitest 346/346 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
f1583fc586 |
fix(backend,web): move cascade-delete admin routes from /api/admin/* to /api/global/*
The /api/admin/* prefix collides with the Azure Functions runtime's built-in
admin paths, which sit behind mTLS client-certificate auth. Browser preflights
to /api/admin/* trigger TLS renegotiation against the Function App's mTLS
boundary and die before our PolicyEnforcementMiddleware ever runs — the
operator sees CORS-shaped errors that aren't actually CORS-shaped.
Repo convention all along has been /api/global/* for cross-tenant GA-only
HTTP endpoints (e.g. global/session-deletions, global/session-reports). The
cascade-delete endpoints landed under /api/admin/* in the original 7-PR
backend branch and went undetected until the admin UI started calling them
from the browser.
Routes renamed:
admin/sessions/{sessionId}/delete/preview → global/sessions/{sessionId}/delete/preview
admin/sessions/{sessionId}/restore → global/sessions/{sessionId}/restore
admin/sessions/{sessionId}/deletion-manifest → global/sessions/{sessionId}/deletion-manifest
admin/tenants/{tenantId}/deletion-manifests → global/tenants/{tenantId}/deletion-manifests
Touched:
- 4 Function classes: Route attribute + XML-doc header
- EndpointAccessPolicyCatalog: 4 entries (TenantScoping.RouteParam preserved
on the tenants route), with a comment explaining why /api/global/* is the
load-bearing prefix for new GA-only HTTP routes
- DeleteSessionFunction: 3 user-facing "Cascade is poisoned; recover via
POST /api/.../restore" error messages updated
- SessionDeletion.cs: XML-doc reference to the restore route
- lib/api.ts: 4 frontend URL builders (storedManifest, preview, restore,
tenantManifests)
- RestoreSessionFunctionTests: 3 EndpointAccessPolicyCatalog.FindPolicy
lookups
- deleteSessionResponse.test.ts: test fixture message string
No semantic change — only the URL prefix moves. CORS config + JWT middleware
+ policy gating all work the same. The Restore Browser tab + the per-row
Preview / Restore buttons in the Session Cleanup admin page now actually
reach the worker from a browser.
Tests: backend xUnit 1538/1538 pass, web vitest 347/347 pass, dotnet build +
tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
44076686b6 |
feat(backend,web): restore-browser tab + detailed dry-run/real-run dialog
Follow-ups on the Session Cleanup admin page after first hands-on review:
an entry point for restoring already-completed cascades (Sessions row gone,
manifest still in the 33-day TTL window), a proper outcome panel that surfaces
what the backend already returned, and a sidebar fix.
== New Restore Browser tab ==
- Backend: GET /api/admin/tenants/{tenantId}/deletion-manifests enumerates
every persisted snapshot blob under <tenantId>/, parses (sessionId,
manifestId, sizeBytes, lastModifiedUtc) from the blob name, groups by
sessionId and sorts both levels by recency. GA-only + TenantScoping.RouteParam
in EndpointAccessPolicyCatalog (pinned by EndpointPolicyCatalogCompletenessTests).
- BlobStorageService.EnumerateDeletionManifestsByTenantAsync — sibling to
the existing maintenance-sweep enumerator but tenant-prefixed and yielding
size + timestamp so the UI can rank without an extra HEAD per blob.
- Web: new tab "Restore Browser" between Stranded and Activity. Tenant
dropdown (sourced from AdminConfigContext.tenants) → collapsible
session/manifest tree on the left, manifest summary on the right with a
Restore… button that opens the dialog with IDs pre-filled. Useful when
the cascade completed cleanly and the row no longer appears on the
In-Flight / Poisoned / Stranded tabs.
== Reusable summary view ==
DeletionManifestSummaryView extracted from the modal so the same fetch +
field block + worker-progress + preflight-counts + last-failure panel renders
both inside the modal (Preview button on the other tabs) and inline on the
Restore Browser's right-hand panel. Modal becomes a thin wrapper.
== Dry-run / real-run side-by-side ==
RestoreConfirmDialog now keeps both outcomes visible:
- Two slots (dryRunResult, realRunResult), each rendered as a full result
section with predicted/actual rows-by-table, inventory re-increments,
duration, backend message, current state, pending manifest id.
- "Start over" resets both slots so the operator can re-run cleanly.
- Submit buttons gate the workflow: pre-submit shows "Run dry-run" + an
explicit "Skip dry-run · Restore now"; after dry-run the second button
becomes "Apply restore" (clear-intent); after real-run all submit
buttons disappear so accidental double-applies aren't possible.
Previously the dialog discarded everything but Outcome + Mode + dryRun
flag, hiding the actual point of dry-run (the wouldRestoreByTable map).
== Codex review follow-ups (this round) ==
- P2: Restore Browser cleared the displayed sessions only after the new
fetch returned, so a fast A→B switch (or out-of-order responses) could
leave the operator looking at tenant A's manifests with the dropdown
saying B. Fix: clear sessions/expandedSessions/selection synchronously
on tenant change, abort the in-flight request via AbortController,
pair the response with a loadedTenantId state, and gate rendering on
loadedTenantId === selectedTenantId. Three-layer protection so a late
response that sneaks past the abort can't paint stale data.
- P3: PoisonedTab's onRestored callback closed the dialog immediately
after a real restore, defeating the new dry-run vs real-run side-by-side
display. Fix: drop setRestoring(null) from the callback so the dialog
stays open until the operator dismisses it via Close / Start over.
Background refresh of the Poisoned list still happens via setRefreshKey.
Now consistent with the Restore Browser tab.
== Sidebar highlight fix ==
GlobalSidebar marked BOTH ga-ops sub-items active when on the longer
nested route, because each sub used pathname.startsWith(sub.href + "/")
independently. Visible bug: navigating to /admin/ops/session-cleanup lit
up both "Maintenance" (/admin/ops) and "Session Cleanup" in the sidebar.
Fix: compute the longest-matching sub.href once per expanded group, mark
only that one active.
Tests: backend xUnit 1538/1538 pass (one new endpoint registered in the
catalog, picked up by the existing completeness test). Web vitest 347/347
pass. TypeScript strict + dotnet build clean.
Deliberately deferred:
- xUnit for GetTenantDeletionManifestsFunction (same HttpRequestData
mock-harness gap as the other read endpoints).
- vitest for RestoreBrowserTab + the new dialog flow (defer until a
real field issue).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
09bfe7a1bc |
feat(backend,web): admin session-cleanup UI for V2 cascade-delete + codex follow-ups
Operationalises the 7-PR backend cascade-delete pipeline that landed via commits
c8658b9f..921994e8: per-tenant V2 opt-in switch, audit-trail consolidation, and
a Global-Admin-only Session Cleanup page to track in-flight / poisoned / stranded
cascades and trigger restores.
== PR-A: per-tenant V2 enrollment ==
- UpdateTenantConfigurationFunction extends the GA-only gate list with
EnableCascadeDeleteV2 so tenant admins cannot self-opt-in / out of the
snapshot+restore pathway.
- TenantManagementSection.tsx adds a purple-bordered "Enable V2 Cascade Delete"
checkbox in the GA edit modal and a "Cascade V2" badge on the tenant card so
rollout state is visible at a glance.
== PR-B: audit consolidation ==
- SessionDeletionHandler drops the 15-per-cascade deletion_step_completed /
deletion_step_failed / deletion_verification_failed audit emissions. Step
telemetry lives in DeletionProgress + structured logs; tenant admins now see
just deletion_started + deletion_completed + deletion_restored.
- SessionDeletionWorker no longer writes a deletion_poisoned tenant audit;
the signal moved to a new SessionDeletionPoisoned OpsEvent (operator-bound,
Telegram-routable, dual-registered in OpsAlertRulesSection.tsx catalog).
IMaintenanceRepository dependency removed from the worker constructor.
- SessionRestoreService stops emitting the deletion_manifest_downloaded
breadcrumb; deletion_restored is the operator-visible outcome.
== PR-C: Session Cleanup admin page ==
- New /admin/ops/session-cleanup route (gated by app/admin/layout.tsx's
ProtectedRoute requireGlobalAdmin). Sidebar entry under ga-ops alongside
Maintenance. Four URL-state tabs: In-Flight / Poisoned / Stranded Queued /
Activity (links to existing /audit page).
- Backend reads: GetSessionDeletionsListFunction
(GET /api/global/session-deletions?state=&strandedSinceMinutes=) +
GetSessionDeletionManifestFunction
(GET /api/admin/sessions/{id}/deletion-manifest?tenantId=&manifestId=&mode=).
Both registered as GlobalAdminOnly in EndpointAccessPolicyCatalog.
- DeletionPreviewModal reads the persisted snapshot via the stored-manifest
endpoint (NOT a fresh dry-run via the existing /delete/preview, which would
enumerate live-but-partially-deleted data for in-flight cascades). Renders
manifest counts, snapshot SHA, worker progress (CompletedSteps,
VerificationDone, TombstoneStarted, aggregate decrements), and the last
verification failure with residual sample. Download button uses
authenticatedFetch + Blob + Object URL so the Bearer token is sent.
- RestoreConfirmDialog drops the Full/Partial radio (mode is auto-selected
by SessionRestoreService based on cascade state — forcing the wrong mode
would corrupt state). Reason field threads through to deletion_restored
audit details. Dry-run default = true and previews the auto-selected mode.
== Codex review follow-ups (across four rounds) ==
- High: tenantId is explicitly accepted on /delete/preview (query) and
/restore (body); GA cross-tenant resolution prefers the explicit value over
JWT TargetTenantId, falling back to SessionsIndex lookup. Without this, a
GA acting on a session in another tenant got their JWT home tenant.
- High: new stored-manifest endpoint replaces the dry-run preview for
in-flight / poisoned rows; previous fix built a fresh manifest against
already-partially-deleted data and misrepresented the cascade.
- Medium: SessionDeletionPoisoned OpsEvent now carries failureType +
failureMessage + observedResidualCount + a small residual preview, persisted
by the handler into DeletionProgress (LastFailureType, LastFailureMessage,
LastObservedResidualCount, LastResidualSampleJson) before the throw so the
worker can include them.
- Medium: dry-run restore reports the AUTO-SELECTED mode (full/partial) so
the dialog's preview reflects what the real run will do, instead of the
literal string "dryRun".
- Medium: residual count field renamed to observedResidualCount with an
honest XML doc — CascadeVerificationService caps at 50 per table AND
short-circuits after the first failing table, so the number is a lower
bound. UI surfaces "N of 50+ observed rows" when N hits the cap.
- Medium: three-layer cap defense on the OpsEvent residual sample to stay
under the 4096-char TableOpsEventRepository truncation: max 5 entries
(OpsEventResidualSamplePreviewSize), per-field max 96 chars with `…` marker
(OpsEventResidualKeyMaxChars), total preview budget 1200 chars
(OpsEventResidualPreviewBudgetChars) with trailing-entry drop. Full sample
stays in the progress blob for the admin UI.
- Low: estimatedSnapshotBytes field name aligned end-to-end; activity tab
text clarifies audit-action vs OpsEvent distinction.
Tests:
- Backend xUnit 1538/1538 pass (up from 1510 baseline; +28 across the new
Session Cleanup endpoints, OpsEvent enrichment, restore-mode reporting,
cap-defense unit + integration tests).
- Web vitest 347/347 pass.
- TypeScript strict + dotnet build clean.
Deliberately deferred:
- xUnit tests for GetSessionDeletionsListFunction +
GetSessionDeletionManifestFunction (no HttpRequestData mock harness in repo;
would be a separate refactor).
- vitest for DeletionPreviewModal + RestoreConfirmDialog (defer until first
field issue).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
c237140d59 |
fix(backend): cascade-delete codex follow-ups #2 (F1 DELETE cross-tenant, F2 register guard, F3 tombstone marker, F4 cross-tenant test, bootstrap-bypass)
F1 — DELETE /api/sessions/{sessionId} bypassed cross-tenant validation
because the catalog entry had no TenantScoping. A Global Admin clicking
"delete" on a session in tenant B from the portal silently routed the
delete into their own JWT tenant A, never finding the row. Catalog
entry gains TenantScoping.QueryParam; DeleteSessionFunction switches
from TenantHelper.GetTenantId (JWT-only) to RequestContext.TargetTenantId
(middleware-validated).
F2 — RegisterSessionFunction wrote sessions without any deletion-state
check; a mid-cascade register could mutate a Preparing/Queued/Running
row, and a fresh register past the tombstone could re-Add a row the
manifest no longer covers. Guard now runs at the entry of the shared
ProcessRegisterAsync core — covering BOTH /agent/register-session
(cert-auth) AND BootstrapRegisterSessionFunction (bootstrap-code-auth),
which previously bypassed the cert-auth entry's check. 410 Gone on
lock-state or tombstone hit.
F3 — SessionDeletionGuard's "Sessions row missing -> silent pass"
contract let late ingests / registers slip past a completed cascade
and orphan rows the manifest couldn't describe. New short-lived
SessionTombstones table (PK=tenantId, RK=sessionId) carries a marker
written by SessionDeletionHandler.ExecuteTombstoneAsync BEFORE the
Sessions-row delete, with a 7-day default ExpiresAt. Guard now reads
the marker on a missing Sessions row; live and present -> throws with
sentinel state "Tombstoned" -> 410 Gone. Full-Restore deletes the
marker after re-inserting the Sessions row; partial-restore leaves it
alone (never written). SessionDeletionMaintenanceFunction pruning
step runs even when the kill-switch is active (markers are race
shields, not policy artefacts).
F4 — CrossTenantAccessTests had no coverage for DELETE
sessions/{sessionId}. Theory entries lifted into the same three asserts
that already cover GET — same-tenant allowed, cross-tenant non-GA
blocked, cross-tenant GA allowed — and into the QueryParam-scoping
catalog theory so the regression that produced F1 surfaces in CI.
Bootstrap follow-up — RegisterSessionFunction.Run had the guard inline
but BootstrapRegisterSessionFunction calls _inner.ProcessRegisterAsync
directly, bypassing it. Guard moved into ProcessRegisterAsync so both
entry points hit it before StoreSessionAsync. Added structural wiring
tests (DI field, internal accessibility, bootstrap delegation type)
that fail in CI if the architecture regresses to inline copy.
Tests:
- SessionDeletionGuardTests +3: marker-active blocks, no-marker passes
on missing row, sessions-row-exists skips tombstone probe.
- SessionDeletionHandlerTests +1: marker is written BEFORE the
Sessions-row delete (Moq Callback ordering assertion).
- SessionRestoreServiceTests +2: full-restore deletes marker,
partial-restore does not.
- SessionDeletionMaintenanceFunctionTests +2: prunes expired markers,
pruning runs even with kill-switch active.
- CrossTenantAccessTests +3 inline DELETE cases.
- RegisterSessionGuardWiringTests (NEW): asserts guard DI field,
ProcessRegisterAsync internal accessibility, BootstrapRegisterSession
delegation type.
- CascadeVerificationServiceTests.FakeReader gains the new
GetActiveSessionTombstoneAsync interface member.
Suites: Backend xUnit 1524, Agent V1 530, Agent V2 1042 — all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
7d05a25c90 |
fix(dashboard/stats): move stats routes out from under sessions/ to dodge Azure Functions route collision
Azure Functions' attribute router does not honour literal-vs-parametric
specificity the way ASP.NET attribute routing does. A literal route of
"sessions/stats" was being eaten by the sibling GetSessionFunction whose
template is "sessions/{sessionId}" — the runtime bound /api/sessions/stats
to GetSession with sessionId="stats", which then 404'd because "stats"
fails GUID validation.
Diagnosed live via App Insights:
/api/global/sessions/stats -> 200 OK -> Functions.GetAllSessionStats
/api/sessions/stats -> 404 -> Functions.GetSession (!)
Per-tenant + GA stats routes moved out from under sessions/ for symmetry:
/api/sessions/stats -> /api/stats/sessions (MemberRead)
/api/global/sessions/stats -> /api/global/stats/sessions (GlobalAdminOnly)
EndpointAccessPolicyCatalog, api.ts, and the URL-builder tests follow.
Function-side + api.ts both carry a comment pinning the rationale so a
future "let's tidy these routes" refactor doesn't regress it.
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> |
||
|
|
77fde56f30 |
feat(backend/security): cert-expiry watcher in maintenance with tiered OpsEvents
Adds proactive monitoring of the embedded Intune CA bundle so the operator
gets a Telegram ping months before agent mTLS would break. Bundle rotation
(re-exporting fresh PEMs from a corporate-enrolled device) is the only fix
and needs lead time - today's outage from
|
||
|
|
0d9ef12f90 |
chore(backend/security): drop legacy duplicate Intune CA PEMs
intune-root-ca.pem and intune-intermediate-ca.pem are bit-for-bit duplicates of intune-root-2030.pem and intune-intermediate-mdm-2026.pem (matching SHA1 fingerprints A1:97:D6:... and B7:E4:96:...). The post-9af3de15 loader globs intune-root-*.pem and intune-intermediate-*.pem and dedupes by thumbprint, so the duplicates were silently de-duped at load. Remove them now to make the bundle self-explanatory: one PEM per real CA, name encodes its NotAfter. Verified by running CertificateValidatorTests (8/8 incl. the real-device-cert happy-path) which loads the bundle via the same glob path the runtime uses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
794800c0cf |
chore(backend/security): include leaf cert Subject/Issuer/Thumbprint in chain-failure log
Post-deploy of
|
||
|
|
9af3de15d4 |
fix(backend/security): pin client cert chain trust to embedded Intune root (v2)
Re-applies
|
||
|
|
6658303c0d |
feat(ops/consent): emit ConsentFlowSuccess to close the consent-flow lifecycle
Pairs with ConsentFlowStarted/Failed in the OpsEvents table so admins can
see whether repeated consent failures (AADSTS, redirect-mismatch, denied)
eventually resolved into a successful grant. New POST endpoint
config/{tenantId}/autopilot-device-validation/consent-success is called
from the post-redirect callback only after consent-status confirms
isConsented=true, so generic status polls do not fire false positives.
Trigger whitelist accepts autopilot, corporate, and device-preparation;
the third value is wired through the type system end-to-end so a future
DevPrep-specific consent flow can use it without a schema bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
5ae7dc40af |
Revert "fix(backend/security): pin client cert chain trust to embedded Intune root"
This reverts commit
|
||
|
|
7e21591b55 |
fix(backend/security): pin client cert chain trust to embedded Intune root
CertificateValidator previously combined AllowUnknownCertificateAuthority
with a DN-substring scan ("Microsoft Intune"/"MDM Device CA" in
Subject/Issuer of any chain element). A self-signed leaf with that DN
satisfied the substring gate while the unknown-root flag masked the
untrusted-root status -> the X.509 trust gate was bypassable.
Switch to X509ChainTrustMode.CustomRootTrust with the embedded Intune
root in CustomTrustStore and the intermediate in ExtraStore. chain.Build()
now requires a signature link to a pinned root regardless of the OS
trust store, which keeps Linux/Flex Consumption working without the
self-signed bypass. Empty roots -> fail-closed.
Adds 7 unit tests including the self-signed-impersonating-Intune
regression test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
b6178db8e6 |
fix(backend/security): enforce body-tenant for non-GA + lift query-tenant guards to middleware
Closes a tenant-boundary write-side hole and removes two manual cross-tenant
checks in favor of catalog-driven middleware enforcement.
SubmitSessionReport + SubmitDiagFilesReport (the violation):
The functions previously fell back to the JWT tenantId only when the body's
TenantId was empty, otherwise trusting the body verbatim. A Tenant Admin of
Tenant-X could POST a body with tenantId=Y and have the report blob, the
SessionReports row, and the audit-log entry all attributed to Tenant-Y
(integrity / non-repudiation issue, no read-side leak).
New behavior: GA-aware. Non-GAs who send a body tenantId that disagrees with
their JWT get a 403 + warning log. GAs may legitimately submit cross-tenant
reports (the UI sends the session's tenantId), but MUST set the body field
explicitly (no silent fallback that would mis-file under the GA's home tenant).
Diagnostics + Progress middleware elevation (defense-in-depth):
diagnostics/download-url and progress/sessions/{sessionId}/events lifted from
TenantScoping.None (in-function guard) to TenantScoping.QueryParam (catalog +
middleware). Same GA bypass semantics — the in-function check was correct but
copy-paste fragile. Functions now read requestCtx.TargetTenantId, which the
middleware has already validated against the JWT.
Test coverage:
CrossTenantAccessTests gains 10 new InlineData rows across the 5 QueryParam
test methods (no-query / same-tenant / cross-non-GA blocked / cross-GA allowed
/ scoping correctness) for both lifted routes. diagnostics/download-url moved
out of JwtImplicitRoutes_NoCrossTenantCheck since it's no longer JWT-implicit.
Verified: dotnet build clean, 1067/1067 backend tests pass.
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> |