mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
7fbfea8f4eb35c4fac362cd3f2d94cead566664f
171 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7fbfea8f4e |
Corp identifier validation: probe serialNumber type + unmask validator errors
First live WDP enrollment 403d at device validation. Root causes: - CorporateIdentifierValidator only searched the manufacturerModelSerial identifier type, but the Device Preparation guidance has admins upload SERIAL-type corporate identifiers - those never matched. The Graph searchExistingIdentities call now probes both types in one round-trip. - SecurityValidator overwrote deviceValidationError per validator, so the 403 Details only showed the LAST failure (CloudPc) and hid the corporate identifier miss. Errors now aggregate in chain order and the transient flag is sticky-OR (any transient failure yields 503 Retry-After instead of a definitive 403). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
789926fcad |
backend: progress-portal serial-knowledge model — server-side lookup, serial proof on events + session-group join
Security finding: GET progress/sessions shipped the tenant's latest 100
sessions to any authenticated (roleless) user with the serial filter
applied only in the browser; events and SignalR session-group joins were
open to any known sessionId.
Model now: knowing the device's serial number is the authorization proof.
- progress/sessions/lookup (AuthenticatedUserWithRole + QueryParam)
resolves at most ONE session server-side: roleless callers need the
exact serial/device name, members/GA keep substring search. The
tenant-wide list route is gone (catalog fail-closes it).
- progress events require ?serial= re-proof on every read; missing
session and serial mismatch answer the same 404 (no existence oracle).
- Session-group joins by roleless same-tenant callers require the serial
(SerialKnowledgeProof, shared with the events route); member/GA/
delegated joins are unchanged. MemberRead on progress routes stays
deliberately rejected (regression history
|
||
|
|
c4280e823c |
fragility audit P5.x + P7: drift guards across SignalR names, RowKeys, config sections, scoped routing
P5.x-A: Constants.SignalRMessages catalog (13 names) + SignalRGroupHelper.HubName;
all backend send sites and web subscriptions typed against the shared-manifest
union (SignalRContext.on/off) — unknown names are tsc errors. Dead eventStream
session-payload handling removed from diagnosis page.
P5.x-B: RowKeyCodec as the single inverted-tick encoder (persisted widths pinned,
UserActivity D20 legacy); DeletionRowDump.Table on tombstone rows with
DeletionTombstoneTables.Resolve replacing the duplicated Contains('_') heuristic
(legacy fallback retained for pre-field manifests).
P7.1: PATCH config/{tenantId}/fields flipped to TenantAdminOrGA (phase 2);
Settings sections save via per-section diff PATCH from sectionFieldMap.ts —
the ~39-field stale round-trip vector is gone; legacy webhook clearing now only
on the notifications save; agent-settings reset no longer discards contact edits.
P7.2/7.3: useGlobalAdminScope is a projection over useAggregatedAdminScope
(resolveConcreteScopeView, test-pinned); scopedApi builders own the tenant/global
endpoint-pair routing for all 11 former hand-rolled call sites; scopedFetch adds
the shared JSON fetch layer.
P7.4: navVisibility extraction + roles x nav-config matrix and nav<->route-guard
consistency test with a ratchet for the two known pre-existing mismatches
(Operator and GlobalReader vs /settings — user decision pending).
P7.5: web build stamp public/version.json (prebuild, GITHUB_SHA), SWA no-store
route, deploy-web verify poll + versions/web.json manifest, Portal Build card.
P7.6: OkAsyncBaselineGuardTests freezes the 44 anonymous-response sites
(shrink-only ratchet).
Verified: dotnet build 0W/0E, Functions.Tests 4123, DecisionCore 519, V2.Core
2206, SummaryDialog 44; tsc 0, ESLint 0, vitest 762/762; mutation proofs for all
new drift nets. Docs: docs/drift-guards.md + versioning.md web row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
842db1603a |
insights: fleet context on analysis cards + rule-filtered session deep link
Each fired analyze rule on the session detail page now reads "Fired in N enrollment(s) in the last 14 days" (summed client-side from the existing rule-stats trend — enrollments, never devices; RuleStats carries no serial) with a "View sessions" deep link. The dashboard accepts ?ruleId=, fetches the rule's hit-session ids once from the new GET /api/metrics/rule-hit-sessions endpoint (MemberRead + QueryParam scoping, capped 2000 with a truncated flag) and filters the loaded list client-side behind a clearable chip. Side fix: the section's stats fetch now follows the session's tenant for GA/delegated viewers (global route + tenantId) instead of showing the viewer's own-tenant numbers next to a foreign session. Dashboard links in fleet/fleet-health now go through the new canonical dashboardUrl() builder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
90b8243894 |
annotations: move tenant list route under the /api/sessions cert-exclusion prefix
/api/session-annotations sat outside every clientCertExclusionPaths
prefix, so browsers got the client-cert picker. Rename-over-exclusion
per the diag-files precedent (the Azure list is portal-only config and
dies on app recreation). Three segments (sessions/annotations/list) on
purpose: a two-segment literal would be eaten by sessions/{sessionId}.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
84a0424789 |
annotations: tenant-scoped overview page listing judged sessions
New GET /api/session-annotations (MemberRead + QueryParam, tenant always the middleware-validated target) backing the portal /annotations page in the Monitoring sidebar group (deliberately no new Insights group). For callers without global scope the repository excludes the globaladmin lane server-side (Lane ne clause), so hidden rows never consume page budget and an explicit lane filter self-contradicts to empty. Rows deep-link to the session annotation section. Verdict pill styling moved to the shared logic module. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2c48968ec6 |
session annotations: role-separated human verdicts on sessions (flywheel labels)
New SessionAnnotations table (PK=tenantId, RK={sessionId}_{lane}, lanes
operator/tenantadmin/globaladmin) storing structured verdicts + notes with
server-stamped authorship and a fired-rule-id snapshot for join-free
rule-quality evaluation. Per-lane write matrix re-gated in-function with
own-tenant binding; the globaladmin lane is platform-internal and filtered
from tenant callers. Portal: SessionAnnotationsCard on session detail.
MCP: list_session_annotations (ga) + annotate_session (strictGa, GA lane
only) + annotations key in get_session_summary. Lifecycle: cascade steps
17-19, offboarding wipe, critical-backup member. Privacy page gains the
customer-entered-content line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
1c5f69a9fe |
w365: CloudPc device validator — cert-CN-bound fallback for Cloud PCs (#127)
* w365: CloudPc device validator — cert-CN-bound fallback for Cloud PCs Windows 365 Cloud PCs are never Autopilot-registered, so the serial lookup (and the Corporate Identifier fallback) structurally reject them and Cloud PC first-connect enrollment could not be monitored. New stage-3 fallback in the device-validation chain: extract the Intune device id from the chain-validated MDM client certificate's Subject CN (field-verified: CN == managedDevice id == cloudPC.managedDeviceId) and require a matching cloudPC object in the claimed tenant (Graph v1.0 virtualEndpoint/cloudPCs, $filter=managedDeviceId eq). Only service-provisioned Cloud PCs have such an object — a regular enrolled non-Autopilot device still ends in the 403, and local W365 markers are never consulted for auth. - Shared: ValidatorType.CloudPc = 5, TenantConfiguration.ValidateCloudPcDevice (default false, tenant-admin writable), GraphAppPermissions.CloudPCReadAll, GraphFeatureCatalog feature W365CloudPcValidation -> CloudPC.Read.All - Backend: CloudPcDeviceValidator (AutopilotDeviceValidator sibling contract: 30/5 min cache, 2 attempts, transient->503; GUID gate before the OData filter; exact-match guard; Graph 403 = definitive "grant missing", not transient), SecurityValidator CN extraction + fallback stage + hard gate, wiring through the 5 agent endpoints, TableConfigRepository persistence - Consent script: W365CloudPcValidation in catalog/ValidateSet/help; NEW GraphFeatureCatalogSyncTests pins C# catalog <-> grant script (previously comment-only lock-step) - Bootstrap dev script v2.3-dev.3: guard 4 (uptime window) exempted while the Cloud-PC relax is active — Cloud PCs run headless for days before first connect; the fresh-profile window is the time anchor there (+ Pester tests) - Web: validation toggle + context plumbing, ValidatedBy "CloudPc" label, privacy-page validation claim generalized (Last updated bumped) - Docs: OKF concept doc backend/cloudpc-device-validation.md + index + log Verified: web tsc clean, ESLint 0 errors (99 pre-existing warnings = ratchet cap, none added). .NET build/tests and Pester run in CI — SDK download and pwsh are unavailable in this sandbox's network policy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqEFrcAHhWgCvgJgHGzMuv * w365: agent-side IsCloudPc flag + engine expectation "no Device-ESP phase" Companion to the CloudPc validator: the agent now classifies Windows 365 Cloud PCs locally with the bootstrap's field-verified marker AND (HKLM\SOFTWARE\Microsoft\Windows365 key + installed CloudManagedDesktopExtension service, probed via its Services registry key so net48 needs no System.ServiceProcess). SKIP-safe: any error resolves false. Never an auth input - backend admission stays exclusively the cert-CN-bound Graph lookup (ValidatedBy=CloudPc); the two signals are independent and may disagree. Two transport rails: - Session metadata: SessionRegistration.IsCloudPc -> Sessions table (sticky-true OR across re-registrations, IsSelfDeployingProfile pattern) -> SessionsIndex full mirror -> SessionSummary -> new isCloudPc search filter end to end (SearchSessions + QueryRawSessions + pagination fingerprint + all three in-memory predicates; MCP search_sessions / query_raw_sessions params + summary projection; web type, Cloud PC pill, SessionInfoCard enrollment label, CSV export appended column). - Decision engine: isCloudPc rides the EnrollmentFactsObserved payload, recorded set-once for BOTH values in EnrollmentScenarioObservations.CloudPc and annotates the scenario profile reason cloud_pc_first_connect:no_device_esp_expected - the engine-recorded expectation that Device-ESP already ran headless at provisioning and the session starts at Account Setup. Deliberately reason+observation only: no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME- classified (Classic), EspConfig keeps configured-FirstSync semantics. Positive marker surfaces as cloud_pc_marker in the audit-trail census. Updater idempotency: the cloudPcAnnotates gate keeps the generic v1 reason branch from re-claiming the slot on repeat posts (would bump EvidenceOrdinal every post). Additive-nullable observation - no state schema bump (v4 convention), serializer roundtrip pinned. Tests: CloudPcDetector resolver (marker AND, throwing probe), registration body, facts-signal payload shape, updater annotation/idempotency/ self-deploying interplay, reducer observation both values + census fold, serializer roundtrip, index projection superset, sticky-true both directions. Suites: DecisionCore 500, Functions 3941, Agent V2 2154, vitest 632 - all green; tsc web+MCP clean; ESLint 0 errors (cap 99). Docs: cloudpc-device-validation.md follow-up section replaced with the shipped mechanism + log entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
75197a4184 |
Logparser pattern testing with the agent's exact .NET engine (test_log_pattern) (#125)
* updated commands * feat(backend): logparser pattern test endpoint with agent-exact semantics POST /api/rules/gather/test-pattern evaluates a logparser gather-rule regex against pasted sample lines exactly the way the agent would on the device. Motivated by a field incident: a customer verified a pattern with a PHP regex tester and the .NET engine matched differently. - CmTraceLogParser moves Agent -> AutopilotMonitor.Shared.Logging so agent (IME tracker, logparser collector, stall probe, IME matching mode) and backend run the SAME parsing implementation. Pure mechanical move, agent behavior unchanged (V2.Core suite 2147/2147 green). - TestLogPatternFunction mirrors LogParserCollector verbatim: Regex(Compiled, 1s timeout, NO IgnoreCase -> case-sensitive), first match per line, group "0" excluded / unsuccessful groups omitted, format cmtrace (default, match against parsed message) vs text (raw line), per-line outcomes incl. parse_failed/regex_timeout, and the agent's "set parameter format=text" hint verbatim. Pure compute, no tenant data. Caps: 200 lines, 8KB/line. - Policy: TenantAdminOrGlobalReader (same tier as the analyze dry-run). - Tests pin the parity showcases: case-sensitivity, envelope invisibility in cmtrace mode, optional-group omission, format hint, request validation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(mcp): test_log_pattern tool + logparser lint and guide hardening - test_log_pattern: wraps POST /api/rules/gather/test-pattern — tests a logparser regex against pasted sample lines with the agent-exact .NET semantics (case-sensitive, cmtrace/text mode). Description steers clients away from JS/PHP/Python regex testing. - validate_rule: logparser parameter lint (missing/non-compiling pattern, unknown format value = silently cmtrace on the agent) + info pointing to test_log_pattern; nextStep for valid logparser drafts routes there too. - rule_authoring_guide: logparser section now states the matching semantics (case-SENSITIVE, .NET, first match per line, cmtrace default) and two new pitfalls (engine mismatch, plain-text log under cmtrace default). - Catalog snapshot + handler/validator tests extended (MCP suite 532 green). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(okf): test-pattern endpoint + shared CmTraceLogParser in rule authoring surface Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3c1229a301 |
MCP rule authoring: deterministic context, validate_rule, and session dry-run (#124)
* feat(backend): dry-run endpoint for draft analyze rules (rule authoring)
POST /api/rules/analyze/dryrun evaluates a DRAFT analyze rule against one
session's events and returns a full diagnostic trace: per-condition
matched/evidence (incl. per-eventType occurrence counts), per-factor
confidence breakdown, verdict, and the would-be MatchedConditions map for
{{token}} interpolation preview. Persists nothing: no RuleResult row, no
session-status change, no notifications.
- RuleEngine.DryRun.cs: DryRunRuleAsync / DryRunEvaluate share the exact
evaluators (EvaluatePrecondition/EvaluateCondition/EvaluateConfidenceFactor)
with the production path but evaluate ALL conditions instead of breaking on
the first failed required one; parity with EvaluateRule is pinned by tests.
- DryRunAnalyzeRuleFunction: TenantAdminOrGlobalReader (read-in-effect POST),
GA/GlobalReader cross-tenant via session tenant resolution, 1 MB body cap,
structural draft validation (unknown source/operator, correlation without
joinField, invalid regex, non-evaluable confidenceFactor DSL) with
actionable error messages.
- EndpointAccessPolicyCatalog: register the new route.
- RuleEngineDryRunTests: trace semantics, production parity matrix,
side-effect freedom (VerifyNoOtherCalls), draft validation matrix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(mcp): rule-authoring resources — guide, schemas, guardrails
Deterministic authoring context for customer AIs, replacing chunked
search_docs retrieval for rule creation: get_resource (and the MCP-protocol
resources) now serve
- rule_authoring_guide: hand-written, complete authoring knowledge (collector
types, triggers, condition sources, operators, confidence model incl. the
exact factor-condition DSL shapes, {{token}} interpolation order, guardrail
matching semantics, pitfalls, validate->dry-run workflow)
- rule_schemas: the JSON Schemas (2020-12) for gather + analyze rules
- rule_guardrails: the on-device collection allowlists
Schemas + guardrails are generated into rule-authoring.generated.ts by a new
combine.js emitter (same pattern as the web guardrails mirror; rules/ stays
the single source of truth and CI guardrails-in-sync guards the file).
rule-authoring-drift.test.ts additionally pins generated == repo source,
guide enums == schema enums, and the get_resource size cap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(mcp): validate_rule + test_analyze_rule tools and author-rule prompt
Closes the authoring loop for customer AIs: draft -> validate -> dry-run
against a real session -> iterate -> create in the portal.
- validate_rule (MCP-local, no backend call): ajv 2020-12 validation against
the baked schemas, guardrail pre-flight mirroring the agent matching
semantics (hive-stripped segment-bounded registry/file prefixes,
whitespace-bounded WMI, exact commands, /-bounded event log channels, hard
blocks), and semantic lint for schema-valid-but-dead rules (unreachable
threshold, non-evaluable factor DSL, unknown eventTypes, unanchored
not_regex allowlists, unresolvable {{tokens}}, trigger cross-fields,
outputEventType conventions/collisions).
- test_analyze_rule: calls the new POST /api/rules/analyze/dryrun and adds
the {{token}}-interpolated explanation/remediation preview via the
existing interpolate-rule-template.
- author-rule MCP prompt guiding scenario -> resources -> draft -> validate
-> dry-run.
- Both tools are visible to every role (backend enforces
TenantAdminOrGlobalReader on the dryrun route); catalog snapshot updated.
- deps: ajv + ajv-formats promoted to direct dependencies.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(okf): rule authoring surface concept doc
docs/rules/rule-authoring-surface.md: the AI-assisted authoring stack
(deterministic MCP resources via combine.js baking, MCP-local validate_rule
layers, the dry-run trace twin with its parity + side-effect invariants, and
what is deliberately absent); registered in index.md, logged 2026-08-05.
Customer docs (rules/ai-assisted-rule-authoring.md + SUMMARY + FAQ entry) are
committed in the autopilotmonitor-docs repository separately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
9d8b0fc254 |
feat: tenant-config field schema - MCP writes get types right on the first attempt (#121)
Follow-up to the E2E finding (95 vs 95.0): a model driving update_tenant_config had to guess field names and JSON types from a config read. New reflection- generated schema surface makes guessing unnecessary: - Backend: GET config/fields-schema (GlobalAdminOnly) - built from the TenantConfiguration model + the patch service deny-lists at runtime, so it can NEVER drift from what the patch endpoint enforces. Per field: camelCase name, JSON type (string/boolean/integer/number), nullability (null clears), format hints (json / date-time), writability + deny reason, GA-only marker (phase 2), revert-protected marker. Literal route; FindPolicy literal-over-template precedence pinned by test (same mechanism as config/all). - MCP: new strictGa READ_ONLY tool get_tenant_config_schema; update_tenant_config description + GA instructions headline steer the model to call it before composing a patch. - Tests: drift canary (every model property appears exactly once), deny-list consistency, type exemplars incl. the decimal SLA column class, policy + route-precedence pins, MCP catalog/registration/request-shape. Backend 3854 green, vitest 465 green, tsc clean. Deploy order: backend first, then MCP redeploy. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
395fdd49df |
Transactional tenant-config writes via MCP + ConfigurationBackups safety net (#120)
* feat(backend): pre-write config backup net - every tenant/admin config save snapshots first New ConfigurationBackups table (PK=tenantId or GlobalConfig, RK=reverse-ticks+guid, newest-first) holds the raw stored row as EntityJson before any overwriting save of TenantConfiguration or AdminConfiguration. Hook lives in TableConfigRepository, the single choke point all writers funnel through - fail-soft (a backup hiccup never breaks a save), skips row creations and noise-only changes (LastAuthClientId flips), prunes to the newest 2 per partition. Source/reason tagging via a new SaveTenantConfigurationAsync overload (not optional params - Moq expression trees, CS0854); portal-put/plan/auth/app-homing/offboard call sites tagged. Offboarding: backup partition wiped in the bulk phase (TenantPartitionTables) and by the marker-cleanup tombstone sweep; the GlobalConfig partition never matches a tenant GUID and survives. Table registered in TableNames.All + CriticalBackupTables.All. Groundwork for the transactional MCP config-write flow (patch/revert endpoints follow). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(backend): transactional tenant-config field patch + revert with verified writes New GA-only surface (policy catalog: GlobalAdminOnly + RouteParam): - PATCH config/{tenantId}/fields - field-level patch through TenantConfigPatchService: fresh ETag read -> field gate (deny-list incl. plan/trial, HomedAppClientId, auth provenance; unknown fields 400 with the name) -> patch onto a clone -> shared validation -> fail-CLOSED backup -> CAS replace -> fresh re-read -> verify that EXACTLY the intended fields changed -> automatic ETag-conditional rollback on drift (catches Store/Map serialization asymmetry). - POST config/{tenantId}/revert - restore from a ConfigurationBackups snapshot (latest by default); snapshots current state first (revert is revertible), preserves plan/homing/provenance fields unless includeProtectedFields. - GET config/{tenantId}/backups - metadata + masked diff only, never EntityJson. Supporting changes: IConfigRepository gains GetTenantConfigurationWithEtagAsync + TryReplaceTenantConfigurationAsync (412 -> false, no implicit backup hook); validators moved from the PUT into shared TenantConfigValidation (PUT re-pointed, forwarding shims keep test call sites); GET config/{tenantId} honors ?view=redacted so a GA's MCP session never pulls clear-text secrets into model context. Caller-tier deny-lists (TenantAdmin/DelegatedAdmin) ship now for the phase-2 flip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(mcp): tenant-config write tools - update/revert with snapshot-backed verified writes First mutating surface on the MCP server, strictGa (real Global Admin) only: - get_tenant_config: full config read, ALWAYS ?view=redacted (secrets never reach model context; pinned by config-write-tools tests) - update_tenant_config: field-level patch via PATCH config/{tenantId}/fields; required reason; response = applied fields + masked diff + backupId - list_tenant_config_backups: snapshot metadata + masked diff only - revert_tenant_config: restore latest/named snapshot; protected fields stay current unless includeProtectedFields; revert is itself revertible New MUTATING annotation + TenantGuidSchema (path-traversal guard) in shared.ts; role-aware instructions headline (non-GA keeps the exact READ-ONLY wording); catalog snapshot test extends GA_FULL + new CONFIG_WRITE_GA_STRICT difference list (Global Reader/tenant/delegated see none of the four). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(backend): de-flake session-deletion heartbeat test - condition-gated handler instead of wall-clock window The heartbeat test held the handler for a FIXED 350ms and expected the 50ms heartbeat timer to tick >=3 times inside that window. On loaded CI runners the heartbeat Task.Delay stretches disproportionately to the handler delay, the handler completes with <3 heartbeats fired, and the awaited condition can never become true afterwards (CI failure on PR #120). The handler now holds the message in-flight UNTIL the test observed 3 heartbeats (with ct + own 15s deadline as termination guards), so the assertion is reached deterministically regardless of scheduler jitter. Verified: 12/12 local runs green, full suite green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0e2d620ad4 |
feat(auth): self-service app-homing flip via consent flow + GA lever
Re-consent, verify and flip collapse into the existing consent flow: the
consent URL funnels legacy-homed tenants to the primary app registration,
and consent-status/access-check verify admin consent via an app-only token
probe under explicit primary credentials, then auto-flip HomedAppClientId.
- AdminConfiguration.SelfServiceAppHomingEnabled: kill switch (default off,
cache-bypassing fail-closed read); gates funnel + auto-flip + tenant-admin
manual flip. GA path deliberately survives the kill switch.
- POST config/{tenantId}/app-homing (TenantAdminOrGA): tenant admins flip to
primary only, probe-gated; GA gets force + revert. Pure decision helper
EvaluateManualFlip shared by function and tests.
- GraphTokenService.GetAccessTokenForAppAsync: probe seam with explicit app
credentials, cache-safe via app-scoped token keys.
- EntraAppRolesEnabled does not block a flip; it emits a Warning ops event
(AppHomingFlippedWithEntraRoles) reminding to re-assign enterprise-app roles.
- Web: homing badge + "App Registration Homing" panel with confirm dialog in
GA tenant management (typed homedAppClientId hardens the PUT round-trip),
funnel/success banners in tenant settings with switchAuthApp re-login,
admin-config kill-switch round-trip, ops event types dual-registered.
- Browser telemetry (App Insights): pre-init event buffer so module-init auth
events survive; strategic events across the whole flow (app_homing_flipped,
consent_flow_*, consent_verify_failed, auth_app_fallback_*, auth_app_switched,
auth_app_homing_learned, app_homing_manual_flip*).
Backend must deploy before web (additive JSON fields); everything is inert
while EntraId:LegacyClientId is unset. Enable the kill switch in the admin
portal after the dual app-reg config swap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
f795fbcb4e |
feat(auth): dual app-reg parallel operation - legacy gktatooine app alongside new C4A8 app
True parallel operation, no forced re-consent: existing tenants keep signing in via the legacy app (1a400946), new tenants land on the C4A8 app (886ab5e2). Model "default legacy, funnel primary, learn in the background": - Web: am_auth_app flag -> NEXT_PUBLIC_ENTRA_DEFAULT_APP -> primary; get-started CTA hands over with ?authapp=primary; auth/me homedApp updates the flag so an operator tenant flip takes effect on each user's NEXT login; AADSTS90094/65001 one-shot cross-app retry, AADSTS65004 -> failed screen with previous-app link; am_login_attempt pins redirect returns to the app that started them. - Backend: TenantConfiguration.HomedAppClientId (null = legacy, GA-only flip via PUT config) + LastAuthClientId/Since observability; EntraAppRegistry resolves the acting app per tenant (no-op until the legacy config pair is set); Graph client-credentials, admin-consent URLs and the surfaced client id follow the homed app (app-scoped cache keys); JwtBearer [Authorize] pipeline now shares the middleware audience builders (was single-audience - would 401 legacy tokens after the swap); middleware trusts EntraId:LegacyClientId implicitly. - well-known association file now lists both app ids (publisher domain); deploy-web passes NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID / _DEFAULT_APP; mcp-server.bicep default client id -> C4A8 app. Backend xUnit 3730/3730, vitest 625/625 (13 new), tsc/ESLint clean, next build ok. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a70c5de667 |
feat(plan): rename Enterprise plan to Pro; Pro includes Bootstrap by default and Unrestricted Mode on request
- FeatureEntitlementCatalog: TenantEdition.Pro, canonical tier "pro", legacy stored "enterprise" stays readable as Pro (no data migration); new entitlements BootstrapIncluded + UnrestrictedModeAvailable - TenantEntitlementService: IsBootstrapEnabled (Pro-included OR additive GA flag) and IsUnrestrictedModeActive (Pro AND GA gate AND tenant toggle, read-time - trial expiry/downgrade re-arms guardrails fail-closed) - All bootstrap endpoint gates + SecurityValidator use the effective check; /auth/me and feature-flags expose effective availability values - PlanManagement accepts community|pro; AlreadyPro; Pro trial wording - Web: edition.ts accepts legacy "enterprise" (deploy-order safety), Pro labels/cards + new Pro feature bullets, effective section gating, terms/privacy renamed with Last-updated bump - Tests: catalog matrix, effective-gate payload tests, helper tests incl. trial-expiry re-gate (backend 3692 green, web 612 green) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f744ad8406 | fix code review findings | ||
|
|
6cbd507b4f |
fix: client-IP extraction was dead on Flex — RemoteIpAddress fallback + AFD-aware rate-limit key
Post-deploy verification of the go endpoint showed every anonymous caller keyed as "unknown": Flex Consumption does not surface X-Forwarded-For to the isolated worker, so ALL per-IP rate-limit buckets (bootstrap-validate, distress, diag-ticket-download, stats/platform) had silently collapsed into one shared bucket — confirmed by DistressReports.SourceIp being "unknown" across the board. - GetTrustedClientIp falls back to HttpContext.Connection.RemoteIpAddress (transport-derived, unspoofable; IPv4-mapped addresses normalized). Heals every existing caller without signature changes. - New GetRateLimitClientIp for AFD-fronted endpoints: honors X-Azure-ClientIP only when X-Azure-FDID carries exactly our profile ID (FrontDoorProfileId app setting, set on autopilotmonitor-api-eu); fail-closed to GetTrustedClientIp on unset/mismatch/multi-value. Used by GetBootstrapScriptFunction (traffic arrives via go.autopilotmonitor.com, where the trusted hop is AFD egress). - FdidMatches table tests incl. append/spoof cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
26bcab9054 |
feat: move OOBE bootstrap script endpoint into the backend (go.autopilotmonitor.com)
The portal's Next.js /go/[code] route was the web app's only request-time
server code and blocked the planned static export. The script generation
now lives in the Function App as GET /api/bootstrap/go/{code}
(GetBootstrapScriptFunction), reached by customers as
irm https://go.autopilotmonitor.com/{code} | iex via a Front Door custom
domain whose /* route rewrites onto the API path.
- New Constants.BootstrapGoBaseUrl; CreateBootstrapSessionFunction now
hands out the go URL (previously unguarded — CreateBootstrapSessionUrlShapeTests
pins it now). go host added to both URL-guard enforced lists.
- BootstrapScriptValueValidator ports the bootstrapValidation.ts injection
invariants (exact GUIDs, https + host allowlist + /agent/*.zip path,
expiry <= 14d) as defense-in-depth for in-process values.
- OobeBootstrapScriptGenerator ports the PS template as a Replace-token
raw string; golden test pins byte parity with the TS template
(TestData/oobe-bootstrap-golden.ps1, captured from route.ts).
- Always-HTTP-200 text/plain contract incl. rate limit (own bucket
bootstrap-script:{ip} 20/min); disabled tenant indistinguishable from
unknown code. Catalog: PublicAnonymous; NoStoreCacheMiddleware covers
/api/bootstrap/go/ (script inlines the bearer token).
- Web: BOOTSTRAP_GO_URL registry entry, sessions list shows the go host,
legacy route marked LEGACY (alive for pre-migration URLs <= 168h,
removal in the static-export PR).
- Docs: new OKF backend/oobe-bootstrap-go-flow.md, url-registry go
section, index/log entries.
Backend tests 3611/3611, web vitest 651/651, tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
7b975d46b6 |
feat(backend): Azure Monitor alert webhook - platform alerts join the ops-event bus
New anonymous POST ops/alert-webhook receives Common Alert Schema notifications from an Action Group webhook receiver and re-emits them as ops events (category Platform, type AzureMonitorAlert), so Azure Monitor alerts flow through the existing OpsEventService -> OpsAlertDispatchService fan-out (Telegram/Teams/Slack + portal ops-events list) like every internally raised operational event. The delivery path is independent of the portal: a SWA outage cannot suppress the notification; the action group's e-mail receiver stays as the backend-independent fallback. Security: action-group webhooks cannot send custom headers, so the shared secret travels as ?secret= and is compared in constant time (FixedTimeEquals) against the OpsAlertWebhookSecret app setting - fail-closed while unset. 64 KB payload cap, defensive essentials-only parsing (only alertRule is mandatory), severity mapped Sev0->Critical, Sev1->Error, Sev2->Warning, Sev3/4->Info with unknown values falling back to Info so schema drift can never escalate. Dual-registered per convention: EndpointAccessPolicyCatalog entry (PublicAnonymous), OPS_EVENT_TYPES in OpsAlertRulesSection (rule wiring) and Platform category in OpsEventsSection (badge + filter). Backend suite 3511 green (incl. new helper tests + catalog completeness), web tsc + vitest 651 green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
88da317bf4 |
feat(insights): F2 surfaces - device-history banner, fleet FTR panel, API routes, MCP tool (PR5)
Three read routes over the PR4 data: metrics/device-history
(MemberRead + tenant query param - one route serves the web banner AND
MCP, GA cross-tenant via ?tenantId= with a sessionId-based owner
fallback; history:null is a normal outcome), metrics/device-journeys
and global/metrics/device-journeys (days selector honored because FTR
counts are additive - window rate = ratio of summed daily rows; the
tenant-less global variant serves the "global" partition and discloses
repeatDevices:null). Attempt numbers are server-computed
(DeviceJourneyCalculator.ComputeAttemptNumber: chain position within
the journey; live sessions via a virtual non-successful attempt so the
redeploy and 30d-gap rules apply) - no consumer re-derives journey
semantics. Repeat-devices violator list: last journey >=2 attempts with
newest terminal ref in-window, attempts desc then recency, cap 10,
failure reason from the newest failed attempt (bounded point-reads).
Web: DeviceHistoryBanner on session detail ("Attempt N for this device
- View history", expandable terminal chain, durations strictly the
authoritative durationSeconds, WG/DevPrep/admin-marked badges) and the
Fleet Health "First-time-right" section (rate gated at >=20 completed
journeys with disclosed n, junk-serial exclusion note, Monday-UTC
weekly trend, attempt histogram, repeat devices) following the 7/30/90
selector. MCP: get_device_history (serial mode / fleet FTR mode,
role-aware routing, all roles - GA_FULL privilege snapshot extended
deliberately). Docs: OKF backend/device-journeys.md + index/log.
Backend 3447/3447 (14 new: attempt-number vectors incl. gap boundaries
and live sessions, additive sums, days clamping, repeat-device
gate/sort/cap), web tsc + vitest 627/627, MCP tsc + 455 passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
6de21e821a |
feat(insights): F1 surfaces — session lane, fleet panel, API routes, MCP tool (PR3)
Insights expansion PR3 (tasks/insights-expansion-spec.md §F1 "Surfaces").
Completes F1: schema (PR1) -> persistence/aggregation (PR2) -> surfaces.
- Rolling range rows: the sweep now also writes "rolling30|{class}"
aggregate rows — range statistics over the window's actual sessions. A
median of per-day medians is not the range median, so the fleet panel
reads these and is deliberately fixed to "last 30 days"; the daily rows
stay in the payload for trends/MCP.
- Routes (all registered in EndpointAccessPolicyCatalog):
GET sessions/{sessionId}/time-attribution (MemberRead + tenant query
param, GA fallback via FindSessionTenantId; breakdown=null is SUCCESS —
a missing lane is a normal state, not an error), GET
metrics/time-attribution (MemberRead) and GET
global/metrics/time-attribution (GlobalReadOrAdmin + tenantId filter),
both returning { windowDays, classes (rolling), daily }.
- Web: TimeAttributionLane under the session-detail phase timeline —
proportional stacked bar of segment totals incl. the explicit
unattributed remainder (bar always sums to the session's authoritative
duration), ESP-blocking apps with critical-path occupancy, reboot badge
(cross-cutting, never a bar slice), quality-flag chips; fetched
fail-soft for terminal sessions only. Fleet Health gains the "Time
attribution" section: per-class stacked medians gated at n>=20
("insufficient data (n=...)" below), flagged/missing exclusions
disclosed, and a top-blocking-apps table with class-scoped rows and
"up to X (p75 Y)" what-if wording. Deliberate deviation: the spec's
per-app trend column is omitted — per-day app rows are too sparse under
the daily n>=5 gate; revisit once rolling history exists.
- MCP: get_time_attribution (one tool, session + fleet mode, role-aware
routing like get_app_install_metrics, registered for all roles) — the
privilege-leak-guard catalog snapshot extended deliberately.
- Docs: OKF concept doc backend/time-attribution.md (invariant, windows,
segment mapping, positive-evidence blocking, Q7 reboot spans,
persistence + surfaces) registered in index.md + log.md.
Backend 3382/3382 (+1 rolling-aggregate test), web tsc clean + vitest
627/627, MCP tsc clean + 455 passed. Post-deploy: web deploy +
MCP redeploy (new tool + docs corpus).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
8a794a1b52 |
fix(rules): cross-tenant GA rule edits no longer misroute to home tenant
A Global Admin editing a gather/analyze rule while viewing a FOREIGN
tenant (override) got a green "saved" banner, but the write landed in
the GA's OWN home tenant: the JWT-scoped rules/{type}/{ruleId} routes
resolve the tenant from the caller's token, and no cross-tenant write
route existed (reads had /api/global/rules/*, writes did not). The
reload re-read the foreign tenant and showed the unchanged rule.
Fix: symmetric cross-tenant WRITE routes mirroring the global reads.
Backend:
- GlobalRulesFunction: PUT/DELETE global/rules/gather/{ruleId} and
global/rules/analyze/{ruleId}; tenant from ?tenantId= (400 if
missing), same 1 MB guard + ValidateScopeAndEmitMode as the JWT
variants, same service calls (toggle-partial/built-in-state/full-edit
semantics are tenant-parameterized and identical).
- Catalog: 4 entries GlobalAdminOnly + TenantScoping.QueryParam — a
write tier, so the delegated-read rescue never fires; only a platform
admin may mutate another tenant's rules. No global CREATE route:
create stays JWT-only and the portal hides Create in override.
Web:
- api.rules.globalGatherRule/globalAnalyzeRule helpers; both rule pages
route save/toggle/mark-failed/notify/delete through a
*RuleWriteUrl(ruleId) switch on isGlobalOverride.
- Analyze template-enable (a create) is blocked in override with a
clear message instead of silently writing to the wrong tenant.
Tests: CrossTenantAccessTests guards the 4 routes are GA-only +
QueryParam and that a GA reaches the named target tenant. Backend
3295/3295, web vitest 627/627, tsc clean.
Note: the old bug planted orphan copies of foreign rules into the GA's
home tenant on each attempted cross-tenant edit; those are not
auto-cleaned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
6c53dc33e7 |
feat(distress): tenant-visible TpmPssUnsupported devices + one-shot bell
TpmPssUnsupported distress reports (TPM cannot RSA-PSS-sign, Schannel drops
the client cert on Win11 25H2+, agent can never authenticate) were operator-
only, yet the remediation is tenant-side: TPM firmware update or device
replacement.
- New GET audit/tpm-pss-unsupported (MemberRead), aggregated per serial,
mirroring audit/device-not-registered
- Bell notification "tpm_pss_unsupported" (Admin audience) once per
(tenant, serial); dedup via the existing rejection tracker table with a
disjoint "tpmpss|{serial}" row-key space; no webhook by design
- Web: TpmIncompatibleDevicesInsights panel in the Hardware Whitelist
section; renders only when a device is affected (no permanent card)
No agent change, no ConfigVersion bump. Backend tests 3287/3287, tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
9ec8644be2 |
feat(settings): operator read-only settings view with real values
Operators previously saw editable settings forms bound to hardcoded defaults
whose Save always 403'd. Now they see the tenant's real configuration
read-only, with no save affordances at all.
Backend:
- GET config/{tenantId}: TenantAdminOrGlobalReader -> MemberRead. Safe because
the handler redacts by default (CanViewSecrets: only GA or own-tenant Admin
see secrets); Operators/Viewers receive RedactedCopyForReader().
- POST diagnostics/files: TenantAdminOrGA -> TenantAdminOrOperator (submitting
logs is a troubleshooting action; Viewer and read-only GlobalReader excluded).
- Tests: policy pins updated; new middleware tests (own-tenant Operator allowed
+ redacted, cross-tenant Operator 403); CanViewSecrets theory for
Operator/Viewer.
Web:
- TenantConfigContext loads the full config for all settings users (403
fallback to feature-flags minimal config for deploy-order safety) and
exposes canEditConfig; saveConfiguration guards on it.
- New ReadOnlyFieldset wraps section content (native fieldset disable, values
stay copyable); SaveResetBar is not rendered at all for read-only viewers.
Applied to Contact, SLA Targets, Hardware Whitelist (+ rejection-insights
toggle), Notifications, Agent Settings, Agent Analyzers, Diagnostics, Data
Management.
- Hidden entirely from Operators (in-page admin-only card + sidebar sub-item
gate): Autopilot Validation, Access Management, Offboarding, Unrestricted
Mode. GlobalReader sidebar parity kept via hasGlobalScope.
Tests: backend 3257/3257, web vitest 627/627, tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
2fee50276e |
feat(backend): operator-tier session actions + diagnostics groundwork for on-demand log collection
- New EndpointPolicy.TenantAdminOrOperator (Admin/Operator/GA, no Viewer) with
middleware evaluator; POST sessions/{sessionId}/actions moves to this tier so
troubleshooting operators can queue request_diagnostics ("Collect Logs").
- QueueSessionActionFunction re-gates per type: terminate_session and
rotate_config stay Admin/GA-only (403 for operators).
- GET config/{tenantId}/feature-flags exposes diagnosticsUploadConfigured so
members below Admin can see whether an on-demand upload would work.
- Offboarding cascade: hosted diagnostics deletion now sweeps the session
prefix {tenantId}/AgentDiagnostics-{sessionId}- (plus the manifest-named
blob) so on-demand packages overwritten on the Sessions row by the terminal
package no longer survive deletion. CustomerSas branch unchanged.
- Notification dismiss routes deliberately NOT migrated: dismissal is still
tenant-shared; operators would clear admin-tier notifications they cannot see.
Tests: middleware role matrix on the actions route, per-type gate matrix,
feature-flag matrix, prefix-sweep + real hosted-branch cascade tests.
Backend suite 3253/3253.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
e0ad51b965 |
refactor(urls): one guard-enforced URL registry per component, agent downloads move to the alias
Every well-known host -- own subdomains, both blob accounts, the API origin, Graph, Entra login -- now lives in exactly one registry per component: Constants.cs (*BaseUrl) for C#, utils/config.ts for the web app, src/config.ts for the MCP server. lib/hostRouting.ts derives its hostnames from the registry and middleware.ts imports from it, retiring what turned out to be a fourth independent copy of the www/portal host pair. Three new guard tests (backend xUnit, web vitest, MCP vitest) scan non-test source and fail on any enforced host outside its registry. This is the lesson of the EU cutover: the registry existed then too, but LatestVersionsService (feeds the portal's version badge) and ValidateBootstrapCodeFunction carried their own literal copies of the legacy blob host and were missed -- kept working only by the fail-soft legacy mirror in build-agent.yml, whose silent failure would have pinned badge and self-update to stale bits with a green release. Both guards proved themselves on their first run, flagging the middleware host copy and a literal in an MCP tool description. Test files stay exempt on purpose: literals there are independent oracles, and ValidateBootstrapCodeResponseShapeTests now pins the full alias URL against the Constants-built one so registry changes must be acknowledged consciously. The two legacy readers plus the agent SelfUpdater now use Constants.AgentDownloadBaseUrl (download.autopilotmonitor.com/agent); the alias was verified live for version.json, version-v2.json and both ZIPs before the switch. The portal's bootstrap allow-list accepts the alias AND the legacy host (AGENT_DOWNLOAD_HOSTNAMES) for already-deployed customer scripts, which imposes a deploy order: web before or with backend. The legacy keepalive probes and the release mirror stay untouched -- they exist for exactly this transition. Also: the bootstrap short-link now targets www directly instead of riding the registrar's apex 301, and the welcome-mail docs button points at docs. instead of bouncing through www./docs. Suites: backend 3194, agent core 1925, DecisionCore 474, web 582, MCP 449 -- all green; next build verified (it, not tsc, proves the next.config.ts registry import). Docs: docs/url-registry.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
757427ffea |
chore(build): clear the compiler and xUnit analyzer warnings CI annotates
The CI test job surfaced 10 annotations, but GitHub caps them at 10 per job — a full rebuild showed 31 nullable warnings plus 26 xUnit analyzer warnings. Production code: - CertificateValidator: X509Certificate2(byte[]) is obsolete (SYSLIB0057) -> X509CertificateLoader.LoadCertificate. - AuthenticationMiddleware: IsValidTenantId now carries [NotNullWhen(true)], so the guard above BuildTenantAuthority is visible to flow analysis. - AgentEndpointMigrationRules: the out params are genuinely nullable on the false path; netstandard2.0 has no usable [NotNullWhen], so the contract is documented instead. - Constants.ClassifyEspAppsFailure: errorCode is optional by design (the "no HRESULT" branch is the timeout fallback) - signature says so now. Tests: nullable annotations on helper signatures, Assert.Single/DoesNotContain overloads instead of pre-filtering with Where, and [InlineData(null)] theories declare nullable parameters. GatherRule helpers now seed Parameters with the model default (empty) rather than null, matching a deserialized rule. No behaviour change. 5616 tests pass; solution builds warning-free apart from the two pre-existing NU1510 pruning notices on the pinned security packages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
da958b5398 |
fix(backend): business timestamps survive storage migrations (OccurredUtc + RowKey decode)
The 2026-07-18 storage migration reset every row's system Timestamp, exposing that AuditLogs, OpsEvents and Events displayed, filtered and retention-swept on it (custom "Timestamp" writes were silent no-ops — reserved system property). All migrated rows read as 2026-07-18. - New Helpers/BusinessTimestamp: RowKey decoders (audit !revtick_guid, ops bare revtick, event yyyyMMddHHmmssfff_seq), RowKey-range OData clause builders (tick-exact, with the mandatory RowKey lt '"' guard so no lower bound can ever match legacy bare-GUID audit rows), and dual DateTimeOffset/DateTime column reads. - Writes in all three tables now persist OccurredUtc (custom column, copied verbatim by future migrations); no-op ["Timestamp"] removed. - Read mappers resolve OccurredUtc -> RowKey decode -> system Timestamp; GetEarliestSessionEventTimestampAsync uses the same resolution so StartedAt/Duration reconciliation can no longer absorb the migration date on migrated sessions. - Date-window filters and retention moved off the system Timestamp onto RowKey ranges; ops retention loses its full-table scan and the post-migration mass-delete cliff; audit retention runs a temporary second pass for legacy GUID rows (TODO: remove after 2027-03-01). - New GlobalAdmin backfill endpoint POST /api/maintenance/ backfill-occurred-utc (dry-run by default, batched, idempotent MERGE of only OccurredUtc, table=audit|ops) — Events rows are deliberately never mutated; their read path decodes the RowKey instead. - Tests: decoders, clause boundary proofs incl. GUID-safety pins, mapper precedence, backfill row decisions (Functions.Tests 3078). - Docs: docs/backend/business-timestamps.md (system Timestamp is never authoritative; migration checklist rule). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d02fa454a7 |
fix(status): honest verdicts across all misclassification audit findings
Misclassification audit 2026-07-16 (1000 non-succeeded sessions, 22 deep
dives) surfaced six systematic misdeclaration sources. This implements
all of them:
1. DecisionCore: HelloNeverObservedPromote in the esp-exit advisory
handler — when the Hello policy read never committed and no wizard
was ever seen, hello_resolution is structurally unresolvable; promote
to AwaitingHello + hello_safety instead of hard-failing a provably
finished enrollment (sessions 2dac8298/7635ab18). Placed after the
enforcement-progress re-arm so active installs still win; advisory-
anchor variant stays exempt.
2. Ingest: both max-lifetime shapes (enrollment_failed/agent_timeout and
agent_shutting_down/max_lifetime) now route through the
EnrollmentTimeoutClassifier instead of hard Failed (tenant a53e67ec:
fully provisioned WG Part 2, user never logged in => AwaitingUser).
agent_timeout no longer counts as HasExplicitFailure; failure
webhooks fire only on actual failure verdicts.
3. Maintenance sweep: dropped the too-broad "IsPreProvisioned ne true"
exclusion (sealed devices are protected by their Pending status);
resumed WG Part-2 runs sweep normally with the window anchored to
ResumedAt. New stage-2 silence guard also fixes a latent bug where an
actively-reporting >5h enrollment could be terminalized mid-run.
4. Registration supersede: a fresh registration resolves older
non-terminal sessions of the same device (serial, placeholder-guarded)
as Incomplete("Superseded by ...") with live SignalR updates — closes
the forever-Pending orphan gap when WG Part 2 runs under a new id.
5. Admin retro-reconcile: POST /api/maintenance/reclassify-legacy
(GlobalAdminOnly, DRY-RUN BY DEFAULT) re-runs the classifier over the
pre-classifier "Session timed out" Failed backlog (~495 rows, sample
projected ~87% misdeclared) and resolves Pending orphans. Requires the
new opt-in allowTerminalReclassification override in the status
writer (admin-marked rows stay untouchable).
6. Polish: "0 of N" AccountSetup rollups keep their total (no more
fabricated "0/0" reasons, "never observed" wording when absent);
late-telemetry reconcile heals Incomplete/AwaitingUser to Succeeded
when straggler evidence proves completion; SLA page knows
AwaitingUser/Incomplete ordinals; script_failed on Success/exit-0 now
names the stderr rule in its message.
Tests: backend 2965, DecisionCore 474 (+6 new), agent 1777, web 557 —
all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
b0105b123a |
feat(maintenance): session-deletion run budget, live watchdog counters, manual trigger
- Watchdog fix: FanoutResult is caller-allocated and mutated live per tenant/session, so 30/60min LongRunning warnings report real progress instead of the always-zero counters (today's warning showed 0/0 while 5 tenants / 54 sessions were already done). - 50min run budget: the retention fanout stops cleanly at the deadline (tenant boundary + per-session check) and reports AbortedByBudget — no more risk of the 60min Flex functionTimeout hard-aborting mid-run. New Warning OpsEvent SessionDeletionMaintenanceBudgetExceeded; Completed gains abortedByBudget. - Blob-lease serialization: dedicated sentinel _lock/session-deletion-maintenance.lock (deletion-manifests container) via SessionDeletionMaintenanceLockStore + reused MaintenanceLeaseHolder; contended runs emit SessionDeletionMaintenanceSkippedLocked and return. - Manual trigger: POST /api/global/session-deletions/maintenance/trigger (GlobalAdminOnly, policy catalog) -> fail-hard queue producer -> SessionDeletionMaintenanceQueueWorker runs the same RunCoreAsync body with triggeredBy = requesting admin; lease probe returns 409 while a run is active. New Started OpsEvent carries triggeredBy. - Web: MaintenanceStatusBanner on the Session Cleanup page derives active / budget-exceeded / failed / idle from the lifecycle ops events and offers a Run-now button (202/409 handling). New event types dual-registered in OPS_EVENT_TYPES. - Tests: 7 new (budget abort immediate/mid-tenant, live counter increments, watchdog live-progress regression, BudgetExceeded + Completed flag, SkippedLocked, Started triggeredBy); suites green (backend 2935, web 557, tsc clean). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d8e8cd15fd |
feat(entitlements): Community/Enterprise editions with self-service trial
Fail-closed entitlement layer (FeatureEntitlementCatalog + TenantEntitlementService): edition resolves at read time (PlanTier == 'enterprise' OR active trial; legacy free/pro => Community, no migration).
- Plan/trial endpoints: PATCH config/{tid}/plan (GA: tier + grant/extend/end trial, audited, cache-safe) and POST config/{tid}/trial (tenant-admin self-service 30d, once per tenant, 409 after). Generic PUT preserves plan/trial fields for all callers. Fixes SetPlanTier writing past the config cache without audit/UpdatedBy.
- Rate limits: Enterprise floor 150/min for BOTH user (portal/JWT) and device (agent/cert) paths - floor raises the global default, an explicit per-tenant override still wins; MSP users follow their home tenant.
- Retention cap: Community 90d / Enterprise 365d, clamped in the retention fanout (warn log) + non-GA write validation 7..cap (only on changed values; 0=infinite stays GA-only).
- Delegated admin (MSP): Enterprise gate on the delegated admin's HOME tenant (JWT tid, fail-closed on unknown) - managed target tenants may be any edition. Grant-time target check removed; inert grants are suppressed at resolve time (single choke point in GetScopeAsync, applied after the UPN-keyed cache).
- MCP quota: McpQuotaService + McpQuotaEnforcementMiddleware (X-Client-Source: mcp only, GA exempt) enforce daily/monthly budgets - per-user plan override > tenant edition plan; limits from SectionUsagePlans with catalog fallbacks (community 100/3000, enterprise 1000/20000); 429 with X-MCP-Quota-* headers + Retry-After; fail-open on counter reads, fail-closed on plan resolution. /api/metrics/mcp-usage/me reports effective plan + counters.
- Trial visibility: TrialExpirySweepFunction (daily 03:30 UTC, informational) emits TenantTrialExpired (Warning, 24h look-back) + TenantTrialExpiring (Info, <=3d heads-up); dual-registered in web OPS_EVENT_TYPES.
- Web: EditionBadge + one-time trial CTA in settings header, Plan & Trial card in the GA tenant edit modal (own PATCH save), retention cap hints from entitlements, pure lib/edition.ts (vitest), feature-flags surface edition/trial/entitlements; MCP TENANT_SAFE_FIELDS + trial fields.
Verified: solution build clean; backend 2653/2653 tests; web tsc + 516/516 vitest; MCP server tsc + tests green.
|
||
|
|
4a33b96db2 |
feat(ratelimit): unify device+user limits as global default + per-tenant override
Replace the denormalized per-tenant RateLimitRequestsPerMinute mirror (and its SaveConfigurationAsync sync job) with compute-at-read resolution: effective limit = per-tenant override ?? global default. Fixes the clobbering bug where a per-tenant edit to the base field was overwritten on the next global config save. - Model: drop TenantConfiguration.RateLimitRequestsPerMinute; add nullable CustomUserRateLimitRequestsPerMinute (device override CustomRateLimit... kept). - RateLimitResolver centralizes precedence (device + user); both paths call it. - SecurityValidator reads the global default (AdminConfigurationService threaded through ValidateSecurityAsync + all device-auth callers); 5-min cached. - UserRateLimitMiddleware: per-tenant user override for tenant-scoped callers only (skipped for HasGlobalScope = GlobalAdmin|GlobalReader); GA -> GA budget, others -> user default. Uses GetConfigurationIfExistsAsync so an unregistered-tenant JWT can no longer auto-create a config row via read endpoints. - Validation: reject rate limits < 1 at the write functions; RateLimitService clamps < 1 to 1 as a safety net (avoids Min()-on-empty / throttle-everything). - Web: Global Settings exposes User + GlobalAdmin limits; tenant modal writes the override fields (blank = inherit); report + usage-plans updated. Legacy RateLimitRequestsPerMinute Table column is ignored on read and dropped on the next Replace save (self-cleaning; no migration). Backend 2577 xUnit green, tsc clean, web 506 vitest green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e9c5bfbafc |
fix(security): backend-review M1-M3 — kill-switch + MCP cache TTL + SignalR broadcast gate
M1: hoist the McpUsers Disabled-row kill-switch above the policy switch so an
explicitly disabled account is denied MCP access under EVERY policy and grant
path (AllMembers, platform roles, delegated auto-grant) — previously inert
outside the WhitelistOnly branch.
M2: drop the MCP access-guard verdict cache from 5 min to 60s. The verdict
carries the kill-switch and the delegated tenant set; 5 min stretched the
backend's 30s revocation target to minutes for MCP callers.
M3: gate same-tenant joins of the plain tenant-{tid} SignalR broadcast group
on a member role (new pure SignalRGroupHelper.IsTenantBroadcastJoinDenied) —
roleless authenticated users (Progress Portal) could passively stream
MemberRead-tier live telemetry they cannot fetch over REST. Companions:
MarkSessionSucceeded/Failed additionally push "newevents" to the per-session
group so a roleless progress watcher still sees admin-marked completion, and
the Progress Portal no longer joins the tenant group (session group covers
all its signals).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
aa9adfbac0 |
refactor: move diag-files report submit to /api/diagnostics/files
The old route /api/diag-files-reports was not covered by any clientCertExclusionPaths prefix on the Function App, so the platform cert gate (clientCertMode=Required) prompted portal users for a client certificate. /api/diagnostics is already excluded, and the endpoint submits diagnostic files, so it moves under that prefix instead of growing the portal-only exclusion list. 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>
|
||
|
|
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> |