diff --git a/docs/backend/session-owner-binding.md b/docs/backend/session-owner-binding.md new file mode 100644 index 00000000..dc033c37 --- /dev/null +++ b/docs/backend/session-owner-binding.md @@ -0,0 +1,157 @@ +--- +type: Concept +title: Session Owner Binding — Session-Level Authorization for Agent Writes +description: Why tenant-level agent authentication (any device certificate, or a bootstrap token) was enough to rewrite every session in the tenant, how each Sessions row is now bound to the device identity that created it, the rule matrix that keeps every legitimate lifecycle working, the shadow-then-enforce rollout, and why enforcement needs the agent to rotate its session id rather than count an auth failure. +resource: src/Backend/AutopilotMonitor.Functions/Security/SessionOwnershipPolicy.cs +tags: + - backend + - security + - authorization + - sessions + - agent + - multi-tenant +timestamp: 2026-08-29T00:00:00+02:00 +--- + +# Problem + +Agent endpoints authenticate at **tenant** granularity: a chain-valid Intune MDM certificate whose +tenant stamp matches the request (see [client-cert-tenant-binding.md](client-cert-tenant-binding.md)), +or a bootstrap token minted for the tenant. They authorize writes at **session** granularity using a +session id the caller supplies — `IngestTelemetryFunction` takes it from the `PartitionKey` of the +first item, `RegisterSessionFunction` from the body, `ReportAgentErrorFunction` from the body. Until +2026-08-29 nothing on the Sessions row recorded which device created it, so nothing could be compared. + +The consequence: one enrolled device — or anyone who saw a 6-character bootstrap short code — could, +for every other session of the tenant, forge `enrollment_complete` / failure events (flipping the live +status, firing webhooks and SLA logic), pollute the timeline, re-register the session with different +device metadata (which also redirects the Progress-Portal serial-knowledge proof), and — worst — send a +Signal-only batch that **fetches and clears the victim's pending ServerActions**, silently stealing +admin commands such as diagnostics collection. Session ids are GUIDs, but they travel in portal URLs +and tenant SignalR messages; they were never meant to be a capability. + +# Mechanism + +## Owner on the row + +`SessionOwner` (Shared) is stamped by the backend from the validated caller identity — never from +agent input — and lives in six primary-only Sessions columns (`OwnerKind`, `OwnerThumbprint`, +`OwnerDeviceId`, `OwnerBootstrapCode`, `OwnerSerial`, `OwnerBoundAt`). They are listed in +`SessionIndexFieldManifest.PrimaryOnly`: never mirrored to `SessionsIndex`, never on `SessionSummary`, +so no certificate identity travels to clients. + +| Auth path | Identity that binds | Proven by | +|---|---|---| +| Certificate | `OwnerThumbprint` + `OwnerDeviceId` (Intune device id from the certificate CN, lower-case GUID) | TLS handshake; the CN survives a certificate re-issue | +| Bootstrap token | `OwnerBootstrapCode` + `OwnerSerial` | Token possession + the serial the caller announced (header, unverified) | + +`SecurityValidationResult.IntuneDeviceId` is new: the CN was already extracted for the Cloud PC +validator, now it is surfaced on the result for every certificate-authenticated request. + +## Rule matrix + +`SessionOwnershipPolicy.Evaluate(existingRow, validation, now)` is pure. It returns an outcome, the +owner to stamp (if any) and whether the caller's serial equals the row's serial. + +| Caller | Row owner | Outcome | Stamps | Would reject | +|---|---|---|---|---| +| any | no row | `Fresh` | caller | no | +| any | no owner columns (legacy), serial equal | `ClaimLegacy` | caller | no | +| any | no owner columns, serial differs | `LegacySerialMismatch` | — | **yes** | +| any | — (validation carried neither thumbprint nor code) | `CallerUnidentified` | — | no | +| Cert | Cert, same thumbprint | `Match` | — | no | +| Cert | Cert, other thumbprint, same device id | `RebindCertRotation` | caller | no | +| Cert | Cert, other thumbprint and device id | `MismatchCert` (+ `serialMatch`) | — | **yes** | +| Cert | Bootstrap, same serial | `RebindBootstrapHandoff` | caller | no | +| Cert | Bootstrap, other serial | `MismatchBootstrapOwned` | — | **yes** | +| Bootstrap | Bootstrap, same code + serial | `Match` | — | no | +| Bootstrap | Bootstrap, otherwise | `MismatchBootstrap` | — | **yes** | +| Bootstrap | Cert | `DowngradeToBootstrap` | — | **yes** | + +The tolerated rows are the legitimate lifecycle: agent restart and reboot (same certificate), +WhiteGlove Part 2 (same certificate), the install→runtime handoff where a bootstrap-registered session +continues under the MDM certificate once it exists, a re-issued certificate of the same Intune device, +and rows that predate the binding. + +`MismatchCert` with `serialMatch=true` is the one shape that is *not* an attack: an Intune +re-enrollment **without a wipe**. `session.id` survives in `%ProgramData%\AutopilotMonitor`, but the +device gets a new Intune device id and therefore a new certificate identity. Under enforcement this +would be refused — which is why enforcement needs the agent-side rotation below. + +## Where it runs + +`SessionOwnerBindingObserver` is the single side-effect carrier; the three call sites hand it the +row they already hold: + +* `RegisterSessionFunction.ProcessRegisterAsync` — the cascade-delete guard now returns the row + (`EnsureWritableAndGetRowAsync`); the decision's owner goes into `StoreSessionAsync(registration, + ownerToStamp)`, whose Replace preserves an existing owner when nothing new is stamped (including a + Merge-stamped owner that landed after the initial read — the CAS re-read selects the owner columns). + The bootstrap wrapper inherits this. +* `IngestTelemetryFunction.Run` — evaluated against `guardSessionRow` (zero extra reads) before + `PersistItemsAsync`, so the Signal-only path that serves pending ServerActions is covered. Legacy + claims and rebinds are written with `UpdateSessionOwnerAsync` (Merge, fail-soft). +* `ReportAgentErrorFunction` and its bootstrap wrapper — one point-read, observe only. + +## Shadow telemetry (stage 1) + +Same three carriers as the cert-tenant binding: + +* **Denominator** — `requests | extend o = tostring(customDimensions.SessionOwnerBinding) | where + isnotempty(o) | summarize count() by o` (request-row dimension via `RequestTelemetryMiddleware`; + worker `LogInformation` never reaches App Insights). +* **Numerator** — `traces | where message startswith "AgentSessionOwnerBinding"` — one Warning per + non-Match/non-Fresh request with `outcome`, `wouldReject`, `callerKind`, `ownerKind`, `serialMatch`, + `endpoint`, `ver`. +* **Operator signal** — ops event `SessionOwnerMismatch` (Security / Warning, dual-registered in the + admin alert-rule catalog) for would-reject outcomes, throttled to one per session+outcome per hour. + `serialMatch=true` counts re-enrollments without wipe; `serialMatch=false` is a foreign device. + +Nothing is refused in stage 1. `SessionOwnershipPolicy` deliberately has no `Rejects` member +(pinned by `SessionOwnershipPolicyTests.Stage1_has_no_Rejects_rule`). + +## Enforcement contract (stage 2, not yet built) + +* Backend: `Rejects(outcome)` = the would-reject set; register/ingest/error answer 403 with + `RegisterSessionResponse.ErrorCode = Constants.AgentErrorCodes.SessionOwnerMismatch` + (`"session_owner_mismatch"`). The code and field already exist so the agent could ship first. +* Agent (shipped dormant with this change): `BackendApiClient` reads `errorCode` off a JSON 401/403 + body into `BackendAuthException.ErrorCode`; `SessionRegistrationHelper` reacts to exactly that code + by calling `rotateSession` once and re-registering immediately — **without** feeding + `AuthFailureTracker` (five consecutive 403s would otherwise soft-shutdown a perfectly authorized + agent). `BackendSessionRegistration.RotateSession` rotates `session.id` (keeping + `whiteglove.complete`), re-targets `agentConfig.SessionId` and the `EmergencyReporter`, and drops + `spool.jsonl` + `upload-cursor.json` because their lines carry the refused partition key. The + `agent_started` event then reports `sessionRotated=true` + `previousSessionId`. +* Registration is the first session-scoped call of every agent start and everything after Phase 6 + reads `agentConfig.SessionId` live, so a mismatch on ingest can only mean an agent older than the + rotation logic or a genuinely foreign caller. + +Switch on only after: the shadow distribution has run for weeks, every `wouldReject=true` is +explained, and the fleet's `X-Agent-Version` is at or above the rotation-capable release. Then add +`Rejects`, delete the stage-1 pin test, update the trust pages (isolation model) and bump their +"Last reviewed" date. + +# Residual risk (accepted) + +* The serial is a caller-supplied header on both paths. A certificate holder who also knows a victim's + serial can still claim a **legacy** row, or a **bootstrap-owned** row during its handoff window. +* One bootstrap token serves many devices; two devices on the same short code can still write into + each other's session until the certificate handoff closes it. +* `SupersedeOrphanedPredecessorsAsync` is keyed on the announced serial and can still resolve foreign + open sessions to `Incomplete`. Follow-up: restrict to rows without owner or with the same + `OwnerSerial`, and require a minimum age. + +None of these are closable without a signature scheme over the request body, which is deliberately +not built (it would duplicate the TLS proof). + +# Citations + +* `src/Backend/AutopilotMonitor.Functions/Security/SessionOwnershipPolicy.cs` — matrix, `WouldRejectUnderEnforcement`, row (de)serialization +* `src/Backend/AutopilotMonitor.Functions/Services/SessionOwnerBindingObserver.cs` — carriers, throttle, stamping +* `src/Shared/AutopilotMonitor.Shared/Models/Enrollment/SessionOwner.cs` — column names and kinds +* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs` — `StoreSessionAsync(…, ownerToStamp)`, `UpdateSessionOwnerAsync` +* `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs` — rotation branch +* `src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendSessionRegistration.cs` — `RotateSession` +* Tests: `SessionOwnershipPolicyTests`, `SessionOwnerBindingObserverTests`, `StoreSessionReregistrationPreserveTests`, agent `SessionRegistrationHelperTests`, `BackendSessionRegistrationTests`, `SessionIdPersistenceTests`, `BackendApiClientAuthClassificationTests` +* Grep marker: `SESSION-OWNER-BINDING` diff --git a/docs/index.md b/docs/index.md index 89602aab..034e5d69 100644 --- a/docs/index.md +++ b/docs/index.md @@ -59,6 +59,7 @@ https://docs.autopilotmonitor.com (separate repository). * [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract. * [Verdict Calibration](backend/verdict-calibration.md) - How every session status write declares its VerdictPath (origin:detail vocabulary incl. classifier rule ids), how overriding a verdict preserves PriorStatus/PriorVerdictPath as the correction stream, the daily per-path calibration aggregate with the 7-day re-enrollment proxy and read-side legacy derivation, the GA endpoint / MCP tool / admin matrix, and the drift radar (share, silence-share, r6 evidence gap) with its operator-only ops events. * [App Version Duration Regression Radar](backend/app-version-regression-radar.md) - How app versions whose median install duration regressed vs. the previous version are detected (first-seen-ordered versions, median lift ≥2× plus a 5-minute absolute floor over measured installs only), how alert episodes live in the appversionregression tracker keyspace with drain/median re-arm semantics, and the bell/ops-event/analytics surfaces. +* [Session Owner Binding](backend/session-owner-binding.md) - Agent endpoints authenticate per tenant but write per caller-supplied session id; each Sessions row is now bound to the device identity that created it (cert thumbprint + Intune device id from the CN, or bootstrap code + serial), the rule matrix that keeps restart / WhiteGlove Part 2 / bootstrap→cert handoff / cert re-issue working, the shadow telemetry (`SessionOwnerBinding` request dimension, `AgentSessionOwnerBinding` warning, `SessionOwnerMismatch` ops event), and the enforcement contract where a 403 `session_owner_mismatch` makes the agent rotate its session id instead of counting an auth failure. * [Plan Downgrade Policy & the Retention Grace Period](backend/plan-downgrade-retention-grace.md) - What happens when a tenant loses Pro: every entitlement gates read-time and reversibly ("gate, don't destroy"), except retention — a 30-day grace keeps the Pro cap before the Community cap starts hard-deleting; the ProDowngradedUtc/trial-expiry anchor model, the TenantPlanDowngraded/RetentionGrace* ops events, and the deliberate non-grace of everything else. * [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other. * [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else. diff --git a/docs/log.md b/docs/log.md index 5519a1e5..4405cf7b 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,9 @@ # Log +## 2026-08-29 (8) + +* **Creation**: Added `backend/session-owner-binding.md` — SESSION-OWNER-BINDING stage 1 (shadow): Sessions rows carry the creating device identity (`OwnerKind/OwnerThumbprint/OwnerDeviceId/OwnerBootstrapCode/OwnerSerial/OwnerBoundAt`, primary-only), `SessionOwnershipPolicy` evaluates every register / telemetry / error-report request against it, outcomes ride on the request row (`SessionOwnerBinding`), warnings and a throttled `SessionOwnerMismatch` ops event; nothing is refused yet. Agent ships the dormant counterpart: `errorCode` parsing on 401/403, one-shot session rotation on `session_owner_mismatch` without feeding the auth-failure tracker, spool drop, `agent_started.sessionRotated`. + ## 2026-08-29 (7) * **Update**: `backend/client-cert-tenant-binding.md` — new "Certificate transport and proof of possession" section: `X-ARR-ClientCert` (platform-set after the mTLS handshake, inbound copies stripped even on excluded paths) is the only certificate source; the client-writable `X-Client-Certificate` fallback is gone; `/api/bootstrap/*` routes are token-only and fail closed on a missing/empty token or unwired token service instead of falling through to the certificate stage. diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BackendSessionRegistrationTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BackendSessionRegistrationTests.cs index 8ef79e64..53f92419 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BackendSessionRegistrationTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BackendSessionRegistrationTests.cs @@ -58,6 +58,47 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program Assert.Same(registration, result.Registration); } + [Fact] + public void RotateSession_rotates_id_updates_config_drops_spool_and_keeps_whiteglove_marker() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp.Path); + var persistence = new SessionIdPersistence(tmp.Path); + var original = persistence.GetOrCreate(); + persistence.SaveWhiteGloveComplete(); + var config = new AgentConfiguration { ApiBaseUrl = "https://example.invalid", TenantId = "t", SessionId = original }; + + var transportDir = Path.Combine(tmp.Path, "Spool"); + Directory.CreateDirectory(transportDir); + File.WriteAllText(Path.Combine(transportDir, "spool.jsonl"), "{}"); + File.WriteAllText(Path.Combine(transportDir, "upload-cursor.json"), "{}"); + File.WriteAllText(Path.Combine(transportDir, "unrelated.txt"), "keep"); + + var rotated = BackendSessionRegistration.RotateSession(config, persistence, auth: null, transportDir, logger); + + Assert.NotEqual(original, rotated); + Assert.Equal(rotated, config.SessionId); + Assert.Equal(rotated, new SessionIdPersistence(tmp.Path).GetOrCreate()); + Assert.True(persistence.IsWhiteGloveResume()); + Assert.False(File.Exists(Path.Combine(transportDir, "spool.jsonl"))); + Assert.False(File.Exists(Path.Combine(transportDir, "upload-cursor.json"))); + Assert.True(File.Exists(Path.Combine(transportDir, "unrelated.txt"))); + } + + [Fact] + public void RotateSession_tolerates_a_missing_transport_directory() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp.Path); + var persistence = new SessionIdPersistence(tmp.Path); + var config = new AgentConfiguration { ApiBaseUrl = "https://example.invalid", TenantId = "t", SessionId = persistence.GetOrCreate() }; + + var rotated = BackendSessionRegistration.RotateSession( + config, persistence, auth: null, Path.Combine(tmp.Path, "does-not-exist"), logger); + + Assert.Equal(rotated, config.SessionId); + } + [Fact] public void Register_throws_on_null_arguments() { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs index 0ddda5b6..53085c24 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs @@ -72,6 +72,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security Assert.NotEqual(first, second); } + [Fact] + public void Rotate_writes_a_new_id_and_keeps_the_whiteglove_marker() + { + using var tmp = new TempDirectory(); + var sut = new SessionIdPersistence(tmp.Path); + var first = sut.GetOrCreate(); + sut.SaveWhiteGloveComplete(); + var createdBefore = File.ReadAllText(Path.Combine(tmp.Path, "session.created")); + + var rotated = sut.Rotate(); + + Assert.True(Guid.TryParse(rotated, out _)); + Assert.NotEqual(first, rotated); + Assert.Equal(rotated, File.ReadAllText(Path.Combine(tmp.Path, "session.id"))); + Assert.Equal(rotated, new SessionIdPersistence(tmp.Path).GetOrCreate()); + Assert.True(sut.IsWhiteGloveResume(), "Rotate must not clear whiteglove.complete (unlike Delete)"); + Assert.True(File.Exists(Path.Combine(tmp.Path, "session.created"))); + Assert.NotEqual(createdBefore, File.ReadAllText(Path.Combine(tmp.Path, "session.created"))); + } + [Fact] public void Ctor_creates_data_directory_when_missing() { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs index ef0bd288..5657610d 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs @@ -96,6 +96,112 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security Assert.Equal(1, tracker.ConsecutiveFailures); } + // ── SESSION-OWNER-BINDING ──────────────────────────────────────────── + + private static BackendAuthException OwnerMismatch() + => new BackendAuthException("Backend returned 403 Forbidden.", statusCode: 403, + errorCode: AutopilotMonitor.Shared.Constants.AgentErrorCodes.SessionOwnerMismatch); + + [Fact] + public async Task Owner_mismatch_rotates_once_and_re_registers_without_recording_an_auth_failure() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp); + var config = NewConfig(); + var originalSessionId = config.SessionId; + var rotatedTo = Guid.NewGuid().ToString(); + var seenSessionIds = new System.Collections.Generic.List(); + + var apiClient = new FakeApiClient( + attempt => attempt == 1 + ? throw OwnerMismatch() + : new RegisterSessionResponse { Success = true, SessionId = rotatedTo, ValidatedBy = ValidatorType.AutopilotV1 }, + onRegister: r => seenSessionIds.Add(r.SessionId)); + + var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, + clock: SystemClock.Instance, logger: logger); + var rotations = 0; + + var result = await SessionRegistrationHelper.RegisterWithRetryAsync( + apiClient, config, agentVersion: "0.0.0", logger: logger, + authFailureTracker: tracker, + backoffDelay: _ => throw new InvalidOperationException("rotation must retry without backoff"), + networkLinkWait: _ => Task.CompletedTask, + rotateSession: () => { rotations++; config.SessionId = rotatedTo; return rotatedTo; }); + + Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome); + Assert.Equal(2, apiClient.CallCount); + Assert.Equal(1, rotations); + Assert.Equal(0, tracker.ConsecutiveFailures); + Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds); + Assert.Equal(originalSessionId, result.RotatedFromSessionId); + } + + [Fact] + public async Task Second_owner_mismatch_after_rotation_is_an_auth_failure() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp); + var config = NewConfig(); + + var apiClient = new FakeApiClient(_ => throw OwnerMismatch()); + var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, + clock: SystemClock.Instance, logger: logger); + + var result = await SessionRegistrationHelper.RegisterWithRetryAsync( + apiClient, config, agentVersion: "0.0.0", logger: logger, + authFailureTracker: tracker, + networkLinkWait: _ => Task.CompletedTask, + rotateSession: () => { config.SessionId = Guid.NewGuid().ToString(); return config.SessionId; }); + + Assert.Equal(SessionRegistrationOutcome.AuthFailed, result.Outcome); + Assert.Equal(403, result.HttpStatusCode); + Assert.Equal(2, apiClient.CallCount); + Assert.Equal(1, tracker.ConsecutiveFailures); + } + + [Fact] + public async Task Owner_mismatch_without_rotation_hook_behaves_like_any_auth_failure() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp); + var config = NewConfig(); + + var apiClient = new FakeApiClient(_ => throw OwnerMismatch()); + var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, + clock: SystemClock.Instance, logger: logger); + + var result = await SessionRegistrationHelper.RegisterWithRetryAsync( + apiClient, config, agentVersion: "0.0.0", logger: logger, + authFailureTracker: tracker, networkLinkWait: _ => Task.CompletedTask); + + Assert.Equal(SessionRegistrationOutcome.AuthFailed, result.Outcome); + Assert.Equal(1, apiClient.CallCount); + Assert.Equal(1, tracker.ConsecutiveFailures); + Assert.Null(result.RotatedFromSessionId); + } + + [Fact] + public async Task Rotation_hook_failure_falls_back_to_auth_failed() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp); + var config = NewConfig(); + + var apiClient = new FakeApiClient(_ => throw OwnerMismatch()); + var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, + clock: SystemClock.Instance, logger: logger); + + var result = await SessionRegistrationHelper.RegisterWithRetryAsync( + apiClient, config, agentVersion: "0.0.0", logger: logger, + authFailureTracker: tracker, networkLinkWait: _ => Task.CompletedTask, + rotateSession: () => throw new IOException("disk full")); + + Assert.Equal(SessionRegistrationOutcome.AuthFailed, result.Outcome); + Assert.Equal(1, apiClient.CallCount); + Assert.Equal(1, tracker.ConsecutiveFailures); + } + [Fact] public async Task Fails_after_six_exceptions_without_auth_error() { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendApiClientAuthClassificationTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendApiClientAuthClassificationTests.cs index 426569fe..4991d370 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendApiClientAuthClassificationTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendApiClientAuthClassificationTests.cs @@ -61,6 +61,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport Assert.Contains("The device is not authorized", ex.Message); } + [Fact] + public async Task Backend_json_403_carries_the_errorCode_when_present() + { + using var client = BuildClient( + HttpStatusCode.Forbidden, + "{\"success\":false,\"message\":\"Session belongs to another device\",\"errorCode\":\"session_owner_mismatch\"}", + "application/json"); + + var ex = await Assert.ThrowsAsync(() => client.GetAgentConfigAsync("tenant-1")); + + Assert.Equal(403, ex.StatusCode); + Assert.False(ex.EndpointUnavailable); + Assert.Equal("session_owner_mismatch", ex.ErrorCode); + } + + [Fact] + public async Task Backend_json_403_without_errorCode_yields_null() + { + using var client = BuildClient( + HttpStatusCode.Forbidden, + "{\"success\":false,\"message\":\"Device validation failed\"}", + "application/json"); + + var ex = await Assert.ThrowsAsync(() => client.GetAgentConfigAsync("tenant-1")); + Assert.Null(ex.ErrorCode); + } + + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData("not json", null)] + [InlineData("403", null)] + [InlineData("{\"errorCode\":42}", null)] + [InlineData("{\"errorCode\":\" \"}", null)] + [InlineData("{\"errorCode\":\"session_owner_mismatch\"}", "session_owner_mismatch")] + [InlineData("{\"ErrorCode\":\"session_owner_mismatch\"}", "session_owner_mismatch")] + [InlineData(" {\"success\":false,\"errorCode\":\" x \"}", "x")] + public void TryExtractErrorCode_is_tolerant(string? body, string? expected) + => Assert.Equal(expected, BackendApiClient.TryExtractErrorCode(body!)); + [Fact] public async Task Platform_html_403_is_reported_as_endpoint_unavailable_with_page_title() { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs index 260c8aba..3e5e6cc1 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs @@ -326,10 +326,39 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport endpointUnavailable: true); } + // Backend JSON bodies may carry a machine-readable errorCode (Shared + // Constants.AgentErrorCodes) that tells the caller how to react — e.g. + // session_owner_mismatch → rotate the session id instead of treating this as a + // certificate rejection. Absent/unparseable → null, classic auth failure. + var errorCode = TryExtractErrorCode(body); throw new BackendAuthException( $"Backend returned {statusText}. " + "The device is not authorized. Check client certificate and Autopilot device validation.", - (int)response.StatusCode); + (int)response.StatusCode, + errorCode: errorCode); + } + + /// + /// Pulls errorCode out of a backend JSON error body. Tolerant: any parse failure + /// or a non-string value yields null, never an exception. + /// + internal static string TryExtractErrorCode(string body) + { + if (string.IsNullOrWhiteSpace(body) || body.TrimStart().Length == 0 || body.TrimStart()[0] != '{') + return null; + try + { + var obj = Newtonsoft.Json.Linq.JObject.Parse(body); + var token = obj["errorCode"] ?? obj["ErrorCode"]; + if (token == null || token.Type != Newtonsoft.Json.Linq.JTokenType.String) + return null; + var value = (string)token; + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + catch + { + return null; + } } /// @@ -429,10 +458,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport /// public bool EndpointUnavailable { get; } - public BackendAuthException(string message, int statusCode = 0, bool endpointUnavailable = false) : base(message) + /// + /// Machine-readable code from the backend's JSON error body (see Shared + /// Constants.AgentErrorCodes), or null when the body carried none. + /// + public string ErrorCode { get; } + + public BackendAuthException(string message, int statusCode = 0, bool endpointUnavailable = false, string errorCode = null) : base(message) { StatusCode = statusCode; EndpointUnavailable = endpointUnavailable; + ErrorCode = errorCode; } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EmergencyReporter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EmergencyReporter.cs index 51f5d2f3..0333d421 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EmergencyReporter.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EmergencyReporter.cs @@ -28,7 +28,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport private const int MinIntervalMinutes = 10; private readonly BackendApiClient _apiClient; - private readonly string _sessionId; + private volatile string _sessionId; private readonly string _tenantId; private readonly string _agentVersion; private readonly AgentLogger _logger; @@ -52,6 +52,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport _logger = logger; } + /// + /// SESSION-OWNER-BINDING: the reporter is built before registration and snapshots the + /// SessionId; a rotation during registration must be reflected here or later reports + /// would name the refused session. + /// + public void UpdateSessionId(string sessionId) + { + if (!string.IsNullOrWhiteSpace(sessionId)) + _sessionId = sessionId; + } + /// /// Attempts to send an emergency report. Returns immediately without awaiting /// (intended to be called as fire-and-forget via _ = reporter.TrySendAsync(...)). diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs index a1e4b15c..f0967f61 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs @@ -175,6 +175,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Security } } + /// + /// Replaces the persisted SessionId with a fresh GUID and restarts the session-age clock. + /// Used when the backend answers register-session with session_owner_mismatch + /// (SESSION-OWNER-BINDING): the id on disk belongs to a session bound to a different device + /// identity — the re-enroll-without-wipe shape — so this run must become a new session. + /// Unlike this leaves whiteglove.complete untouched: a Part-2 + /// resume that has to rotate is still a Part-2 resume. + /// + /// The new SessionId. + public string Rotate(AgentLogger logger = null) + { + lock (_lockObject) + { + var fresh = Guid.NewGuid().ToString(); + WriteAtomic(fresh, logger); + SaveSessionCreatedAtInternal(DateTime.UtcNow); + logger?.Info($"SessionIdPersistence: rotated SessionId to {fresh}."); + return fresh; + } + } + private static void TryDelete(string path, AgentLogger logger) { try { if (File.Exists(path)) File.Delete(path); } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs index dbc0c971..ed808651 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs @@ -4,6 +4,7 @@ using AutopilotMonitor.Agent.V2.Core.Configuration; using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo; using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; +using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models; namespace AutopilotMonitor.Agent.V2.Core.Security @@ -33,6 +34,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Security /// (which fires the first-failure distress + may trip /// the shutdown threshold) and return /// without retrying — the backend has definitively rejected the device cert. + /// Exception to the above — a 403 carrying error code + /// session_owner_mismatch (SESSION-OWNER-BINDING): the persisted SessionId names a + /// session bound to another device identity (typically an Intune re-enrollment without a + /// wipe, where session.id survived but the certificate identity changed). That is + /// not an auth failure: the session is rotated through rotateSession and + /// registration retried exactly once. The tracker is NOT fed — five of these would + /// otherwise soft-shutdown a perfectly authorized agent. /// Any other exception on the last attempt → /// with AgentErrorType.RegisterSessionFailed so operators see the final cause. /// @@ -72,7 +80,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security Func backoffDelay = null, Func onTerminalTransportFailure = null, (string Manufacturer, string Model, string SerialNumber)? deviceHardware = null, - Func networkLinkWait = null) + Func networkLinkWait = null, + Func rotateSession = null) { if (apiClient == null) throw new ArgumentNullException(nameof(apiClient)); if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig)); @@ -86,6 +95,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security catch (Exception ex) { logger.Debug($"Pre-registration network wait failed ({ex.Message}) — registering anyway."); } string lastError = null; Exception lastException = null; + string rotatedFromSessionId = null; for (int attempt = 1; attempt <= MaxAttempts; attempt++) { @@ -97,12 +107,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Security if (response != null && response.Success) { logger.Info($"Session registered successfully: {response.SessionId} (validatedBy={response.ValidatedBy}, adminAction={response.AdminAction ?? "(none)"})"); - return SessionRegistrationResult.Succeeded(response); + return SessionRegistrationResult.Succeeded(response, rotatedFromSessionId); } lastError = response?.Message ?? "(null response)"; logger.Warning($"Session registration failed: {lastError}"); } + catch (BackendAuthException ex) when ( + ex.ErrorCode == Constants.AgentErrorCodes.SessionOwnerMismatch + && rotateSession != null + && rotatedFromSessionId == null) + { + // SESSION-OWNER-BINDING: the id on disk belongs to a session this device + // identity does not own. Become a new session and register once more — + // immediately, no backoff, no auth-failure bookkeeping. + rotatedFromSessionId = registration.SessionId; + string newSessionId; + try + { + newSessionId = rotateSession(); + } + catch (Exception rotateEx) + { + logger.Error($"Session owner mismatch reported by backend but session rotation failed: {rotateEx.Message}", rotateEx); + authFailureTracker?.RecordFailure(ex.StatusCode, "agent/register-session", ex.EndpointUnavailable); + return SessionRegistrationResult.AuthFailed(ex.StatusCode, ex.Message); + } + logger.Warning( + $"Backend refused registration with {Constants.AgentErrorCodes.SessionOwnerMismatch}: session {rotatedFromSessionId} " + + $"is bound to another device identity (re-enrollment without wipe?). Rotated to {newSessionId}; re-registering."); + registration = BuildRegistration(agentConfig, agentVersion, deviceHardware); + continue; + } catch (BackendAuthException ex) { logger.Error($"Session registration authentication failed ({ex.StatusCode}): {ex.Message}"); @@ -237,20 +273,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Security public string AdminAction => Response?.AdminAction; public ValidatorType ValidatedBy => Response?.ValidatedBy ?? ValidatorType.Unknown; + /// + /// SESSION-OWNER-BINDING: the SessionId this run started with when the backend refused it + /// with session_owner_mismatch and the session was rotated before the successful + /// registration. Null on the normal path. + /// + public string RotatedFromSessionId { get; } + private SessionRegistrationResult( SessionRegistrationOutcome outcome, RegisterSessionResponse response, int httpStatusCode, - string errorMessage) + string errorMessage, + string rotatedFromSessionId = null) { Outcome = outcome; Response = response; HttpStatusCode = httpStatusCode; ErrorMessage = errorMessage; + RotatedFromSessionId = rotatedFromSessionId; } - public static SessionRegistrationResult Succeeded(RegisterSessionResponse response) - => new SessionRegistrationResult(SessionRegistrationOutcome.Succeeded, response, 200, null); + public static SessionRegistrationResult Succeeded(RegisterSessionResponse response, string rotatedFromSessionId = null) + => new SessionRegistrationResult(SessionRegistrationOutcome.Succeeded, response, 200, null, rotatedFromSessionId); public static SessionRegistrationResult AuthFailed(int statusCode, string message) => new SessionRegistrationResult(SessionRegistrationOutcome.AuthFailed, null, statusCode, message); diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs index 448bf310..c8f1aad2 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs @@ -269,7 +269,9 @@ namespace AutopilotMonitor.Agent.V2 // mapping + on-failure client disposal) is encapsulated in BackendSessionRegistration. // Exit codes: 6 = AuthFailed, 7 = anything else. See Runtime/BackendSessionRegistration.cs. var registration = Runtime.BackendSessionRegistration.Register( - bootstrap.AgentConfig, auth, telemetry.MtlsHttpClient, GetAgentVersion(), consoleMode, logger); + bootstrap.AgentConfig, auth, telemetry.MtlsHttpClient, GetAgentVersion(), consoleMode, logger, + rotateSession: () => Runtime.BackendSessionRegistration.RotateSession( + bootstrap.AgentConfig, bootstrap.SessionPersistence, auth, transportDir, logger)); if (registration.ShouldExit) { return registration.ExitCode; diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs index 310c083a..404e7e02 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs @@ -344,7 +344,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime orchestrator.Start(ingress => { lifecyclePost = new InformationalEventPost(ingress, SystemClock.Instance, logger); - LifecycleEmitters.EmitAgentStarted(lifecyclePost, agentConfig, previousExit, agentVersion, remoteConfigService, logger); + LifecycleEmitters.EmitAgentStarted(lifecyclePost, agentConfig, previousExit, agentVersion, remoteConfigService, logger, + rotatedFromSessionId: registrationResult?.RotatedFromSessionId); // Wire-visible signal that the agent is running on defaults/cache // rather than the live tenant config — closes the cold-start // blind spot observed in session 8f2bef72 (2026-05-19). diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendSessionRegistration.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendSessionRegistration.cs index 03ff268e..99ac8b34 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendSessionRegistration.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendSessionRegistration.cs @@ -27,7 +27,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime HttpClient mtlsHttpClient, string agentVersion, bool consoleMode, - AgentLogger logger) + AgentLogger logger, + Func rotateSession = null) { if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig)); if (auth == null) throw new ArgumentNullException(nameof(auth)); @@ -43,7 +44,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime onTerminalTransportFailure: ex => DiagnoseTerminalTransportFailure(ex, auth, logger), // Reuse the single hardened hardware read from the auth bundle so the session row // matches the security headers the backend validated against (no second WMI query). - deviceHardware: (auth.Manufacturer, auth.Model, auth.SerialNumber)) + deviceHardware: (auth.Manufacturer, auth.Model, auth.SerialNumber), + rotateSession: rotateSession) .GetAwaiter().GetResult(); if (registrationResult.Outcome != SessionRegistrationOutcome.Succeeded) @@ -63,6 +65,48 @@ namespace AutopilotMonitor.Agent.V2.Runtime return SessionRegistrationOutcomeResult.Continue(registrationResult); } + /// + /// SESSION-OWNER-BINDING: the one place that turns "backend says this session id is not + /// ours" into a new session. Rotates the persisted id, points the runtime config at it, + /// drops the transport spool (its lines carry the refused session's partition key and + /// would otherwise be uploaded against it after the rotation) and re-targets the + /// emergency reporter, which snapshotted the id before registration. Everything else + /// reads agentConfig.SessionId live after Phase 6. + /// + internal static string RotateSession( + AgentConfiguration agentConfig, + SessionIdPersistence sessionPersistence, + BackendAuthBundle auth, + string transportDir, + AgentLogger logger) + { + var newSessionId = sessionPersistence.Rotate(logger); + agentConfig.SessionId = newSessionId; + auth?.EmergencyReporter?.UpdateSessionId(newSessionId); + DropTransportSpool(transportDir, logger); + return newSessionId; + } + + private static void DropTransportSpool(string transportDir, AgentLogger logger) + { + if (string.IsNullOrWhiteSpace(transportDir)) return; + foreach (var name in new[] { "spool.jsonl", "upload-cursor.json" }) + { + var path = System.IO.Path.Combine(transportDir, name); + try + { + if (!System.IO.File.Exists(path)) continue; + var size = new System.IO.FileInfo(path).Length; + System.IO.File.Delete(path); + logger.Info($"Session rotation: dropped {name} ({size} bytes) — its items belonged to the refused session."); + } + catch (Exception ex) + { + logger.Warning($"Session rotation: could not drop {name}: {ex.Message}"); + } + } + } + /// /// Off-hot-path diagnostic invoked once after all session-registration retries fail. /// Detects whether the terminal exception was a TLS-layer SecureChannelFailure diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs index 72aa1582..be36465a 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs @@ -42,7 +42,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime Program.PreviousExitSummary previousExit, string agentVersion, RemoteConfigService remoteConfigService, - AgentLogger logger) + AgentLogger logger, + string rotatedFromSessionId = null) { try { @@ -67,6 +68,11 @@ namespace AutopilotMonitor.Agent.V2.Runtime { "diagnosticsUploadMode", agentConfig.DiagnosticsUploadMode ?? "Off" }, { "previousExitType", previousExit?.ExitType ?? "unknown" }, { "unrestrictedMode", agentConfig.UnrestrictedMode }, + // SESSION-OWNER-BINDING: true when the backend refused the persisted session + // id (session_owner_mismatch) and this run rotated to a new session before + // registering — the re-enroll-without-wipe shape, made visible on the timeline. + { "sessionRotated", rotatedFromSessionId != null }, + { "previousSessionId", rotatedFromSessionId ?? string.Empty }, // remoteConfigFetched answers "is the agent running on a live-fetched // config" — true also when the post-registration recovery replaced a // failed startup fetch (remoteConfigOutcome then still names the startup diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionOwnerBindingObserverTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionOwnerBindingObserverTests.cs new file mode 100644 index 00000000..5abf72bb --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionOwnerBindingObserverTests.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// SESSION-OWNER-BINDING-SHADOW carriers: the outcome lands on the request row for every +/// request (denominator), non-Match outcomes produce a Warning, would-reject outcomes raise one +/// throttled ops event, stamping is fail-soft, and nothing is ever refused. +/// +public class SessionOwnerBindingObserverTests +{ + private const string Tenant = "11111111-1111-1111-1111-111111111111"; + private const string Session = "22222222-2222-2222-2222-222222222222"; + private const string Thumb1 = "AA11BB22CC33DD44EE55FF6677889900AABBCCDD"; + private const string Thumb2 = "0011223344556677889900AABBCCDDEEFF001122"; + + private sealed class Rig + { + public List Ops { get; } = new(); + public Mock Repo { get; } = new(); + public CapturingLogger Logger { get; } = new(); + public SessionOwnerBindingObserver Sut { get; } + + public Rig() + { + var opsRepo = new Mock(); + opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny())) + .Callback(e => { lock (Ops) Ops.Add(e); }) + .Returns(Task.CompletedTask); + var adminConfig = new Mock( + Mock.Of(), NullLogger.Instance, + new MemoryCache(new MemoryCacheOptions())) { CallBase = false }; + var alertDispatch = new OpsAlertDispatchService( + adminConfig.Object, + new TelegramNotificationService(new HttpClient(), Mock.Of(), + NullLogger.Instance), + new WebhookNotificationService(new HttpClient(), NullLogger.Instance), + NullLogger.Instance); + var opsService = new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch); + + Sut = new SessionOwnerBindingObserver( + Logger, opsService, new MemoryCache(new MemoryCacheOptions()), Repo.Object); + } + } + + private static (HttpRequestData Req, Dictionary Items) Request(string agentVersion = "2.0.9999") + { + var items = new Dictionary(); + var contextMock = new Mock(); + contextMock.SetupGet(c => c.Items).Returns(items); + var reqMock = new Mock(contextMock.Object); + reqMock.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection { { "X-Agent-Version", agentVersion } }); + return (reqMock.Object, items); + } + + private static SecurityValidationResult Cert(string thumb, string serial = "SN-1") => new() + { + IsValid = true, CertificateThumbprint = thumb, IntuneDeviceId = Guid.NewGuid().ToString(), SerialNumber = serial, + }; + + private static TableEntity OwnedRow(string thumb, string serial = "SN-1") + { + var row = new TableEntity(Tenant, Session) { ["SerialNumber"] = serial }; + SessionOwnershipPolicy.ApplyTo(row, new SessionOwner + { + Kind = SessionOwner.Kinds.Cert, Thumbprint = thumb, DeviceId = Guid.NewGuid().ToString(), Serial = serial, BoundAt = DateTime.UtcNow, + }); + return row; + } + + private static async Task> SettleAsync(Rig rig, int expected) + { + // The ops event is fire-and-forget; give the continuation a moment. + for (var i = 0; i < 50; i++) + { + lock (rig.Ops) if (rig.Ops.Count >= expected) break; + await Task.Delay(20); + } + lock (rig.Ops) return rig.Ops.ToList(); + } + + [Fact] + public void Match_lands_on_the_request_row_and_stays_silent() + { + var rig = new Rig(); + var (req, items) = Request(); + + var d = rig.Sut.Observe(req, Tenant, Session, OwnedRow(Thumb1), Cert(Thumb1), "agent/telemetry"); + + Assert.Equal(SessionOwnershipPolicy.Outcome.Match, d.Outcome); + Assert.Equal(SessionOwnershipPolicy.Outcome.Match, items[SessionOwnershipPolicy.RequestItemKey]); + Assert.DoesNotContain(rig.Logger.Entries, e => e.Level == LogLevel.Warning); + Assert.Empty(rig.Ops); + } + + [Fact] + public async Task Would_reject_outcome_warns_and_raises_one_ops_event_per_session_and_outcome() + { + var rig = new Rig(); + var (req, items) = Request("2.0.1500"); + var row = OwnedRow(Thumb1); + + var first = rig.Sut.Observe(req, Tenant, Session, row, Cert(Thumb2, "SN-ATTACKER"), "agent/telemetry"); + var second = rig.Sut.Observe(req, Tenant, Session, row, Cert(Thumb2, "SN-ATTACKER"), "agent/telemetry"); + + Assert.Equal(SessionOwnershipPolicy.Outcome.MismatchCert, first.Outcome); + Assert.True(first.WouldReject); + Assert.Equal(SessionOwnershipPolicy.Outcome.MismatchCert, items[SessionOwnershipPolicy.RequestItemKey]); + + var warnings = rig.Logger.Entries.Where(e => e.Level == LogLevel.Warning).ToList(); + Assert.Equal(2, warnings.Count); + Assert.All(warnings, w => Assert.Contains("AgentSessionOwnerBinding outcome=MismatchCert enforced=False wouldReject=True", w.Message)); + Assert.Contains("serialMatch=False", warnings[0].Message); + Assert.Contains("ver=2.0.1500", warnings[0].Message); + + var ops = await SettleAsync(rig, 1); + var evt = Assert.Single(ops); + Assert.Equal("SessionOwnerMismatch", evt.EventType); + Assert.Equal(OpsEventCategory.Security, evt.Category); + Assert.Equal(Tenant, evt.TenantId); + Assert.Contains("MismatchCert", evt.Message); + Assert.Contains("shadow", evt.Message); + Assert.DoesNotContain(Thumb1, evt.Message); + Assert.DoesNotContain(Thumb2, evt.Message); + Assert.Contains("\"serialMatch\":false", evt.Details); + Assert.Contains("\"enforced\":false", evt.Details); + _ = second; + } + + [Fact] + public void Rebind_warns_but_raises_no_ops_event_and_hands_back_the_owner() + { + var rig = new Rig(); + var (req, _) = Request(); + var row = new TableEntity(Tenant, Session) { ["SerialNumber"] = "SN-1" }; + SessionOwnershipPolicy.ApplyTo(row, new SessionOwner { Kind = SessionOwner.Kinds.Bootstrap, BootstrapCode = "ABC123", Serial = "SN-1", BoundAt = DateTime.UtcNow }); + + var d = rig.Sut.Observe(req, Tenant, Session, row, Cert(Thumb1), "agent/register-session"); + + Assert.Equal(SessionOwnershipPolicy.Outcome.RebindBootstrapHandoff, d.Outcome); + Assert.NotNull(d.OwnerToStamp); + Assert.Contains(rig.Logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("wouldReject=False")); + Assert.Empty(rig.Ops); + } + + [Fact] + public async Task Stamp_writes_the_owner_and_swallows_storage_failures() + { + var rig = new Rig(); + rig.Repo.Setup(r => r.UpdateSessionOwnerAsync(Tenant, Session, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("storage down")); + var (req, _) = Request(); + var legacyRow = new TableEntity(Tenant, Session) { ["SerialNumber"] = "SN-1" }; + + var d = rig.Sut.Observe(req, Tenant, Session, legacyRow, Cert(Thumb1), "agent/telemetry"); + Assert.Equal(SessionOwnershipPolicy.Outcome.ClaimLegacy, d.Outcome); + + await rig.Sut.StampAsync(Tenant, Session, d); + + rig.Repo.Verify(r => r.UpdateSessionOwnerAsync(Tenant, Session, It.Is(o => o.Thumbprint == Thumb1)), Times.Once); + Assert.Contains(rig.Logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("stamp failed")); + } + + [Fact] + public async Task Stamp_is_a_no_op_without_an_owner() + { + var rig = new Rig(); + await rig.Sut.StampAsync(Tenant, Session, new SessionOwnershipPolicy.Decision(SessionOwnershipPolicy.Outcome.Match, null, true)); + rig.Repo.Verify(r => r.UpdateSessionOwnerAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + private sealed record LogEntry(LogLevel Level, string Message); + + private sealed class CapturingLogger : ILogger + { + public List Entries { get; } = new(); + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => Entries.Add(new LogEntry(logLevel, formatter(state, exception))); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionOwnershipPolicyTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionOwnershipPolicyTests.cs new file mode 100644 index 00000000..da9aef48 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionOwnershipPolicyTests.cs @@ -0,0 +1,267 @@ +using System; +using System.Linq; +using System.Reflection; +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// SESSION-OWNER-BINDING rule matrix. The policy is pure, so every lifecycle shape that must keep +/// working (restart, WhiteGlove Part 2, bootstrap→cert handoff, certificate re-issue, rows from +/// before the binding) and every shape that must be visible as a would-reject (foreign cert, +/// foreign bootstrap code, bootstrap on a cert-owned row, re-enroll without wipe) is pinned here. +/// +public class SessionOwnershipPolicyTests +{ + private static readonly DateTime Now = new(2026, 8, 29, 12, 0, 0, DateTimeKind.Utc); + private const string Thumb1 = "AA11BB22CC33DD44EE55FF6677889900AABBCCDD"; + private const string Thumb2 = "0011223344556677889900AABBCCDDEEFF001122"; + private const string Dev1 = "0f4e1c2d-1111-4aaa-8bbb-000000000001"; + private const string Dev2 = "0f4e1c2d-2222-4aaa-8bbb-000000000002"; + + private static SecurityValidationResult Cert(string thumb, string? deviceId, string? serial = "SN-1") => new() + { + IsValid = true, + CertificateThumbprint = thumb, + IntuneDeviceId = deviceId, + SerialNumber = serial, + ValidatedBy = ValidatorType.AutopilotV1, + }; + + private static SecurityValidationResult Bootstrap(string code, string? serial = "SN-1") => new() + { + IsValid = true, + IsBootstrapAuth = true, + BootstrapShortCode = code, + SerialNumber = serial, + ValidatedBy = ValidatorType.Bootstrap, + }; + + private static TableEntity Row(string? serial = "SN-1", SessionOwner? owner = null) + { + var row = new TableEntity("t", "s") { ["SerialNumber"] = serial ?? string.Empty }; + if (owner != null) SessionOwnershipPolicy.ApplyTo(row, owner); + return row; + } + + private static SessionOwner CertOwner(string thumb, string? deviceId, string serial = "SN-1") => + new() { Kind = SessionOwner.Kinds.Cert, Thumbprint = thumb, DeviceId = deviceId, Serial = serial, BoundAt = Now }; + + private static SessionOwner BootstrapOwner(string code, string serial = "SN-1") => + new() { Kind = SessionOwner.Kinds.Bootstrap, BootstrapCode = code, Serial = serial, BoundAt = Now }; + + // ── no row / unidentified ──────────────────────────────────────────────── + + [Fact] + public void No_row_is_Fresh_and_yields_the_caller_as_owner() + { + var d = SessionOwnershipPolicy.Evaluate(null, Cert(Thumb1, Dev1), Now); + Assert.Equal(SessionOwnershipPolicy.Outcome.Fresh, d.Outcome); + Assert.NotNull(d.OwnerToStamp); + Assert.Equal(SessionOwner.Kinds.Cert, d.OwnerToStamp!.Kind); + Assert.Equal(Thumb1, d.OwnerToStamp.Thumbprint); + Assert.Equal(Dev1, d.OwnerToStamp.DeviceId); + Assert.Equal("SN-1", d.OwnerToStamp.Serial); + Assert.False(d.WouldReject); + } + + [Fact] + public void Validation_without_thumbprint_or_code_is_CallerUnidentified_and_never_rejects() + { + var v = new SecurityValidationResult { IsValid = true, SerialNumber = "SN-1" }; + var d = SessionOwnershipPolicy.Evaluate(Row(owner: CertOwner(Thumb1, Dev1)), v, Now); + Assert.Equal(SessionOwnershipPolicy.Outcome.CallerUnidentified, d.Outcome); + Assert.Null(d.OwnerToStamp); + Assert.False(d.WouldReject); + } + + // ── legacy rows (pre-binding) ──────────────────────────────────────────── + + [Fact] + public void Legacy_row_with_same_serial_is_claimed() + { + var d = SessionOwnershipPolicy.Evaluate(Row(serial: "sn-1"), Cert(Thumb1, Dev1, "SN-1 "), Now); + Assert.Equal(SessionOwnershipPolicy.Outcome.ClaimLegacy, d.Outcome); + Assert.NotNull(d.OwnerToStamp); + Assert.True(d.SerialMatch); + Assert.False(d.WouldReject); + } + + [Fact] + public void Legacy_row_with_different_serial_would_reject_and_is_not_claimed() + { + var d = SessionOwnershipPolicy.Evaluate(Row(serial: "OTHER"), Cert(Thumb1, Dev1), Now); + Assert.Equal(SessionOwnershipPolicy.Outcome.LegacySerialMismatch, d.Outcome); + Assert.Null(d.OwnerToStamp); + Assert.True(d.WouldReject); + } + + [Fact] + public void Legacy_row_without_serial_cannot_be_claimed() + { + var d = SessionOwnershipPolicy.Evaluate(Row(serial: ""), Cert(Thumb1, Dev1), Now); + Assert.Equal(SessionOwnershipPolicy.Outcome.LegacySerialMismatch, d.Outcome); + } + + // ── cert caller ────────────────────────────────────────────────────────── + + [Fact] + public void Same_thumbprint_is_Match_case_insensitively() + { + var d = SessionOwnershipPolicy.Evaluate(Row(owner: CertOwner(Thumb1, Dev1)), Cert(Thumb1.ToLowerInvariant(), Dev1), Now); + Assert.Equal(SessionOwnershipPolicy.Outcome.Match, d.Outcome); + Assert.Null(d.OwnerToStamp); + } + + [Fact] + public void Reissued_certificate_of_the_same_device_rebinds() + { + var d = SessionOwnershipPolicy.Evaluate(Row(owner: CertOwner(Thumb1, Dev1)), Cert(Thumb2, Dev1.ToUpperInvariant()), Now); + Assert.Equal(SessionOwnershipPolicy.Outcome.RebindCertRotation, d.Outcome); + Assert.Equal(Thumb2, d.OwnerToStamp!.Thumbprint); + Assert.Equal(Dev1, d.OwnerToStamp.DeviceId); + Assert.False(d.WouldReject); + } + + [Fact] + public void Foreign_certificate_is_MismatchCert_with_serialMatch_false() + { + var d = SessionOwnershipPolicy.Evaluate(Row(owner: CertOwner(Thumb1, Dev1)), Cert(Thumb2, Dev2, "SN-ATTACKER"), Now); + Assert.Equal(SessionOwnershipPolicy.Outcome.MismatchCert, d.Outcome); + Assert.False(d.SerialMatch); + Assert.Null(d.OwnerToStamp); + Assert.True(d.WouldReject); + } + + [Fact] + public void Reenroll_without_wipe_is_MismatchCert_with_serialMatch_true() + { + // Same chassis (serial), new Intune device id + new certificate, old session.id on disk. + var d = SessionOwnershipPolicy.Evaluate(Row(owner: CertOwner(Thumb1, Dev1)), Cert(Thumb2, Dev2, "SN-1"), Now); + Assert.Equal(SessionOwnershipPolicy.Outcome.MismatchCert, d.Outcome); + Assert.True(d.SerialMatch); + Assert.True(d.WouldReject); + } + + [Fact] + public void Cert_without_device_id_never_rebinds_on_thumbprint_change() + { + var d = SessionOwnershipPolicy.Evaluate(Row(owner: CertOwner(Thumb1, null)), Cert(Thumb2, null), Now); + Assert.Equal(SessionOwnershipPolicy.Outcome.MismatchCert, d.Outcome); + } + + [Fact] + public void Cert_on_bootstrap_owned_row_with_same_serial_is_the_handoff_and_rebinds_to_cert() + { + var d = SessionOwnershipPolicy.Evaluate(Row(owner: BootstrapOwner("ABC123")), Cert(Thumb1, Dev1), Now); + Assert.Equal(SessionOwnershipPolicy.Outcome.RebindBootstrapHandoff, d.Outcome); + Assert.Equal(SessionOwner.Kinds.Cert, d.OwnerToStamp!.Kind); + Assert.Null(d.OwnerToStamp.BootstrapCode); + Assert.False(d.WouldReject); + } + + [Fact] + public void Cert_on_bootstrap_owned_row_with_other_serial_would_reject() + { + var d = SessionOwnershipPolicy.Evaluate(Row(owner: BootstrapOwner("ABC123")), Cert(Thumb1, Dev1, "OTHER"), Now); + Assert.Equal(SessionOwnershipPolicy.Outcome.MismatchBootstrapOwned, d.Outcome); + Assert.True(d.WouldReject); + } + + // ── bootstrap caller ───────────────────────────────────────────────────── + + [Fact] + public void Bootstrap_same_code_and_serial_is_Match() + { + var d = SessionOwnershipPolicy.Evaluate(Row(owner: BootstrapOwner("ABC123")), Bootstrap("abc123"), Now); + Assert.Equal(SessionOwnershipPolicy.Outcome.Match, d.Outcome); + } + + [Theory] + [InlineData("XYZ789", "SN-1")] + [InlineData("ABC123", "SN-2")] + public void Bootstrap_with_other_code_or_serial_would_reject(string code, string serial) + { + var d = SessionOwnershipPolicy.Evaluate(Row(owner: BootstrapOwner("ABC123")), Bootstrap(code, serial), Now); + Assert.Equal(SessionOwnershipPolicy.Outcome.MismatchBootstrap, d.Outcome); + Assert.True(d.WouldReject); + } + + [Fact] + public void Bootstrap_on_cert_owned_row_is_a_downgrade_and_would_reject() + { + var d = SessionOwnershipPolicy.Evaluate(Row(owner: CertOwner(Thumb1, Dev1)), Bootstrap("ABC123"), Now); + Assert.Equal(SessionOwnershipPolicy.Outcome.DowngradeToBootstrap, d.Outcome); + Assert.True(d.WouldReject); + } + + [Fact] + public void Bootstrap_validation_without_short_code_is_unidentified() + { + var v = new SecurityValidationResult { IsValid = true, IsBootstrapAuth = true, SerialNumber = "SN-1" }; + Assert.Equal(SessionOwnershipPolicy.Outcome.CallerUnidentified, SessionOwnershipPolicy.Evaluate(Row(), v, Now).Outcome); + } + + // ── row round-trip ─────────────────────────────────────────────────────── + + [Fact] + public void ApplyTo_and_FromRow_round_trip_and_clear_the_other_kind() + { + var row = Row(owner: BootstrapOwner("ABC123")); + SessionOwnershipPolicy.ApplyTo(row, CertOwner(Thumb1, Dev1)); + + Assert.False(row.ContainsKey(SessionOwner.Columns.BootstrapCode)); + var back = SessionOwnershipPolicy.FromRow(row)!; + Assert.Equal(SessionOwner.Kinds.Cert, back.Kind); + Assert.Equal(Thumb1, back.Thumbprint); + Assert.Equal(Dev1, back.DeviceId); + Assert.Equal("SN-1", back.Serial); + Assert.Equal(Now, back.BoundAt); + Assert.Equal(DateTimeKind.Utc, back.BoundAt.Kind); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Unknown")] + public void FromRow_treats_unknown_kind_as_legacy(string? kind) + { + var row = Row(); + if (kind != null) row[SessionOwner.Columns.Kind] = kind; + Assert.Null(SessionOwnershipPolicy.FromRow(row)); + } + + // ── enforcement rule pins ──────────────────────────────────────────────── + + [Theory] + [InlineData(SessionOwnershipPolicy.Outcome.Fresh)] + [InlineData(SessionOwnershipPolicy.Outcome.Match)] + [InlineData(SessionOwnershipPolicy.Outcome.ClaimLegacy)] + [InlineData(SessionOwnershipPolicy.Outcome.RebindCertRotation)] + [InlineData(SessionOwnershipPolicy.Outcome.RebindBootstrapHandoff)] + [InlineData(SessionOwnershipPolicy.Outcome.CallerUnidentified)] + public void Lifecycle_outcomes_are_deliberately_tolerated(string outcome) + => Assert.False(SessionOwnershipPolicy.WouldRejectUnderEnforcement(outcome)); + + [Theory] + [InlineData(SessionOwnershipPolicy.Outcome.LegacySerialMismatch)] + [InlineData(SessionOwnershipPolicy.Outcome.MismatchBootstrapOwned)] + [InlineData(SessionOwnershipPolicy.Outcome.MismatchCert)] + [InlineData(SessionOwnershipPolicy.Outcome.MismatchBootstrap)] + [InlineData(SessionOwnershipPolicy.Outcome.DowngradeToBootstrap)] + public void Foreign_identity_outcomes_would_reject(string outcome) + => Assert.True(SessionOwnershipPolicy.WouldRejectUnderEnforcement(outcome)); + + [Fact] + public void Stage1_has_no_Rejects_rule() + { + // Enforcement is a deliberate, visible code change (stage 2) — not something that can + // slip in through a refactor. Delete this test in the change that adds Rejects. + var rejects = typeof(SessionOwnershipPolicy).GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name == "Rejects").ToArray(); + Assert.Empty(rejects); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/StoreSessionReregistrationPreserveTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/StoreSessionReregistrationPreserveTests.cs index b4e51cc4..1b43b86c 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/StoreSessionReregistrationPreserveTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/StoreSessionReregistrationPreserveTests.cs @@ -305,6 +305,84 @@ public class StoreSessionReregistrationPreserveTests /// try/catch in the SUT, so leaving those table clients unconfigured (null) is harmless and /// keeps the test focused on the Sessions Replace payload. /// + // ── SESSION-OWNER-BINDING ──────────────────────────────────────────────── + + [Fact] + public async Task StoreSessionAsync_preserves_the_owner_when_no_owner_is_stamped() + { + var existing = new TableEntity(TenantId, SessionId) + { + ["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)), + ["Status"] = "InProgress", + ["OwnerKind"] = "Cert", + ["OwnerThumbprint"] = "AA11BB22", + ["OwnerDeviceId"] = "0f4e1c2d-1111-4aaa-8bbb-000000000001", + ["OwnerSerial"] = "SN-1", + ["OwnerBoundAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 5, DateTimeKind.Utc)), + }; + existing.ETag = new ETag("0xEXISTING"); + var harness = new Harness(existing); + + var ok = await harness.Sut.StoreSessionAsync(new SessionRegistration + { + TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1", + StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc), + }); + + Assert.True(ok); + Assert.NotNull(harness.Written); + Assert.Equal("Cert", harness.Written!["OwnerKind"]); + Assert.Equal("AA11BB22", harness.Written["OwnerThumbprint"]); + Assert.Equal("0f4e1c2d-1111-4aaa-8bbb-000000000001", harness.Written["OwnerDeviceId"]); + Assert.Equal("SN-1", harness.Written["OwnerSerial"]); + Assert.True(harness.Written.ContainsKey("OwnerBoundAt")); + } + + [Fact] + public async Task StoreSessionAsync_replaces_the_owner_when_one_is_stamped_and_clears_the_other_kind() + { + var existing = new TableEntity(TenantId, SessionId) + { + ["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)), + ["Status"] = "InProgress", + ["OwnerKind"] = "Bootstrap", + ["OwnerBootstrapCode"] = "ABC123", + ["OwnerSerial"] = "SN-1", + ["OwnerBoundAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 5, DateTimeKind.Utc)), + }; + existing.ETag = new ETag("0xEXISTING"); + var harness = new Harness(existing); + + var boundAt = new DateTime(2026, 1, 1, 9, 0, 1, DateTimeKind.Utc); + var ok = await harness.Sut.StoreSessionAsync(new SessionRegistration + { + TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1", + StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc), + }, new SessionOwner { Kind = "Cert", Thumbprint = "CC33DD44", DeviceId = "dev-1", Serial = "SN-1", BoundAt = boundAt }); + + Assert.True(ok); + Assert.Equal("Cert", harness.Written!["OwnerKind"]); + Assert.Equal("CC33DD44", harness.Written["OwnerThumbprint"]); + Assert.False(harness.Written.ContainsKey("OwnerBootstrapCode")); + Assert.Equal(new DateTimeOffset(boundAt), harness.Written.GetDateTimeOffset("OwnerBoundAt")); + } + + [Fact] + public async Task StoreSessionAsync_fresh_row_carries_the_stamped_owner() + { + var harness = new Harness(existing: null); + var ok = await harness.Sut.StoreSessionAsync(new SessionRegistration + { + TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1", + StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc), + }, new SessionOwner { Kind = "Bootstrap", BootstrapCode = "ABC123", Serial = "SN-1", BoundAt = DateTime.UtcNow }); + + Assert.True(ok); + Assert.Equal("Bootstrap", harness.Written!["OwnerKind"]); + Assert.Equal("ABC123", harness.Written["OwnerBootstrapCode"]); + Assert.False(harness.Written.ContainsKey("OwnerThumbprint")); + } + private sealed class Harness { public Mock Sessions { get; } diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs index b103ac77..d6a8ce92 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs @@ -22,9 +22,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage _publisher = publisher; } - public async Task StoreSessionAsync(SessionRegistration registration) + public async Task StoreSessionAsync(SessionRegistration registration, SessionOwner? ownerToStamp = null) { - var result = await _storage.StoreSessionAsync(registration); + var result = await _storage.StoreSessionAsync(registration, ownerToStamp); if (result) await _publisher.PublishAsync("session.created", registration, registration.TenantId); return result; @@ -137,6 +137,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public Task> GetImeOffsetOriginHistogramAsync(string tenantId, string sessionId, int maxRows = 500) => _storage.GetImeOffsetOriginHistogramAsync(tenantId, sessionId, maxRows); + public Task UpdateSessionOwnerAsync(string tenantId, string sessionId, SessionOwner owner) + => _storage.UpdateSessionOwnerAsync(tenantId, sessionId, owner); + public Task UpdateSessionDiagnosticsBlobAsync( string tenantId, string sessionId, string blobName, string? destination = null) => _storage.UpdateSessionDiagnosticsBlobAsync(tenantId, sessionId, blobName, destination); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs index 46c3cafc..f1604825 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs @@ -79,7 +79,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap return req.CreateResponse(HttpStatusCode.NotFound); } - var (_, errorResponse) = await req.ValidateSecurityAsync( + var (validation, errorResponse) = await req.ValidateSecurityAsync( tenantId, _configService, _adminConfigService, _rateLimitService, _autopilotDeviceValidator, _corporateIdentifierValidator, _logger, bootstrapSessionService: _bootstrapSessionService, @@ -90,7 +90,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap return errorResponse; } - return await _inner.ProcessReportErrorAsync(req, tenantId); + return await _inner.ProcessReportErrorAsync(req, tenantId, validation); } catch (Exception ex) { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs index 9a552aa1..469ffe5c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs @@ -55,6 +55,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest private readonly BootstrapSessionService _bootstrapSessionService; private readonly KillSwitchEvaluator _killSwitchEvaluator; private readonly SessionDeletionGuard _deletionGuard; + private readonly SessionOwnerBindingObserver _ownerBinding; public IngestTelemetryFunction( ILogger logger, @@ -73,7 +74,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest IntuneDeviceBindingValidator intuneDeviceBindingValidator, BootstrapSessionService bootstrapSessionService, KillSwitchEvaluator killSwitchEvaluator, - SessionDeletionGuard deletionGuard) + SessionDeletionGuard deletionGuard, + SessionOwnerBindingObserver ownerBinding) { _logger = logger; _sessionRepo = sessionRepo; @@ -92,6 +94,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest _bootstrapSessionService = bootstrapSessionService; _killSwitchEvaluator = killSwitchEvaluator; _deletionGuard = deletionGuard; + _ownerBinding = ownerBinding; } [Function("IngestTelemetry")] @@ -225,6 +228,15 @@ namespace AutopilotMonitor.Functions.Functions.Ingest // EventIngestProcessor, saving one Sessions point-read per batch. var preFetchedStatus = TryReadSessionStatus(guardSessionRow); + // SESSION-OWNER-BINDING-SHADOW: is the device behind this certificate/token the + // device this session belongs to? Same guard row, zero extra reads. Stage 1 records + // the outcome (request-row dimension + Warning + throttled ops event) and stamps + // legacy claims / rebinds; it never refuses the batch. Covers the Signal-only path + // too — that is where pending ServerActions get fetched-and-cleared. + var ownerDecision = _ownerBinding.Observe(req, bodyTenantId, sessionId, guardSessionRow, validation, "agent/telemetry"); + if (guardSessionRow != null) + await _ownerBinding.StampAsync(bodyTenantId, sessionId, ownerDecision); + // P14: device-clock send time of this upload (one value per request). Separates // spool delay from device-vs-server clock offset downstream; absent/garbage → null. var sentAtHeader = req.Headers.Contains("X-Send-Time-Utc") diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs index 6b13fd74..0aa73cc2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs @@ -40,6 +40,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest private readonly BootstrapSessionService _bootstrapSessionService; private readonly ISessionRepository _sessionRepo; private readonly OpsEventService _opsEventService; + private readonly Services.Deletion.ISessionDeletionInventoryReader _sessionRowReader; + private readonly SessionOwnerBindingObserver _ownerBinding; public ReportAgentErrorFunction( ILogger logger, @@ -54,7 +56,9 @@ namespace AutopilotMonitor.Functions.Functions.Ingest TelemetryClient telemetryClient, BootstrapSessionService bootstrapSessionService, ISessionRepository sessionRepo, - OpsEventService opsEventService) + OpsEventService opsEventService, + Services.Deletion.ISessionDeletionInventoryReader sessionRowReader, + SessionOwnerBindingObserver ownerBinding) { _logger = logger; _configService = configService; @@ -69,6 +73,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest _bootstrapSessionService = bootstrapSessionService; _sessionRepo = sessionRepo; _opsEventService = opsEventService; + _sessionRowReader = sessionRowReader; + _ownerBinding = ownerBinding; } [Function("ReportAgentError")] @@ -91,7 +97,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest // Full security validation: cert, rate limit, hardware whitelist, Autopilot (if enabled). // Autopilot positive cache TTL is 30 min — devices that have recently called /ingest // will get a cache hit here at essentially zero cost. - var (_, errorResponse) = await req.ValidateSecurityAsync( + var (validation, errorResponse) = await req.ValidateSecurityAsync( tenantId, _configService, _adminConfigService, @@ -110,7 +116,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest return errorResponse; } - return await ProcessReportErrorAsync(req, tenantId); + return await ProcessReportErrorAsync(req, tenantId, validation); } catch (Exception ex) { @@ -124,7 +130,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest /// Core error reporting logic: parse body, log to App Insights. /// Called by both the cert-auth Run() method and the bootstrap wrapper. /// - internal async Task ProcessReportErrorAsync(HttpRequestData req, string tenantId) + internal async Task ProcessReportErrorAsync(HttpRequestData req, string tenantId, SecurityValidationResult validation) { // Request body size limit (1 MB) if (req.Headers.TryGetValues("Content-Length", out var clValues) @@ -180,6 +186,11 @@ namespace AutopilotMonitor.Functions.Functions.Ingest ["AgentTimestamp"] = report.Timestamp.ToString("O"), }); + // SESSION-OWNER-BINDING-SHADOW: the emergency-break path below writes timeline events + // into whatever session the report names. Observe the binding here as well (one + // point-read — this function has no guard row in hand); no stamping from this path. + await ObserveSessionOwnerAsync(req, tenantId, report.SessionId, validation); + await MaterializeEmergencyBreakArtifactsAsync(report, tenantId, _sessionRepo, _opsEventService, _logger); var adminConfig = await _adminConfigService.GetConfigurationAsync(); @@ -190,6 +201,21 @@ namespace AutopilotMonitor.Functions.Functions.Ingest return req.CreateResponse(HttpStatusCode.OK); } + private async Task ObserveSessionOwnerAsync(HttpRequestData req, string tenantId, string? sessionId, SecurityValidationResult validation) + { + if (!SecurityValidator.IsValidGuid(sessionId) || !SecurityValidator.IsValidGuid(tenantId)) + return; + try + { + var row = await _sessionRowReader.GetSessionRowAsync(tenantId, sessionId!); + _ownerBinding.Observe(req, tenantId, sessionId!, row, validation, "agent/error"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ReportAgentError: session-owner observation skipped for session {SessionId}", sessionId); + } + } + /// /// Regex over OUR OWN report format (AgentRuntimeConfig.VerifyBinaryIntegrity): /// "... actual=<sha256>, expected=<sha256>". Parsed leniently — an unparseable diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs index af398263..bd3a6304 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs @@ -37,6 +37,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions private readonly WebhookNotificationService _webhookNotificationService; private readonly SessionDeletionGuard _deletionGuard; private readonly AdminConfigurationService _adminConfigService; + private readonly SessionOwnerBindingObserver _ownerBinding; public RegisterSessionFunction( ILogger logger, @@ -52,7 +53,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions IntuneDeviceBindingValidator intuneDeviceBindingValidator, BootstrapSessionService bootstrapSessionService, WebhookNotificationService webhookNotificationService, - SessionDeletionGuard deletionGuard) + SessionDeletionGuard deletionGuard, + SessionOwnerBindingObserver ownerBinding) { _logger = logger; _sessionRepo = sessionRepo; @@ -68,6 +70,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions _bootstrapSessionService = bootstrapSessionService; _webhookNotificationService = webhookNotificationService; _deletionGuard = deletionGuard; + _ownerBinding = ownerBinding; } [Function("RegisterSession")] @@ -148,9 +151,10 @@ namespace AutopilotMonitor.Functions.Functions.Sessions // Refuses with 410 Gone on lock states (Preparing/Queued/Running/Poisoned) and on a // fresh tombstone marker — without this gate the bootstrap path could still mutate a // mid-cascade row or Add-revive a session past the tombstone. + Azure.Data.Tables.TableEntity? guardSessionRow; try { - await _deletionGuard.EnsureWritableAsync( + guardSessionRow = await _deletionGuard.EnsureWritableAndGetRowAsync( registration.TenantId, registration.SessionId, "RegisterSession"); } catch (SessionDeletionLockedException locked) @@ -173,8 +177,17 @@ namespace AutopilotMonitor.Functions.Functions.Sessions bool isFreshRegistration = preExistingSession == null; bool isWhiteGloveResume = preExistingSession?.Status == SessionStatus.Pending; + // SESSION-OWNER-BINDING-SHADOW: compare the caller's validated identity against the + // owner already on the row (reusing the guard's read). Stage 1 never refuses — the + // decision only records the outcome and yields the owner to stamp (fresh bind, + // legacy claim, cert rotation, bootstrap→cert handoff). Enforcement (stage 2) will turn + // a would-reject here into 403 + AgentErrorCodes.SessionOwnerMismatch, on which the + // agent rotates its session id. + var ownerDecision = _ownerBinding.Observe( + req, registration.TenantId, registration.SessionId, guardSessionRow, validation, "agent/register-session"); + // Store session in Azure Table Storage - var stored = await _sessionRepo.StoreSessionAsync(registration); + var stored = await _sessionRepo.StoreSessionAsync(registration, ownerDecision.OwnerToStamp); if (!stored) { diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs index 8cf93cfd..bc8f8dc0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs @@ -123,6 +123,11 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware if (context.Items.TryGetValue(CertTenantBinding.RequestItemKey, out var binding) && binding is string bindingOutcome) requestTelemetry.Properties["CertTenantBinding"] = bindingOutcome; + // SESSION-OWNER-BINDING-SHADOW — set by SessionOwnerBindingObserver on register / + // telemetry / error-report requests. Same carrier and same reasoning as above. + if (context.Items.TryGetValue(SessionOwnershipPolicy.RequestItemKey, out var ownerBinding) && ownerBinding is string ownerOutcome) + requestTelemetry.Properties["SessionOwnerBinding"] = ownerOutcome; + var reqCtx = context.GetRequestContext(); var tenantId = reqCtx.TenantId; if (string.IsNullOrEmpty(tenantId)) diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index a87e66fd..14f3ec99 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -282,6 +282,7 @@ builder.Services.AddSingleton< AutopilotMonitor.Functions.Services.Monitoring.AzurePoisonQueueProbe>(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs index 9ef293f2..b7fc3ae1 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs @@ -585,10 +585,12 @@ namespace AutopilotMonitor.Functions.Security } // All checks passed + TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var certIntuneDeviceId); return new SecurityValidationResult { IsValid = true, CertificateThumbprint = certValidation.Thumbprint, + IntuneDeviceId = certIntuneDeviceId, Manufacturer = manufacturer, Model = model, SerialNumber = serialNumber, @@ -767,6 +769,14 @@ namespace AutopilotMonitor.Functions.Security /// public string? CertificateThumbprint { get; set; } + /// + /// Intune device id taken from the client certificate CN (lower-case GUID). Set only on + /// the certificate path and only when the CN has that shape. TLS-proven — the one device + /// identifier that survives a certificate re-issue, which is why SESSION-OWNER-BINDING + /// falls back to it when the thumbprint changed. + /// + public string? IntuneDeviceId { get; set; } + /// /// Device manufacturer (if validation succeeded) /// diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SessionOwnershipPolicy.cs b/src/Backend/AutopilotMonitor.Functions/Security/SessionOwnershipPolicy.cs new file mode 100644 index 00000000..05626322 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Security/SessionOwnershipPolicy.cs @@ -0,0 +1,268 @@ +using System; +using Azure.Data.Tables; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Security +{ + /// + /// SESSION-OWNER-BINDING — decides whether the device behind a validated agent request is the + /// device a session belongs to. + /// + /// Agent endpoints authenticate at TENANT granularity (any device certificate of the tenant, + /// or a bootstrap token) but write at SESSION granularity using a session id the caller + /// supplies. Without this binding one enrolled device — or anyone who saw a bootstrap short + /// code — can rewrite the timeline, flip the status and drain the pending server actions of + /// every other session in the tenant. The session row therefore carries the identity that + /// created it (), and every session-scoped write is compared against + /// it here. + /// + /// + /// + /// STAGE 1 — SHADOW (SESSION-OWNER-BINDING-SHADOW): nothing is rejected. Every outcome + /// is recorded on the request row (denominator), non-Match outcomes are logged as Warnings and + /// would-reject outcomes raise a throttled SessionOwnerMismatch ops event. Bindings are + /// stamped from stage 1 on so the data exists when enforcement is switched on. + /// + /// Enforcement (stage 2, separate change, after a long observation window) adds a + /// Rejects rule and answers a would-reject with 403 + error code + /// , on which + /// the agent rotates its session id instead of counting an auth failure. That is what makes the + /// one legitimate collision — an Intune re-enrollment WITHOUT a wipe, where session.id + /// survives but the device gets a new certificate identity — resolve into a fresh session rather + /// than a dead one. Deliberately no Rejects in this file yet: KQL written against the + /// shadow field must keep working, and the switch has to be a visible code change. + /// + /// + /// What this does NOT prove: the serial number is a caller-supplied header on both auth paths. + /// A certificate holder who also knows a victim's serial can still claim a Legacy row or a + /// bootstrap-owned row during its handoff window. Those are the accepted residuals; the + /// TLS-proven identity closes everything else. + /// + /// + public static class SessionOwnershipPolicy + { + /// + /// item key under which the + /// outcome reaches RequestTelemetryMiddleware, which stamps it onto the request row as + /// the SessionOwnerBinding dimension — the shadow denominator (worker-side + /// LogInformation never reaches App Insights, so the bulk Match outcome has no other carrier). + /// + public const string RequestItemKey = "SessionOwnerBinding"; + + /// + /// Stable outcome codes. Queried by exact match in KQL — keep the strings stable. + /// + public static class Outcome + { + /// No Sessions row yet (first registration, or ingest ahead of registration). Nothing to compare; the registration stamps the owner. + public const string Fresh = "Fresh"; + + /// Caller identity equals the stored owner — the expected case. + public const string Match = "Match"; + + /// Row predates the binding (no Owner columns) and the caller's serial equals the row's serial — claimed by this caller. + public const string ClaimLegacy = "ClaimLegacy"; + + /// Row predates the binding and the serials differ. Nothing ties the caller to the session. + public const string LegacySerialMismatch = "LegacySerialMismatch"; + + /// Cert caller, thumbprint differs, but the certificate CN (Intune device id) is the same device — re-issued certificate. Rebound. + public const string RebindCertRotation = "RebindCertRotation"; + + /// Cert caller on a bootstrap-owned session announcing the same serial — the install→runtime handoff. Rebound to the certificate. + public const string RebindBootstrapHandoff = "RebindBootstrapHandoff"; + + /// Cert caller on a bootstrap-owned session announcing a different serial. + public const string MismatchBootstrapOwned = "MismatchBootstrapOwned"; + + /// Cert caller whose thumbprint AND device id differ from the owning certificate. With serialMatch=true this is the re-enroll-without-wipe shape. + public const string MismatchCert = "MismatchCert"; + + /// Bootstrap caller on a bootstrap-owned session with a different short code or serial. + public const string MismatchBootstrap = "MismatchBootstrap"; + + /// Bootstrap caller on a cert-owned session — an auth downgrade, never legitimate. + public const string DowngradeToBootstrap = "DowngradeToBootstrap"; + + /// Validated request carried neither a certificate identity nor a bootstrap code (device validation disabled paths). Cannot bind; allowed. + public const string CallerUnidentified = "CallerUnidentified"; + } + + /// Result of . + public sealed class Decision + { + public Decision(string outcome, SessionOwner? ownerToStamp, bool serialMatch) + { + Outcome = outcome; + OwnerToStamp = ownerToStamp; + SerialMatch = serialMatch; + } + + /// One of the codes. + public string Outcome { get; } + + /// Owner the caller should write onto the row (fresh bind, legacy claim, rebind); null when the row is to be left alone. + public SessionOwner? OwnerToStamp { get; } + + /// Whether the caller's announced serial equals the serial on the row. Diagnostic — distinguishes re-enroll-without-wipe from a foreign device on . + public bool SerialMatch { get; } + + public bool WouldReject => WouldRejectUnderEnforcement(Outcome); + } + + /// + /// Evaluates the binding. Pure — no logging, no I/O, no side effects. + /// + /// The Sessions row as loaded (null when absent). + /// The successful security validation of the current request. + /// Clock for the stamp. + public static Decision Evaluate(TableEntity? existingRow, SecurityValidationResult validation, DateTime nowUtc) + { + var caller = FromValidation(validation, nowUtc); + if (caller == null) + return new Decision(Outcome.CallerUnidentified, null, false); + + if (existingRow == null) + return new Decision(Outcome.Fresh, caller, false); + + var rowSerial = existingRow.GetString("SerialNumber"); + var serialMatch = SerialEquals(caller.Serial, rowSerial); + + var owner = FromRow(existingRow); + if (owner == null) + { + return serialMatch + ? new Decision(Outcome.ClaimLegacy, caller, true) + : new Decision(Outcome.LegacySerialMismatch, null, false); + } + + if (caller.IsCert) + { + if (owner.IsCert) + { + if (ThumbprintEquals(caller.Thumbprint, owner.Thumbprint)) + return new Decision(Outcome.Match, null, serialMatch); + if (caller.DeviceId != null && string.Equals(caller.DeviceId, owner.DeviceId, StringComparison.OrdinalIgnoreCase)) + return new Decision(Outcome.RebindCertRotation, caller, serialMatch); + return new Decision(Outcome.MismatchCert, null, serialMatch); + } + + // Bootstrap-owned row: the serial announced under the token is the only thread + // between the two auth eras — the certificate did not exist when the row was made. + return SerialEquals(caller.Serial, owner.Serial) + ? new Decision(Outcome.RebindBootstrapHandoff, caller, serialMatch) + : new Decision(Outcome.MismatchBootstrapOwned, null, serialMatch); + } + + // Bootstrap caller + if (owner.IsCert) + return new Decision(Outcome.DowngradeToBootstrap, null, serialMatch); + + var sameCode = string.Equals(caller.BootstrapCode, owner.BootstrapCode, StringComparison.OrdinalIgnoreCase); + return sameCode && SerialEquals(caller.Serial, owner.Serial) + ? new Decision(Outcome.Match, null, serialMatch) + : new Decision(Outcome.MismatchBootstrap, null, serialMatch); + } + + /// + /// Whether an outcome will block the request once enforcement is on. Recorded on every + /// non-Match observation so the shadow data answers "what would we have rejected". + /// + /// Tolerated by design (never reject): , , + /// (rows from before the binding must not lock their own + /// device out), both Rebind outcomes (certificate re-issue and install→runtime handoff are + /// normal lifecycle), and (nothing to compare). + /// + /// + public static bool WouldRejectUnderEnforcement(string outcome) => + outcome == Outcome.LegacySerialMismatch + || outcome == Outcome.MismatchBootstrapOwned + || outcome == Outcome.MismatchCert + || outcome == Outcome.MismatchBootstrap + || outcome == Outcome.DowngradeToBootstrap; + + /// + /// Builds the caller's identity from a successful validation. Null when the request carried + /// no bindable identity (no certificate thumbprint and no bootstrap code). + /// + public static SessionOwner? FromValidation(SecurityValidationResult validation, DateTime nowUtc) + { + var serial = NormalizeSerial(validation.SerialNumber); + + if (validation.IsBootstrapAuth) + { + if (string.IsNullOrWhiteSpace(validation.BootstrapShortCode)) + return null; + return new SessionOwner + { + Kind = SessionOwner.Kinds.Bootstrap, + BootstrapCode = validation.BootstrapShortCode!.Trim(), + Serial = serial, + BoundAt = nowUtc, + }; + } + + if (string.IsNullOrWhiteSpace(validation.CertificateThumbprint)) + return null; + + return new SessionOwner + { + Kind = SessionOwner.Kinds.Cert, + Thumbprint = validation.CertificateThumbprint!.Trim(), + DeviceId = string.IsNullOrWhiteSpace(validation.IntuneDeviceId) ? null : validation.IntuneDeviceId!.Trim().ToLowerInvariant(), + Serial = serial, + BoundAt = nowUtc, + }; + } + + /// Reads the owner off a Sessions row. Null when the row predates the binding. + public static SessionOwner? FromRow(TableEntity row) + { + var kind = row.GetString(SessionOwner.Columns.Kind); + if (kind != SessionOwner.Kinds.Cert && kind != SessionOwner.Kinds.Bootstrap) + return null; + + return new SessionOwner + { + Kind = kind, + Thumbprint = row.GetString(SessionOwner.Columns.Thumbprint), + DeviceId = row.GetString(SessionOwner.Columns.DeviceId), + BootstrapCode = row.GetString(SessionOwner.Columns.BootstrapCode), + Serial = row.GetString(SessionOwner.Columns.Serial), + BoundAt = row.GetDateTimeOffset(SessionOwner.Columns.BoundAt)?.UtcDateTime ?? DateTime.MinValue, + }; + } + + /// + /// Writes the owner columns onto . Columns of the other kind are + /// removed so a Bootstrap→Cert rebind does not leave a stale short code behind. + /// + public static void ApplyTo(TableEntity entity, SessionOwner owner) + { + foreach (var col in SessionOwner.Columns.All) + entity.Remove(col); + + entity[SessionOwner.Columns.Kind] = owner.Kind; + if (!string.IsNullOrEmpty(owner.Thumbprint)) entity[SessionOwner.Columns.Thumbprint] = owner.Thumbprint; + if (!string.IsNullOrEmpty(owner.DeviceId)) entity[SessionOwner.Columns.DeviceId] = owner.DeviceId; + if (!string.IsNullOrEmpty(owner.BootstrapCode)) entity[SessionOwner.Columns.BootstrapCode] = owner.BootstrapCode; + if (!string.IsNullOrEmpty(owner.Serial)) entity[SessionOwner.Columns.Serial] = owner.Serial; + entity[SessionOwner.Columns.BoundAt] = new DateTimeOffset(DateTime.SpecifyKind(owner.BoundAt, DateTimeKind.Utc)); + } + + private static string? NormalizeSerial(string? serial) + => string.IsNullOrWhiteSpace(serial) ? null : serial!.Trim(); + + private static bool SerialEquals(string? a, string? b) + { + var na = NormalizeSerial(a); + var nb = NormalizeSerial(b); + if (na == null || nb == null) return false; + return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase); + } + + private static bool ThumbprintEquals(string? a, string? b) + => !string.IsNullOrWhiteSpace(a) && !string.IsNullOrWhiteSpace(b) + && string.Equals(a!.Trim(), b!.Trim(), StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs index 4eb11619..e5b7e694 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs @@ -290,6 +290,22 @@ namespace AutopilotMonitor.Functions.Services $"Agent version pattern '{pattern}' blocked", null, blockedBy, new { pattern }); + /// + /// SESSION-OWNER-BINDING-SHADOW: a validated agent request targeted a session that is bound + /// to a different device identity (see SessionOwnershipPolicy). Stage 1 records only — + /// the request went through. Throttled by SessionOwnerBindingObserver to one event per + /// session+outcome per hour. serialMatch=true on MismatchCert is the + /// re-enroll-without-wipe shape (same device, new certificate identity, old session id); + /// serialMatch=false is a foreign device naming somebody else's session. + /// + public Task RecordSessionOwnerMismatchAsync( + string tenantId, string sessionId, string outcome, string callerKind, string ownerKind, + bool serialMatch, string? agentVersion, string endpoint) + => WriteAsync(OpsEventCategory.Security, "SessionOwnerMismatch", OpsEventSeverity.Warning, + $"Session owner mismatch ({outcome}) on {endpoint}: {callerKind} caller vs {ownerKind}-owned session {sessionId} (serialMatch={serialMatch.ToString().ToLowerInvariant()}, shadow - allowed)", + tenantId, "System.SessionOwnerBinding", + new { sessionId, outcome, callerKind, ownerKind, serialMatch, agentVersion, endpoint, enforced = false }); + /// /// Fired by when a Kill signal was actually SERVED to /// an agent (as opposed to DeviceBlocked/VersionBlocked, which fire when the admin diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SessionIndexFieldManifest.cs b/src/Backend/AutopilotMonitor.Functions/Services/SessionIndexFieldManifest.cs index 36da7309..343a80a1 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SessionIndexFieldManifest.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SessionIndexFieldManifest.cs @@ -105,6 +105,15 @@ namespace AutopilotMonitor.Functions.Services "PendingActionsQueuedAt", "DeletionState", "PendingDeletionManifestId", + // SESSION-OWNER-BINDING: device identity the session is bound to. Consumed only by the + // agent write paths (which read the primary row anyway) — deliberately never mirrored + // and never surfaced on SessionSummary, so no certificate identity travels to clients. + "OwnerKind", + "OwnerThumbprint", + "OwnerDeviceId", + "OwnerBootstrapCode", + "OwnerSerial", + "OwnerBoundAt", }; private static readonly HashSet AllSet = new(All, StringComparer.Ordinal); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SessionOwnerBindingObserver.cs b/src/Backend/AutopilotMonitor.Functions/Services/SessionOwnerBindingObserver.cs new file mode 100644 index 00000000..78a6263c --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/SessionOwnerBindingObserver.cs @@ -0,0 +1,152 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// SESSION-OWNER-BINDING-SHADOW — the single side-effect carrier around + /// . Every session-scoped agent write (register, telemetry + /// ingest, error report) runs its already-loaded Sessions row and validation through + /// , which records the outcome and returns the decision; callers that own + /// a write path then stamp the binding (register through StoreSessionAsync, ingest through + /// ). + /// + /// Three carriers, deliberately (same shape as CERT-TENANT-BINDING): + /// the request-row dimension SessionOwnerBinding for every outcome (denominator — worker + /// LogInformation never reaches App Insights), a Warning trace for every non-Match/non-Fresh + /// outcome (numerator with detail), and a throttled SessionOwnerMismatch ops event for + /// would-reject outcomes so operators can wire an alert rule. Nothing here rejects. + /// + /// + public sealed class SessionOwnerBindingObserver + { + private static readonly TimeSpan OpsEventThrottle = TimeSpan.FromHours(1); + + private readonly ILogger _logger; + private readonly OpsEventService _opsEvents; + private readonly IMemoryCache _cache; + private readonly ISessionRepository _sessionRepo; + + public SessionOwnerBindingObserver( + ILogger logger, + OpsEventService opsEvents, + IMemoryCache cache, + ISessionRepository sessionRepo) + { + _logger = logger; + _opsEvents = opsEvents; + _cache = cache; + _sessionRepo = sessionRepo; + } + + /// + /// Evaluates and records the binding for one request. Never throws — an exception in the + /// evaluation is our defect, not evidence of a foreign device, and yields a Match decision + /// with no stamp. + /// + /// The request (for the FunctionContext item and the agent-version header). + /// Validated tenant. + /// Session named by the request. + /// The Sessions row as already loaded by the caller (null when absent). + /// Successful security validation of this request. + /// Short label of the calling function for the trace/ops event. + public SessionOwnershipPolicy.Decision Observe( + HttpRequestData req, + string tenantId, + string sessionId, + TableEntity? existingRow, + SecurityValidationResult validation, + string endpoint) + { + try + { + var decision = SessionOwnershipPolicy.Evaluate(existingRow, validation, DateTime.UtcNow); + + var items = req.FunctionContext?.Items; + if (items != null) + items[SessionOwnershipPolicy.RequestItemKey] = decision.Outcome; + + if (decision.Outcome == SessionOwnershipPolicy.Outcome.Match + || decision.Outcome == SessionOwnershipPolicy.Outcome.Fresh) + return decision; + + var callerKind = validation.IsBootstrapAuth ? SessionOwner.Kinds.Bootstrap : SessionOwner.Kinds.Cert; + var ownerKind = existingRow == null ? "none" : (SessionOwnershipPolicy.FromRow(existingRow)?.Kind ?? "legacy"); + var agentVersion = req.Headers.Contains("X-Agent-Version") + ? req.Headers.GetValues("X-Agent-Version").FirstOrDefault() ?? "n/a" + : "n/a"; + + // Identities stay out of the message: thumbprints/codes are on the row and in the + // rejection log of the validator; here the actionable fields are the shape of the + // mismatch and whether the serial agrees (re-enroll-without-wipe reads as + // MismatchCert + serialMatch=true). + _logger.LogWarning( + "AgentSessionOwnerBinding outcome={Outcome} enforced={Enforced} wouldReject={WouldReject} " + + "tenant={TenantId} session={SessionId} callerKind={CallerKind} ownerKind={OwnerKind} " + + "serialMatch={SerialMatch} endpoint={Endpoint} ver={AgentVersion}", + decision.Outcome, false, decision.WouldReject, tenantId, sessionId, callerKind, ownerKind, + decision.SerialMatch, endpoint, agentVersion); + + if (decision.WouldReject) + RaiseOpsEventThrottled(tenantId, sessionId, decision, callerKind, ownerKind, agentVersion, endpoint); + + return decision; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "AgentSessionOwnerBinding evaluation failed for tenant {TenantId} session {SessionId} - request allowed", + tenantId, sessionId); + return new SessionOwnershipPolicy.Decision(SessionOwnershipPolicy.Outcome.Match, null, false); + } + } + + /// + /// Writes the decision's owner onto the Sessions row from a path that does not otherwise + /// replace the row (telemetry ingest: legacy claim and rebinds). No-op without an owner to + /// stamp. Fail-soft — the binding is observational in stage 1 and must never cost a batch. + /// + public async Task StampAsync(string tenantId, string sessionId, SessionOwnershipPolicy.Decision decision) + { + if (decision.OwnerToStamp == null) + return; + + try + { + await _sessionRepo.UpdateSessionOwnerAsync(tenantId, sessionId, decision.OwnerToStamp); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "AgentSessionOwnerBinding stamp failed for tenant {TenantId} session {SessionId} outcome {Outcome}", + tenantId, sessionId, decision.Outcome); + } + } + + private void RaiseOpsEventThrottled( + string tenantId, string sessionId, SessionOwnershipPolicy.Decision decision, + string callerKind, string ownerKind, string agentVersion, string endpoint) + { + // Ingest fires once per batch; one ops event per (session, outcome) per hour is the + // signal operators need, the rest is in the Warning trace. + var key = $"sob:{tenantId}:{sessionId}:{decision.Outcome}"; + if (_cache.TryGetValue(key, out _)) + return; + _cache.Set(key, true, OpsEventThrottle); + + _ = _opsEvents.RecordSessionOwnerMismatchAsync( + tenantId, sessionId, decision.Outcome, callerKind, ownerKind, decision.SerialMatch, agentVersion, endpoint) + .ContinueWith( + t => _logger.LogWarning(t.Exception?.InnerException, "SessionOwnerMismatch ops event failed"), + TaskContinuationOptions.OnlyOnFaulted); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs index f0299893..7a65b84d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs @@ -444,9 +444,15 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Stores a session registration + /// Stores a session registration. /// - public async Task StoreSessionAsync(SessionRegistration registration) + /// Agent-supplied registration (server-stamped fields already applied by the caller). + /// + /// SESSION-OWNER-BINDING: the device identity to bind the row to, decided by + /// SessionOwnershipPolicy (fresh bind, legacy claim, rebind). Null preserves whatever + /// owner the row already carries — the Replace below must never blank it. + /// + public async Task StoreSessionAsync(SessionRegistration registration, SessionOwner? ownerToStamp = null) { SecurityValidator.EnsureValidGuid(registration.TenantId, "TenantId"); SecurityValidator.EnsureValidGuid(registration.SessionId, "SessionId"); @@ -508,11 +514,16 @@ namespace AutopilotMonitor.Functions.Services string verdictPath = VerdictPaths.RegisterNew; string? priorStatus = null; string? priorVerdictPath = null; + // SESSION-OWNER-BINDING: preserved verbatim through the Replace unless the caller + // hands in a new owner (bind / claim / rebind). Ingest may stamp these via Merge + // (UpdateSessionOwnerAsync) — the CAS re-read below re-applies the fresh values. + SessionOwner? existingOwner = null; try { var existing = await tableClient.GetEntityAsync(registration.TenantId, registration.SessionId); var existingEntity = existing.Value; + existingOwner = SessionOwnershipPolicy.FromRow(existingEntity); var existingStartedAt = existingEntity.GetDateTimeOffset("StartedAt")?.UtcDateTime; if (existingStartedAt.HasValue && existingStartedAt.Value < startedAt) @@ -692,6 +703,11 @@ namespace AutopilotMonitor.Functions.Services if (!string.IsNullOrEmpty(existingIndexRowKey)) entity["IndexRowKey"] = existingIndexRowKey; + if (ownerToStamp != null) + SessionOwnershipPolicy.ApplyTo(entity, ownerToStamp); + else if (existingOwner != null) + SessionOwnershipPolicy.ApplyTo(entity, existingOwner); + // PR3 (codex round 2 follow-up): ETag-bound CAS write loop. The plain // UpsertEntity (Replace) had a TOCTOU race against the cascade-delete producer: // (T1) StoreSessionAsync reads row → DeletionState=None, captures preserved fields. @@ -718,6 +734,10 @@ namespace AutopilotMonitor.Functions.Services "Status", "CurrentPhase", "CompletedAt", "FailureReason", "ReconcileReason", "FailureSnapshotJson", "FailureSource", "AdminMarkedAction", "DurationSeconds", "EspSoftFailure", "CompletionSource", "VerdictPath", "PriorStatus", "PriorVerdictPath", + // SESSION-OWNER-BINDING columns — a Merge-stamped owner (ingest claim / + // rebind) that landed after the initial read must survive the Replace. + SessionOwner.Columns.Kind, SessionOwner.Columns.Thumbprint, SessionOwner.Columns.DeviceId, + SessionOwner.Columns.BootstrapCode, SessionOwner.Columns.Serial, SessionOwner.Columns.BoundAt, }); freshEtag = freshResponse.Value.ETag; freshEntity = freshResponse.Value; @@ -732,6 +752,13 @@ namespace AutopilotMonitor.Functions.Services // Stamp cascade-lock columns from the FRESH read so the Replace below // doesn't silently clear them. This kicks in even when the initial read // saw None and the producer transitioned to Preparing in between. + if (ownerToStamp == null && freshEntity != null) + { + var freshOwner = SessionOwnershipPolicy.FromRow(freshEntity); + if (freshOwner != null) + SessionOwnershipPolicy.ApplyTo(entity, freshOwner); + } + var freshDeletionState = freshEntity?.GetString("DeletionState"); var freshPendingDeletionManifestId = freshEntity?.GetString("PendingDeletionManifestId"); if (!string.IsNullOrEmpty(freshDeletionState)) @@ -2862,6 +2889,31 @@ namespace AutopilotMonitor.Functions.Services /// container — required so that a later tenant switch from one destination to /// the other doesn't break downloads for already-uploaded sessions. /// + /// + /// SESSION-OWNER-BINDING: Merge-stamps the owner columns onto an existing Sessions row from + /// a path that does not replace the row (telemetry ingest: legacy claim, cert rotation, + /// bootstrap→cert handoff). Primary-only columns — no SessionsIndex mirror (see + /// ). Throws on storage failure; the + /// observer catches (fail-soft) because the binding is observational in stage 1. + /// + public async Task UpdateSessionOwnerAsync(string tenantId, string sessionId, SessionOwner owner) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); + + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); + var update = new TableEntity(tenantId, sessionId); + SessionOwnershipPolicy.ApplyTo(update, owner); + // A Merge leaves columns it does not mention untouched; a Bootstrap→Cert handoff must + // therefore null the other kind's columns explicitly so no stale short code stays. + foreach (var col in SessionOwner.Columns.All) + { + if (!update.ContainsKey(col)) + update[col] = null; + } + await tableClient.UpdateEntityAsync(update, ETag.All, TableUpdateMode.Merge); + } + public async Task UpdateSessionDiagnosticsBlobAsync( string tenantId, string sessionId, string blobName, string? destination = null) { diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 23264830..1bef9c7a 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -249,6 +249,21 @@ namespace AutopilotMonitor.Shared // API endpoint paths (relative to ApiBaseUrl) // ----------------------------------------------------------------------- + /// + /// Machine-readable error codes the backend returns in agent-facing error bodies + /// (RegisterSessionResponse.ErrorCode). Wire contract — keep stable. + /// + public static class AgentErrorCodes + { + /// + /// SESSION-OWNER-BINDING: the session named by the request is bound to a different + /// device identity than the caller presented. The agent's correct reaction is to + /// rotate its SessionId and register afresh — NOT to count it as an auth failure. + /// Reserved in stage 1 (shadow); emitted only once enforcement is switched on. + /// + public const string SessionOwnerMismatch = "session_owner_mismatch"; + } + /// /// API endpoint paths used by the agent /// diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs index 35887613..43be3eed 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs @@ -15,7 +15,10 @@ namespace AutopilotMonitor.Shared.DataAccess public interface ISessionRepository { // --- Session CRUD --- - Task StoreSessionAsync(SessionRegistration registration); + /// SESSION-OWNER-BINDING: owner to bind the row to; null preserves the existing binding. + Task StoreSessionAsync(SessionRegistration registration, SessionOwner? ownerToStamp = null); + /// SESSION-OWNER-BINDING: Merge-stamps the owner onto an existing row (ingest claim / rebind). Throws on storage failure. + Task UpdateSessionOwnerAsync(string tenantId, string sessionId, SessionOwner owner); Task GetSessionAsync(string tenantId, string sessionId); /// /// Resolves the tenantId owning — point-read on the diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Enrollment/SessionOwner.cs b/src/Shared/AutopilotMonitor.Shared/Models/Enrollment/SessionOwner.cs new file mode 100644 index 00000000..bafd3289 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Enrollment/SessionOwner.cs @@ -0,0 +1,60 @@ +using System; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// The device identity a session is bound to — stamped by the backend from the validated + /// caller identity at registration and carried on the Sessions row. Never populated from + /// agent input. + /// + /// SESSION-OWNER-BINDING: a session-scoped write (telemetry ingest, re-registration, error + /// report) is compared against this owner. Cert-authenticated callers are identified by the + /// TLS-proven certificate (thumbprint, plus the Intune device id from the certificate CN so a + /// re-issued certificate of the same device still matches); bootstrap-token callers by the + /// short code that admitted them plus the serial they announced. + /// + /// + public sealed class SessionOwner + { + /// Sessions-row column names. Kept here so writer and reader share one spelling. + public static class Columns + { + public const string Kind = "OwnerKind"; + public const string Thumbprint = "OwnerThumbprint"; + public const string DeviceId = "OwnerDeviceId"; + public const string BootstrapCode = "OwnerBootstrapCode"; + public const string Serial = "OwnerSerial"; + public const string BoundAt = "OwnerBoundAt"; + + public static readonly string[] All = { Kind, Thumbprint, DeviceId, BootstrapCode, Serial, BoundAt }; + } + + /// Values of . Stable strings — queried by exact match. + public static class Kinds + { + public const string Cert = "Cert"; + public const string Bootstrap = "Bootstrap"; + } + + /// or . + public string Kind { get; set; } = default!; + + /// Cert-owned only: thumbprint of the TLS client certificate that registered the session. + public string? Thumbprint { get; set; } + + /// Cert-owned only: Intune device id from the certificate CN (lower-case GUID), when the CN had that shape. + public string? DeviceId { get; set; } + + /// Bootstrap-owned only: the 6-character short code whose token admitted the caller. + public string? BootstrapCode { get; set; } + + /// Serial number the caller announced in X-Device-SerialNumber when the binding was made. Trimmed, original casing. + public string? Serial { get; set; } + + /// When this binding was stamped (UTC). + public DateTime BoundAt { get; set; } + + public bool IsCert => Kind == Kinds.Cert; + public bool IsBootstrap => Kind == Kinds.Bootstrap; + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs index 651f89e0..dd91b068 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs @@ -49,6 +49,12 @@ namespace AutopilotMonitor.Shared.Models /// Older backends that do not set this return Unknown — agent falls back to its own detection. /// public ValidatorType ValidatedBy { get; set; } = ValidatorType.Unknown; + + /// + /// Machine-readable reason on a failure response (see ). + /// Null on success and on failures that carry no agent-actionable code. + /// + public string? ErrorCode { get; set; } } /// diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx index 61868ba6..a6ab1c8a 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx @@ -99,6 +99,10 @@ const OPS_EVENT_TYPES: Record = { // creation. Emitted by KillSwitchEvaluator, throttled 24h per tenant+serial+pattern. // Dual-register per memory feedback_ops_event_types_dual_register. "KillSignalDelivered", + // SESSION-OWNER-BINDING-SHADOW: a validated agent request named a session bound to a + // different device identity (SessionOwnerBindingObserver, throttled 1h per session+outcome). + // Stage 1 observes only. Dual-register per memory feedback_ops_event_types_dual_register. + "SessionOwnerMismatch", "EmbeddedCertExpiringSoon", "EmbeddedCertExpiringUrgent", "EmbeddedCertExpired",