Compare commits

...

11 Commits

Author SHA1 Message Date
Oliver Kieselbach c41c2327ab fix(security): validate enrollment summary branding image URL (https-only, no quotes); launcher drops unsafe URLs instead of interpolating them into the dialog command line
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 23:15:07 +02:00
Oliver Kieselbach 83effea592 fix(security): hosted diagnostics upload SAS is create-only and bound to the requesting session; existing blob => 409
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 23:13:00 +02:00
Oliver Kieselbach 1a4a37b77a fix(web): hardware whitelist editor splits typed comma lists into entries ("Dell*, *" stays allow-all); only insights buttons neutralize ','
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:56:37 +02:00
Oliver Kieselbach f56851fc9f fix(security): hardware whitelist — one added entry is always one pattern; server rejects malformed CSV
The tenant hardware whitelist is stored as a CSV and split on ',' at
enforcement time. Manufacturer/model strings in the Rejected-Hardware
insights come from unauthenticated distress signals, so a value like
"Dell Inc.,*" added via "+ Mfr" appended a standalone "*" and silently
turned the manufacturer gate into allow-all.

- Web: shared hardwareWhitelist helper; "+ Mfr"/"+ Model" and the manual
  editor neutralize ',' to the single-char wildcard '?' so a value can
  never split the list.
- Backend: TenantConfigValidation.ValidateHardwareWhitelist in ValidateModel
  (PUT + patch) rejects blank items, control chars, over-long/too many entries.
- Shared: ParseWhitelist trims entries so enforcement matches the editor view.
- Tests: 8 xUnit + 5 vitest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:51:55 +02:00
Oliver Kieselbach cbe410cbbc fix(security): reject path-shaped sessionId/tenantId in session-report blob names; BlobNameGuard at every GetBlobClient sink
SubmitSessionReport built the report blob name from the raw route sessionId. Route values are
percent-decoded, so an encoded '/'+'..' reached Azure.Storage.Blobs, which keeps separators, and
System.Uri collapsed the dot segments into a write outside the session-reports container.

- SubmitSessionReport / SubmitDiagFilesReport: 400 unless sessionId/tenantId are GUIDs
- SessionReportService: EnsureValidGuid at entry of both submit paths
- New Security/BlobNameGuard (flat-name invariant) applied in SessionReportService,
  SessionReportDiagnosticsArchiveCopier (pre-check + upload) and the GA download function
- Tests: BlobNameGuardTests, copier rejects non-flat destination without storage calls

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:51:13 +02:00
Oliver Kieselbach 43ddc60111 fix(security): enforce tenant suspension in PolicyEnforcementMiddleware, not only auth/me
TenantConfiguration.Disabled/DisabledUntil was checked solely in AuthFunction.BuildAuthResult
(GET /api/auth/me), so a direct REST caller with a valid token kept full read/write access for
the whole suspension window and during an offboarding wipe. DecideAsync now denies 403
TenantSuspended when the caller's home tenant or the request's target tenant is currently
disabled (cached, side-effect-free config read). GA/GlobalReader bypass to administer the
tenant; device/anonymous routes keep their SecurityValidator gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:34:29 +02:00
Oliver Kieselbach 82b26b4ab9 fix(security): HTML-encode domain in transactional mails; freeze DomainName on config PUT, validate first-login seed
Welcome/farewell mails rendered DomainName unencoded while the full-model
config PUT persisted it verbatim, letting a tenant admin ship attacker HTML
in a signed product mail to an arbitrary recipient. Encode at Render() and
the built-in builders, preserve DomainName from the stored row on PUT for
every caller, and only seed a strict DNS host name at first login.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:33:52 +02:00
Oliver Kieselbach 1733f4fd48 fix(agent): SignalIngress worker re-entrancy — never block the single consumer on its own post
EffectRunner posts ClassifierVerdictIssued from inside DecisionStepProcessor.ApplyStep,
i.e. on the SignalIngress worker thread. With the bounded channel (256) full, Post's
back-pressure slow path blocked the channel's only consumer as a producer: nothing could
ever drain, every collector Post blocked behind it, and the whole decision/monitoring
pipeline froze silently until the max-lifetime watchdog.

Post now detects a call from the worker thread itself and routes the item to a
worker-local follow-up queue that WorkerLoop drains (iteratively, ahead of the channel)
right after the current item. Same processing path — ordinals, SignalLog, emit,
reduce, ApplyStep — just never blocking. Worker posts are accepted during the Stop
drain too, since the verdict belongs to the step being finished. ReentrantPostCount
exposed for observability/tests.

Tests: full channel + worker post releases a blocked foreign producer and processes the
verdict before the next channel item (fails against the previous code by hanging);
follow-up chain drained in order without recursion; verdict posted during Stop drain is
not lost. Docs: agent/decision-engine.md, agent/overview.md, log.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:11:25 +02:00
Oliver Kieselbach fc46b4c78e feat(security): session-owner binding stage 1 (shadow) + dormant agent session rotation
Agent endpoints authenticated per tenant but authorized session writes on a caller-supplied
session id. Sessions rows now carry the creating device identity (cert thumbprint + Intune
device id from the CN, or bootstrap code + serial) in primary-only Owner* columns.
SessionOwnershipPolicy evaluates register / telemetry / error-report requests against it;
SessionOwnerBindingObserver records the outcome on the request row, as a warning and as a
throttled SessionOwnerMismatch ops event. Nothing is rejected in this stage.

Agent: errorCode parsing on 401/403, one-shot session rotation on session_owner_mismatch
without feeding the auth-failure tracker, spool drop, agent_started.sessionRotated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 21:32:24 +02:00
Oliver Kieselbach c18de5d76c fix(security): drop client-writable certificate header; bootstrap routes token-only, fail-closed
SecurityValidator accepted certificate bytes from X-Client-Certificate when X-ARR-ClientCert was
absent. That header is client-writable, and certificate content alone carries no proof of private-key
possession — the TLS handshake does. On platform-cert-enforced routes the fallback was unreachable,
but /api/bootstrap/* is excluded from the platform cert requirement and an EMPTY X-Bootstrap-Token
passed the function gate (Headers.Contains) while the validator treated it as no token and fell
through to the certificate stage, where the fallback header was parsed (live-verified 2026-08-29).

- Only X-ARR-ClientCert is read; the platform strips inbound copies even on excluded paths.
- IsBootstrapRoute (path prefix mirroring the exclusion entry): missing/empty token or unwired
  BootstrapSessionService => 401 before the certificate stage.
- GetBootstrapToken treats an empty header as absent; the three bootstrap functions use it.
- Tests: BootstrapRouteFailClosedTests. Docs: client-cert-tenant-binding.md transport section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 20:04:04 +02:00
Oliver Kieselbach 250cc667dd fix(web): show full UPN in admin feedback list instead of a 200px truncation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 19:58:08 +02:00
80 changed files with 3464 additions and 168 deletions
+6 -1
View File
@@ -216,7 +216,12 @@ Wired by `EnrollmentOrchestrator.Start` (`src/Agent/AutopilotMonitor.Agent.V2.Co
- `SignalIngress` — bounded queue (256, back-pressure) + **one** worker thread. Per
signal, in order: assign ordinals → `SignalLog.Append` (durable, *before* reducing;
append failure ⇒ reducer does not run, no ordinal gap) → `engine.Reduce` →
`DecisionStepProcessor.ApplyStep`.
`DecisionStepProcessor.ApplyStep`. A `Post` issued *by the worker thread itself*
(the `EffectRunner` posting `ClassifierVerdictIssued` from inside `ApplyStep`) never
enters the bounded queue: it goes to a worker-local follow-up queue that is drained,
ahead of the channel, right after the current item — the worker is the queue's only
consumer, so blocking on a full channel there would freeze the pipeline for the rest
of the run.
- `DecisionStepProcessor.ApplyStep` (same thread, no locks): journal append (the only
hard-throwing path; 3 consecutive failures escalate to quarantine) → effects run
synchronously via `EffectRunner` → best-effort snapshot → advance `CurrentState` →
+3 -1
View File
@@ -109,7 +109,9 @@ telemetry directly:
```
Collector callback
→ SignalAdapter (dual emission: decision signal + InformationalEvent)
→ SignalIngress.Post() (bounded queue 256, back-pressure blocks producer)
→ SignalIngress.Post() (bounded queue 256, back-pressure blocks producer;
posts from the worker thread itself bypass the
queue via a worker-local follow-up queue)
→ single worker thread:
assign ordinals → SignalLog.Append (durable) ← source of truth
→ DecisionEngine.Reduce → apply step/effects
+36 -1
View File
@@ -10,7 +10,7 @@ tags:
- certificates
- intune
- multi-tenant
timestamp: 2026-08-24T00:00:00+02:00
timestamp: 2026-08-29T00:00:00+02:00
---
# Problem
@@ -129,6 +129,41 @@ Grep marker for every site involved: `CERT-TENANT-BINDING`.
The bootstrap-token path (`X-Bootstrap-Token`) bypasses certificate validation by design and is
unaffected by either stage.
# Certificate transport and proof of possession
Every stage above validates certificate *content*: chain, validity window, EKU, tenant extension.
None of them proves the caller holds the private key — a certificate is public material (sent in
clear in a TLS 1.2 client-auth handshake, readable from the machine store by any local user, present
on resold hardware). Proof of possession comes from the TLS handshake alone: the platform runs
`clientCertMode=Required` on every path not listed in its `clientCertExclusionPaths`, forwards the
handshake certificate as `X-ARR-ClientCert`, and **strips any inbound copy of that header — on
excluded paths too** (verified 2026-08-29 with a spoofed header against `/api/bootstrap/config`:
the validator saw "No certificate provided"). So `X-ARR-ClientCert` is trustworthy exactly because
the caller cannot write it.
Two consequences shape `SecurityValidator`:
* **No other certificate header is ever read.** A former local-dev fallback (`X-Client-Certificate`)
accepted certificate bytes from a client-writable header, which decouples the certificate from
key possession: anyone holding a device's public certificate would authenticate as that device.
Removed 2026-08-29. Local development sets `X-ARR-ClientCert` directly — Core Tools does not strip
it.
* **Bootstrap routes are token-only, fail-closed.** `/api/bootstrap/*` is in the platform's
exclusion list so cert-less OOBE agents can reach it — which also means nothing on those paths
proves a handshake happened. The token is therefore the sole credential there:
`SecurityValidator.IsBootstrapRoute` (path prefix, mirroring the exclusion entry so no function
can forget to opt in) rejects a missing or **empty** `X-Bootstrap-Token`, and a missing
`BootstrapSessionService`, with 401 before the certificate stage can run. The live gap this
closes: the function-level `Headers.Contains` gate accepted an empty header while the validator
treated it as "no token" and fell through to the certificate stage, where the fallback header
above was parsed. Bootstrap tokens are never accepted as a *replacement* for the certificate on
platform-enforced routes either — there the cert requirement is already satisfied by the
handshake before the request reaches code.
The exclusion list is portal-only state (no IaC). Adding `/api/agent` to it, or moving a
device-authenticated function under an excluded prefix, would silently reopen the header path;
`BootstrapRouteFailClosedTests` pins the code half of this contract.
**Stage 3 — device binding (Global-Admin-only preview).** Tenant binding proves the certificate was
issued to this tenant; it does not prove the certificate belongs to a device the tenant still has.
`IntuneDeviceBindingValidator` closes that by resolving the Intune managedDevice id from the
+157
View File
@@ -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`
@@ -107,6 +107,13 @@ properties `Html`, `UpdatedBy`, `UpdatedUtc`), cached 5 minutes, invalidated on
* The only placeholder is `{{domainName}}`; an empty domain renders as "your organization",
matching the built-ins. Subjects are not editable (pinned constants).
* The domain is HTML-encoded at the point of substitution (`EmailTemplateService.Render` and the
built-in template builders) — the mail goes out under the product's signed sender to a
tenant-chosen recipient, so no writer of `DomainName` may ever inject markup into it.
`DomainName` itself is tenant identity: seeded once at first login from the UPN domain
(`AuthFunction`, only when it passes `TenantConfigValidation.IsValidDomainName` — a strict
dotted host name), preserved from the stored row on the full-model config PUT for every
caller, and "never writable" in the field-patch flow.
* Limit 30 000 characters (Table string property cap is 32K UTF-16 chars).
* The send path is fail-soft: a storage error falls back to the built-in body. The admin
read path is not — a broken store surfaces as an error instead of silently showing the
+2 -1
View File
@@ -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.
@@ -80,7 +81,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID.
* [Vulnerability Correlation Sources](backend/vulnerability-correlation-sources.md) - How the software inventory becomes CVE findings from four cached primaries (NVD per-CPE cache with budget and TTLs, CISA KEV, MSRC, FIRST EPSS stored on the cached CVE and re-scored nightly), the NVD throttle contract (a refused answer is never cached as "0 CVEs"; bounded process-wide cooldown honouring Retry-After), the persisted CVSS vector, and the act/attend/track priority that keeps severity and likelihood as two visible signals instead of one composite score.
* [Client-Certificate Tenant Binding](backend/client-cert-tenant-binding.md) - Why a chain-valid Intune MDM certificate only proves "issued to some tenant", which extension actually carries the Entra tenant id (`…5.14`) versus the two that look like it (`…5.6` Intune Account id, `…5.4` device id, different encodings), why the Entra join certificate is not part of the auth path, and the shadow-then-enforce rollout with its volume-controlled telemetry.
* [Client-Certificate Tenant Binding](backend/client-cert-tenant-binding.md) - Why a chain-valid Intune MDM certificate only proves "issued to some tenant", which extension actually carries the Entra tenant id (`…5.14`) versus the two that look like it (`…5.6` Intune Account id, `…5.4` device id, different encodings), why the Entra join certificate is not part of the auth path, the shadow-then-enforce rollout with its volume-controlled telemetry, and why `X-ARR-ClientCert` is the only certificate transport (TLS handshake = proof of possession; bootstrap routes token-only, fail-closed).
* [Admin Identity Binding](backend/admin-identity-binding.md) - Why a GlobalAdmins / DelegatedAdmins row keyed on a UPN string must never confer access by itself in an API that accepts tokens from every Entra tenant (domain re-registration, UPN recycling), the AdminIdentityBindings table pinning each cross-tenant-role UPN to its immutable home tid + oid, the fail-closed resolution order in both role services (row → binding → entitlement), the first-sign-in object-id pin with its race semantics, the 409 on grant-time conflicts, and the explicit GET/PUT/DELETE rebind surface.
* [IME Installer Archive](backend/ime-msi-archive.md) - How every fleet-observed IME build is archived as its MSI: the ime_agent_version sighting that queues the job (first sighting, or a later sighting with a version-authoritative CSP URL after a 24 h backoff), the primary/secondary/hotfix candidate walk, the managed MSI ProductVersion check that keeps another ring's build out of the version folder (Failed:VersionMismatch), provenance with the full candidate walk, and the skill-side overwrite path for corrections.
+16
View File
@@ -1,5 +1,21 @@
# Log
## 2026-08-29 (10)
* **Update**: `backend/tenant-activation-welcome-mail.md` — welcome/farewell mails HTML-encode the domain at substitution; `DomainName` is frozen on the full-model config PUT (was persisted verbatim) and the first-login seed only accepts a strict DNS host name.
## 2026-08-29 (9)
* **Update**: `agent/decision-engine.md`, `agent/overview.md` — `SignalIngress.Post` is worker-thread re-entrancy-safe: signals the worker posts to itself from inside `ApplyStep` (EffectRunner `ClassifierVerdictIssued`) go to a worker-local follow-up queue drained ahead of the bounded channel instead of blocking on it, so a full channel can no longer deadlock the single consumer.
## 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.
## 2026-08-29 (6)
* **Update**: `backend/admin-identity-binding.md` — grants are self-maintaining: `AdminIdentityResolver` derives the home tenant + object id from sign-in history (`UserActivity`), falls back to the UPN domain of an onboarded tenant, refuses to guess when a UPN was seen under two tenants; 422 `HomeTenantUnresolved` + portal tenant picker only for a never-seen domain. Identity fields and pills removed from the delegated / tenant-group forms; inspect/correct lives in Tenant Management → Edit Tenant Configuration → Identity bindings (collapsed).
@@ -498,6 +498,207 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
Assert.Equal(5, observedCount);
}
// ============================================================ Worker re-entrancy (2026-08-29)
[Fact]
public void Worker_thread_post_with_full_channel_does_not_deadlock_and_is_processed_next()
{
// Channel capacity 1, the worker is parked inside ApplyStep on a gate while a
// second producer keeps the channel full. When the gate opens, ApplyStep posts a
// verdict from the worker thread. Before the fix, Post would block in
// BlockingCollection.Add forever (the worker is the only consumer). After the fix,
// the verdict goes to the worker-local queue, is processed BEFORE the queued channel
// item, and the worker keeps draining.
using var tmp = new TempDirectory();
var log = new SignalLogWriter(tmp.File("signal-log.jsonl"));
SignalIngress? ingRef = null;
var gate = new ManualResetEventSlim(false);
var inApply = new ManualResetEventSlim(false);
var processor = new GatedReentrantProcessor(() => ingRef!, gate, inApply);
var ing = new SignalIngress(
engine: new DecisionEngine(),
signalLog: log,
traceCounter: new SessionTraceOrdinalProvider(),
processor: processor,
clock: new VirtualClock(At),
channelCapacity: 1);
ingRef = ing;
ing.Start();
// Item 0 — worker takes it and parks inside ApplyStep on the gate.
ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence("s0"));
Assert.True(inApply.Wait(5000));
// Item 1 fills the single slot; item 2 blocks a foreign producer (channel full).
ing.Post(DecisionSignalKind.DesktopArrived, At.AddSeconds(1), "Collector", RawEvidence("s1"));
var blockedProducer = Task.Run(() =>
ing.Post(DecisionSignalKind.DesktopArrived, At.AddSeconds(2), "Collector", RawEvidence("s2")));
Assert.True(WaitFor(() => ing.ApproximateQueueLength == 1));
Assert.False(blockedProducer.Wait(200)); // genuinely blocked by back-pressure
// Release the worker: ApplyStep(s0) now posts the verdict from the worker thread
// into a full channel.
gate.Set();
Assert.True(blockedProducer.Wait(5000), "foreign producer must be released — worker must keep draining");
Assert.True(WaitFor(() => processor.CallCount == 4));
ing.Stop();
Assert.Equal(
new[]
{
DecisionSignalKind.SessionStarted,
DecisionSignalKind.ClassifierVerdictIssued, // follow-up drained BEFORE the next channel item
DecisionSignalKind.DesktopArrived,
DecisionSignalKind.DesktopArrived,
},
processor.Kinds);
Assert.Equal(1, ing.ReentrantPostCount);
Assert.Equal(0, ing.PendingSignalCount);
// Same worker thread for every step — single-writer invariant intact.
Assert.Single(processor.ThreadIds.Distinct());
// Follow-up went through the normal path: ordinal assigned + durably logged.
var logged = log.ReadAll();
Assert.Equal(4, logged.Count);
Assert.Equal(DecisionSignalKind.ClassifierVerdictIssued, logged[1].Kind);
Assert.Equal(1, logged[1].SessionSignalOrdinal);
}
[Fact]
public void Worker_thread_post_chain_is_drained_iteratively_in_order()
{
// A follow-up whose own ApplyStep posts another follow-up: both are processed on
// the worker, in order, before the next channel item, without recursion.
using var tmp = new TempDirectory();
var log = new SignalLogWriter(tmp.File("signal-log.jsonl"));
SignalIngress? ingRef = null;
var processor = new ChainingProcessor(() => ingRef!);
var ing = new SignalIngress(
engine: new DecisionEngine(),
signalLog: log,
traceCounter: new SessionTraceOrdinalProvider(),
processor: processor,
clock: new VirtualClock(At));
ingRef = ing;
ing.Start();
ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence("s0"));
ing.Post(DecisionSignalKind.DesktopArrived, At.AddSeconds(1), "Collector", RawEvidence("s1"));
ing.Stop();
Assert.Equal(
new[] { "Collector", "chain:1", "chain:2", "Collector" },
processor.Origins);
Assert.Equal(2, ing.ReentrantPostCount);
Assert.Equal(4, log.ReadAll().Count);
}
[Fact]
public void Worker_thread_post_during_Stop_drain_is_still_processed()
{
// Stop() completes adding, but a verdict posted by the worker while it finishes
// the last channel item must not be dropped — it belongs to that step.
using var tmp = new TempDirectory();
var log = new SignalLogWriter(tmp.File("signal-log.jsonl"));
SignalIngress? ingRef = null;
var gate = new ManualResetEventSlim(false);
var inApply = new ManualResetEventSlim(false);
var processor = new GatedReentrantProcessor(() => ingRef!, gate, inApply);
var ing = new SignalIngress(
engine: new DecisionEngine(),
signalLog: log,
traceCounter: new SessionTraceOrdinalProvider(),
processor: processor,
clock: new VirtualClock(At));
ingRef = ing;
ing.Start();
ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence("s0"));
Assert.True(inApply.Wait(5000));
var stopTask = Task.Run(() => ing.Stop());
Assert.True(WaitFor(() => ing.ApproximateQueueLength == 0 && !stopTask.IsCompleted));
gate.Set();
Assert.True(stopTask.Wait(5000));
Assert.Equal(
new[] { DecisionSignalKind.SessionStarted, DecisionSignalKind.ClassifierVerdictIssued },
processor.Kinds);
Assert.Equal(2, log.ReadAll().Count);
}
/// <summary>
/// Parks inside ApplyStep(SessionStarted) on a gate, then posts a verdict back into the
/// ingress from the worker thread — the EffectRunner/RunClassifier shape.
/// </summary>
private sealed class GatedReentrantProcessor : IDecisionStepProcessor
{
private readonly Func<ISignalIngressSink> _sink;
private readonly ManualResetEventSlim _gate;
private readonly ManualResetEventSlim _inApply;
private readonly List<(DecisionSignalKind Kind, int ThreadId)> _calls = new List<(DecisionSignalKind, int)>();
private DecisionCore.State.DecisionState _state = DecisionCore.State.DecisionState.CreateInitial("S1", "T1");
public GatedReentrantProcessor(Func<ISignalIngressSink> sink, ManualResetEventSlim gate, ManualResetEventSlim inApply)
{
_sink = sink;
_gate = gate;
_inApply = inApply;
}
public int CallCount { get { lock (_calls) { return _calls.Count; } } }
public DecisionSignalKind[] Kinds { get { lock (_calls) { return _calls.Select(c => c.Kind).ToArray(); } } }
public int[] ThreadIds { get { lock (_calls) { return _calls.Select(c => c.ThreadId).ToArray(); } } }
public DecisionCore.State.DecisionState CurrentState => _state;
public EffectRunResult ApplyStep(DecisionStep step, DecisionSignal signal)
{
lock (_calls) { _calls.Add((signal.Kind, Thread.CurrentThread.ManagedThreadId)); }
_state = step.NewState;
if (signal.Kind == DecisionSignalKind.SessionStarted)
{
_inApply.Set();
_gate.Wait();
_sink().Post(
DecisionSignalKind.ClassifierVerdictIssued,
signal.OccurredAtUtc,
"effectrunner:classifier:test",
new Evidence(EvidenceKind.Synthetic, "classifier:test:h", "Strong/90"),
new Dictionary<string, string> { ["level"] = "Strong" });
}
return EffectRunResult.Empty();
}
}
private sealed class ChainingProcessor : IDecisionStepProcessor
{
private readonly Func<ISignalIngressSink> _sink;
private DecisionCore.State.DecisionState _state = DecisionCore.State.DecisionState.CreateInitial("S1", "T1");
public List<string> Origins { get; } = new List<string>();
public ChainingProcessor(Func<ISignalIngressSink> sink) { _sink = sink; }
public DecisionCore.State.DecisionState CurrentState => _state;
public EffectRunResult ApplyStep(DecisionStep step, DecisionSignal signal)
{
Origins.Add(signal.SourceOrigin);
_state = step.NewState;
string? next = signal.Kind == DecisionSignalKind.SessionStarted ? "chain:1"
: signal.SourceOrigin == "chain:1" ? "chain:2"
: null;
if (next != null)
{
_sink().Post(
DecisionSignalKind.ClassifierVerdictIssued,
signal.OccurredAtUtc,
next,
new Evidence(EvidenceKind.Synthetic, next, next));
}
return EffectRunResult.Empty();
}
}
[Fact]
public void Post_via_ISignalIngressSink_reaches_reducer_like_any_signal()
{
@@ -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()
{
@@ -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()
{
@@ -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<string>();
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()
{
@@ -50,5 +50,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
Assert.Equal(a, b);
}
// The branding URL is tenant config; the dialog parses arguments last-occurrence-wins, so
// an embedded quote must never terminate the argument and inject extra flags (CWE-88).
private const string BaseArgs = "--status-file \"C:\\x\\final-status.json\" --timeout 30 --cleanup";
[Fact]
public void BuildDialogArguments_omits_branding_when_empty()
{
var args = SummaryDialogLauncher.BuildDialogArguments(@"C:\x\final-status.json", 30, null, null);
Assert.Equal(BaseArgs, args);
}
[Fact]
public void BuildDialogArguments_appends_clean_https_url_as_single_quoted_argument()
{
var args = SummaryDialogLauncher.BuildDialogArguments(@"C:\x\final-status.json", 30, "https://cdn.example/logo.png?v=2", null);
Assert.Equal(BaseArgs + " --branding-url \"https://cdn.example/logo.png?v=2\"", args);
}
[Theory]
[InlineData("x\" --status-file \"C:\\some\\file\" --timeout \"0")]
[InlineData("https://cdn.example/logo.png\" --cleanup")]
[InlineData("https://cdn.example/logo.png --timeout 0")]
[InlineData("https://cdn.example/lo'go.png")]
[InlineData("https://cdn.example/logo.png\r\n")]
[InlineData("http://cdn.example/logo.png")]
[InlineData("file:///C:/Windows/x.png")]
[InlineData("cdn.example/logo.png")]
public void BuildDialogArguments_drops_unsafe_branding_url(string url)
{
var args = SummaryDialogLauncher.BuildDialogArguments(@"C:\x\final-status.json", 30, url, null);
Assert.Equal(BaseArgs, args);
}
}
}
@@ -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<BackendAuthException>(() => 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<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
Assert.Null(ex.ErrorCode);
}
[Theory]
[InlineData(null, null)]
[InlineData("", null)]
[InlineData("not json", null)]
[InlineData("<html><body>403</body></html>", 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()
{
@@ -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);
}
/// <summary>
/// Pulls <c>errorCode</c> out of a backend JSON error body. Tolerant: any parse failure
/// or a non-string value yields null, never an exception.
/// </summary>
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;
}
}
/// <summary>
@@ -429,10 +458,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
/// </summary>
public bool EndpointUnavailable { get; }
public BackendAuthException(string message, int statusCode = 0, bool endpointUnavailable = false) : base(message)
/// <summary>
/// Machine-readable code from the backend's JSON error body (see Shared
/// <c>Constants.AgentErrorCodes</c>), or null when the body carried none.
/// </summary>
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;
}
}
}
@@ -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;
}
/// <summary>
/// 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.
/// </summary>
public void UpdateSessionId(string sessionId)
{
if (!string.IsNullOrWhiteSpace(sessionId))
_sessionId = sessionId;
}
/// <summary>
/// Attempts to send an emergency report. Returns immediately without awaiting
/// (intended to be called as fire-and-forget via <c>_ = reporter.TrySendAsync(...)</c>).
@@ -88,9 +88,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private int _passThroughStepsSinceSnapshot; // H1a — pass-through steps skipped since last snapshot
private bool _parkedTripwireFired; // liveness PR1 — one-shot per agent run, no state-schema touch
// M1 — parked-tripwire dwell machinery. The timer thread is also what fixes L8: the
// M1 — parked-tripwire dwell machinery. The timer thread is also what fixed L8: the
// tripwire post no longer runs on the ingress worker, so a full channel cannot
// self-deadlock on it. Guarded by _parkedLock because the callback races ApplyStep.
// self-deadlock on it. (Since 2026-08-29 SignalIngress.Post itself is re-entrancy-safe
// — worker-thread posts go to a worker-local queue — so the EffectRunner's
// ClassifierVerdictIssued post from inside ApplyStep is covered too.) Guarded by
// _parkedLock because the callback races ApplyStep.
private readonly object _parkedLock = new object();
private readonly TimeSpan _parkedTripwireDwell;
private Timer? _parkedDwellTimer;
@@ -27,6 +27,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// Die Back-Pressure-Emission läuft NICHT durch den Signal-Channel (würde deadlocken).
/// </para>
/// <para>
/// <b>Worker re-entrancy</b>: a <see cref="Post"/> issued ON the worker thread itself
/// (EffectRunner posting a <c>ClassifierVerdictIssued</c> from inside
/// <c>ApplyStep</c>) must never touch the bounded channel — the worker is the channel's
/// only consumer, so a full channel would block the consumer as a producer and freeze
/// the whole pipeline for the rest of the run. Such posts go to a worker-local
/// follow-up queue that the worker drains (ahead of the channel) right after the
/// current item finishes. Same processing path, same ordinal/log/reduce semantics —
/// just never blocking.
/// </para>
/// <para>
/// <b>Shutdown</b>: <see cref="Stop"/> ruft <see cref="BlockingCollection{T}.CompleteAdding"/>,
/// wartet auf das Drain-Ende via <c>Thread.Join</c>. Nach <c>Stop</c> wirft jeder
/// weitere <see cref="Post"/> <see cref="InvalidOperationException"/>.
@@ -79,6 +89,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly object _backPressureLock = new object();
private Thread? _worker;
// Worker-thread-only: signals posted re-entrantly from ApplyStep (EffectRunner
// classifier verdicts). Drained by WorkerLoop after every channel item, before the
// next channel item is taken. Only the worker enqueues and dequeues — no lock.
private readonly Queue<IngressItem> _workerLocalQueue = new Queue<IngressItem>();
private long _reentrantPostCount;
private long _nextSignalOrdinal; // worker-thread-only after Start; seeded from SignalLog
private int _started; // 0 = not started, 1 = running
private int _stopRequested; // 0 = live, 1 = Stop called
@@ -135,6 +150,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// </summary>
public long PendingSignalCount => Interlocked.Read(ref _unprocessedCount);
/// <summary>
/// Signals that were posted from the worker thread itself and routed through the
/// worker-local follow-up queue instead of the bounded channel. Observability/tests.
/// </summary>
public long ReentrantPostCount => Interlocked.Read(ref _reentrantPostCount);
/// <summary>
/// Fires synchronously from <see cref="Post"/> after a signal has been accepted into
/// the channel (for both the fast-path <see cref="BlockingCollection{T}.TryAdd"/> and
@@ -218,6 +239,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// Reicht ein Raw-/Derived-/Synthetic-Signal in den Ingress ein. Thread-safe; darf von
/// beliebig vielen Collector-Threads (und vom <see cref="EffectRunner"/>) parallel
/// gerufen werden. Blockiert, wenn der Channel voll ist — Plan §2.1a Back-Pressure.
/// Ausnahme: Aufrufe vom Worker-Thread selbst blockieren nie (worker-local queue).
/// </summary>
public void Post(
DecisionSignalKind kind,
@@ -228,10 +250,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
int kindSchemaVersion = 1,
object? typedPayload = null)
{
if (Volatile.Read(ref _stopRequested) == 1 || _channel.IsAddingCompleted)
{
throw new InvalidOperationException("SignalIngress has been stopped.");
}
if (evidence == null)
{
throw new ArgumentNullException(nameof(evidence));
@@ -243,6 +261,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
var item = new IngressItem(kind, kindSchemaVersion, occurredAtUtc, sourceOrigin, evidence, payload, typedPayload);
// Worker re-entrancy (see class doc): a post from the worker thread itself goes to
// the worker-local follow-up queue, never into the bounded channel. Checked before
// the stop gate on purpose — while Stop() drains the channel the worker may still
// produce verdicts for the items it is finishing, and those must not be lost.
if (ReferenceEquals(Thread.CurrentThread, _worker))
{
Interlocked.Increment(ref _unprocessedCount);
_workerLocalQueue.Enqueue(item);
Interlocked.Increment(ref _reentrantPostCount);
if (_workerLocalQueue.Count > 1)
{
// One follow-up per step is the expected shape (one RunClassifier effect).
// More means effects are chaining on the worker — worth seeing in the log.
_logger?.Debug($"SignalIngress: worker-local follow-up queue depth={_workerLocalQueue.Count} kind={kind} origin={sourceOrigin}");
}
RaiseSignalPosted(kind, payload);
return;
}
if (Volatile.Read(ref _stopRequested) == 1 || _channel.IsAddingCompleted)
{
throw new InvalidOperationException("SignalIngress has been stopped.");
}
// Codex Finding 2 (2026-04-30): bump the pending-counter BEFORE handing the item
// to the channel. Decrement in three places: (a) when TryAdd throws because
// CompleteAdding raced between the post-stop check and the enqueue, (b) when
@@ -353,6 +395,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
foreach (var item in _channel.GetConsumingEnumerable())
{
ProcessItem(item);
DrainWorkerLocalQueue();
}
}
catch (Exception ex)
@@ -366,6 +409,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
}
/// <summary>
/// Processes signals the worker posted to itself during the previous item (see class
/// doc, worker re-entrancy). Iterative, not recursive: a follow-up that produces
/// another follow-up is simply appended and handled in the same loop.
/// </summary>
private void DrainWorkerLocalQueue()
{
while (_workerLocalQueue.Count > 0)
{
ProcessItem(_workerLocalQueue.Dequeue());
}
}
private void ProcessItem(IngressItem item)
{
// Codex Finding 2 (2026-04-30): match the Interlocked.Increment that Post()
@@ -175,6 +175,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
}
}
/// <summary>
/// Replaces the persisted SessionId with a fresh GUID and restarts the session-age clock.
/// Used when the backend answers register-session with <c>session_owner_mismatch</c>
/// (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 <see cref="Delete"/> this leaves <c>whiteglove.complete</c> untouched: a Part-2
/// resume that has to rotate is still a Part-2 resume.
/// </summary>
/// <returns>The new SessionId.</returns>
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); }
@@ -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
/// <see cref="AuthFailureTracker"/> (which fires the first-failure distress + may trip
/// the shutdown threshold) and return <see cref="SessionRegistrationOutcome.AuthFailed"/>
/// without retrying — the backend has definitively rejected the device cert.</item>
/// <item>Exception to the above — a 403 carrying error code
/// <c>session_owner_mismatch</c> (SESSION-OWNER-BINDING): the persisted SessionId names a
/// session bound to another device identity (typically an Intune re-enrollment without a
/// wipe, where <c>session.id</c> survived but the certificate identity changed). That is
/// not an auth failure: the session is rotated through <c>rotateSession</c> and
/// registration retried exactly once. The tracker is NOT fed — five of these would
/// otherwise soft-shutdown a perfectly authorized agent.</item>
/// <item>Any other exception on the last attempt → <see cref="EmergencyReporter.TrySendAsync"/>
/// with <c>AgentErrorType.RegisterSessionFailed</c> so operators see the final cause.</item>
/// </list>
@@ -72,7 +80,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
Func<int, Task> backoffDelay = null,
Func<Exception, Task> onTerminalTransportFailure = null,
(string Manufacturer, string Model, string SerialNumber)? deviceHardware = null,
Func<AgentLogger, Task> networkLinkWait = null)
Func<AgentLogger, Task> networkLinkWait = null,
Func<string> 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;
/// <summary>
/// SESSION-OWNER-BINDING: the SessionId this run started with when the backend refused it
/// with <c>session_owner_mismatch</c> and the session was rotated before the successful
/// registration. Null on the normal path.
/// </summary>
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);
@@ -132,10 +132,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
}
// 4) Compose args. Legacy contract: --status-file <path> --timeout <s> --cleanup [--branding-url <url>].
var tempStatusArg = Path.Combine(tempDir, FinalStatusFileName);
var args = $"--status-file \"{tempStatusArg}\" --timeout {configuration.EnrollmentSummaryTimeoutSeconds} --cleanup";
if (!string.IsNullOrWhiteSpace(configuration.EnrollmentSummaryBrandingImageUrl))
args += $" --branding-url \"{configuration.EnrollmentSummaryBrandingImageUrl}\"";
var args = BuildDialogArguments(
Path.Combine(tempDir, FinalStatusFileName),
configuration.EnrollmentSummaryTimeoutSeconds,
configuration.EnrollmentSummaryBrandingImageUrl,
logger);
// 5) Launch in user session with retry.
var retrySeconds = configuration.EnrollmentSummaryLaunchRetrySeconds > 0
@@ -151,6 +152,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
return launched;
}
/// <summary>
/// Composes the dialog command line. The branding URL is tenant-supplied config and the
/// dialog parses arguments last-occurrence-wins, so a value carrying a double quote could
/// terminate its argument and override <c>--status-file</c>/<c>--timeout</c>/<c>--cleanup</c>
/// (CWE-88). The backend rejects such values, but the agent is the last line of defence:
/// only a plain absolute HTTPS URL without quotes, whitespace or control characters is
/// ever placed on the command line — anything else is dropped (the dialog then simply
/// shows no branding image).
/// </summary>
internal static string BuildDialogArguments(string statusFilePath, int timeoutSeconds, string brandingImageUrl, AgentLogger logger)
{
var args = $"--status-file \"{statusFilePath}\" --timeout {timeoutSeconds} --cleanup";
if (string.IsNullOrWhiteSpace(brandingImageUrl))
return args;
if (IsSafeBrandingImageUrl(brandingImageUrl))
return args + $" --branding-url \"{brandingImageUrl}\"";
logger?.Warning("SummaryDialogLauncher: EnrollmentSummaryBrandingImageUrl is not a plain absolute HTTPS URL — ignoring it.");
return args;
}
internal static bool IsSafeBrandingImageUrl(string url)
{
if (string.IsNullOrEmpty(url) || url.Length > 2048)
return false;
foreach (var ch in url)
{
if (ch == '"' || ch == '\'' || char.IsWhiteSpace(ch) || char.IsControl(ch))
return false;
}
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
&& uri.Scheme == Uri.UriSchemeHttps
&& !string.IsNullOrEmpty(uri.Host);
}
private static void TryCopy(string source, string destination)
{
try { if (File.Exists(source)) File.Copy(source, destination, overwrite: true); }
@@ -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;
@@ -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).
@@ -27,7 +27,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
HttpClient mtlsHttpClient,
string agentVersion,
bool consoleMode,
AgentLogger logger)
AgentLogger logger,
Func<string> 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);
}
/// <summary>
/// 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 <c>agentConfig.SessionId</c> live after Phase 6.
/// </summary>
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}");
}
}
}
/// <summary>
/// Off-hot-path diagnostic invoked once after all session-registration retries fail.
/// Detects whether the terminal exception was a TLS-layer <c>SecureChannelFailure</c>
@@ -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
@@ -123,6 +123,22 @@ public class AuthFunctionSideEffectTests
// HandleNewTenantDomainAsync
// -------------------------------------------------------------------------
[Fact]
public async Task HandleNewTenantDomain_WhenUpnDomainIsNotAHostName_DoesNotSeed()
{
var config = DefaultConfig();
config.DomainName = null!;
config.OnboardedBy = null;
// The seed is rendered into transactional mails: anything but a strict host name is refused.
await _sut.HandleNewTenantDomainAsync(config, TenantId, "user@<b>contoso.com</b>");
Assert.True(string.IsNullOrEmpty(config.DomainName));
Assert.Null(config.OnboardedBy);
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task HandleNewTenantDomain_WhenDomainEmpty_ExtractsAndSaves()
{
@@ -0,0 +1,39 @@
using AutopilotMonitor.Functions.Security;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Request-derived blob names must be flat. Azure.Storage.Blobs keeps '/' in the blob URI and
/// System.Uri collapses '..' segments, so "x/../../other-container/y" would resolve outside the
/// intended container. The guard is the last line at every GetBlobClient sink.
/// </summary>
public class BlobNameGuardTests
{
[Theory]
[InlineData("11111111-1111-1111-1111-111111111111_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa_diag_request_20260829_120000.zip")]
[InlineData("report.zip")]
[InlineData("a.b.c.zip")]
public void IsFlat_AcceptsFlatNames(string name)
{
Assert.True(BlobNameGuard.IsFlat(name));
Assert.Equal(name, BlobNameGuard.EnsureFlat(name, "name"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("TA_x/../../hosted-diagnostics/victim/payload_diag_request_1.zip")]
[InlineData("a/b.zip")]
[InlineData("a\\b.zip")]
[InlineData("..zip")]
[InlineData("a%2F..%2Fb.zip")]
[InlineData("a?b.zip")]
[InlineData("a#b.zip")]
public void IsFlat_RejectsPathShapedNames(string? name)
{
Assert.False(BlobNameGuard.IsFlat(name));
Assert.Throws<ArgumentException>(() => BlobNameGuard.EnsureFlat(name, "name"));
}
}
@@ -1,4 +1,4 @@
using System.Net;
using System.Net;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
@@ -99,8 +99,9 @@ public sealed class BootstrapAuthDisabledTenantTests
{
// Sanity: a non-disabled tenant DOES reach the bootstrap branch (which will then
// run its own validation against _bootstrapSessionService). With _bootstrapSessionService
// null in this test, the bootstrap branch falls through to the cert path — proving
// that the §0 gate did NOT block.
// null and no request URL (= not a /api/bootstrap/* route, where that combination is
// rejected fail-closed — see BootstrapRouteFailClosedTests), the branch falls through to
// the platform-cert path — proving that the §0 gate did NOT block.
var enabled = TenantConfiguration.CreateDefault(TenantId);
enabled.Disabled = false;
enabled.ValidateAutopilotDevice = false;
@@ -0,0 +1,161 @@
using System.Net;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Bootstrap routes (<c>/api/bootstrap/*</c>) are excluded from the platform's TLS client-cert
/// requirement, so nothing on those paths proves an mTLS handshake ever happened. The bootstrap
/// token must therefore be the ONLY credential accepted there — a request that reaches the
/// certificate stage on a bootstrap route would authenticate from header bytes alone (a public
/// certificate is replayable without its private key). Live probe 2026-08-29: an empty
/// <c>X-Bootstrap-Token</c> passed the function gate, skipped the token branch and had a
/// client-supplied certificate header parsed. These tests pin the fail-closed behaviour.
/// </summary>
public sealed class BootstrapRouteFailClosedTests
{
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
private const string BootstrapRoute = "https://example.invalid/api/bootstrap/config";
private const string AgentRoute = "https://example.invalid/api/agent/telemetry";
// A well-formed DER certificate is not needed: what matters is whether the certificate stage
// ran at all, and the "No certificate provided" vs. parse-error details tell the two apart.
private const string JunkCertBase64 = "QUJD";
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task BootstrapRoute_TokenMissingOrEmpty_Rejected401_BeforeCertificateStage(string? token)
{
var sut = BuildValidator(bootstrapService: null);
var req = BuildRequest(BootstrapRoute, token, ("X-ARR-ClientCert", JunkCertBase64));
var result = await sut.ValidateRequestAsync(req, TenantId);
Assert.False(result.IsValid);
Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode);
Assert.Equal("X-Bootstrap-Token header is required", result.ErrorMessage);
}
[Fact]
public async Task BootstrapRoute_ClientSuppliedCertificateHeader_NeverParsed()
{
var sut = BuildValidator(bootstrapService: null);
var req = BuildRequest(BootstrapRoute, token: "", ("X-Client-Certificate", JunkCertBase64));
var result = await sut.ValidateRequestAsync(req, TenantId);
Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode);
Assert.Equal("X-Bootstrap-Token header is required", result.ErrorMessage);
Assert.DoesNotContain("certificate", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task AgentRoute_ClientCertificateHeader_IsIgnored()
{
// The removed X-Client-Certificate fallback: on a platform-enforced route the only
// certificate source is X-ARR-ClientCert. A client-writable header must read as "no cert".
var sut = BuildValidator(bootstrapService: null);
var req = BuildRequest(AgentRoute, token: null, ("X-Client-Certificate", JunkCertBase64));
var result = await sut.ValidateRequestAsync(req, TenantId);
Assert.False(result.IsValid);
Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode);
Assert.Equal("Invalid or missing client certificate", result.ErrorMessage);
Assert.Equal("No certificate provided", result.Details);
}
[Fact]
public async Task AgentRoute_ArrClientCertHeader_ReachesCertificateStage()
{
// Control: the platform-set header is still the one consulted (junk bytes → parse error,
// proving the certificate stage ran on the supplied value).
var sut = BuildValidator(bootstrapService: null);
var req = BuildRequest(AgentRoute, token: null, ("X-ARR-ClientCert", JunkCertBase64));
var result = await sut.ValidateRequestAsync(req, TenantId);
Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode);
Assert.Equal("Invalid or missing client certificate", result.ErrorMessage);
Assert.NotEqual("No certificate provided", result.Details);
}
[Fact]
public void GetBootstrapToken_TreatsEmptyAsAbsent()
{
Assert.Null(SecurityValidator.GetBootstrapToken(BuildRequest(BootstrapRoute, token: "")));
Assert.Null(SecurityValidator.GetBootstrapToken(BuildRequest(BootstrapRoute, token: null)));
Assert.Equal("abc", SecurityValidator.GetBootstrapToken(BuildRequest(BootstrapRoute, token: "abc")));
}
[Theory]
[InlineData("https://example.invalid/api/bootstrap/config", true)]
[InlineData("https://example.invalid/API/Bootstrap/register-session", true)]
[InlineData("https://example.invalid/api/bootstrapx/config", false)]
[InlineData("https://example.invalid/api/agent/telemetry", false)]
public void IsBootstrapRoute_MatchesPlatformExclusionPrefix(string url, bool expected)
{
Assert.Equal(expected, SecurityValidator.IsBootstrapRoute(BuildRequest(url, token: null)));
}
// ---------------------------------------------------------------- harness
private static SecurityValidator BuildValidator(BootstrapSessionService? bootstrapService)
{
var configRepo = Mock.Of<IConfigRepository>();
var cache = new MemoryCache(new MemoryCacheOptions());
// AllowInsecureAgentRequests clears the "at least one device validator" gate so agent-route
// requests reach the certificate stage — the stage these tests reason about.
var config = TenantConfiguration.CreateDefault(TenantId);
config.AllowInsecureAgentRequests = true;
var configServiceMock = new Mock<TenantConfigurationService>(
configRepo, Mock.Of<ILogger<TenantConfigurationService>>(), cache)
{ CallBase = false };
configServiceMock
.Setup(x => x.TryGetConfigurationAsync(It.IsAny<string>()))
.ReturnsAsync((config, true));
var adminConfigServiceMock = new Mock<AdminConfigurationService>(
configRepo, Mock.Of<ILogger<AdminConfigurationService>>(), cache)
{ CallBase = false };
adminConfigServiceMock
.Setup(x => x.GetConfigurationAsync())
.ReturnsAsync(new AdminConfiguration());
return new SecurityValidator(
configService: configServiceMock.Object,
adminConfigService: adminConfigServiceMock.Object,
rateLimitService: new RateLimitService(cache, Mock.Of<ILogger<RateLimitService>>()),
autopilotDeviceValidator: null!,
corporateIdentifierValidator: null!,
logger: Mock.Of<ILogger>(),
bootstrapSessionService: bootstrapService,
deviceAssociationValidator: null);
}
private static HttpRequestData BuildRequest(string url, string? token, params (string Name, string Value)[] extraHeaders)
{
var contextMock = new Mock<Microsoft.Azure.Functions.Worker.FunctionContext>();
contextMock.SetupGet(c => c.Items).Returns(new Dictionary<object, object>());
var reqMock = new Mock<HttpRequestData>(contextMock.Object);
var headers = new HttpHeadersCollection();
if (token != null) headers.Add("X-Bootstrap-Token", token);
foreach (var (name, value) in extraHeaders) headers.Add(name, value);
reqMock.SetupGet(r => r.Headers).Returns(headers);
reqMock.SetupGet(r => r.Url).Returns(new Uri(url));
return reqMock.Object;
}
}
@@ -0,0 +1,68 @@
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests
{
/// <summary>
/// The enrollment-summary branding image URL is delivered verbatim to every enrolling device,
/// fetched from the signed-in user's session and spliced into the summary-dialog command line.
/// The server must therefore only ever persist a plain absolute HTTPS URL — a value with an
/// embedded quote or a non-HTTPS scheme must fail the config PUT/PATCH.
/// </summary>
public class BrandingImageUrlValidationTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("https://cdn.example/logo.png")]
[InlineData("https://cdn.example:8443/assets/logo.png?v=2&x=y")]
[InlineData("https://cdn.example/logo%20one.png")]
public void ValidateBrandingImageUrl_WellFormed_ReturnsNull(string? url)
{
Assert.Null(TenantConfigValidation.ValidateBrandingImageUrl(url));
}
[Theory]
[InlineData("x\" --status-file \"C:\\some\\file\" --timeout \"0", "quotes")]
[InlineData("https://cdn.example/logo.png\"", "quotes")]
[InlineData("https://cdn.example/lo'go.png", "quotes")]
[InlineData("https://cdn.example/logo.png --cleanup", "whitespace")]
[InlineData("https://cdn.example/logo.png\r\n", "whitespace")]
[InlineData("http://cdn.example/logo.png", "HTTPS")]
[InlineData("file:///C:/Windows/logo.png", "HTTPS")]
[InlineData("ftp://cdn.example/logo.png", "HTTPS")]
[InlineData("cdn.example/logo.png", "absolute URL")]
[InlineData("https://localhost/logo.png", "localhost")]
[InlineData("https://10.0.0.1/logo.png", "IP address")]
public void ValidateBrandingImageUrl_Malformed_Rejected(string url, string expectedFragment)
{
var error = TenantConfigValidation.ValidateBrandingImageUrl(url);
Assert.NotNull(error);
Assert.Contains(expectedFragment, error);
}
[Fact]
public void ValidateBrandingImageUrl_Overlong_Rejected()
{
var url = "https://cdn.example/" + new string('a', TenantConfigValidation.MaxBrandingImageUrlLength);
Assert.Contains("characters", TenantConfigValidation.ValidateBrandingImageUrl(url));
}
[Fact]
public void ValidateModel_RejectsInjectedBrandingUrl()
{
var existing = new TenantConfiguration();
var candidate = new TenantConfiguration
{
EnrollmentSummaryBrandingImageUrl = "x\" --status-file \"C:\\some\\file\" --timeout \"0",
};
var error = TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: true);
Assert.NotNull(error);
Assert.StartsWith("Invalid enrollment summary branding image URL", error);
}
}
}
@@ -247,6 +247,18 @@ public sealed class EmailServiceTests
Assert.Contains("linkedin.com", html);
}
[Fact]
public void BuiltInTemplates_HtmlEncodeDomain()
{
const string hostile = "<a href=\"https://evil.invalid\">x</a>";
foreach (var html in new[] { EmailTemplates.GetOffboardingFarewellHtml(hostile), EmailTemplates.GetPreviewApprovedHtml(hostile) })
{
Assert.DoesNotContain(hostile, html);
Assert.Contains("&lt;a href=&quot;https://evil.invalid&quot;&gt;x&lt;/a&gt;", html);
}
}
[Fact]
public void FarewellTemplate_EmptyDomain_FallsBackToGenericLabel()
{
@@ -29,6 +29,30 @@ public sealed class EmailTemplateServiceTests
Assert.DoesNotContain(EmailTemplateService.DomainPlaceholder, html);
}
[Theory]
[InlineData("<b>x</b>", "&lt;b&gt;x&lt;/b&gt;")]
[InlineData("a\"b&c", "a&quot;b&amp;c")]
public void Render_HtmlEncodesDomain_ForOverrideTemplates(string domain, string expected)
{
var html = EmailTemplateService.Render("<p>{{domainName}}</p>", domain);
Assert.Equal($"<p>{expected}</p>", html);
}
[Theory]
[InlineData(EmailTemplateKind.Welcome)]
[InlineData(EmailTemplateKind.Farewell)]
public async Task BuiltIn_HtmlEncodesDomain_NoDoubleEncoding(EmailTemplateKind kind)
{
var (sut, _) = Build();
var html = await sut.GetHtmlAsync(kind, "<img src=x onerror=alert(1)>&\"");
Assert.DoesNotContain("<img", html);
Assert.Contains("&lt;img src=x onerror=alert(1)&gt;&amp;&quot;", html);
Assert.DoesNotContain("&amp;lt;", html);
}
[Fact]
public async Task Override_WinsAndRendersPlaceholder()
{
@@ -0,0 +1,102 @@
using System.Linq;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests
{
/// <summary>
/// The hardware whitelist is stored as one CSV and split at enforcement time. A value that
/// carries the delimiter (e.g. an attacker-chosen distress-signal manufacturer "Dell Inc.,*")
/// must never be able to widen the gate: the server rejects malformed lists and the parser
/// yields exactly the trimmed, non-empty items.
/// </summary>
public class HardwareWhitelistCsvIntegrityTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("*")]
[InlineData("Dell*,HP*,Lenovo*,Microsoft Corporation")]
[InlineData("Dell Inc.?*")]
[InlineData(" Dell* , HP* ")]
public void ValidateHardwareWhitelist_WellFormed_ReturnsNull(string? csv)
{
Assert.Null(TenantConfigValidation.ValidateHardwareWhitelist(csv));
}
[Theory]
[InlineData("Dell Inc.,")]
[InlineData(",Dell*")]
[InlineData("Dell*,,HP*")]
[InlineData("Dell*, ,HP*")]
[InlineData(",")]
public void ValidateHardwareWhitelist_BlankItem_Rejected(string csv)
{
var error = TenantConfigValidation.ValidateHardwareWhitelist(csv);
Assert.NotNull(error);
Assert.Contains("must not be empty", error);
}
[Fact]
public void ValidateHardwareWhitelist_ControlCharacter_Rejected()
{
var csv = "Dell*" + (char)1 + ",HP*";
Assert.Contains("control characters", TenantConfigValidation.ValidateHardwareWhitelist(csv));
}
[Fact]
public void ValidateHardwareWhitelist_OverlongEntry_Rejected()
{
Assert.Contains("128 characters",
TenantConfigValidation.ValidateHardwareWhitelist(new string('a', 129)));
}
[Fact]
public void ValidateHardwareWhitelist_TooManyEntries_Rejected()
{
var csv = string.Join(",", Enumerable.Repeat("x", 201));
Assert.Contains("200 entries", TenantConfigValidation.ValidateHardwareWhitelist(csv));
}
[Fact]
public void ValidateModel_RejectsMalformedWhitelists_WithFieldSpecificMessage()
{
var existing = TenantConfiguration.CreateDefault("tenant");
var badMfr = TenantConfiguration.CreateDefault("tenant");
badMfr.ManufacturerWhitelist = "Dell*,Dell Inc.,";
Assert.StartsWith("Invalid manufacturer whitelist:",
TenantConfigValidation.ValidateModel(badMfr, existing, isGlobalAdmin: false));
var badModel = TenantConfiguration.CreateDefault("tenant");
badModel.ModelWhitelist = "Latitude*,,";
Assert.StartsWith("Invalid model whitelist:",
TenantConfigValidation.ValidateModel(badModel, existing, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidateModel(
TenantConfiguration.CreateDefault("tenant"), existing, isGlobalAdmin: false));
}
[Fact]
public void ParseWhitelist_TrimsEntries_SoEditorViewMatchesEnforcement()
{
// The web editor displays trimmed items; enforcement must match the same patterns.
Assert.Equal(new[] { "Dell*", "HP*" }, TenantConfiguration.ParseWhitelist("Dell*, HP*"));
Assert.Equal(new[] { "*" }, TenantConfiguration.ParseWhitelist(" "));
}
[Fact]
public void NeutralizedDistressValue_MatchesOnlyItself_NotEveryManufacturer()
{
// "Dell Inc.,*" neutralized by the web helper to the single pattern "Dell Inc.?*".
var whitelist = TenantConfiguration.ParseWhitelist("Dell*,Dell Inc.?*");
Assert.DoesNotContain("*", whitelist);
Assert.False(HardwareWhitelistValidator.ValidateHardware("Contoso", "X1", whitelist, new[] { "*" }).IsValid);
Assert.True(HardwareWhitelistValidator.ValidateHardware("Dell Inc.,*", "X1", whitelist, new[] { "*" }).IsValid);
}
}
}
@@ -26,7 +26,9 @@ public class HostedDiagnosticsBlobServiceTests
{
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private const string Filename = "AgentDiagnostics-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa-20260519T120000.zip";
private const string SessionA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
private const string SessionB = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
private const string Filename = "AgentDiagnostics-" + SessionA + "-20260519T120000.zip";
// ── Path build + validation ────────────────────────────────────────────────────
@@ -89,7 +91,15 @@ public class HostedDiagnosticsBlobServiceTests
{
var svc = new FakeHostedDiagnosticsBlobService();
await Assert.ThrowsAsync<ArgumentException>(
() => svc.GenerateUploadSasAsync("not-a-guid", Filename));
() => svc.GenerateUploadSasAsync("not-a-guid", SessionA, Filename));
}
[Fact]
public async Task GenerateUploadSas_RejectsNonGuidSessionId()
{
var svc = new FakeHostedDiagnosticsBlobService();
await Assert.ThrowsAsync<ArgumentException>(
() => svc.GenerateUploadSasAsync(TenantA, "not-a-guid", Filename));
}
[Fact]
@@ -97,14 +107,64 @@ public class HostedDiagnosticsBlobServiceTests
{
var svc = new FakeHostedDiagnosticsBlobService();
await Assert.ThrowsAsync<ArgumentException>(
() => svc.GenerateUploadSasAsync(TenantA, "../other-tenant/escape.zip"));
() => svc.GenerateUploadSasAsync(TenantA, SessionA, "../other-tenant/escape.zip"));
}
// ── Session binding — one device must not be able to name another session's package ──
[Theory]
[InlineData("AgentDiagnostics-" + SessionB + "-20260519T120000.zip")] // foreign session
[InlineData("AgentDiagnostics-" + SessionA + "20260519T120000.zip")] // missing separator
[InlineData("AgentDiagnostics-" + SessionA + "-")] // nothing after the session
[InlineData("AgentDiagnostics-" + SessionA + "-20260519T120000.txt")] // not a zip
[InlineData("x-AgentDiagnostics-" + SessionA + "-20260519T120000.zip")] // wrong prefix
[InlineData("other.zip")]
public async Task GenerateUploadSas_RejectsFilenameNotBoundToSession(string filename)
{
var svc = new FakeHostedDiagnosticsBlobService();
await Assert.ThrowsAsync<ArgumentException>(
() => svc.GenerateUploadSasAsync(TenantA, SessionA, filename));
Assert.Empty(svc.CapturedSasBuilders);
}
[Theory]
[InlineData("AgentDiagnostics-" + SessionA + "-20260519T120000.zip")]
[InlineData("AgentDiagnostics-" + SessionA + "-20260519T120000-preprov.zip")]
[InlineData("AgentDiagnostics-" + SessionA + "-20260724170902-server-requested.zip")]
[InlineData("agentdiagnostics-" + SessionA + "-20260519T120000.ZIP")] // case-insensitive
public async Task GenerateUploadSas_AcceptsEveryAgentProducedShapeForOwnSession(string filename)
{
var svc = new FakeHostedDiagnosticsBlobService();
var result = await svc.GenerateUploadSasAsync(TenantA, SessionA, filename);
Assert.Equal($"{TenantA}/{filename}", result.BlobPath);
}
// ── Create-only — an existing blob is never re-issued ─────────────────────────
[Fact]
public async Task GenerateUploadSas_ExistingBlob_ThrowsAlreadyExists_AndMintsNothing()
{
var svc = new FakeHostedDiagnosticsBlobService { ExistingBlobPath = $"{TenantA}/{Filename}" };
var ex = await Assert.ThrowsAsync<HostedBlobAlreadyExistsException>(
() => svc.GenerateUploadSasAsync(TenantA, SessionA, Filename));
Assert.Equal($"{TenantA}/{Filename}", ex.BlobPath);
Assert.Empty(svc.CapturedSasBuilders);
}
[Fact]
public async Task GenerateUploadSas_ExistingBlobInOtherTenant_DoesNotBlockSameName()
{
// Existence is checked on the tenant-prefixed path, not on the bare filename.
var svc = new FakeHostedDiagnosticsBlobService { ExistingBlobPath = $"{TenantB}/{Filename}" };
var result = await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
Assert.Equal($"{TenantA}/{Filename}", result.BlobPath);
}
[Fact]
public async Task GenerateUploadSas_ReturnsTenantPrefixedBlobPath()
{
var svc = new FakeHostedDiagnosticsBlobService();
var result = await svc.GenerateUploadSasAsync(TenantA, Filename);
var result = await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
Assert.Equal($"{TenantA}/{Filename}", result.BlobPath);
}
@@ -112,9 +172,9 @@ public class HostedDiagnosticsBlobServiceTests
public async Task GenerateUploadSas_EnsuresContainerOnce()
{
var svc = new FakeHostedDiagnosticsBlobService();
await svc.GenerateUploadSasAsync(TenantA, Filename);
await svc.GenerateUploadSasAsync(TenantA, Filename);
await svc.GenerateUploadSasAsync(TenantB, "other.zip");
await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
await svc.GenerateUploadSasAsync(TenantB, SessionA, Filename);
Assert.Equal(1, svc.EnsureContainerCallCount);
}
@@ -124,7 +184,7 @@ public class HostedDiagnosticsBlobServiceTests
var svc = new FakeHostedDiagnosticsBlobService();
var requested = TimeSpan.FromHours(24);
var before = DateTime.UtcNow;
var result = await svc.GenerateUploadSasAsync(TenantA, Filename, requested);
var result = await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename, requested);
var maxAllowed = before.Add(HostedDiagnosticsBlobService.MaxUploadSasTtl).AddMinutes(1); // tolerance
Assert.True(result.ExpiresAt <= maxAllowed,
$"ExpiresAt {result.ExpiresAt:O} exceeded MaxUploadSasTtl-bounded {maxAllowed:O}");
@@ -135,16 +195,16 @@ public class HostedDiagnosticsBlobServiceTests
{
var svc = new FakeHostedDiagnosticsBlobService();
var before = DateTime.UtcNow;
var result = await svc.GenerateUploadSasAsync(TenantA, Filename);
var result = await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
var delta = result.ExpiresAt - before;
Assert.InRange(delta.TotalMinutes, 14, 16);
}
[Fact]
public async Task GenerateUploadSas_PassesBlobScopedResourceAndWriteCreateOnlyPermsToBuilder()
public async Task GenerateUploadSas_PassesBlobScopedResourceAndCreateOnlyPermsToBuilder()
{
var svc = new FakeHostedDiagnosticsBlobService();
await svc.GenerateUploadSasAsync(TenantA, Filename);
await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
Assert.Single(svc.CapturedSasBuilders);
var builder = svc.CapturedSasBuilders.Single();
@@ -152,24 +212,26 @@ public class HostedDiagnosticsBlobServiceTests
Assert.Equal($"{TenantA}/{Filename}", builder.BlobName);
Assert.Equal("diagnostics", builder.BlobContainerName);
// Reconstruct the permissions string to ensure ONLY Write + Create are set —
// ONLY Create — no Write (would allow replacing an existing same-tenant blob),
// no Read (would expose other-tenant blobs in cross-tenant scenarios),
// no Delete (cleanup is service-side via DeleteIfExistsAsync),
// no List (agent has no enumeration need).
var permsString = builder.Permissions ?? string.Empty;
Assert.Contains("w", permsString);
Assert.Contains("c", permsString);
Assert.DoesNotContain("r", permsString);
Assert.DoesNotContain("d", permsString);
Assert.DoesNotContain("l", permsString);
Assert.Equal("c", builder.Permissions);
}
[Fact]
public void UploadSasPermissions_IsCreateOnly()
{
// Pinned: Write must never come back — it is what turns a create into an overwrite.
Assert.Equal(BlobSasPermissions.Create, HostedDiagnosticsBlobService.UploadSasPermissions);
}
[Fact]
public async Task GenerateUploadSas_DifferentTenants_GetDifferentBlobPaths()
{
var svc = new FakeHostedDiagnosticsBlobService();
var a = await svc.GenerateUploadSasAsync(TenantA, Filename);
var b = await svc.GenerateUploadSasAsync(TenantB, Filename);
var a = await svc.GenerateUploadSasAsync(TenantA, SessionA, Filename);
var b = await svc.GenerateUploadSasAsync(TenantB, SessionA, Filename);
Assert.NotEqual(a.BlobPath, b.BlobPath);
Assert.StartsWith(TenantA + "/", a.BlobPath);
Assert.StartsWith(TenantB + "/", b.BlobPath);
@@ -177,8 +239,6 @@ public class HostedDiagnosticsBlobServiceTests
// ── DeleteBySessionPrefixAsync — cascade sweep for multi-package sessions ──────
private const string SessionA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
[Theory]
[InlineData("not-a-guid", SessionA)]
[InlineData(TenantA, "not-a-guid")]
@@ -310,6 +370,9 @@ public class HostedDiagnosticsBlobServiceTests
public int EnsureContainerCallCount { get; private set; }
public List<BlobSasBuilder> CapturedSasBuilders { get; } = new();
/// <summary>Blob path that the fake reports as already existing (null = nothing exists).</summary>
public string? ExistingBlobPath { get; set; }
public FakeHostedDiagnosticsBlobService()
: base(
new BlobServiceClient("UseDevelopmentStorage=true"),
@@ -324,6 +387,9 @@ public class HostedDiagnosticsBlobServiceTests
return Task.CompletedTask;
}
protected override Task<bool> BlobExistsAsync(string blobPath, CancellationToken cancellationToken)
=> Task.FromResult(ExistingBlobPath == blobPath);
protected override Task<Uri> BuildUploadSasUriAsync(
string blobPath, DateTimeOffset expiresOn, CancellationToken cancellationToken)
{
@@ -338,7 +404,7 @@ public class HostedDiagnosticsBlobServiceTests
StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5),
ExpiresOn = expiresOn,
};
builder.SetPermissions(BlobSasPermissions.Write | BlobSasPermissions.Create);
builder.SetPermissions(HostedDiagnosticsBlobService.UploadSasPermissions);
CapturedSasBuilders.Add(builder);
return Task.FromResult(new Uri($"https://fake.blob.core.windows.net/diagnostics/{blobPath}?fake=sas"));
@@ -1308,4 +1308,119 @@ public class PolicyEnforcementMiddlewareTests
Assert.True(result.Allowed);
Assert.Equal(string.Empty, result.Context!.CallerId);
}
// ── Tenant suspension gate (TenantConfiguration.Disabled / DisabledUntil) ──────────
// The operator kill-switch must revoke API access on the shared authorization path, not only
// on the SPA's auth/me probe — a direct REST caller with a valid token must get 403 too.
private static void AsSuspended(Harness h, string tenantId, DateTime? until = null, string? reason = null) =>
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(tenantId))
.ReturnsAsync(new TenantConfiguration
{
TenantId = tenantId,
Disabled = true,
DisabledUntil = until,
DisabledReason = reason,
});
[Fact]
public async Task Suspended_HomeTenant_ReadRoute_IsForbidden_WithTenantSuspendedCode()
{
const string upn = "admin@contoso.com";
var h = BuildHarness();
h.AsTenantAdmin(TenantA, upn);
AsSuspended(h, TenantA, until: DateTime.UtcNow.AddDays(30), reason: "Abuse investigation");
var result = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
Assert.Equal("TenantSuspended", result.ErrorCode);
Assert.Equal("Abuse investigation", result.ErrorMessage);
}
[Fact]
public async Task Suspended_HomeTenant_WriteRoute_IsForbidden()
{
const string upn = "admin@contoso.com";
var h = BuildHarness();
h.AsTenantAdmin(TenantA, upn);
AsSuspended(h, TenantA); // indefinite suspension (no DisabledUntil)
var result = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal("TenantSuspended", result.ErrorCode);
}
[Fact]
public async Task Suspended_ExpiredDisabledUntil_IsAllowed()
{
const string upn = "admin@contoso.com";
var h = BuildHarness();
h.AsTenantAdmin(TenantA, upn);
AsSuspended(h, TenantA, until: DateTime.UtcNow.AddMinutes(-1)); // suspension window elapsed
var result = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
}
[Fact]
public async Task Suspended_GlobalAdmin_BypassesGate_ToAdministerTenant()
{
const string upn = "ga@platform.example";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
AsSuspended(h, TenantA);
AsSuspended(h, TenantB);
var own = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
var cross = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
Assert.True(own.Allowed);
Assert.True(cross.Allowed);
}
[Fact]
public async Task Suspended_DelegatedReader_TargetTenantSuspended_IsForbidden()
{
const string upn = "msp@partner.example";
var h = BuildHarness();
h.AsDelegated(TenantB);
AsSuspended(h, TenantB); // home tenant A is fine, managed target B is suspended
var result = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal("TenantSuspended", result.ErrorCode);
}
[Fact]
public async Task Suspended_DelegatedReader_HomeTenantSuspended_LosesDelegatedReach()
{
const string upn = "msp@partner.example";
var h = BuildHarness();
h.AsDelegated(TenantB);
AsSuspended(h, TenantA); // the MSP's own home tenant is suspended
var result = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal("TenantSuspended", result.ErrorCode);
}
[Fact]
public async Task Suspended_DeviceRoute_NoPrincipal_GateNotApplied()
{
// Anonymous/device routes carry no principal — SecurityValidator owns their tenant gate. The
// middleware must not touch tenant config here (no tenant to key on).
var h = BuildHarness();
AsSuspended(h, TenantA);
var result = await h.Middleware.DecideAsync("GET", "/api/health", null, null);
Assert.True(result.Allowed);
h.ConfigRepo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny<string>()), Times.Never);
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<OpsEventEntry> Ops { get; } = new();
public Mock<ISessionRepository> Repo { get; } = new();
public CapturingLogger Logger { get; } = new();
public SessionOwnerBindingObserver Sut { get; }
public Rig()
{
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => { lock (Ops) Ops.Add(e); })
.Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
Sut = new SessionOwnerBindingObserver(
Logger, opsService, new MemoryCache(new MemoryCacheOptions()), Repo.Object);
}
}
private static (HttpRequestData Req, Dictionary<object, object> Items) Request(string agentVersion = "2.0.9999")
{
var items = new Dictionary<object, object>();
var contextMock = new Mock<Microsoft.Azure.Functions.Worker.FunctionContext>();
contextMock.SetupGet(c => c.Items).Returns(items);
var reqMock = new Mock<HttpRequestData>(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<List<OpsEventEntry>> 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<SessionOwner>()))
.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<SessionOwner>(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<string>(), It.IsAny<string>(), It.IsAny<SessionOwner>()), Times.Never);
}
private sealed record LogEntry(LogLevel Level, string Message);
private sealed class CapturingLogger : ILogger<SessionOwnerBindingObserver>
{
public List<LogEntry> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
=> Entries.Add(new LogEntry(logLevel, formatter(state, exception)));
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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);
}
}
@@ -55,6 +55,23 @@ public class SessionReportDiagnosticsArchiveCopierTests
Assert.Equal(0, copier.HostedOpenCalls + copier.CustomerOpenCalls + copier.UploadCalls);
}
[Theory]
[InlineData("x/../../hosted-diagnostics/22222222-2222-2222-2222-222222222222/payload_diag_archive_1.zip")]
[InlineData("sub/dir_diag_archive_1.zip")]
[InlineData("..\\escape_diag_archive_1.zip")]
public async Task CopyAsync_NonFlatDestination_Rejected_WithoutReadOrWrite(string destination)
{
// A path-shaped destination would let the SDK + System.Uri resolve the write outside
// the session-reports container. Reject before touching any storage.
var copier = new RecordingCopier();
var result = await copier.CopyAsync(TenantA, SessionId, HostedBlob, destination);
Assert.False(result.Success);
Assert.Equal(SessionReportDiagnosticsArchiveCopier.Statuses.FailedInvalidBlobName, result.Status);
Assert.Equal(0, copier.HostedOpenCalls + copier.CustomerOpenCalls + copier.UploadCalls);
}
[Fact]
public async Task CopyAsync_Hosted_HappyPath_UploadsWithFlatName()
{
@@ -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.
/// </summary>
// ── 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<TableClient> Sessions { get; }
@@ -1,11 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Azure;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
@@ -386,4 +388,40 @@ public class TenantContactEmailTests
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()), Times.Never);
}
// -------------------------------------------------------------------------
// IsValidDomainName — the only shape DomainName may be persisted in
// -------------------------------------------------------------------------
[Theory]
[InlineData("contoso.com")]
[InlineData("sub.domain.org")]
[InlineData("x-y.example")]
[InlineData("A1.B2.c3")]
public void IsValidDomainName_AcceptsHostNames(string domain)
=> Assert.True(TenantConfigValidation.IsValidDomainName(domain));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("localhost")]
[InlineData(".contoso.com")]
[InlineData("contoso.com.")]
[InlineData("con..toso.com")]
[InlineData("-contoso.com")]
[InlineData("contoso-.com")]
[InlineData("con toso.com")]
[InlineData("<b>contoso.com</b>")]
[InlineData("contoso.com\"onerror=\"x")]
[InlineData("contoso.com/path")]
[InlineData("user@contoso.com")]
public void IsValidDomainName_RejectsNonHostNames(string? domain)
=> Assert.False(TenantConfigValidation.IsValidDomainName(domain));
[Fact]
public void IsValidDomainName_RejectsOverlongNamesAndLabels()
{
Assert.False(TenantConfigValidation.IsValidDomainName(new string('a', 64) + ".com"));
Assert.False(TenantConfigValidation.IsValidDomainName(string.Join(".", Enumerable.Repeat(new string('a', 60), 5))));
}
}
@@ -22,9 +22,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
_publisher = publisher;
}
public async Task<bool> StoreSessionAsync(SessionRegistration registration)
public async Task<bool> 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<Dictionary<string, int>> 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);
@@ -1,4 +1,4 @@
using System;
using System;
using System.Net;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Security;
@@ -53,8 +53,10 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
try
{
// Bootstrap-only: reject requests without X-Bootstrap-Token
if (!req.Headers.Contains("X-Bootstrap-Token"))
// Bootstrap-only: reject requests without a (non-empty) X-Bootstrap-Token before any
// parsing. SecurityValidator enforces the same rule fail-closed for every
// /api/bootstrap/* route; this is only the cheap early exit.
if (SecurityValidator.GetBootstrapToken(req) == null)
{
var noToken = req.CreateResponse(HttpStatusCode.Unauthorized);
await noToken.WriteAsJsonAsync(new { error = "X-Bootstrap-Token header is required" });
@@ -1,4 +1,4 @@
using System;
using System;
using System.Net;
using AutopilotMonitor.Functions.Functions.Sessions;
using AutopilotMonitor.Functions.Security;
@@ -56,8 +56,10 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
try
{
// Bootstrap-only: reject requests without X-Bootstrap-Token
if (!req.Headers.Contains("X-Bootstrap-Token"))
// Bootstrap-only: reject requests without a (non-empty) X-Bootstrap-Token before any
// parsing. SecurityValidator enforces the same rule fail-closed for every
// /api/bootstrap/* route; this is only the cheap early exit.
if (SecurityValidator.GetBootstrapToken(req) == null)
{
var noToken = req.CreateResponse(HttpStatusCode.Unauthorized);
await noToken.WriteAsJsonAsync(new { success = false, message = "X-Bootstrap-Token header is required" });
@@ -1,4 +1,4 @@
using System;
using System;
using System.Net;
using AutopilotMonitor.Functions.Functions.Ingest;
using AutopilotMonitor.Functions.Security;
@@ -53,8 +53,10 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
try
{
// Bootstrap-only: reject requests without X-Bootstrap-Token
if (!req.Headers.Contains("X-Bootstrap-Token"))
// Bootstrap-only: reject requests without a (non-empty) X-Bootstrap-Token before any
// parsing. SecurityValidator enforces the same rule fail-closed for every
// /api/bootstrap/* route; this is only the cheap early exit.
if (SecurityValidator.GetBootstrapToken(req) == null)
{
var noToken = req.CreateResponse(HttpStatusCode.Unauthorized);
await noToken.WriteAsJsonAsync(new { error = "X-Bootstrap-Token header is required" });
@@ -77,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,
@@ -88,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)
{
@@ -110,6 +110,12 @@ namespace AutopilotMonitor.Functions.Functions.Config
// admins are served the FULL config so this is normally a no-op for them.)
config.RestoreRedactedSecretsFrom(existingConfig);
// DomainName is tenant identity: seeded once from the first login's UPN (AuthFunction)
// and never client-writable — for ANY caller, GA included — mirroring the patch flow
// ("identity — never writable"). Without this preserve the full-model PUT persisted an
// arbitrary string here, which is rendered into the welcome/farewell mails.
config.DomainName = existingConfig.DomainName;
// Shared model validation (rate limits, contact address, webhook/Teams SSRF, custom
// headers, notification channels, diagnostics SAS, retention cap) — single source
// with the transactional field-patch flow (TenantConfigValidation).
@@ -2,6 +2,7 @@
using System.Web;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Functions.Services.Diagnostics;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
@@ -19,8 +20,10 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
/// container SAS URL stored in <see cref="TenantConfiguration.DiagnosticsBlobSasUrl"/>.
/// Data stays in the customer's own storage account.</item>
/// <item><c>Hosted</c> (opt-in): mints a freshly-stamped, 15-min, blob-scoped,
/// Write+Create-only SAS pinned to <c>{tenantId}/{filename}</c> in the backend's
/// own diagnostics container. Requires explicit admin opt-in via the UI; never
/// Create-only SAS pinned to <c>{tenantId}/{filename}</c> in the backend's
/// own diagnostics container. The filename must carry the requesting session id
/// and the blob must not exist yet, so one device can neither name nor replace
/// another session's package. Requires explicit admin opt-in via the UI; never
/// silent.</item>
/// </list>
/// Called by the agent just before uploading — never at startup, never cached.
@@ -56,6 +59,8 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly HostedDiagnosticsBlobService _hostedDiagnostics;
private readonly ISessionDeletionInventoryReader _sessionRowReader;
private readonly SessionOwnerBindingObserver _ownerBinding;
public GetDiagnosticsUploadUrlFunction(
ILogger<GetDiagnosticsUploadUrlFunction> logger,
@@ -68,8 +73,12 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
CloudPcDeviceValidator cloudPcDeviceValidator,
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
BootstrapSessionService bootstrapSessionService,
HostedDiagnosticsBlobService hostedDiagnostics)
HostedDiagnosticsBlobService hostedDiagnostics,
ISessionDeletionInventoryReader sessionRowReader,
SessionOwnerBindingObserver ownerBinding)
{
_sessionRowReader = sessionRowReader;
_ownerBinding = ownerBinding;
_logger = logger;
_configService = configService;
_adminConfigService = adminConfigService;
@@ -145,7 +154,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
if (destination == DestinationHosted)
{
return await IssueHostedSasAsync(req, requestBody);
return await IssueHostedSasAsync(req, requestBody, validation);
}
if (destination == DestinationCustomerSas)
@@ -237,7 +246,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
}
private async Task<HttpResponseData> IssueHostedSasAsync(
HttpRequestData req, GetDiagnosticsUploadUrlRequest requestBody)
HttpRequestData req, GetDiagnosticsUploadUrlRequest requestBody, SecurityValidationResult validation)
{
if (string.IsNullOrEmpty(requestBody.FileName))
{
@@ -250,18 +259,36 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
return badRequest;
}
// The blob name is bound to the session below, so the session id has to be a real
// one — and it is the coordinate for the owner-binding observation.
if (!SecurityValidator.IsValidGuid(requestBody.SessionId))
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
{
Success = false,
Message = "sessionId is required for hosted destination"
});
return badRequest;
}
// SESSION-OWNER-BINDING-SHADOW: same observation as the session-scoped writes, so
// stage-2 enforcement covers this endpoint with the same data. Never rejects here.
await ObserveSessionOwnerAsync(req, requestBody.TenantId, requestBody.SessionId, validation);
HostedUploadSasResult sasResult;
try
{
sasResult = await _hostedDiagnostics.GenerateUploadSasAsync(
requestBody.TenantId, requestBody.FileName);
requestBody.TenantId, requestBody.SessionId, requestBody.FileName);
}
catch (ArgumentException ex)
{
// Filename validation failed (path separator, traversal, oversize, etc.)
// Filename validation failed (path separator, traversal, oversize, not bound
// to the requesting session, etc.)
_logger.LogWarning(
"GetDiagnosticsUploadUrl: rejecting hosted SAS for tenant {TenantId}, file {FileName}: {Reason}",
requestBody.TenantId, requestBody.FileName, ex.Message);
"GetDiagnosticsUploadUrl: rejecting hosted SAS for tenant {TenantId}, session {SessionId}, file {FileName}: {Reason}",
requestBody.TenantId, requestBody.SessionId, requestBody.FileName, ex.Message);
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
{
@@ -270,6 +297,21 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
});
return badRequest;
}
catch (HostedBlobAlreadyExistsException ex)
{
// A legitimate agent never re-requests an existing name (every package is
// freshly timestamped); this is either a replay or an overwrite attempt.
_logger.LogWarning(
"GetDiagnosticsUploadUrl: rejecting hosted SAS for tenant {TenantId}, session {SessionId}: blob {BlobPath} already exists",
requestBody.TenantId, requestBody.SessionId, ex.BlobPath);
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
await conflict.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
{
Success = false,
Message = "A diagnostics package with this name already exists"
});
return conflict;
}
_logger.LogInformation(
"GetDiagnosticsUploadUrl: Issuing Hosted upload URL for tenant {TenantId}, session {SessionId}, blob {BlobPath}, SAS expires {ExpiresAt}",
@@ -288,6 +330,21 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
return response;
}
private async Task ObserveSessionOwnerAsync(
HttpRequestData req, string tenantId, string sessionId, SecurityValidationResult validation)
{
try
{
var row = await _sessionRowReader.GetSessionRowAsync(tenantId, sessionId);
_ownerBinding.Observe(req, tenantId, sessionId, row, validation, "agent/upload-url");
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"GetDiagnosticsUploadUrl: session-owner observation skipped for session {SessionId}", sessionId);
}
}
/// <summary>
/// Normalizes the destination string from TenantConfiguration. Null/empty becomes
/// CustomerSas (preserves existing behaviour for tenants that predate this field).
@@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.Net;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
@@ -336,6 +337,14 @@ public class AuthFunction
if (string.IsNullOrEmpty(domain))
return;
// The seeded value is rendered into transactional mails — persist only a strict host name,
// regardless of what the identity provider's UPN charset happens to allow.
if (!TenantConfigValidation.IsValidDomainName(domain))
{
_logger.LogWarning("Refusing to seed DomainName for tenant {TenantId}: UPN domain is not a valid host name", tenantId);
return;
}
_logger.LogInformation("Setting domain name for tenant {TenantId}: {Domain}", tenantId, domain);
tenantConfig.DomainName = domain;
tenantConfig.UpdatedBy = upn;
@@ -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<IngestTelemetryFunction> 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")
@@ -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<ReportAgentErrorFunction> 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.
/// </summary>
internal async Task<HttpResponseData> ProcessReportErrorAsync(HttpRequestData req, string tenantId)
internal async Task<HttpResponseData> 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);
}
}
/// <summary>
/// Regex over OUR OWN report format (AgentRuntimeConfig.VerifyBinaryIntegrity):
/// "... actual=&lt;sha256&gt;, expected=&lt;sha256&gt;". Parsed leniently — an unparseable
@@ -1,5 +1,6 @@
using System.Net;
using System.Web;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -44,7 +45,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
}
// Prevent path traversal
if (blobName.Contains("..") || blobName.Contains("/") || blobName.Contains("\\"))
if (!BlobNameGuard.IsFlat(blobName))
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new { success = false, message = "Invalid blob name." });
@@ -1,5 +1,6 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
@@ -106,6 +107,14 @@ namespace AutopilotMonitor.Functions.Functions.Reports
return bad;
}
// TenantId becomes part of the blob name — must be a GUID, never path-shaped.
if (!SecurityValidator.IsValidGuid(request.TenantId))
{
var invalid = req.CreateResponse(HttpStatusCode.BadRequest);
await invalid.WriteAsJsonAsync(new { success = false, message = "Invalid tenantId." });
return invalid;
}
var metadata = await _sessionReportService.SubmitDiagFilesReportAsync(request, userIdentifier);
// Audit log — skip for Global Admins (their actions are not tenant-scoped)
@@ -1,5 +1,6 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
@@ -105,6 +106,19 @@ namespace AutopilotMonitor.Functions.Functions.Reports
return bad;
}
// Identifier format gate: both values become part of the report blob name.
// Route values are percent-decoded, so an encoded '/' or '..' would otherwise
// reach the storage layer as a path segment.
if (!SecurityValidator.IsValidGuid(request.SessionId) || !SecurityValidator.IsValidGuid(request.TenantId))
{
_logger.LogWarning(
"SubmitSessionReport: BLOCKED non-GUID identifier user={User} tenant={TenantId} sessionIdLength={SessionIdLength}",
userIdentifier, request.TenantId, request.SessionId?.Length ?? 0);
var invalid = req.CreateResponse(HttpStatusCode.BadRequest);
await invalid.WriteAsJsonAsync(new { success = false, message = "Invalid sessionId or tenantId." });
return invalid;
}
// Submit report
var metadata = await _sessionReportService.SubmitReportAsync(request, userIdentifier);
@@ -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<RegisterSessionFunction> 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)
{
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Config;
namespace AutopilotMonitor.Functions.Helpers
{
@@ -21,6 +22,120 @@ namespace AutopilotMonitor.Functions.Helpers
// RFC 5321 caps a forward path at 254 characters.
internal const int MaxContactEmailLength = 254;
// RFC 1035: a host name is at most 253 characters, labels at most 63.
internal const int MaxDomainNameLength = 253;
/// <summary>
/// True when <paramref name="domainName"/> is a strict DNS host name: dotted labels of
/// ASCII letters, digits and inner hyphens only. This is the ONLY shape DomainName may
/// ever be persisted in — it is rendered into transactional mails and admin views.
/// </summary>
internal static bool IsValidDomainName(string? domainName)
{
if (string.IsNullOrEmpty(domainName) || domainName.Length > MaxDomainNameLength)
return false;
var labels = domainName.Split('.');
if (labels.Length < 2)
return false;
foreach (var label in labels)
{
if (label.Length == 0 || label.Length > 63)
return false;
if (label[0] == '-' || label[label.Length - 1] == '-')
return false;
foreach (var ch in label)
{
var ok = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-';
if (!ok)
return false;
}
}
return true;
}
private const int MaxWhitelistEntries = 200;
private const int MaxWhitelistEntryLength = 128;
/// <summary>
/// Validates a hardware whitelist CSV (<see cref="TenantConfiguration.ManufacturerWhitelist"/> /
/// <see cref="TenantConfiguration.ModelWhitelist"/>). The CSV is split on ',' at enforcement
/// time, so this is the last line of defence against a value that was meant as ONE entry but
/// alters the list structure: blank items (",,", ", ,") are rejected rather than silently
/// dropped, and every item must be a printable, bounded pattern. Returns a user-facing
/// reason, or null when the list is well-formed. Empty/whitespace = allow all (documented).
/// </summary>
internal static string? ValidateHardwareWhitelist(string? csv)
{
if (string.IsNullOrWhiteSpace(csv))
return null;
var items = csv!.Split(',');
if (items.Length > MaxWhitelistEntries)
return $"at most {MaxWhitelistEntries} entries are allowed.";
foreach (var raw in items)
{
var entry = raw.Trim();
if (entry.Length == 0)
return "entries must not be empty (check for a stray or doubled comma).";
if (entry.Length > MaxWhitelistEntryLength)
return $"each entry must be at most {MaxWhitelistEntryLength} characters.";
foreach (var ch in entry)
{
if (char.IsControl(ch))
return "entries must not contain control characters.";
}
}
return null;
}
// Generous for a CDN asset URL; bounds the command line the agent composes from it.
internal const int MaxBrandingImageUrlLength = 2048;
/// <summary>
/// Validates <see cref="TenantConfiguration.EnrollmentSummaryBrandingImageUrl"/>. The value
/// is delivered verbatim to every enrolling device, fetched from the signed-in user's
/// session and spliced into the summary-dialog command line, so it is the one piece of
/// tenant config that is both a device egress target and a process argument. It must be a
/// plain absolute HTTPS URL to a DNS host — no quotes, whitespace or control characters
/// that could terminate a quoted argument. Returns an error message, or null when
/// valid/empty (empty = no branding image).
/// </summary>
internal static string? ValidateBrandingImageUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url))
return null;
if (url!.Length > MaxBrandingImageUrlLength)
return $"must be at most {MaxBrandingImageUrlLength} characters.";
foreach (var ch in url)
{
if (char.IsControl(ch) || char.IsWhiteSpace(ch))
return "must not contain whitespace or control characters.";
if (ch == '"' || ch == '\'')
return "must not contain quotes.";
}
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
return "is not a valid absolute URL.";
if (uri.Scheme != Uri.UriSchemeHttps)
return "must use HTTPS.";
if (uri.IsLoopback || string.IsNullOrEmpty(uri.Host))
return "must not target localhost.";
if (System.Net.IPAddress.TryParse(uri.Host, out _))
return "must use a DNS hostname, not an IP address.";
return null;
}
/// <summary>
/// Validates a candidate configuration against the stored one. Returns a
/// user-facing error message (same wording the PUT endpoint always produced),
@@ -58,6 +173,18 @@ namespace AutopilotMonitor.Functions.Helpers
if (channelsError != null)
return $"Invalid notification channels: {channelsError}";
var brandingUrlError = ValidateBrandingImageUrl(candidate.EnrollmentSummaryBrandingImageUrl);
if (brandingUrlError != null)
return $"Invalid enrollment summary branding image URL: {brandingUrlError}";
var manufacturerWhitelistError = ValidateHardwareWhitelist(candidate.ManufacturerWhitelist);
if (manufacturerWhitelistError != null)
return $"Invalid manufacturer whitelist: {manufacturerWhitelistError}";
var modelWhitelistError = ValidateHardwareWhitelist(candidate.ModelWhitelist);
if (modelWhitelistError != null)
return $"Invalid model whitelist: {modelWhitelistError}";
// Only validate the customer-supplied SAS URL when the tenant has actually selected
// the CustomerSas destination — a stale value left over from a prior CustomerSas
// configuration must not block a Hosted save (mirrors GetDiagnosticsUploadUrlFunction).
@@ -5,6 +5,7 @@ using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Middleware;
@@ -212,6 +213,29 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
decision.UserIdentifier, decision.UserRole, "CrossTenant");
}
// Tenant suspension gate — the operator kill-switch (TenantConfiguration.Disabled/DisabledUntil) and
// the offboarding tombstone. Enforced HERE, on the shared authorization path, so it covers every
// tenant-scoped API call and not just the SPA's auth/me login probe (which also carries it, for the
// richer client payload). Both the caller's HOME tenant (JWT tid) and the request's TARGET tenant are
// checked: a suspended tenant's members lose everything, including delegated reach into other tenants,
// and nobody without platform scope reaches a suspended target. Platform operators (GA/Reader) bypass —
// they must still administer the suspended tenant. Anonymous/device routes bring their own gate
// (SecurityValidator → 403 TenantDisabled). Cached, side-effect-free read; a missing row is not disabled.
if (principal != null && !hasGlobalScope)
{
var suspended = await FindSuspendedTenantAsync(jwtTenantId, targetTenantId);
if (suspended != null)
{
_logger.LogWarning("[PolicyEnforcement] BLOCKED suspended tenant: user={User} tenant={Tenant} path={Path}",
LogSanitizer.Clean(decision.UserIdentifier), LogSanitizer.Clean(suspended.TenantId), logPath);
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "TenantSuspended",
!string.IsNullOrEmpty(suspended.DisabledReason)
? suspended.DisabledReason
: "Your tenant has been suspended. Please contact support for more information.",
decision.UserIdentifier, decision.UserRole, "TenantSuspended");
}
}
// Merge delegated state from the two delegated admission paths:
// • scoped-route (RouteParam/QueryParam): a single-tenant cross-tenant read — delegatedRole +
// allowedTenantIds were set above.
@@ -259,6 +283,26 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return PolicyResult.Allow(requestContext, decision.UserIdentifier, decision.UserRole);
}
/// <summary>
/// Returns the configuration of the first currently-suspended tenant among the caller's home tenant and
/// the request target (null when neither is suspended). <see cref="TenantConfigurationService.TryGetConfigurationAsync"/>
/// is cached (5 min TTL, same as the agent gate) and never persists a default row.
/// <see cref="TenantConfiguration.IsCurrentlyDisabled"/> already treats an elapsed DisabledUntil as
/// re-enabled, so the gate lifts on its own; auth/me persists the flip on the next login.
/// </summary>
private async Task<TenantConfiguration?> FindSuspendedTenantAsync(string jwtTenantId, string targetTenantId)
{
var (homeConfig, _) = await _tenantConfigService.TryGetConfigurationAsync(jwtTenantId);
if (homeConfig.IsCurrentlyDisabled())
return homeConfig;
if (string.Equals(targetTenantId, jwtTenantId, StringComparison.OrdinalIgnoreCase))
return null;
var (targetConfig, _) = await _tenantConfigService.TryGetConfigurationAsync(targetTenantId);
return targetConfig.IsCurrentlyDisabled() ? targetConfig : null;
}
/// <summary>
/// Resolves the request's target tenant. Returns the effective TargetTenantId (defaults to the JWT
/// tenant) and the explicitly-named tenant from the route/query (null when the route does not name one,
@@ -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))
@@ -282,6 +282,7 @@ builder.Services.AddSingleton<
AutopilotMonitor.Functions.Services.Monitoring.AzurePoisonQueueProbe>();
builder.Services.AddSingleton<OpsAlertDispatchService>();
builder.Services.AddSingleton<OpsEventService>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.SessionOwnerBindingObserver>();
builder.Services.AddSingleton<BlockedDeviceService>();
builder.Services.AddSingleton<HardwareRejectionThrottleService>();
builder.Services.AddSingleton<BlockedVersionService>();
@@ -0,0 +1,28 @@
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// Last-line guard for request-derived blob names. Azure.Storage.Blobs appends the name to
/// the container URI preserving '/' and System.Uri collapses '..' segments, so a name such as
/// "x/../../other-container/y" resolves OUTSIDE the intended container. Every blob name that
/// contains caller-influenced material must be flat: no separators, no dot segments.
/// </summary>
public static class BlobNameGuard
{
public static bool IsFlat(string? blobName)
{
if (string.IsNullOrWhiteSpace(blobName)) return false;
if (blobName.Contains('/') || blobName.Contains('\\')) return false;
if (blobName.Contains("..")) return false;
if (blobName.Contains('%') || blobName.Contains('?') || blobName.Contains('#')) return false;
return true;
}
/// <summary>Throws <see cref="ArgumentException"/> when the name is not flat.</summary>
public static string EnsureFlat(string? blobName, string parameterName)
{
if (!IsFlat(blobName))
throw new ArgumentException("Blob name must be a flat name without path separators or dot segments.", parameterName);
return blobName!;
}
}
}
@@ -86,7 +86,7 @@ namespace AutopilotMonitor.Functions.Security
/// <summary>
/// Validates a client certificate from HTTP request header
/// </summary>
/// <param name="certificateBase64">Base64-encoded certificate from X-Client-Certificate header</param>
/// <param name="certificateBase64">Base64-encoded certificate from the platform-set X-ARR-ClientCert header</param>
/// <param name="logger">Logger for diagnostic output</param>
/// <returns>Validation result with certificate details</returns>
public static CertificateValidationResult ValidateCertificate(string? certificateBase64, ILogger? logger = null)
@@ -41,6 +41,32 @@ namespace AutopilotMonitor.Functions.Security
throw new ArgumentException($"Invalid {parameterName} format. Expected a valid GUID.", parameterName);
}
/// <summary>
/// Route prefix (below the host's <c>api</c> route prefix) of the token-only bootstrap
/// endpoints. Mirrors the <c>/api/bootstrap</c> entry of the Function App's
/// clientCertExclusionPaths: requests under it never carry a platform-verified certificate.
/// </summary>
public const string BootstrapRoutePrefix = "/api/bootstrap/";
/// <summary>
/// True when the request targets a bootstrap route (<see cref="BootstrapRoutePrefix"/>),
/// where the bootstrap token is the sole accepted credential.
/// </summary>
public static bool IsBootstrapRoute(HttpRequestData req)
=> req.Url?.AbsolutePath?.StartsWith(BootstrapRoutePrefix, StringComparison.OrdinalIgnoreCase) == true;
/// <summary>
/// The X-Bootstrap-Token header value, or null when absent or empty. Callers must treat an
/// empty header exactly like a missing one — header presence proves nothing.
/// </summary>
public static string? GetBootstrapToken(HttpRequestData req)
{
var value = req.Headers.Contains("X-Bootstrap-Token")
? req.Headers.GetValues("X-Bootstrap-Token").FirstOrDefault()
: null;
return string.IsNullOrWhiteSpace(value) ? null : value;
}
private readonly TenantConfigurationService _configService;
private readonly AdminConfigurationService _adminConfigService;
private readonly RateLimitService _rateLimitService;
@@ -138,9 +164,30 @@ namespace AutopilotMonitor.Functions.Security
// If the agent sends an X-Bootstrap-Token header, validate it and skip all other checks
// (cert, rate limit, hardware, device validation). This enables agents started before
// Intune enrollment to authenticate using a time-limited token generated by the admin.
var bootstrapTokenHeader = req.Headers.Contains("X-Bootstrap-Token")
? req.Headers.GetValues("X-Bootstrap-Token").FirstOrDefault()
: null;
var bootstrapTokenHeader = GetBootstrapToken(req);
// Bootstrap routes (/api/bootstrap/*) are excluded from the platform's TLS client-cert
// requirement so cert-less OOBE agents can reach them. That makes the token the ONLY
// authority there: the certificate stage below must never run for these routes, because
// on an excluded path nothing proves the caller ever completed an mTLS handshake.
// Fail closed on a missing/empty token or a missing token service instead of falling
// through. Derived from the request path — the same prefix the platform excludes — so
// no function can forget to opt in.
if (IsBootstrapRoute(req) && (bootstrapTokenHeader == null || _bootstrapSessionService == null))
{
if (_bootstrapSessionService == null)
_logger.LogError("Rejected bootstrap request for tenant {TenantId}: BootstrapSessionService is not wired", tenantId);
else
_logger.LogWarning("Rejected bootstrap request for tenant {TenantId}: X-Bootstrap-Token header missing or empty", tenantId);
return new SecurityValidationResult
{
IsValid = false,
StatusCode = HttpStatusCode.Unauthorized,
ErrorMessage = "X-Bootstrap-Token header is required",
Details = "Bootstrap endpoints authenticate with a bootstrap token only."
};
}
if (!string.IsNullOrEmpty(bootstrapTokenHeader) && _bootstrapSessionService != null)
{
@@ -253,15 +300,16 @@ namespace AutopilotMonitor.Functions.Security
}
// 1. Validate client certificate
// Production: Azure App Service mTLS forwards client cert in X-ARR-ClientCert header.
// Local dev fallback: X-Client-Certificate header for testing with Azure Functions Core Tools
// (which does not support mTLS). This fallback never triggers in production because
// requests without a TLS client cert are rejected by Azure before reaching the function.
// The platform terminates mTLS (clientCertMode=Required on every non-excluded path) and
// forwards the handshake certificate as X-ARR-ClientCert; it strips any inbound copy of
// that header, including on excluded paths (verified 2026-08-29). The TLS handshake is the
// proof that the caller holds the private key — the certificate bytes alone are public
// and would be replayable. That is why NO other header is ever accepted here: a
// client-writable header decouples the certificate from key possession. Local
// development sets X-ARR-ClientCert directly (Core Tools does not strip it).
var certHeader = req.Headers.Contains("X-ARR-ClientCert")
? req.Headers.GetValues("X-ARR-ClientCert").FirstOrDefault()
: req.Headers.Contains("X-Client-Certificate")
? req.Headers.GetValues("X-Client-Certificate").FirstOrDefault()
: null;
: null;
var certValidation = CertificateValidator.ValidateCertificate(certHeader, _logger);
if (!certValidation.IsValid)
@@ -537,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,
@@ -719,6 +769,14 @@ namespace AutopilotMonitor.Functions.Security
/// </summary>
public string? CertificateThumbprint { get; set; }
/// <summary>
/// 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.
/// </summary>
public string? IntuneDeviceId { get; set; }
/// <summary>
/// Device manufacturer (if validation succeeded)
/// </summary>
@@ -0,0 +1,268 @@
using System;
using Azure.Data.Tables;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// SESSION-OWNER-BINDING — decides whether the device behind a validated agent request is the
/// device a session belongs to.
/// <para>
/// 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 (<see cref="SessionOwner"/>), and every session-scoped write is compared against
/// it here.
/// </para>
/// </summary>
/// <remarks>
/// STAGE 1 — SHADOW (<c>SESSION-OWNER-BINDING-SHADOW</c>): 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 <c>SessionOwnerMismatch</c> ops event. Bindings are
/// stamped from stage 1 on so the data exists when enforcement is switched on.
/// <para>
/// Enforcement (stage 2, separate change, after a long observation window) adds a
/// <c>Rejects</c> rule and answers a would-reject with 403 + error code
/// <see cref="AutopilotMonitor.Shared.Constants.AgentErrorCodes.SessionOwnerMismatch"/>, 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 <c>session.id</c>
/// survives but the device gets a new certificate identity — resolve into a fresh session rather
/// than a dead one. Deliberately no <c>Rejects</c> in this file yet: KQL written against the
/// shadow field must keep working, and the switch has to be a visible code change.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public static class SessionOwnershipPolicy
{
/// <summary>
/// <see cref="Microsoft.Azure.Functions.Worker.FunctionContext"/> item key under which the
/// outcome reaches <c>RequestTelemetryMiddleware</c>, which stamps it onto the request row as
/// the <c>SessionOwnerBinding</c> dimension — the shadow denominator (worker-side
/// LogInformation never reaches App Insights, so the bulk Match outcome has no other carrier).
/// </summary>
public const string RequestItemKey = "SessionOwnerBinding";
/// <summary>
/// Stable outcome codes. Queried by exact match in KQL — keep the strings stable.
/// </summary>
public static class Outcome
{
/// <summary>No Sessions row yet (first registration, or ingest ahead of registration). Nothing to compare; the registration stamps the owner.</summary>
public const string Fresh = "Fresh";
/// <summary>Caller identity equals the stored owner — the expected case.</summary>
public const string Match = "Match";
/// <summary>Row predates the binding (no Owner columns) and the caller's serial equals the row's serial — claimed by this caller.</summary>
public const string ClaimLegacy = "ClaimLegacy";
/// <summary>Row predates the binding and the serials differ. Nothing ties the caller to the session.</summary>
public const string LegacySerialMismatch = "LegacySerialMismatch";
/// <summary>Cert caller, thumbprint differs, but the certificate CN (Intune device id) is the same device — re-issued certificate. Rebound.</summary>
public const string RebindCertRotation = "RebindCertRotation";
/// <summary>Cert caller on a bootstrap-owned session announcing the same serial — the install→runtime handoff. Rebound to the certificate.</summary>
public const string RebindBootstrapHandoff = "RebindBootstrapHandoff";
/// <summary>Cert caller on a bootstrap-owned session announcing a different serial.</summary>
public const string MismatchBootstrapOwned = "MismatchBootstrapOwned";
/// <summary>Cert caller whose thumbprint AND device id differ from the owning certificate. With serialMatch=true this is the re-enroll-without-wipe shape.</summary>
public const string MismatchCert = "MismatchCert";
/// <summary>Bootstrap caller on a bootstrap-owned session with a different short code or serial.</summary>
public const string MismatchBootstrap = "MismatchBootstrap";
/// <summary>Bootstrap caller on a cert-owned session — an auth downgrade, never legitimate.</summary>
public const string DowngradeToBootstrap = "DowngradeToBootstrap";
/// <summary>Validated request carried neither a certificate identity nor a bootstrap code (device validation disabled paths). Cannot bind; allowed.</summary>
public const string CallerUnidentified = "CallerUnidentified";
}
/// <summary>Result of <see cref="Evaluate"/>.</summary>
public sealed class Decision
{
public Decision(string outcome, SessionOwner? ownerToStamp, bool serialMatch)
{
Outcome = outcome;
OwnerToStamp = ownerToStamp;
SerialMatch = serialMatch;
}
/// <summary>One of the <see cref="SessionOwnershipPolicy.Outcome"/> codes.</summary>
public string Outcome { get; }
/// <summary>Owner the caller should write onto the row (fresh bind, legacy claim, rebind); null when the row is to be left alone.</summary>
public SessionOwner? OwnerToStamp { get; }
/// <summary>Whether the caller's announced serial equals the serial on the row. Diagnostic — distinguishes re-enroll-without-wipe from a foreign device on <see cref="SessionOwnershipPolicy.Outcome.MismatchCert"/>.</summary>
public bool SerialMatch { get; }
public bool WouldReject => WouldRejectUnderEnforcement(Outcome);
}
/// <summary>
/// Evaluates the binding. Pure — no logging, no I/O, no side effects.
/// </summary>
/// <param name="existingRow">The Sessions row as loaded (null when absent).</param>
/// <param name="validation">The successful security validation of the current request.</param>
/// <param name="nowUtc">Clock for the <see cref="SessionOwner.BoundAt"/> stamp.</param>
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);
}
/// <summary>
/// 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".
/// <para>
/// Tolerated by design (never reject): <see cref="Outcome.Fresh"/>, <see cref="Outcome.Match"/>,
/// <see cref="Outcome.ClaimLegacy"/> (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 <see cref="Outcome.CallerUnidentified"/> (nothing to compare).
/// </para>
/// </summary>
public static bool WouldRejectUnderEnforcement(string outcome) =>
outcome == Outcome.LegacySerialMismatch
|| outcome == Outcome.MismatchBootstrapOwned
|| outcome == Outcome.MismatchCert
|| outcome == Outcome.MismatchBootstrap
|| outcome == Outcome.DowngradeToBootstrap;
/// <summary>
/// Builds the caller's identity from a successful validation. Null when the request carried
/// no bindable identity (no certificate thumbprint and no bootstrap code).
/// </summary>
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,
};
}
/// <summary>Reads the owner off a Sessions row. Null when the row predates the binding.</summary>
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,
};
}
/// <summary>
/// Writes the owner columns onto <paramref name="entity"/>. Columns of the other kind are
/// removed so a Bootstrap→Cert rebind does not leave a stale short code behind.
/// </summary>
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);
}
}
@@ -27,8 +27,8 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
/// (<c>AzureStorageAccountName</c>) or connection string (<c>AzureBlobStorageConnectionString</c>).
/// SAS issuance for the agent upload-url endpoint differs by auth path — User
/// Delegation SAS under MI, account-key SAS under connection-string — both produce
/// a blob-scoped, Write+Create-only, 15-min token that cannot reach other tenants'
/// prefixes.
/// a blob-scoped, Create-only, 15-min token that cannot reach other tenants'
/// prefixes and cannot replace an existing blob.
/// </para>
/// </summary>
public class HostedDiagnosticsBlobService
@@ -40,6 +40,16 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
// requesting hour-long tokens.
public static readonly TimeSpan MaxUploadSasTtl = TimeSpan.FromMinutes(60);
/// <summary>
/// Permissions on the upload SAS. Create-only by design: the agent PUTs one new block
/// blob per package (fresh timestamped name), so Write — which would allow replacing an
/// existing blob under the same tenant prefix — is never needed.
/// </summary>
public const BlobSasPermissions UploadSasPermissions = BlobSasPermissions.Create;
/// <summary>Canonical package-name prefix; the session id follows immediately after.</summary>
internal const string PackageNamePrefix = "AgentDiagnostics-";
private readonly BlobServiceClient _blobServiceClient;
private readonly ILogger<HostedDiagnosticsBlobService> _logger;
private readonly bool _usesManagedIdentity;
@@ -94,16 +104,21 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
// -------- Public API --------
/// <summary>
/// Mints a blob-scoped, Write+Create-only SAS URI pinned to the EXACT path
/// Mints a blob-scoped, Create-only SAS URI pinned to the EXACT path
/// <c>{tenantId}/{filename}</c> for an agent's diagnostics-package upload. Cannot
/// be redirected to other tenants' prefixes. TTL is capped at
/// <see cref="MaxUploadSasTtl"/> to keep the exposure window tight.
/// be redirected to other tenants' prefixes, and the filename must carry the
/// caller's <paramref name="sessionId"/> (<c>AgentDiagnostics-{sessionId}-…zip</c>)
/// so one device cannot name another session's package. An already-existing blob
/// is never re-issued (<see cref="HostedBlobAlreadyExistsException"/>). TTL is
/// capped at <see cref="MaxUploadSasTtl"/> to keep the exposure window tight.
/// </summary>
public virtual async Task<HostedUploadSasResult> GenerateUploadSasAsync(
string tenantId, string filename, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
string tenantId, string sessionId, string filename, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
ValidateFilename(filename);
EnsureFilenameBoundToSession(filename, sessionId);
var effectiveTtl = ttl ?? TimeSpan.FromMinutes(15);
if (effectiveTtl > MaxUploadSasTtl)
@@ -116,6 +131,14 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
await EnsureContainerAsync(cancellationToken);
// Create-only semantics, enforced twice: the SAS carries no Write permission (an
// existing blob cannot be replaced with it), and issuance itself refuses a name
// that is already taken so the conflict surfaces here as a 409 instead of as an
// opaque 403 from storage. Every package gets a fresh timestamped name, so a
// legitimate agent never asks for an existing one.
if (await BlobExistsAsync(blobPath, cancellationToken))
throw new HostedBlobAlreadyExistsException(blobPath);
var sasUri = await BuildUploadSasUriAsync(blobPath, expiresOn, cancellationToken);
return new HostedUploadSasResult
@@ -185,7 +208,7 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
// Trailing dash bounds the match to this exact session id (GUIDs are fixed-length,
// but the explicit separator keeps the invariant obvious and layout-proof).
var prefix = $"{tenantId}/AgentDiagnostics-{sessionId}-";
var prefix = $"{tenantId}/{PackageNamePrefix}{sessionId}-";
var deleted = 0;
await foreach (var item in containerClient.GetBlobsAsync(
BlobTraits.None, BlobStates.None, prefix: prefix, cancellationToken: cancellationToken))
@@ -272,7 +295,17 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
}
/// <summary>
/// Builds the blob-scoped Write+Create-only SAS URI for upload. Branch on the
/// Test seam: whether a blob already exists at <paramref name="blobPath"/>. Production
/// issues a HEAD; tests override to simulate a taken name without Azurite.
/// </summary>
protected virtual async Task<bool> BlobExistsAsync(string blobPath, CancellationToken cancellationToken)
{
var response = await GetContainerClient().GetBlobClient(blobPath).ExistsAsync(cancellationToken);
return response.Value;
}
/// <summary>
/// Builds the blob-scoped Create-only SAS URI for upload. Branch on the
/// auth mode: MI uses User Delegation SAS (no account key needed); connection
/// string uses the underlying StorageSharedKeyCredential via
/// <see cref="BlobClient.GenerateSasUri(BlobSasBuilder)"/>.
@@ -290,9 +323,10 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5), // clock-skew tolerance
ExpiresOn = expiresOn,
};
// Write + Create only — agent must not be able to Read other tenants' blobs,
// Delete anything, or List the container.
sasBuilder.SetPermissions(BlobSasPermissions.Write | BlobSasPermissions.Create);
// Create only — the agent does a single Put Blob of a brand-new name. No Write
// (would let the holder replace an existing blob of the same tenant), no Read,
// no Delete, no List.
sasBuilder.SetPermissions(UploadSasPermissions);
if (UsesManagedIdentity)
{
@@ -337,6 +371,26 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
throw new ArgumentException("filename must not contain null bytes", nameof(filename));
}
/// <summary>
/// Binds an upload filename to the requesting session: it must be
/// <c>AgentDiagnostics-{sessionId}-{anything}.zip</c> (the shape the agent has always
/// produced, incl. the <c>-preprov</c>/<c>-server-requested</c> suffixes). Without this
/// an authenticated device could name any other session's package in its tenant.
/// Throws <see cref="ArgumentException"/> on rejection.
/// </summary>
internal static void EnsureFilenameBoundToSession(string filename, string sessionId)
{
var required = $"{PackageNamePrefix}{sessionId}-";
if (!filename.StartsWith(required, StringComparison.OrdinalIgnoreCase)
|| filename.Length <= required.Length
|| !filename.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException(
"filename must be of the form 'AgentDiagnostics-{sessionId}-{timestamp}.zip' for the requesting session",
nameof(filename));
}
}
/// <summary>
/// Validates a stored blob path (<c>{tenantId}/{filename}</c>) on the read paths.
/// Defends the download proxy against path-traversal even if a row was tampered with.
@@ -358,6 +412,22 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
}
}
/// <summary>
/// Thrown by <see cref="HostedDiagnosticsBlobService.GenerateUploadSasAsync"/> when the
/// requested blob name is already taken. Distinct from <see cref="ArgumentException"/>
/// (malformed input → 400) so the Function can answer 409.
/// </summary>
public sealed class HostedBlobAlreadyExistsException : InvalidOperationException
{
public HostedBlobAlreadyExistsException(string blobPath)
: base($"Hosted diagnostics blob already exists: {blobPath}")
{
BlobPath = blobPath;
}
public string BlobPath { get; }
}
/// <summary>
/// Return value of <see cref="HostedDiagnosticsBlobService.GenerateUploadSasAsync"/>.
/// </summary>
@@ -1,4 +1,5 @@
using AutopilotMonitor.Functions.Functions.Diagnostics;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Storage.Blobs;
@@ -100,6 +101,16 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
if (string.IsNullOrEmpty(sourceBlobName))
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedNoDiagnostics, null);
// Destination is derived from request data (tenant/session ids). A non-flat name
// would let the SDK + System.Uri walk out of the session-reports container.
if (!BlobNameGuard.IsFlat(destinationBlobName))
{
_logger.LogWarning(
"DiagnosticsArchiveCopier: rejecting non-flat destination blob name for tenant {TenantId}, session {SessionId}",
tenantId, sessionId);
return new DiagnosticsArchiveCopyResult(false, Statuses.FailedInvalidBlobName, null);
}
try
{
var (destination, classifyErr) = DiagnosticsDownloadFunction.ClassifyBlobName(sourceBlobName, tenantId);
@@ -219,7 +230,7 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
{
var containerClient = _blobStorage.GetContainerClient(ReportsContainerName);
await containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken);
var blobClient = containerClient.GetBlobClient(destinationBlobName);
var blobClient = containerClient.GetBlobClient(BlobNameGuard.EnsureFlat(destinationBlobName, nameof(destinationBlobName)));
await blobClient.UploadAsync(content, new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = "application/zip" }
@@ -2,6 +2,7 @@ using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using System.Net;
namespace AutopilotMonitor.Functions.Services;
@@ -73,10 +74,15 @@ public class EmailTemplateService : IEmailTemplateProvider
? EmailTemplates.GetPreviewApprovedHtml(DomainPlaceholder)
: EmailTemplates.GetOffboardingFarewellHtml(DomainPlaceholder);
/// <summary>Substitutes the placeholder; an empty domain becomes "your organization".</summary>
/// <summary>
/// Substitutes the placeholder; an empty domain becomes "your organization". The value is
/// HTML-encoded at the point of substitution: the mail goes out under the product's signed
/// sender to a tenant-chosen recipient, so no writer of DomainName — current or future — may
/// ever be able to inject markup into it.
/// </summary>
public static string Render(string rawHtml, string? domainName)
{
var display = string.IsNullOrWhiteSpace(domainName) ? EmptyDomainLabel : domainName.Trim();
var display = string.IsNullOrWhiteSpace(domainName) ? EmptyDomainLabel : WebUtility.HtmlEncode(domainName.Trim());
return rawHtml.Replace(DomainPlaceholder, display, StringComparison.Ordinal);
}
@@ -1,3 +1,4 @@
using System.Net;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Services;
@@ -26,7 +27,7 @@ public static class EmailTemplates
/// </summary>
public static string GetOffboardingFarewellHtml(string domainName)
{
var displayDomain = string.IsNullOrWhiteSpace(domainName) ? "your organization" : domainName;
var displayDomain = string.IsNullOrWhiteSpace(domainName) ? "your organization" : WebUtility.HtmlEncode(domainName);
return $@"
<!DOCTYPE html>
@@ -125,7 +126,7 @@ public static class EmailTemplates
/// </summary>
public static string GetPreviewApprovedHtml(string domainName)
{
var displayDomain = string.IsNullOrWhiteSpace(domainName) ? "your organization" : domainName;
var displayDomain = string.IsNullOrWhiteSpace(domainName) ? "your organization" : WebUtility.HtmlEncode(domainName);
return $@"
<!DOCTYPE html>
@@ -290,6 +290,22 @@ namespace AutopilotMonitor.Functions.Services
$"Agent version pattern '{pattern}' blocked",
null, blockedBy, new { pattern });
/// <summary>
/// SESSION-OWNER-BINDING-SHADOW: a validated agent request targeted a session that is bound
/// to a different device identity (see <c>SessionOwnershipPolicy</c>). Stage 1 records only —
/// the request went through. Throttled by <c>SessionOwnerBindingObserver</c> to one event per
/// session+outcome per hour. <c>serialMatch=true</c> on <c>MismatchCert</c> is the
/// re-enroll-without-wipe shape (same device, new certificate identity, old session id);
/// <c>serialMatch=false</c> is a foreign device naming somebody else's session.
/// </summary>
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 });
/// <summary>
/// Fired by <see cref="KillSwitchEvaluator"/> when a Kill signal was actually SERVED to
/// an agent (as opposed to DeviceBlocked/VersionBlocked, which fire when the admin
@@ -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<string> AllSet = new(All, StringComparer.Ordinal);
@@ -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
{
/// <summary>
/// SESSION-OWNER-BINDING-SHADOW — the single side-effect carrier around
/// <see cref="SessionOwnershipPolicy"/>. Every session-scoped agent write (register, telemetry
/// ingest, error report) runs its already-loaded Sessions row and validation through
/// <see cref="Observe"/>, which records the outcome and returns the decision; callers that own
/// a write path then stamp the binding (register through <c>StoreSessionAsync</c>, ingest through
/// <see cref="StampAsync"/>).
/// <para>
/// Three carriers, deliberately (same shape as CERT-TENANT-BINDING):
/// the request-row dimension <c>SessionOwnerBinding</c> 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 <c>SessionOwnerMismatch</c> ops event for
/// would-reject outcomes so operators can wire an alert rule. Nothing here rejects.
/// </para>
/// </summary>
public sealed class SessionOwnerBindingObserver
{
private static readonly TimeSpan OpsEventThrottle = TimeSpan.FromHours(1);
private readonly ILogger<SessionOwnerBindingObserver> _logger;
private readonly OpsEventService _opsEvents;
private readonly IMemoryCache _cache;
private readonly ISessionRepository _sessionRepo;
public SessionOwnerBindingObserver(
ILogger<SessionOwnerBindingObserver> logger,
OpsEventService opsEvents,
IMemoryCache cache,
ISessionRepository sessionRepo)
{
_logger = logger;
_opsEvents = opsEvents;
_cache = cache;
_sessionRepo = sessionRepo;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="req">The request (for the FunctionContext item and the agent-version header).</param>
/// <param name="tenantId">Validated tenant.</param>
/// <param name="sessionId">Session named by the request.</param>
/// <param name="existingRow">The Sessions row as already loaded by the caller (null when absent).</param>
/// <param name="validation">Successful security validation of this request.</param>
/// <param name="endpoint">Short label of the calling function for the trace/ops event.</param>
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);
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
}
@@ -6,6 +6,7 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Azure.Storage.Blobs.Models;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.Diagnostics;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
@@ -60,6 +61,11 @@ namespace AutopilotMonitor.Functions.Services
SubmitSessionReportRequest request,
string submittedBy)
{
// Both identifiers shape blob names below; a non-GUID value could smuggle path
// separators / dot segments into the storage URI (cross-container write).
SecurityValidator.EnsureValidGuid(request.TenantId, nameof(request.TenantId));
SecurityValidator.EnsureValidGuid(request.SessionId, nameof(request.SessionId));
var reportId = Guid.NewGuid().ToString("N")[..12];
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
var blobName = $"{request.TenantId}_{request.SessionId}_diag_request_{timestamp}.zip";
@@ -203,7 +209,7 @@ namespace AutopilotMonitor.Functions.Services
zipStream.Position = 0;
var containerClient = _blobStorage.GetContainerClient(ContainerName);
await containerClient.CreateIfNotExistsAsync();
var blobClient = containerClient.GetBlobClient(blobName);
var blobClient = containerClient.GetBlobClient(BlobNameGuard.EnsureFlat(blobName, nameof(blobName)));
await blobClient.UploadAsync(zipStream, new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = "application/zip" }
@@ -245,6 +251,8 @@ namespace AutopilotMonitor.Functions.Services
SubmitDiagFilesReportRequest request,
string submittedBy)
{
SecurityValidator.EnsureValidGuid(request.TenantId, nameof(request.TenantId));
var reportId = Guid.NewGuid().ToString("N")[..12];
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
var blobName = $"{request.TenantId}_diag_files_{timestamp}.zip";
@@ -292,7 +300,7 @@ namespace AutopilotMonitor.Functions.Services
zipStream.Position = 0;
var containerClient = _blobStorage.GetContainerClient(ContainerName);
await containerClient.CreateIfNotExistsAsync();
var blobClient = containerClient.GetBlobClient(blobName);
var blobClient = containerClient.GetBlobClient(BlobNameGuard.EnsureFlat(blobName, nameof(blobName)));
await blobClient.UploadAsync(zipStream, new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = "application/zip" }
@@ -444,9 +444,15 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Stores a session registration
/// Stores a session registration.
/// </summary>
public async Task<bool> StoreSessionAsync(SessionRegistration registration)
/// <param name="registration">Agent-supplied registration (server-stamped fields already applied by the caller).</param>
/// <param name="ownerToStamp">
/// SESSION-OWNER-BINDING: the device identity to bind the row to, decided by
/// <c>SessionOwnershipPolicy</c> (fresh bind, legacy claim, rebind). Null preserves whatever
/// owner the row already carries — the Replace below must never blank it.
/// </param>
public async Task<bool> 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<TableEntity>(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.
/// </summary>
/// <summary>
/// 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
/// <see cref="SessionIndexFieldManifest.PrimaryOnly"/>). Throws on storage failure; the
/// observer catches (fail-soft) because the binding is observational in stage 1.
/// </summary>
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)
{
@@ -249,6 +249,21 @@ namespace AutopilotMonitor.Shared
// API endpoint paths (relative to ApiBaseUrl)
// -----------------------------------------------------------------------
/// <summary>
/// Machine-readable error codes the backend returns in agent-facing error bodies
/// (<c>RegisterSessionResponse.ErrorCode</c>). Wire contract — keep stable.
/// </summary>
public static class AgentErrorCodes
{
/// <summary>
/// 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.
/// </summary>
public const string SessionOwnerMismatch = "session_owner_mismatch";
}
/// <summary>
/// API endpoint paths used by the agent
/// </summary>
@@ -15,7 +15,10 @@ namespace AutopilotMonitor.Shared.DataAccess
public interface ISessionRepository
{
// --- Session CRUD ---
Task<bool> StoreSessionAsync(SessionRegistration registration);
/// <param name="ownerToStamp">SESSION-OWNER-BINDING: owner to bind the row to; null preserves the existing binding.</param>
Task<bool> StoreSessionAsync(SessionRegistration registration, SessionOwner? ownerToStamp = null);
/// <summary>SESSION-OWNER-BINDING: Merge-stamps the owner onto an existing row (ingest claim / rebind). Throws on storage failure.</summary>
Task UpdateSessionOwnerAsync(string tenantId, string sessionId, SessionOwner owner);
Task<SessionSummary?> GetSessionAsync(string tenantId, string sessionId);
/// <summary>
/// Resolves the tenantId owning <paramref name="sessionId"/> — point-read on the
@@ -1008,25 +1008,32 @@ namespace AutopilotMonitor.Shared.Models
}
/// <summary>
/// Gets manufacturer whitelist as array
/// Gets manufacturer whitelist as array (empty/whitespace = allow all)
/// </summary>
public string[] GetManufacturerWhitelist()
{
if (string.IsNullOrEmpty(ManufacturerWhitelist))
return new[] { "*" };
return ManufacturerWhitelist.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
}
public string[] GetManufacturerWhitelist() => ParseWhitelist(ManufacturerWhitelist);
/// <summary>
/// Gets model whitelist as array
/// Gets model whitelist as array (empty/whitespace = allow all)
/// </summary>
public string[] GetModelWhitelist()
public string[] GetModelWhitelist() => ParseWhitelist(ModelWhitelist);
/// <summary>
/// Splits a stored hardware whitelist CSV into individual match patterns: trimmed, empty
/// entries dropped. The CSV is the ONLY storage format, so a ',' can never be part of a
/// pattern — writers (web editor, <c>TenantConfigValidation</c>) must guarantee each
/// intended entry lands as exactly one CSV item.
/// </summary>
public static string[] ParseWhitelist(string? csv)
{
if (string.IsNullOrEmpty(ModelWhitelist))
if (string.IsNullOrWhiteSpace(csv))
return new[] { "*" };
return ModelWhitelist.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
var entries = csv!.Split(',')
.Select(e => e.Trim())
.Where(e => e.Length > 0)
.ToArray();
return entries.Length == 0 ? new[] { "*" } : entries;
}
/// <summary>
@@ -0,0 +1,60 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// 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.
/// <para>
/// 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.
/// </para>
/// </summary>
public sealed class SessionOwner
{
/// <summary>Sessions-row column names. Kept here so writer and reader share one spelling.</summary>
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 };
}
/// <summary>Values of <see cref="Kind"/>. Stable strings — queried by exact match.</summary>
public static class Kinds
{
public const string Cert = "Cert";
public const string Bootstrap = "Bootstrap";
}
/// <summary><see cref="Kinds.Cert"/> or <see cref="Kinds.Bootstrap"/>.</summary>
public string Kind { get; set; } = default!;
/// <summary>Cert-owned only: thumbprint of the TLS client certificate that registered the session.</summary>
public string? Thumbprint { get; set; }
/// <summary>Cert-owned only: Intune device id from the certificate CN (lower-case GUID), when the CN had that shape.</summary>
public string? DeviceId { get; set; }
/// <summary>Bootstrap-owned only: the 6-character short code whose token admitted the caller.</summary>
public string? BootstrapCode { get; set; }
/// <summary>Serial number the caller announced in <c>X-Device-SerialNumber</c> when the binding was made. Trimmed, original casing.</summary>
public string? Serial { get; set; }
/// <summary>When this binding was stamped (UTC).</summary>
public DateTime BoundAt { get; set; }
public bool IsCert => Kind == Kinds.Cert;
public bool IsBootstrap => Kind == Kinds.Bootstrap;
}
}
@@ -49,6 +49,12 @@ namespace AutopilotMonitor.Shared.Models
/// Older backends that do not set this return Unknown — agent falls back to its own detection.
/// </summary>
public ValidatorType ValidatedBy { get; set; } = ValidatorType.Unknown;
/// <summary>
/// Machine-readable reason on a failure response (see <see cref="Constants.AgentErrorCodes"/>).
/// Null on success and on failures that carry no agent-actionable code.
/// </summary>
public string? ErrorCode { get; set; }
}
/// <summary>
@@ -273,18 +273,21 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro
}`}
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span
className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate"
title={entry.upn}
>
{entry.upn}
</span>
{entry.submitted ? renderStars(entry.rating) : (
<span className="text-xs text-gray-400 dark:text-gray-500 italic">dismissed</span>
<span className="text-xs text-gray-400 dark:text-gray-500 italic flex-shrink-0">dismissed</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
<span>{formatTimeAgo(entry.interactedAt)}</span>
<span className="hidden sm:inline">·</span>
<span className="hidden sm:inline truncate max-w-[120px]">
<span className="hidden sm:inline" title={entry.tenantId}>
{entry.tenantId.substring(0, 8)}...
</span>
</div>
@@ -313,10 +316,10 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro
{entry.domainName || entry.tenantId.substring(0, 8) + "…"}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>{formatTimeAgo(entry.interactedAt)}</span>
<span className="hidden sm:inline">·</span>
<span className="hidden sm:inline truncate max-w-[200px]">
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 min-w-0">
<span className="flex-shrink-0">{formatTimeAgo(entry.interactedAt)}</span>
<span className="hidden sm:inline flex-shrink-0">·</span>
<span className="hidden sm:inline truncate" title={entry.upn}>
{entry.upn}
</span>
</div>
@@ -99,6 +99,10 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
// 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",
@@ -3,6 +3,7 @@
import { useState } from "react";
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { joinList, parseList } from "../lib/hardwareWhitelist";
interface HardwareWhitelistSectionProps {
manufacturerWhitelist: string;
@@ -16,14 +17,6 @@ interface HardwareWhitelistSectionProps {
readOnly?: boolean;
}
function parseList(csv: string): string[] {
return csv.split(",").map((s) => s.trim()).filter(Boolean);
}
function joinList(items: string[]): string {
return items.join(",");
}
function WhitelistEditor({
label,
description,
@@ -38,14 +31,17 @@ function WhitelistEditor({
setItems: (items: string[]) => void;
}) {
const [newItem, setNewItem] = useState("");
const trimmed = newItem.trim();
const isDuplicate =
trimmed !== "" &&
items.some((a) => a.toLowerCase() === trimmed.toLowerCase());
// Admin-typed input is a LIST: "Dell*, *" adds two patterns (the placeholder invites
// exactly that). Only the insights buttons neutralize ',' — their values are untrusted.
const existing = new Set(items.map((a) => a.toLowerCase()));
const typed = parseList(newItem);
const fresh = typed.filter((t, i) =>
!existing.has(t.toLowerCase()) && typed.findIndex((u) => u.toLowerCase() === t.toLowerCase()) === i);
const isDuplicate = typed.length > 0 && fresh.length === 0;
const addItem = () => {
if (!trimmed || isDuplicate) return;
setItems([...items, trimmed]);
if (fresh.length === 0) return;
setItems([...items, ...fresh]);
setNewItem("");
};
@@ -110,7 +106,7 @@ function WhitelistEditor({
/>
<button
onClick={addItem}
disabled={!trimmed || isDuplicate}
disabled={fresh.length === 0}
className="px-4 py-1.5 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap"
>
Add
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { addWhitelistEntry, parseList, toWhitelistEntry } from "../hardwareWhitelist";
describe("hardware whitelist CSV helpers", () => {
it("adds a plain value as one entry", () => {
expect(addWhitelistEntry("Dell*,HP*", "Lenovo")).toBe("Dell*,HP*,Lenovo");
});
it("never lets a delimiter-bearing value expand into extra patterns", () => {
// Attacker-controlled distress-signal string: would become [..., "Dell Inc.", "*"] verbatim.
const csv = addWhitelistEntry("Dell*", "Dell Inc.,*");
expect(parseList(csv)).toEqual(["Dell*", "Dell Inc.?*"]);
expect(parseList(csv)).not.toContain("*");
});
it("neutralizes a trailing comma instead of creating an empty/stray entry", () => {
expect(parseList(addWhitelistEntry("Dell*", "Dell Inc.,"))).toEqual(["Dell*", "Dell Inc.?"]);
});
it("ignores empty and duplicate values", () => {
expect(addWhitelistEntry("Dell*", " ")).toBe("Dell*");
// A lone delimiter becomes the single-character pattern "?" — one entry, never a split.
expect(parseList(addWhitelistEntry("Dell*", ","))).toEqual(["Dell*", "?"]);
expect(addWhitelistEntry("Dell*", "dell*")).toBe("Dell*");
});
it("toWhitelistEntry trims and replaces every comma", () => {
expect(toWhitelistEntry(" a,b,,c ")).toBe("a?b??c");
});
});
@@ -0,0 +1,37 @@
/**
* Hardware whitelist CSV helpers.
*
* The tenant whitelist is persisted as ONE comma-separated string and split on ',' at
* enforcement time (TenantConfiguration.GetManufacturerWhitelist). A comma inside a single
* entry therefore changes the LIST STRUCTURE: "Dell Inc.,*" appended as one "manufacturer"
* becomes two patterns, the second being the allow-all wildcard. Manufacturer/model strings
* shown in the rejection insights come from UNAUTHENTICATED distress signals, so every value
* that enters the list must round-trip as exactly one pattern.
*/
export function parseList(csv: string): string[] {
return csv.split(",").map((s) => s.trim()).filter(Boolean);
}
export function joinList(items: string[]): string {
return items.join(",");
}
/**
* Normalizes a value so it is stored as exactly one whitelist pattern. The delimiter is
* replaced by the single-character wildcard '?', which still matches the original string
* literally (and nothing structurally different) instead of splitting the list.
* Returns "" when nothing usable remains.
*/
export function toWhitelistEntry(value: string): string {
return value.replace(/,/g, "?").trim();
}
/** Appends `value` as one pattern unless an equal (case-insensitive) entry already exists. */
export function addWhitelistEntry(csv: string, value: string): string {
const entry = toWhitelistEntry(value);
if (!entry) return csv;
const items = parseList(csv);
if (items.some((i) => i.toLowerCase() === entry.toLowerCase())) return csv;
return joinList([...items, entry]);
}
@@ -7,14 +7,7 @@ import { TenantNotifications } from "../../TenantNotifications";
import HardwareWhitelistSection from "../../components/HardwareWhitelistSection";
import HardwareRejectionInsights from "../../components/HardwareRejectionInsights";
import TpmIncompatibleDevicesInsights from "../../components/TpmIncompatibleDevicesInsights";
function parseList(csv: string): string[] {
return csv.split(",").map((s) => s.trim()).filter(Boolean);
}
function joinList(items: string[]): string {
return items.join(",");
}
import { addWhitelistEntry } from "../../lib/hardwareWhitelist";
export function SectionHardwareWhitelist() {
const { getAccessToken } = useAuth();
@@ -30,18 +23,14 @@ export function SectionHardwareWhitelist() {
const hasWebhook = notificationChannels.some((c) => c.enabled && (c.url ?? "").length > 0);
// Values come from UNAUTHENTICATED distress signals — addWhitelistEntry guarantees each
// click adds exactly one pattern (a ',' inside the value cannot split the list).
const handleAddManufacturer = useCallback((value: string) => {
const items = parseList(manufacturerWhitelist);
if (!items.some((i) => i.toLowerCase() === value.toLowerCase())) {
setManufacturerWhitelist(joinList([...items, value]));
}
setManufacturerWhitelist(addWhitelistEntry(manufacturerWhitelist, value));
}, [manufacturerWhitelist, setManufacturerWhitelist]);
const handleAddModel = useCallback((value: string) => {
const items = parseList(modelWhitelist);
if (!items.some((i) => i.toLowerCase() === value.toLowerCase())) {
setModelWhitelist(joinList([...items, value]));
}
setModelWhitelist(addWhitelistEntry(modelWhitelist, value));
}, [modelWhitelist, setModelWhitelist]);
return (