mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc46b4c78e | |||
| c18de5d76c | |||
| 250cc667dd | |||
| d3261fb890 | |||
| b3bbcf1300 | |||
| a306cd9e40 |
@@ -83,19 +83,30 @@ winner). Two accounts racing the first sign-in inside the home tenant therefore
|
||||
Row lookups stay cached by UPN; the binding has its own 30 s cache keyed by UPN and is invalidated on
|
||||
pin, rebind and removal. A rebind on one scaled-out instance converges on the others within seconds.
|
||||
|
||||
## Grants bind first
|
||||
## Grants bind first — and resolve the identity themselves
|
||||
|
||||
`AddGlobalAdminAsync`, `DelegatedAdminService.UpsertAsync` and `AssignGroupAsync` take
|
||||
`homeTenantId` (GUID, required) and `objectId` (GUID, optional) and call `EnsureBoundAsync` **before**
|
||||
writing the role row. `EnsureBoundAsync` creates the binding, keeps a compatible one (same tenant; the
|
||||
Granting is "type the UPN". The three grant endpoints (`POST auth/global-admins`,
|
||||
`POST global/delegated-admins`, `POST global/tenant-groups/{id}/assignees`) accept `homeTenantId` /
|
||||
`objectId` only as optional overrides; normally `AdminIdentityResolver` supplies them:
|
||||
|
||||
1. **Sign-in history** (`UserActivity`, filtered on the UPN): the person has signed in before ⇒ tid + oid
|
||||
are known, both from validated tokens. The most recent sign-in wins for the oid. A UPN seen under
|
||||
**more than one tenant** is never auto-resolved — that is the attack shape the binding exists for.
|
||||
2. **UPN domain → onboarded tenant** (`TenantConfiguration.DomainName`, exactly one match): tid only; the
|
||||
oid is pinned on the first sign-in.
|
||||
3. Neither ⇒ **422 `HomeTenantUnresolved`**; the portal then shows a home-tenant picker (onboarded
|
||||
tenants) and retries with `homeTenantId`. This is the only manual step, and only for a UPN whose
|
||||
domain the platform has never seen.
|
||||
|
||||
`AddGlobalAdminAsync`, `DelegatedAdminService.UpsertAsync` and `AssignGroupAsync` then take the resolved
|
||||
`homeTenantId` / `objectId` and call `EnsureBoundAsync` **before** writing the role row. `EnsureBoundAsync` creates the binding, keeps a compatible one (same tenant; the
|
||||
supplied object id equals the pin or upgrades an unpinned one), and throws
|
||||
`IdentityBindingConflictException` for a different tenant or a different pinned object id — the
|
||||
functions map that to **409**. A grant can therefore never silently re-home a UPN.
|
||||
|
||||
Why the object id may be omitted: the operator granting an external MSP admin has no directory access
|
||||
to the partner tenant, so the person's `oid` is frequently unknown at grant time; the home tenant is
|
||||
always known (it is who the grant is for). The first sign-in from that tenant pins it; from then on a
|
||||
recycled UPN in the same tenant is refused.
|
||||
Why the object id may be unknown: a person who has never signed in has no history to resolve from, and
|
||||
the operator granting an external MSP admin has no directory access to the partner tenant. The first
|
||||
sign-in from the bound tenant pins it; from then on a recycled UPN in the same tenant is refused.
|
||||
|
||||
## Explicit rebind surface
|
||||
|
||||
@@ -106,9 +117,10 @@ recycled UPN in the same tenant is refused.
|
||||
| `DELETE global/identity-bindings/{upn}` | GlobalAdminOnly | Make every role row of the UPN inert without touching the rows. Self-removal is refused. |
|
||||
|
||||
Both mutations are audited under the binding's home tenant (`AdminIdentityBinding` entity, previous
|
||||
values in the details) and logged at Warning. The list endpoints of delegated admins and tenant groups
|
||||
return `bindings` alongside their rows so the management UI can show a per-UPN context pill (home tenant,
|
||||
pin state) and require the home tenant in its grant / assign forms.
|
||||
values in the details) and logged at Warning. The portal surfaces this in **Tenant Management → Edit
|
||||
Tenant Configuration → Identity bindings** (collapsed): the bindings homed in that tenant, with inline
|
||||
edit (re-home / re-pin; empty object id ⇒ re-pinned on the next sign-in) and remove. The grant forms
|
||||
themselves carry no identity fields — the binding is a background property of the grant.
|
||||
|
||||
## Lifecycle and secondary effects
|
||||
|
||||
@@ -144,6 +156,8 @@ Same row, binding without an object id: the first `tid=1111…` sign-in pins its
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Security/AdminIdentity.cs` — the identity record and its factories.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityBindingService.cs` — verdict, pin, grant-time binding, rebind; the table entity.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityResolver.cs` — sign-in-history / UPN-domain resolution at grant time (`AdminIdentityResolverTests`).
|
||||
* `src/Web/autopilot-monitor-web/app/admin/components/IdentityBindingsSection.tsx` — the per-tenant inspect/correct panel.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs`, `DelegatedAdminService.cs` — resolution order.
|
||||
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs` — `TryPinIdentityObjectIdAsync` and the binding CRUD.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Admin/IdentityBindingManagementFunction.cs` — the rebind surface and the shared `IdentityBindingRequest.Validate`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
+2
-1
@@ -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.
|
||||
|
||||
|
||||
+12
@@ -1,5 +1,17 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-29 (8)
|
||||
|
||||
* **Creation**: Added `backend/session-owner-binding.md` — SESSION-OWNER-BINDING stage 1 (shadow): Sessions rows carry the creating device identity (`OwnerKind/OwnerThumbprint/OwnerDeviceId/OwnerBootstrapCode/OwnerSerial/OwnerBoundAt`, primary-only), `SessionOwnershipPolicy` evaluates every register / telemetry / error-report request against it, outcomes ride on the request row (`SessionOwnerBinding`), warnings and a throttled `SessionOwnerMismatch` ops event; nothing is refused yet. Agent ships the dormant counterpart: `errorCode` parsing on 401/403, one-shot session rotation on `session_owner_mismatch` without feeding the auth-failure tracker, spool drop, `agent_started.sessionRotated`.
|
||||
|
||||
## 2026-08-29 (7)
|
||||
|
||||
* **Update**: `backend/client-cert-tenant-binding.md` — new "Certificate transport and proof of possession" section: `X-ARR-ClientCert` (platform-set after the mTLS handshake, inbound copies stripped even on excluded paths) is the only certificate source; the client-writable `X-Client-Certificate` fallback is gone; `/api/bootstrap/*` routes are token-only and fail closed on a missing/empty token or unwired token service instead of falling through to the certificate stage.
|
||||
|
||||
## 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).
|
||||
|
||||
## 2026-08-29 (5)
|
||||
|
||||
* **Creation**: Added `backend/admin-identity-binding.md` — cross-tenant roles (GlobalAdmin / GlobalReader, delegated MSP) were resolved from the UPN string alone while the API accepts tokens from every Entra tenant. New `AdminIdentityBindings` table (RK = UPN → home `TenantId` + `ObjectId`), `AdminIdentity` (upn + tid + oid) as the key both role services resolve on, binding checked after the row and before the Pro gate, object id pinned on the first sign-in from the bound tenant (ETag-guarded), grants require `homeTenantId` and refuse to re-home (409), `global/identity-bindings` GET/PUT/DELETE as the audited rebind path, `RequestContext.ObjectId`, throttle bucket keyed on oid. Legacy rows are inert until bound — seed bindings before the backend deploy.
|
||||
|
||||
+10
-3
@@ -527,7 +527,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
{
|
||||
// On-demand mid-session (no outcome yet): the OnFailure gate must NOT skip —
|
||||
// there is no known success to skip on. Reaching the archive build (sentinel
|
||||
// throw → error result) proves the gate was passed; a skip would return null.
|
||||
// throw → error result) proves the gate was passed; a skip would return Skipped.
|
||||
using var rig = new Rig();
|
||||
var result = await BuildGateProbe(rig.Logger, "OnFailure")
|
||||
.CreateAndUploadAsync(enrollmentSucceeded: null, fileNameSuffix: "server-requested");
|
||||
@@ -543,7 +543,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
var result = await BuildGateProbe(rig.Logger, "OnFailure")
|
||||
.CreateAndUploadAsync(enrollmentSucceeded: true);
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result!.Skipped);
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(DiagnosticsUploadResult.SkipOnFailureSucceeded, result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -553,7 +556,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
var result = await BuildGateProbe(rig.Logger, "Off")
|
||||
.CreateAndUploadAsync(enrollmentSucceeded: null);
|
||||
|
||||
Assert.Null(result);
|
||||
// The gate name travels in ErrorCode so the dispatcher's server_action_failed
|
||||
// event says "mode_off" instead of a generic "diagnostics_upload_failed".
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result!.Skipped);
|
||||
Assert.Equal(DiagnosticsUploadResult.SkipModeOff, result.ErrorCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
+22
@@ -122,6 +122,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
Assert.Equal("config_fetch_failed", (string)typed["failureReason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_request_diagnostics_skipped_by_gate_surfaces_gate_name_as_failure_reason()
|
||||
{
|
||||
// Session 2550bcea (2026-08-29): the on-demand collection was refused by the
|
||||
// mode=Off gate and the portal only saw a generic "diagnostics_upload_failed".
|
||||
// The gate name must travel through to the event so the operator can tell a
|
||||
// config gate from a real upload error.
|
||||
using var rig = new Rig();
|
||||
rig.DiagnosticsResult = DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipModeOff);
|
||||
|
||||
await rig.Build().DispatchAsync(new List<ServerAction>
|
||||
{
|
||||
new ServerAction { Type = ServerActionTypes.RequestDiagnostics, QueuedAt = DateTime.UtcNow },
|
||||
});
|
||||
|
||||
Assert.Equal(1, rig.DiagnosticsCalls);
|
||||
Assert.Empty(rig.EventsOfType("server_action_executed"));
|
||||
var failed = rig.EventsOfType("server_action_failed").Single();
|
||||
var typed = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(failed.TypedPayload!);
|
||||
Assert.Equal(DiagnosticsUploadResult.SkipModeOff, (string)typed["failureReason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_unknown_action_type_emits_failed_with_unknown_action_type_reason()
|
||||
{
|
||||
|
||||
+41
@@ -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()
|
||||
{
|
||||
|
||||
+106
@@ -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()
|
||||
{
|
||||
|
||||
+40
@@ -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()
|
||||
{
|
||||
|
||||
+24
-7
@@ -45,10 +45,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// </summary>
|
||||
public string SasUrlPrefix { get; set; }
|
||||
|
||||
/// <summary>Human-readable error code/message when upload failed. Null on success or skip.</summary>
|
||||
/// <summary>Human-readable error code/message when upload failed or was skipped. Null on success.</summary>
|
||||
public string ErrorCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when no package was built because a configuration gate said so (mode=Off,
|
||||
/// destination not configured, OnFailure + known success). <see cref="ErrorCode"/>
|
||||
/// then carries the machine-readable gate name so the emitted
|
||||
/// <c>server_action_failed</c> / <c>diagnostics_upload_failed</c> event names the
|
||||
/// actual reason instead of a generic "upload failed".
|
||||
/// </summary>
|
||||
public bool Skipped { get; set; }
|
||||
|
||||
public bool Success => BlobName != null;
|
||||
|
||||
public const string SkipModeOff = "diagnostics_skipped_mode_off";
|
||||
public const string SkipNotConfigured = "diagnostics_skipped_not_configured";
|
||||
public const string SkipOnFailureSucceeded = "diagnostics_skipped_onfailure_success";
|
||||
|
||||
public static DiagnosticsUploadResult SkippedBy(string gate) =>
|
||||
new DiagnosticsUploadResult { Skipped = true, ErrorCode = gate };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -191,7 +207,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// <summary>
|
||||
/// Creates a diagnostics ZIP and uploads it to the configured Blob Storage container.
|
||||
/// Returns a DiagnosticsUploadResult with BlobName set on success, or ErrorCode set on failure.
|
||||
/// Returns null if the upload was skipped (mode=Off, not configured, or OnFailure+succeeded).
|
||||
/// A configuration gate (mode=Off, not configured, OnFailure+succeeded) returns a result
|
||||
/// with <see cref="DiagnosticsUploadResult.Skipped"/> and the gate name in ErrorCode.
|
||||
/// This method is non-fatal: all exceptions are caught and logged.
|
||||
/// </summary>
|
||||
/// <param name="enrollmentSucceeded">
|
||||
@@ -214,14 +231,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
|
||||
if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.Debug("Diagnostics upload disabled (mode=Off)");
|
||||
return null;
|
||||
_logger.Info("Diagnostics upload skipped: DiagnosticsUploadMode=Off on the running agent.");
|
||||
return DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipModeOff);
|
||||
}
|
||||
|
||||
if (!_configuration.DiagnosticsUploadEnabled)
|
||||
{
|
||||
_logger.Debug("Diagnostics upload skipped: not configured for this tenant");
|
||||
return null;
|
||||
_logger.Info($"Diagnostics upload skipped: no upload destination configured for this tenant (mode={mode}).");
|
||||
return DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipNotConfigured);
|
||||
}
|
||||
|
||||
// Null outcome (on-demand mid-session) passes through: the OnFailure gate only
|
||||
@@ -229,7 +246,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded == true)
|
||||
{
|
||||
_logger.Info("Diagnostics upload skipped: enrollment succeeded and mode=OnFailure");
|
||||
return null;
|
||||
return DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipOnFailureSucceeded);
|
||||
}
|
||||
|
||||
_logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={(enrollmentSucceeded.HasValue ? enrollmentSucceeded.ToString() : "n/a")}{(fileNameSuffix != null ? $", suffix={fileNameSuffix}" : "")})...");
|
||||
|
||||
@@ -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>).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -115,10 +115,11 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
|
||||
/// <summary>
|
||||
/// Applies a live-fetched config to the runtime: merge, tracker ceilings, log level,
|
||||
/// integrity SHA + check, bootstrap-config cleanup. Shared by the Phase 4 startup path
|
||||
/// and the post-registration recovery path so both apply identical semantics.
|
||||
/// integrity SHA + check, bootstrap-config cleanup. Shared by the Phase 4 startup path,
|
||||
/// the post-registration recovery path and the live <c>rotate_config</c> ServerAction
|
||||
/// (<see cref="ServerControlPlane.BuildDispatcher"/>) so all three apply identical semantics.
|
||||
/// </summary>
|
||||
private static RemoteConfigMergeResult ApplyFetchedConfig(
|
||||
internal static RemoteConfigMergeResult ApplyFetchedConfig(
|
||||
AgentConfiguration agentConfig,
|
||||
AgentConfigResponse remoteConfig,
|
||||
BackendAuthBundle auth,
|
||||
|
||||
@@ -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).
|
||||
@@ -420,6 +421,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
terminationHandler: terminationHandler,
|
||||
remoteConfigService: remoteConfigService,
|
||||
diagnosticsService: diagnosticsService,
|
||||
auth: auth,
|
||||
agentVersion: agentVersion,
|
||||
consoleMode: consoleMode,
|
||||
shutdown: shutdown,
|
||||
shutdownComplete: shutdownComplete,
|
||||
post: lifecyclePost,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,6 +33,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
EnrollmentTerminationHandler terminationHandler,
|
||||
RemoteConfigService remoteConfigService,
|
||||
DiagnosticsPackageService diagnosticsService,
|
||||
BackendAuthBundle auth,
|
||||
string agentVersion,
|
||||
bool consoleMode,
|
||||
ManualResetEventSlim shutdown,
|
||||
ManualResetEventSlim shutdownComplete,
|
||||
InformationalEventPost post,
|
||||
@@ -41,9 +44,31 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
return new ServerActionDispatcher(
|
||||
configuration: agentConfig,
|
||||
logger: logger,
|
||||
// rotate_config = fetch AND apply. FetchConfigAsync only stores the response
|
||||
// in RemoteConfigService.CurrentConfig; the projection onto the live
|
||||
// AgentConfiguration (DiagnosticsUploadMode, LogLevel, SelfDestruct, ...)
|
||||
// is ApplyFetchedConfig — the same path the startup fetch uses. Without it
|
||||
// the portal's "enable diagnostics, then collect" flow fetched the new
|
||||
// config and still ran the diagnostics gate against the stale mode.
|
||||
// FetchConfigAsync never throws: a failed fetch falls back to cache/defaults
|
||||
// and reports it via LastFetchOutcome — that is NOT an executed rotation.
|
||||
rotateConfigAsync: async () =>
|
||||
{
|
||||
try { var _ = await remoteConfigService.FetchConfigAsync(); return true; }
|
||||
try
|
||||
{
|
||||
var fetched = await remoteConfigService.FetchConfigAsync();
|
||||
if (remoteConfigService.LastFetchOutcome != RemoteConfigFetchOutcome.Succeeded)
|
||||
{
|
||||
logger.Warning($"ServerAction rotate_config: live fetch failed (outcome={remoteConfigService.LastFetchOutcome}, {remoteConfigService.LastFetchFailureMessage}) — runtime config unchanged.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var mergeResult = AgentRuntimeConfig.ApplyFetchedConfig(
|
||||
agentConfig, fetched, auth, agentVersion, consoleMode, logger);
|
||||
LifecycleEmitters.EmitUnrestrictedModeAuditIfChanged(post, agentConfig, mergeResult, logger);
|
||||
logger.Info($"ServerAction rotate_config: applied ConfigVersion={fetched.ConfigVersion} to the running agent.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) { logger.Warning($"ServerAction rotate_config failed: {ex.Message}"); return false; }
|
||||
},
|
||||
// On-demand request mid-enrollment — the session has no outcome yet. Null keeps
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="AdminIdentityResolver"/> — what makes granting a cross-tenant role "type the UPN":
|
||||
/// sign-in history first (tid + oid, both from validated tokens), then UPN domain → onboarded tenant
|
||||
/// (tid only, oid pinned on first sign-in), and NO answer when the history is ambiguous (same UPN under
|
||||
/// two tenants — exactly the situation the binding exists for) or the domain is unknown.
|
||||
/// </summary>
|
||||
public class AdminIdentityResolverTests
|
||||
{
|
||||
private const string Upn = "msp@partner.example";
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
private const string OidOld = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa";
|
||||
private const string OidNew = "bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb";
|
||||
|
||||
private static AdminIdentityResolver Build(
|
||||
IEnumerable<UserSignInIdentity>? signIns = null,
|
||||
params (string TenantId, string Domain)[] tenants)
|
||||
{
|
||||
var metrics = new Mock<IMetricsRepository>();
|
||||
metrics.Setup(m => m.GetSignInIdentitiesByUpnAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((signIns ?? Array.Empty<UserSignInIdentity>()).ToList());
|
||||
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.Setup(r => r.GetAllTenantConfigurationsAsync())
|
||||
.ReturnsAsync(tenants.Select(t => new TenantConfiguration { TenantId = t.TenantId, DomainName = t.Domain }).ToList());
|
||||
var configService = new TenantConfigurationService(
|
||||
configRepo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
return new AdminIdentityResolver(metrics.Object, configService, NullLogger<AdminIdentityResolver>.Instance);
|
||||
}
|
||||
|
||||
private static UserSignInIdentity SignIn(string tid, string oid, int daysAgo) => new()
|
||||
{
|
||||
TenantId = tid, ObjectId = oid, LastLoginAt = DateTime.UtcNow.AddDays(-daysAgo), LoginCount = 1,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task SignInHistory_SingleTenant_ResolvesTidAndOid()
|
||||
{
|
||||
var r = await Build(new[] { SignIn(TenantA, OidOld, 3) }).ResolveAsync("MSP@Partner.Example");
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal(TenantA, r!.TenantId);
|
||||
Assert.Equal(OidOld, r.ObjectId);
|
||||
Assert.Equal(ResolvedAdminIdentity.SourceSignIn, r.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignInHistory_SameTenantTwoOids_TakesMostRecent()
|
||||
{
|
||||
// Account re-created in the same tenant: the newer oid is the one that will sign in next.
|
||||
var r = await Build(new[] { SignIn(TenantA, OidOld, 30), SignIn(TenantA, OidNew, 1) }).ResolveAsync(Upn);
|
||||
Assert.Equal(OidNew, r!.ObjectId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignInHistory_TwoTenants_IsAmbiguous_NotResolved_EvenIfDomainMatches()
|
||||
{
|
||||
// The same UPN string seen under two tenants is the attack shape; never auto-pick, even when the
|
||||
// domain fallback would have an answer.
|
||||
var r = await Build(new[] { SignIn(TenantA, OidOld, 3), SignIn(TenantB, OidNew, 1) }, (TenantA, "partner.example"))
|
||||
.ResolveAsync(Upn);
|
||||
Assert.Null(r);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoSignIns_DomainOfOnboardedTenant_ResolvesTidOnly()
|
||||
{
|
||||
var r = await Build(null, (TenantA, "Partner.Example"), (TenantB, "other.example")).ResolveAsync(Upn);
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal(TenantA, r!.TenantId);
|
||||
Assert.Null(r.ObjectId);
|
||||
Assert.Equal(ResolvedAdminIdentity.SourceDomain, r.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoSignIns_TwoTenantsClaimTheDomain_NotResolved()
|
||||
{
|
||||
var r = await Build(null, (TenantA, "partner.example"), (TenantB, "partner.example")).ResolveAsync(Upn);
|
||||
Assert.Null(r);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoSignIns_UnknownDomain_NotResolved()
|
||||
{
|
||||
var r = await Build(null, (TenantA, "other.example")).ResolveAsync(Upn);
|
||||
Assert.Null(r);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("nodomain")]
|
||||
[InlineData("trailing@")]
|
||||
public async Task MalformedUpn_NotResolved(string upn)
|
||||
=> Assert.Null(await Build(null, (TenantA, "partner.example")).ResolveAsync(upn));
|
||||
}
|
||||
@@ -96,7 +96,9 @@ public class AuthFunctionSideEffectTests
|
||||
_autoApproveEnqueuerMock.Object,
|
||||
new AutopilotMonitor.Functions.Security.EntraAppRegistry(
|
||||
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
|
||||
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()));
|
||||
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()),
|
||||
new Mock<AdminIdentityResolver>(
|
||||
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object);
|
||||
|
||||
// Default: all fire-and-forget calls succeed
|
||||
_tenantConfigMock
|
||||
|
||||
@@ -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,97 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The targeted push signal: "someone clicked Collect Logs and switched on Hosted upload
|
||||
/// through the quick-config dialog" must land as its own ops event type, while every other
|
||||
/// enable path keeps the generic type — an alert rule filters by EventType only.
|
||||
/// </summary>
|
||||
public class CollectLogsQuickConfigOpsEventTests
|
||||
{
|
||||
private static (OpsEventService Service, List<OpsEventEntry> Saved) Rig()
|
||||
{
|
||||
var saved = new List<OpsEventEntry>();
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(e => { lock (saved) saved.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);
|
||||
return (new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch), saved);
|
||||
}
|
||||
|
||||
private static DiagnosticsUploadConfigChange EnableFlip() =>
|
||||
DiagnosticsUploadConfigChange.Detect(
|
||||
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "Off" },
|
||||
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "OnFailure", DiagnosticsUploadDestination = "Hosted" })!;
|
||||
|
||||
[Theory]
|
||||
[InlineData("collect-logs", UpdateTenantConfigurationFunction.CollectLogsSource)]
|
||||
[InlineData("COLLECT-LOGS", UpdateTenantConfigurationFunction.CollectLogsSource)]
|
||||
[InlineData(null, "portal-put")]
|
||||
[InlineData("", "portal-put")]
|
||||
[InlineData("something-else", "portal-put")]
|
||||
public void ResolveWriteSource_only_honours_the_allow_listed_intent(string? intent, string expected)
|
||||
{
|
||||
Assert.Equal(expected, UpdateTenantConfigurationFunction.ResolveWriteSource(intent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Quick_config_enable_lands_as_its_own_event_type()
|
||||
{
|
||||
var (service, saved) = Rig();
|
||||
|
||||
await service.RecordDiagnosticsUploadConfigChangedAsync(
|
||||
"t1", "contoso.com", EnableFlip(), "admin@contoso.com", UpdateTenantConfigurationFunction.CollectLogsSource);
|
||||
|
||||
var evt = Assert.Single(saved);
|
||||
Assert.Equal("CollectLogsQuickConfigEnabled", evt.EventType);
|
||||
Assert.Equal(OpsEventCategory.Tenant, evt.Category);
|
||||
Assert.Contains("admin@contoso.com", evt.Message);
|
||||
Assert.Contains("Hosted", evt.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("portal-put")]
|
||||
[InlineData("mcp-patch")]
|
||||
public async Task Other_enable_paths_keep_the_generic_event_type(string source)
|
||||
{
|
||||
var (service, saved) = Rig();
|
||||
|
||||
await service.RecordDiagnosticsUploadConfigChangedAsync("t1", "contoso.com", EnableFlip(), "admin@contoso.com", source);
|
||||
|
||||
Assert.Equal("DiagnosticsUploadEnabled", Assert.Single(saved).EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Quick_config_source_on_a_disable_is_the_generic_disabled_type()
|
||||
{
|
||||
var (service, saved) = Rig();
|
||||
var disable = DiagnosticsUploadConfigChange.Detect(
|
||||
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "Always", DiagnosticsUploadDestination = "Hosted" },
|
||||
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "Off", DiagnosticsUploadDestination = "Hosted" })!;
|
||||
|
||||
await service.RecordDiagnosticsUploadConfigChangedAsync("t1", null, disable, "ga@x", UpdateTenantConfigurationFunction.CollectLogsSource);
|
||||
|
||||
Assert.Equal("DiagnosticsUploadDisabled", Assert.Single(saved).EventType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The Collect Logs capability flip that feeds the DiagnosticsUploadEnabled/Disabled ops
|
||||
/// events: only a change of "would an on-demand upload work" counts, never a plain edit.
|
||||
/// </summary>
|
||||
public class DiagnosticsUploadConfigChangeTests
|
||||
{
|
||||
private static TenantConfiguration Cfg(string? mode, string? destination = null, string? sas = null) => new()
|
||||
{
|
||||
TenantId = "t1",
|
||||
DiagnosticsUploadMode = mode!,
|
||||
DiagnosticsUploadDestination = destination!,
|
||||
DiagnosticsBlobSasUrl = sas!,
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null, null, false)]
|
||||
[InlineData("Off", "Hosted", null, false)]
|
||||
[InlineData("OnFailure", null, null, false)]
|
||||
[InlineData("OnFailure", "Hosted", null, true)]
|
||||
[InlineData("Always", "CustomerSas", "https://x.blob.core.windows.net/c?sig=1", true)]
|
||||
[InlineData("Always", null, "https://x.blob.core.windows.net/c?sig=1", true)]
|
||||
public void IsConfigured_mirrors_the_feature_flag_semantics(string? mode, string? dest, string? sas, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, DiagnosticsUploadConfigChange.IsConfigured(Cfg(mode, dest, sas)));
|
||||
Assert.False(DiagnosticsUploadConfigChange.IsConfigured(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_quick_config_flip_reports_enabled_with_destination_and_mode()
|
||||
{
|
||||
// The Collect Logs quick-config dialog: Off/unset -> Hosted + OnFailure.
|
||||
var change = DiagnosticsUploadConfigChange.Detect(Cfg("Off"), Cfg("OnFailure", "Hosted"));
|
||||
|
||||
Assert.NotNull(change);
|
||||
Assert.True(change!.Enabled);
|
||||
Assert.Equal("Hosted", change.Destination);
|
||||
Assert.Equal("OnFailure", change.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_missing_before_row_counts_as_not_configured()
|
||||
{
|
||||
var change = DiagnosticsUploadConfigChange.Detect(null, Cfg("Always", "Hosted"));
|
||||
Assert.True(change?.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_reports_disabled_when_mode_goes_off()
|
||||
{
|
||||
var change = DiagnosticsUploadConfigChange.Detect(Cfg("Always", "Hosted"), Cfg("Off", "Hosted"));
|
||||
|
||||
Assert.NotNull(change);
|
||||
Assert.False(change!.Enabled);
|
||||
Assert.Equal("Off", change.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_ignores_edits_that_keep_the_capability_state()
|
||||
{
|
||||
// Mode change while staying on: no flip.
|
||||
Assert.Null(DiagnosticsUploadConfigChange.Detect(Cfg("OnFailure", "Hosted"), Cfg("Always", "Hosted")));
|
||||
// Destination not yet usable in both states: no flip.
|
||||
Assert.Null(DiagnosticsUploadConfigChange.Detect(Cfg("Off"), Cfg("OnFailure")));
|
||||
// Unchanged.
|
||||
Assert.Null(DiagnosticsUploadConfigChange.Detect(Cfg("Off"), Cfg("Off")));
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,14 @@ public class RevokeEnforcementTests
|
||||
|
||||
private static readonly StubAdminIdentityBindingService Bindings = new(bound: true);
|
||||
|
||||
/// <summary>Resolver stub: never consulted by the revoke/validation seams under test (loose mock ⇒ null).</summary>
|
||||
private static AdminIdentityResolver Resolver() =>
|
||||
new Mock<AdminIdentityResolver>(
|
||||
Mock.Of<IMetricsRepository>(),
|
||||
new Mock<TenantConfigurationService>(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())) { CallBase = false }.Object,
|
||||
NullLogger<AdminIdentityResolver>.Instance) { CallBase = false }.Object;
|
||||
|
||||
private static DelegatedAdminService BuildService(Mock<IAdminRepository> repo) =>
|
||||
new(
|
||||
repo.Object,
|
||||
@@ -58,7 +66,7 @@ public class RevokeEnforcementTests
|
||||
var audit = BuildAuditRepo();
|
||||
var signalR = new FakeSignalRNotificationService();
|
||||
var fn = new DelegatedAdminManagementFunction(
|
||||
NullLogger<DelegatedAdminManagementFunction>.Instance, BuildService(repo), Bindings, audit.Object, signalR);
|
||||
NullLogger<DelegatedAdminManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, signalR);
|
||||
return (fn, repo, audit, signalR);
|
||||
}
|
||||
|
||||
@@ -75,7 +83,7 @@ public class RevokeEnforcementTests
|
||||
var audit = BuildAuditRepo();
|
||||
var signalR = new FakeSignalRNotificationService();
|
||||
var fn = new TenantGroupManagementFunction(
|
||||
NullLogger<TenantGroupManagementFunction>.Instance, BuildService(repo), Bindings, audit.Object, signalR);
|
||||
NullLogger<TenantGroupManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, signalR);
|
||||
return (fn, repo, audit, signalR);
|
||||
}
|
||||
|
||||
@@ -105,20 +113,25 @@ public class RevokeEnforcementTests
|
||||
new GrantDelegatedAdminRequest { Upn = Upn, TenantId = tenantId }, out _));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("partner.example")]
|
||||
[InlineData("11111111-1111-1111-1111")]
|
||||
public void ValidateGrant_MissingOrNonGuidHomeTenant_Rejected(string? homeTenantId)
|
||||
public void ValidateGrant_NonGuidHomeTenant_Rejected(string homeTenantId)
|
||||
{
|
||||
// The grantee's HOME tenant is what the identity binding is keyed on — without it the grant would be
|
||||
// inert at best and UPN-only (the original cross-tenant hole) at worst. Required, GUID, fail-closed.
|
||||
// The home tenant is OPTIONAL at the API (resolved from sign-in history / UPN domain when absent),
|
||||
// but a supplied value must be a GUID — a typo must not become a binding.
|
||||
var error = DelegatedAdminManagementFunction.ValidateGrantRequest(
|
||||
new GrantDelegatedAdminRequest { Upn = Upn, TenantId = TenantA, HomeTenantId = homeTenantId }, out _);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("homeTenantId", error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void ValidateGrant_AbsentHomeTenant_IsValid_ResolvedLater(string? homeTenantId)
|
||||
=> Assert.Null(DelegatedAdminManagementFunction.ValidateGrantRequest(
|
||||
new GrantDelegatedAdminRequest { Upn = Upn, TenantId = TenantA, HomeTenantId = homeTenantId }, out _));
|
||||
|
||||
[Fact]
|
||||
public void ValidateGrant_NonGuidObjectId_Rejected()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+78
@@ -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.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -35,6 +37,8 @@ public class TenantConfigPatchServiceTests
|
||||
public Mock<IConfigRepository> Repo { get; } = new();
|
||||
public Mock<IConfigBackupRepository> Backup { get; } = new();
|
||||
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
|
||||
public Mock<IOpsEventRepository> OpsRepo { get; } = new();
|
||||
public List<OpsEventEntry> OpsEvents { get; } = new();
|
||||
public TenantConfigPatchService Sut { get; }
|
||||
|
||||
public TenantConfiguration? Current;
|
||||
@@ -89,8 +93,23 @@ public class TenantConfigPatchServiceTests
|
||||
Repo.Object, NullLogger<TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
OpsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(e => { lock (OpsEvents) OpsEvents.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 TenantConfigPatchService(
|
||||
Repo.Object, Backup.Object, configService, Maintenance.Object,
|
||||
Repo.Object, Backup.Object, configService, Maintenance.Object, opsService,
|
||||
NullLogger<TenantConfigPatchService>.Instance);
|
||||
}
|
||||
|
||||
@@ -116,6 +135,41 @@ public class TenantConfigPatchServiceTests
|
||||
return o;
|
||||
}
|
||||
|
||||
// ── Ops visibility: Collect Logs capability flip ────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_EnablingDiagnosticsUpload_EmitsOpsEvent_OnlyOnTheFlip()
|
||||
{
|
||||
var stored = Stored();
|
||||
stored.DiagnosticsUploadMode = "Off";
|
||||
var harness = new Harness(stored);
|
||||
|
||||
// Settings page / MCP: turn the capability on (mode + Hosted destination).
|
||||
var on = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("diagnosticsUploadMode", "OnFailure"), ("diagnosticsUploadDestination", "Hosted")),
|
||||
Ga, "mcp-patch", "enable collect logs");
|
||||
Assert.True(on.Success, on.Error);
|
||||
|
||||
var enabled = Assert.Single(harness.OpsEvents);
|
||||
Assert.Equal("DiagnosticsUploadEnabled", enabled.EventType);
|
||||
Assert.Equal(TenantId, enabled.TenantId);
|
||||
Assert.Contains("contoso.com", enabled.Message);
|
||||
Assert.Contains("mcp-patch", enabled.Message);
|
||||
|
||||
// A further edit that keeps it on is not a flip.
|
||||
var tweak = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("diagnosticsUploadMode", "Always")), Ga, "mcp-patch", null);
|
||||
Assert.True(tweak.Success, tweak.Error);
|
||||
Assert.Single(harness.OpsEvents);
|
||||
|
||||
// Turning it off is the opposite flip.
|
||||
var off = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("diagnosticsUploadMode", "Off")), Ga, "mcp-patch", null);
|
||||
Assert.True(off.Success, off.Error);
|
||||
Assert.Equal(2, harness.OpsEvents.Count);
|
||||
Assert.Equal("DiagnosticsUploadDisabled", harness.OpsEvents[1].EventType);
|
||||
}
|
||||
|
||||
// ── Happy path ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
+3
@@ -86,6 +86,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<int> DeleteUserActivityOlderThanAsync(DateTime cutoffUtc)
|
||||
=> _storage.DeleteUserActivityOlderThanAsync(cutoffUtc);
|
||||
|
||||
public Task<List<UserSignInIdentity>> GetSignInIdentitiesByUpnAsync(string upn)
|
||||
=> _storage.GetSignInIdentitiesByUpnAsync(upn);
|
||||
|
||||
public Task RecordUserPresenceAsync(string tenantId, string upn, string userRole)
|
||||
=> _storage.RecordUserPresenceAsync(tenantId, upn, userRole);
|
||||
|
||||
|
||||
+5
-2
@@ -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);
|
||||
|
||||
+28
-20
@@ -24,37 +24,33 @@ public class DelegatedAdminManagementFunction
|
||||
{
|
||||
private readonly ILogger<DelegatedAdminManagementFunction> _logger;
|
||||
private readonly DelegatedAdminService _delegatedAdminService;
|
||||
private readonly AdminIdentityBindingService _bindings;
|
||||
private readonly AdminIdentityResolver _identityResolver;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly ISignalRNotificationService _signalRService;
|
||||
|
||||
public DelegatedAdminManagementFunction(
|
||||
ILogger<DelegatedAdminManagementFunction> logger,
|
||||
DelegatedAdminService delegatedAdminService,
|
||||
AdminIdentityBindingService bindings,
|
||||
AdminIdentityResolver identityResolver,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
ISignalRNotificationService signalRService)
|
||||
{
|
||||
_logger = logger;
|
||||
_delegatedAdminService = delegatedAdminService;
|
||||
_bindings = bindings;
|
||||
_identityResolver = identityResolver;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_signalRService = signalRService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/delegated-admins — list every delegated assignment plus the identity bindings of the
|
||||
/// assigned UPNs (so the UI can show which tenant/object id each grant is actually usable from). GlobalReadOrAdmin.
|
||||
/// </summary>
|
||||
/// <summary>GET /api/global/delegated-admins — list every delegated assignment. GlobalReadOrAdmin.</summary>
|
||||
[Function("GetDelegatedAdmins")]
|
||||
[Authorize]
|
||||
public async Task<HttpResponseData> GetDelegatedAdmins(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/delegated-admins")] HttpRequestData req)
|
||||
{
|
||||
var assignments = await _delegatedAdminService.GetAllAsync();
|
||||
var bindings = await _bindings.GetAllAsync();
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { assignments, bindings });
|
||||
await response.WriteAsJsonAsync(new { assignments });
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -75,18 +71,23 @@ public class DelegatedAdminManagementFunction
|
||||
if (validationError != null)
|
||||
return await Bad(req, validationError);
|
||||
|
||||
// Edition note: TARGET tenants may be any edition — an MSP on Pro may manage Community
|
||||
// customers. The Pro requirement applies to the delegated admin's HOME tenant (homeTenantId,
|
||||
// which also becomes the UPN's identity binding) and is enforced at resolve time
|
||||
// (DelegatedAdminService.GetScopeAsync gates on the JWT tid). Grants to non-Pro-homed admins are
|
||||
// simply inert. A binding conflict (UPN already homed in another tenant) is a 409, never a silent rebind.
|
||||
// The UPN's identity binding (home tenant + object id) is resolved automatically — sign-in history,
|
||||
// then UPN domain → onboarded tenant; the body may override it. Unresolvable ⇒ 422 and the UI asks
|
||||
// the operator to pick the tenant. Edition note: TARGET tenants may be any edition — an MSP on Pro
|
||||
// may manage Community customers; the Pro requirement applies to the HOME tenant and is enforced at
|
||||
// resolve time (DelegatedAdminService.GetScopeAsync gates on the JWT tid), so grants to non-Pro-homed
|
||||
// admins are simply inert. A binding conflict (UPN already homed elsewhere) is a 409, never a silent rebind.
|
||||
var identity = await IdentityBindingRequest.ResolveForGrantAsync(_identityResolver, body!.Upn, body.HomeTenantId, body.ObjectId);
|
||||
if (identity == null)
|
||||
return await Unresolved(req);
|
||||
|
||||
DelegatedAdminEntry entry;
|
||||
try
|
||||
{
|
||||
entry = await _delegatedAdminService.UpsertAsync(
|
||||
body!.Upn, body.TenantId, role,
|
||||
body.Upn, body.TenantId, role,
|
||||
Constants.DelegatedStatus.Active, Constants.DelegatedSource.OperatorGranted, currentUpn ?? "",
|
||||
body.HomeTenantId!, body.ObjectId);
|
||||
identity.Value.TenantId, identity.Value.ObjectId);
|
||||
}
|
||||
catch (IdentityBindingConflictException ex)
|
||||
{
|
||||
@@ -100,7 +101,7 @@ public class DelegatedAdminManagementFunction
|
||||
{ "Role", entry.Role },
|
||||
{ "Status", entry.Status },
|
||||
{ "Source", entry.Source },
|
||||
{ "HomeTenantId", body.HomeTenantId!.ToLowerInvariant() },
|
||||
{ "HomeTenantId", identity.Value.TenantId.ToLowerInvariant() },
|
||||
});
|
||||
|
||||
_logger.LogInformation("Delegated grant: {Upn} -> {TenantId} ({Role}) by {By}",
|
||||
@@ -228,7 +229,7 @@ public class DelegatedAdminManagementFunction
|
||||
if (!Guid.TryParse(body.TenantId, out _))
|
||||
return "a valid tenantId (GUID) is required";
|
||||
|
||||
var bindingError = IdentityBindingRequest.Validate(body.HomeTenantId, body.ObjectId);
|
||||
var bindingError = IdentityBindingRequest.ValidateOptional(body.HomeTenantId, body.ObjectId);
|
||||
if (bindingError != null)
|
||||
return bindingError;
|
||||
|
||||
@@ -252,6 +253,13 @@ public class DelegatedAdminManagementFunction
|
||||
await conflict.WriteAsJsonAsync(new { error });
|
||||
return conflict;
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> Unresolved(HttpRequestData req)
|
||||
{
|
||||
var unresolved = req.CreateResponse(HttpStatusCode.UnprocessableEntity);
|
||||
await unresolved.WriteAsJsonAsync(new { error = IdentityBindingRequest.HomeTenantUnresolvedMessage, code = IdentityBindingRequest.HomeTenantUnresolvedCode });
|
||||
return unresolved;
|
||||
}
|
||||
}
|
||||
|
||||
public class GrantDelegatedAdminRequest
|
||||
@@ -260,8 +268,8 @@ public class GrantDelegatedAdminRequest
|
||||
/// <summary>The MANAGED (target) tenant the UPN may read.</summary>
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
public string? Role { get; set; }
|
||||
/// <summary>The grantee's HOME Entra tenant id (required) — the JWT tid their tokens carry; becomes the identity binding.</summary>
|
||||
/// <summary>The grantee's HOME Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.</summary>
|
||||
public string? HomeTenantId { get; set; }
|
||||
/// <summary>The grantee's Entra object id (optional) — pinned on their first sign-in when omitted.</summary>
|
||||
/// <summary>The grantee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.</summary>
|
||||
public string? ObjectId { get; set; }
|
||||
}
|
||||
|
||||
+33
-1
@@ -143,16 +143,48 @@ public class IdentityBindingManagementFunction
|
||||
/// <summary>Body of PUT global/identity-bindings/{upn}; also the shared validator for the binding fields every grant carries.</summary>
|
||||
public class IdentityBindingRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The identity a grant binds: the operator-supplied values when present, otherwise the resolver's answer.
|
||||
/// Returns null when neither yields a home tenant — the endpoint answers 422 <see cref="HomeTenantUnresolvedCode"/>
|
||||
/// and the UI offers a tenant picker. A supplied object id always wins over a resolved one.
|
||||
/// </summary>
|
||||
public static async Task<(string TenantId, string? ObjectId)?> ResolveForGrantAsync(
|
||||
AdminIdentityResolver resolver, string upn, string? homeTenantId, string? objectId)
|
||||
{
|
||||
objectId = string.IsNullOrWhiteSpace(objectId) ? null : objectId;
|
||||
if (!string.IsNullOrWhiteSpace(homeTenantId))
|
||||
return (homeTenantId, objectId);
|
||||
|
||||
var resolved = await resolver.ResolveAsync(upn);
|
||||
return resolved == null ? null : (resolved.TenantId, objectId ?? resolved.ObjectId);
|
||||
}
|
||||
|
||||
public string? HomeTenantId { get; set; }
|
||||
public string? ObjectId { get; set; }
|
||||
|
||||
/// <summary>Returns the 400 message, or null when the fields are valid (home tenant = GUID required; object id = GUID or absent).</summary>
|
||||
public static string? Validate(string? homeTenantId, string? objectId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(homeTenantId) || !Guid.TryParse(homeTenantId, out _))
|
||||
if (string.IsNullOrWhiteSpace(homeTenantId))
|
||||
return "a valid homeTenantId (GUID — the Entra tenant the person signs in from) is required";
|
||||
return ValidateOptional(homeTenantId, objectId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grant-time variant: the home tenant MAY be absent (the backend resolves it from sign-in history or the
|
||||
/// UPN domain — see <see cref="AdminIdentityResolver"/>); when supplied it must be a GUID, as must the object id.
|
||||
/// </summary>
|
||||
public static string? ValidateOptional(string? homeTenantId, string? objectId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(homeTenantId) && !Guid.TryParse(homeTenantId, out _))
|
||||
return "homeTenantId must be a GUID (the Entra tenant the person signs in from) when supplied";
|
||||
if (!string.IsNullOrWhiteSpace(objectId) && !Guid.TryParse(objectId, out _))
|
||||
return "objectId must be a GUID (the person's Entra object id) when supplied";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Error code the grant endpoints return (HTTP 422) when the home tenant could not be resolved automatically.</summary>
|
||||
public const string HomeTenantUnresolvedCode = "HomeTenantUnresolved";
|
||||
public const string HomeTenantUnresolvedMessage =
|
||||
"The person's home tenant could not be resolved automatically (no previous sign-in, and the UPN domain does not belong to an onboarded tenant). Select the tenant they sign in from.";
|
||||
}
|
||||
|
||||
+21
-16
@@ -29,7 +29,7 @@ public class TenantGroupManagementFunction
|
||||
{
|
||||
private readonly ILogger<TenantGroupManagementFunction> _logger;
|
||||
private readonly DelegatedAdminService _delegatedAdminService;
|
||||
private readonly AdminIdentityBindingService _bindings;
|
||||
private readonly AdminIdentityResolver _identityResolver;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly ISignalRNotificationService _signalRService;
|
||||
|
||||
@@ -38,30 +38,26 @@ public class TenantGroupManagementFunction
|
||||
public TenantGroupManagementFunction(
|
||||
ILogger<TenantGroupManagementFunction> logger,
|
||||
DelegatedAdminService delegatedAdminService,
|
||||
AdminIdentityBindingService bindings,
|
||||
AdminIdentityResolver identityResolver,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
ISignalRNotificationService signalRService)
|
||||
{
|
||||
_logger = logger;
|
||||
_delegatedAdminService = delegatedAdminService;
|
||||
_bindings = bindings;
|
||||
_identityResolver = identityResolver;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_signalRService = signalRService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/tenant-groups — list every group with tenants + assignee count, plus the identity
|
||||
/// bindings of the assigned UPNs (which tenant/object id each assignment is usable from). GlobalReadOrAdmin.
|
||||
/// </summary>
|
||||
/// <summary>GET /api/global/tenant-groups — list every group with tenants + assignee count. GlobalReadOrAdmin.</summary>
|
||||
[Function("GetTenantGroups")]
|
||||
[Authorize]
|
||||
public async Task<HttpResponseData> GetTenantGroups(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/tenant-groups")] HttpRequestData req)
|
||||
{
|
||||
var groups = await _delegatedAdminService.GetAllGroupsAsync();
|
||||
var bindings = await _bindings.GetAllAsync();
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { groups, bindings });
|
||||
await response.WriteAsJsonAsync(new { groups });
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -231,9 +227,10 @@ public class TenantGroupManagementFunction
|
||||
/// <summary>
|
||||
/// POST /api/global/tenant-groups/{groupId}/assignees — assign a UPN to the group. GlobalAdminOnly.
|
||||
/// Body: { "upn": "user@domain.com", "role": "DelegatedReader" | "DelegatedAdmin",
|
||||
/// "homeTenantId": "<the assignee's Entra tenant>", "objectId": "<optional>" }.
|
||||
/// homeTenantId binds the UPN to the identity that may use the assignment (409 if the UPN is already
|
||||
/// bound elsewhere — rebind explicitly via global/identity-bindings).
|
||||
/// "homeTenantId": "<optional override>", "objectId": "<optional>" }.
|
||||
/// The UPN's identity binding is resolved automatically (sign-in history, then UPN domain → onboarded
|
||||
/// tenant); 422 HomeTenantUnresolved when neither works, 409 if the UPN is already bound elsewhere
|
||||
/// (rebind explicitly via global/identity-bindings).
|
||||
/// </summary>
|
||||
[Function("AssignTenantGroup")]
|
||||
[Authorize]
|
||||
@@ -252,7 +249,7 @@ public class TenantGroupManagementFunction
|
||||
if (role != Constants.DelegatedRoles.DelegatedReader && role != Constants.DelegatedRoles.DelegatedAdmin)
|
||||
return await Bad(req, $"role must be '{Constants.DelegatedRoles.DelegatedReader}' or '{Constants.DelegatedRoles.DelegatedAdmin}'");
|
||||
|
||||
var bindingError = IdentityBindingRequest.Validate(body.HomeTenantId, body.ObjectId);
|
||||
var bindingError = IdentityBindingRequest.ValidateOptional(body.HomeTenantId, body.ObjectId);
|
||||
if (bindingError != null)
|
||||
return await Bad(req, bindingError);
|
||||
|
||||
@@ -262,12 +259,20 @@ public class TenantGroupManagementFunction
|
||||
return await NotFound(req);
|
||||
|
||||
var upn = body.Upn.ToLowerInvariant();
|
||||
var identity = await IdentityBindingRequest.ResolveForGrantAsync(_identityResolver, upn, body.HomeTenantId, body.ObjectId);
|
||||
if (identity == null)
|
||||
{
|
||||
var unresolved = req.CreateResponse(HttpStatusCode.UnprocessableEntity);
|
||||
await unresolved.WriteAsJsonAsync(new { error = IdentityBindingRequest.HomeTenantUnresolvedMessage, code = IdentityBindingRequest.HomeTenantUnresolvedCode });
|
||||
return unresolved;
|
||||
}
|
||||
|
||||
// The service re-checks existence (covers a delete racing this assign) — skip the audit if it no-ops.
|
||||
bool assigned;
|
||||
try
|
||||
{
|
||||
assigned = await _delegatedAdminService.AssignGroupAsync(
|
||||
upn, groupId, role, true, currentUpn ?? "", body.HomeTenantId!, body.ObjectId);
|
||||
upn, groupId, role, true, currentUpn ?? "", identity.Value.TenantId, identity.Value.ObjectId);
|
||||
}
|
||||
catch (IdentityBindingConflictException ex)
|
||||
{
|
||||
@@ -390,8 +395,8 @@ public class AssignGroupRequest
|
||||
{
|
||||
public string Upn { get; set; } = string.Empty;
|
||||
public string? Role { get; set; }
|
||||
/// <summary>The assignee's HOME Entra tenant id (required) — becomes the UPN's identity binding.</summary>
|
||||
/// <summary>The assignee's HOME Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.</summary>
|
||||
public string? HomeTenantId { get; set; }
|
||||
/// <summary>The assignee's Entra object id (optional) — pinned on their first sign-in when omitted.</summary>
|
||||
/// <summary>The assignee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.</summary>
|
||||
public string? ObjectId { get; set; }
|
||||
}
|
||||
|
||||
+5
-3
@@ -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" });
|
||||
|
||||
+5
-3
@@ -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" });
|
||||
|
||||
+7
-5
@@ -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)
|
||||
{
|
||||
|
||||
+1
-4
@@ -86,10 +86,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
// succeed right now (mode not Off + a usable destination). Members below Admin cannot
|
||||
// read the full config, so this boolean is their only signal. Deliberately exposes no
|
||||
// destination detail — just "would an upload work".
|
||||
diagnosticsUploadConfigured =
|
||||
!string.Equals(config.DiagnosticsUploadMode ?? "Off", "Off", StringComparison.OrdinalIgnoreCase)
|
||||
&& GetAgentConfigFunction.ResolveDiagnosticsUploadEnabled(
|
||||
config.DiagnosticsBlobSasUrl, config.DiagnosticsUploadDestination),
|
||||
diagnosticsUploadConfigured = DiagnosticsUploadConfigChange.IsConfigured(config),
|
||||
// Drives the "Autopilot Device Validation disabled" dashboard banner
|
||||
// (useTenantSecurityConfig).
|
||||
validateAutopilotDevice = config.ValidateAutopilotDevice,
|
||||
|
||||
+27
-2
@@ -21,17 +21,32 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
private readonly ILogger<UpdateTenantConfigurationFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly OpsEventService _opsEventService;
|
||||
|
||||
public UpdateTenantConfigurationFunction(
|
||||
ILogger<UpdateTenantConfigurationFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
IMaintenanceRepository maintenanceRepo)
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
OpsEventService opsEventService)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_opsEventService = opsEventService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write-path label for the session-detail Collect Logs quick-config dialog. The dialog
|
||||
/// sends <c>?intent=collect-logs</c> so the backup snapshot and the ops event can name
|
||||
/// the path — a plain Settings/Admin PUT is <c>portal-put</c>. Allow-listed, never echoed
|
||||
/// from arbitrary input.
|
||||
/// </summary>
|
||||
internal const string CollectLogsIntent = "collect-logs";
|
||||
internal const string CollectLogsSource = "portal-collect-logs";
|
||||
|
||||
internal static string ResolveWriteSource(string? intent) =>
|
||||
string.Equals(intent, CollectLogsIntent, StringComparison.OrdinalIgnoreCase) ? CollectLogsSource : "portal-put";
|
||||
|
||||
[Function("UpdateTenantConfiguration")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "put", "post", Route = "config/{tenantId}")] HttpRequestData req,
|
||||
@@ -183,7 +198,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
config.TrialGrantedBy = existingConfig.TrialGrantedBy;
|
||||
|
||||
// Save configuration (retention cap already enforced by ValidateModel above)
|
||||
await _configService.SaveConfigurationAsync(config, "portal-put", null);
|
||||
var writeSource = ResolveWriteSource(req.Query["intent"]);
|
||||
await _configService.SaveConfigurationAsync(config, writeSource, null);
|
||||
|
||||
var changes = ConfigDiffHelper.GetChanges(existingConfig, config);
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
@@ -195,6 +211,15 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
changes.Count > 0 ? changes : null
|
||||
);
|
||||
|
||||
// Operator visibility for the Collect Logs capability flip (the quick-config
|
||||
// dialog writes through this PUT). OpsEventService never throws.
|
||||
var diagChange = DiagnosticsUploadConfigChange.Detect(existingConfig, config);
|
||||
if (diagChange != null)
|
||||
{
|
||||
await _opsEventService.RecordDiagnosticsUploadConfigChangedAsync(
|
||||
requestCtx.TargetTenantId, config.DomainName, diagChange, userIdentifier ?? "unknown", writeSource);
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
|
||||
@@ -32,6 +32,7 @@ public class AuthFunction
|
||||
private readonly McpUserService _mcpUserService;
|
||||
private readonly Services.Activation.ITenantAutoApproveEnqueuer _tenantAutoApproveEnqueuer;
|
||||
private readonly EntraAppRegistry _appRegistry;
|
||||
private readonly AdminIdentityResolver _identityResolver;
|
||||
|
||||
public AuthFunction(
|
||||
ILogger<AuthFunction> logger,
|
||||
@@ -45,9 +46,11 @@ public class AuthFunction
|
||||
GlobalNotificationService globalNotificationService,
|
||||
McpUserService mcpUserService,
|
||||
Services.Activation.ITenantAutoApproveEnqueuer tenantAutoApproveEnqueuer,
|
||||
EntraAppRegistry appRegistry)
|
||||
EntraAppRegistry appRegistry,
|
||||
AdminIdentityResolver identityResolver)
|
||||
{
|
||||
_logger = logger;
|
||||
_identityResolver = identityResolver;
|
||||
_globalAdminService = globalAdminService;
|
||||
_delegatedAdminService = delegatedAdminService;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
@@ -251,20 +254,27 @@ public class AuthFunction
|
||||
await badRequestResponse.WriteAsJsonAsync(new { error = "UPN is required" });
|
||||
return badRequestResponse;
|
||||
}
|
||||
// The grantee's home tenant is mandatory: the row is inert until the UPN is bound to the identity
|
||||
// that may use it, and the UPN string alone cannot tell which tenant that is.
|
||||
var bindingError = IdentityBindingRequest.Validate(body.HomeTenantId, body.ObjectId);
|
||||
// The row is inert until the UPN is bound to the identity that may use it. The home tenant is resolved
|
||||
// automatically (sign-in history, then UPN domain → onboarded tenant); the body may override it.
|
||||
var bindingError = IdentityBindingRequest.ValidateOptional(body.HomeTenantId, body.ObjectId);
|
||||
if (bindingError != null)
|
||||
{
|
||||
var badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequestResponse.WriteAsJsonAsync(new { error = bindingError });
|
||||
return badRequestResponse;
|
||||
}
|
||||
var identity = await IdentityBindingRequest.ResolveForGrantAsync(_identityResolver, body.Upn, body.HomeTenantId, body.ObjectId);
|
||||
if (identity == null)
|
||||
{
|
||||
var unresolved = req.CreateResponse(HttpStatusCode.UnprocessableEntity);
|
||||
await unresolved.WriteAsJsonAsync(new { error = IdentityBindingRequest.HomeTenantUnresolvedMessage, code = IdentityBindingRequest.HomeTenantUnresolvedCode });
|
||||
return unresolved;
|
||||
}
|
||||
|
||||
GlobalAdminEntity newAdmin;
|
||||
try
|
||||
{
|
||||
newAdmin = await _globalAdminService.AddGlobalAdminAsync(body.Upn, currentUpn!, body.HomeTenantId!, body.ObjectId);
|
||||
newAdmin = await _globalAdminService.AddGlobalAdminAsync(body.Upn, currentUpn!, identity.Value.TenantId, identity.Value.ObjectId);
|
||||
}
|
||||
catch (IdentityBindingConflictException ex)
|
||||
{
|
||||
@@ -636,8 +646,8 @@ internal class AuthDecisionResult
|
||||
public class AddGlobalAdminRequest
|
||||
{
|
||||
public string Upn { get; set; } = string.Empty;
|
||||
/// <summary>The grantee's home Entra tenant id (required) — the JWT tid their tokens will carry.</summary>
|
||||
/// <summary>The grantee's home Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.</summary>
|
||||
public string? HomeTenantId { get; set; }
|
||||
/// <summary>The grantee's Entra object id (optional) — pinned on their first sign-in when omitted.</summary>
|
||||
/// <summary>The grantee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.</summary>
|
||||
public string? ObjectId { get; set; }
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
+30
-4
@@ -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=<sha256>, expected=<sha256>". Parsed leniently — an unparseable
|
||||
|
||||
+10
-1
@@ -4,6 +4,7 @@ using System.IO;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.ApplicationInsights;
|
||||
@@ -24,6 +25,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
private readonly ILogger<QueueSessionActionFunction> _logger;
|
||||
private readonly ISessionRepository _sessionRepo;
|
||||
private readonly TelemetryClient _telemetryClient;
|
||||
private readonly OpsEventService _opsEventService;
|
||||
|
||||
private static readonly HashSet<string> AllowedTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -57,11 +59,13 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
public QueueSessionActionFunction(
|
||||
ILogger<QueueSessionActionFunction> logger,
|
||||
ISessionRepository sessionRepo,
|
||||
TelemetryClient telemetryClient)
|
||||
TelemetryClient telemetryClient,
|
||||
OpsEventService opsEventService)
|
||||
{
|
||||
_logger = logger;
|
||||
_sessionRepo = sessionRepo;
|
||||
_telemetryClient = telemetryClient;
|
||||
_opsEventService = opsEventService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -131,6 +135,11 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
return notFound;
|
||||
}
|
||||
|
||||
// Ops feed: who uses the on-demand session controls (Collect Logs, quick-config,
|
||||
// terminate). App Insights alone is invisible to alert rules. Never throws.
|
||||
await _opsEventService.RecordSessionActionQueuedAsync(
|
||||
tenantId, sessionId, action.Type, action.Reason, userIdentifier ?? "unknown");
|
||||
|
||||
_telemetryClient.TrackEvent("ServerActionQueued", new Dictionary<string, string>
|
||||
{
|
||||
{ "tenantId", tenantId },
|
||||
|
||||
+16
-3
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -252,6 +252,7 @@ builder.Services.AddSingleton<SlaMetricsService>();
|
||||
builder.Services.AddSingleton<SlaBreachEvaluationService>();
|
||||
// Identity binding (tid + oid behind every cross-tenant-role UPN) — consulted by both role services.
|
||||
builder.Services.AddSingleton<AdminIdentityBindingService>();
|
||||
builder.Services.AddSingleton<AdminIdentityResolver>();
|
||||
builder.Services.AddSingleton<GlobalAdminService>();
|
||||
builder.Services.AddSingleton<DelegatedAdminService>();
|
||||
builder.Services.AddSingleton<McpUserService>();
|
||||
@@ -281,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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the Entra identity (home tenant id + object id) behind a UPN at grant time WITHOUT operator
|
||||
/// input, so granting a cross-tenant role is "type the UPN" and the identity binding is self-maintaining:
|
||||
/// <list type="number">
|
||||
/// <item><b>Sign-in history</b> (<see cref="Constants.TableNames.UserActivity"/>): the person has signed in
|
||||
/// before ⇒ tid + oid are known and verified (both came from validated tokens). Ambiguous history (the same
|
||||
/// UPN string seen under more than one tenant) is NOT auto-resolved — that is the very situation the binding
|
||||
/// exists for, and only the operator can say which one is meant.</item>
|
||||
/// <item><b>UPN domain → onboarded tenant</b>: the person has never signed in, but their UPN domain is the
|
||||
/// domain of an onboarded tenant ⇒ tid is known; the oid is pinned on their first sign-in from that tenant.</item>
|
||||
/// </list>
|
||||
/// Neither ⇒ null; the caller asks the operator to pick the home tenant (the only manual step left, and only
|
||||
/// for a UPN whose domain the platform has never seen).
|
||||
/// </summary>
|
||||
public class AdminIdentityResolver
|
||||
{
|
||||
private readonly IMetricsRepository _metricsRepo;
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
private readonly ILogger<AdminIdentityResolver> _logger;
|
||||
|
||||
public AdminIdentityResolver(
|
||||
IMetricsRepository metricsRepo,
|
||||
TenantConfigurationService tenantConfigService,
|
||||
ILogger<AdminIdentityResolver> logger)
|
||||
{
|
||||
_metricsRepo = metricsRepo;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public virtual async Task<ResolvedAdminIdentity?> ResolveAsync(string upn)
|
||||
{
|
||||
upn = upn.ToLowerInvariant();
|
||||
|
||||
var signIns = await _metricsRepo.GetSignInIdentitiesByUpnAsync(upn);
|
||||
var tenants = signIns.Select(s => s.TenantId.ToLowerInvariant()).Distinct().ToList();
|
||||
if (tenants.Count == 1)
|
||||
{
|
||||
// Most recent sign-in wins for the oid (a re-created account in the same tenant shows up as a
|
||||
// newer oid; the older one would be refused at sign-in anyway).
|
||||
var latest = signIns.OrderByDescending(s => s.LastLoginAt).First();
|
||||
return new ResolvedAdminIdentity(tenants[0],
|
||||
string.IsNullOrWhiteSpace(latest.ObjectId) ? null : latest.ObjectId.ToLowerInvariant(),
|
||||
ResolvedAdminIdentity.SourceSignIn);
|
||||
}
|
||||
if (tenants.Count > 1)
|
||||
{
|
||||
_logger.LogWarning("[IdentityBinding] {Upn} has sign-ins from {Count} different tenants — not auto-resolving the home tenant",
|
||||
upn, tenants.Count);
|
||||
return null;
|
||||
}
|
||||
|
||||
var at = upn.LastIndexOf('@');
|
||||
if (at <= 0 || at == upn.Length - 1)
|
||||
return null;
|
||||
var domain = upn[(at + 1)..];
|
||||
|
||||
var configs = await _tenantConfigService.GetAllConfigurationsAsync();
|
||||
var byDomain = configs
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.DomainName)
|
||||
&& string.Equals(c.DomainName.Trim(), domain, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(c => c.TenantId.ToLowerInvariant())
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (byDomain.Count == 1)
|
||||
return new ResolvedAdminIdentity(byDomain[0], null, ResolvedAdminIdentity.SourceDomain);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of <see cref="AdminIdentityResolver.ResolveAsync"/>: the home tenant, the object id when the
|
||||
/// person has signed in before (null ⇒ pinned on first sign-in), and where the answer came from.
|
||||
/// </summary>
|
||||
public sealed record ResolvedAdminIdentity(string TenantId, string? ObjectId, string Source)
|
||||
{
|
||||
public const string SourceSignIn = "sign-in";
|
||||
public const string SourceDomain = "domain";
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// "Can an on-demand diagnostics upload succeed for this tenant?" — the single definition
|
||||
/// behind the session-detail Collect Logs button (feature flag
|
||||
/// <c>diagnosticsUploadConfigured</c>) and the ops-event tripwire that reports when a
|
||||
/// tenant admin turns that capability on or off (portal Settings, the Collect Logs
|
||||
/// quick-config dialog, MCP <c>update_tenant_config</c>, or a config revert).
|
||||
/// </summary>
|
||||
public sealed class DiagnosticsUploadConfigChange
|
||||
{
|
||||
public bool Enabled { get; }
|
||||
public string? Destination { get; }
|
||||
public string Mode { get; }
|
||||
|
||||
private DiagnosticsUploadConfigChange(bool enabled, string? destination, string mode)
|
||||
{
|
||||
Enabled = enabled;
|
||||
Destination = destination;
|
||||
Mode = mode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mode not Off AND a usable destination (customer SAS or Hosted). Mirrors the agent's
|
||||
/// own gate (<c>DiagnosticsUploadMode</c> + <c>DiagnosticsUploadEnabled</c>), so
|
||||
/// "configured" here means the agent would actually build and upload a package.
|
||||
/// </summary>
|
||||
public static bool IsConfigured(TenantConfiguration? config)
|
||||
{
|
||||
if (config == null) return false;
|
||||
return !string.Equals(config.DiagnosticsUploadMode ?? "Off", "Off", StringComparison.OrdinalIgnoreCase)
|
||||
&& GetAgentConfigFunction.ResolveDiagnosticsUploadEnabled(
|
||||
config.DiagnosticsBlobSasUrl, config.DiagnosticsUploadDestination);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the flip when <paramref name="after"/> changes the configured state relative
|
||||
/// to <paramref name="before"/>; null when the capability did not change (including
|
||||
/// destination/mode edits that keep it on). A missing <paramref name="before"/> row
|
||||
/// counts as "not configured".
|
||||
/// </summary>
|
||||
public static DiagnosticsUploadConfigChange? Detect(TenantConfiguration? before, TenantConfiguration after)
|
||||
{
|
||||
if (after == null) throw new ArgumentNullException(nameof(after));
|
||||
|
||||
var wasConfigured = IsConfigured(before);
|
||||
var isConfigured = IsConfigured(after);
|
||||
if (wasConfigured == isConfigured) return null;
|
||||
|
||||
return new DiagnosticsUploadConfigChange(
|
||||
isConfigured,
|
||||
after.DiagnosticsUploadDestination,
|
||||
after.DiagnosticsUploadMode ?? "Off");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -717,8 +733,52 @@ namespace AutopilotMonitor.Functions.Services
|
||||
lift,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// A tenant admin turned on-demand diagnostics upload on or off — the capability behind
|
||||
/// the session-detail Collect Logs button (portal Settings, the Collect Logs quick-config
|
||||
/// dialog, MCP update_tenant_config, or a config revert). Fires only on the flip, not on
|
||||
/// every diagnostics edit (<see cref="DiagnosticsUploadConfigChange.Detect"/>).
|
||||
/// Info-tier. The Collect Logs quick-config dialog (source <c>portal-collect-logs</c>)
|
||||
/// gets its own type <c>CollectLogsQuickConfigEnabled</c> so a rule can push on exactly
|
||||
/// "someone clicked Collect Logs and switched on Hosted upload" without also firing on
|
||||
/// Settings-page edits; every other enable path is <c>DiagnosticsUploadEnabled</c>.
|
||||
/// Dual-register per memory feedback_ops_event_types_dual_register.
|
||||
/// </summary>
|
||||
public Task RecordDiagnosticsUploadConfigChangedAsync(
|
||||
string tenantId, string? domainName, DiagnosticsUploadConfigChange change, string changedBy, string source)
|
||||
{
|
||||
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
|
||||
var quickConfig = string.Equals(source, Functions.Config.UpdateTenantConfigurationFunction.CollectLogsSource, StringComparison.Ordinal);
|
||||
if (change.Enabled && quickConfig)
|
||||
{
|
||||
return WriteAsync(OpsEventCategory.Tenant, "CollectLogsQuickConfigEnabled", OpsEventSeverity.Info,
|
||||
$"Collect Logs: {changedBy} enabled diagnostics upload for tenant {tenantLabel} via the session quick-config dialog (destination {change.Destination ?? "?"}, mode {change.Mode})",
|
||||
tenantId, changedBy, new { domainName, change.Destination, change.Mode, source });
|
||||
}
|
||||
|
||||
return change.Enabled
|
||||
? WriteAsync(OpsEventCategory.Tenant, "DiagnosticsUploadEnabled", OpsEventSeverity.Info,
|
||||
$"Diagnostics upload enabled for tenant {tenantLabel} (destination {change.Destination ?? "?"}, mode {change.Mode}) via {source} by {changedBy}",
|
||||
tenantId, changedBy, new { domainName, change.Destination, change.Mode, source })
|
||||
: WriteAsync(OpsEventCategory.Tenant, "DiagnosticsUploadDisabled", OpsEventSeverity.Info,
|
||||
$"Diagnostics upload disabled for tenant {tenantLabel} (mode {change.Mode}) via {source} by {changedBy}",
|
||||
tenantId, changedBy, new { domainName, change.Destination, change.Mode, source });
|
||||
}
|
||||
|
||||
// ── Agent ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A portal user queued a ServerAction for a live session (Collect Logs =
|
||||
/// <c>request_diagnostics</c>, quick-config = <c>rotate_config</c>, admin
|
||||
/// <c>terminate_session</c>). Rule-engine-queued actions do NOT pass through here —
|
||||
/// this is the "who uses the on-demand controls, and how often" signal.
|
||||
/// Dual-register per memory feedback_ops_event_types_dual_register.
|
||||
/// </summary>
|
||||
public Task RecordSessionActionQueuedAsync(string tenantId, string sessionId, string actionType, string? reason, string queuedBy)
|
||||
=> WriteAsync(OpsEventCategory.Agent, "SessionActionQueued", OpsEventSeverity.Info,
|
||||
$"Server action '{actionType}' queued for session {sessionId} by {queuedBy}: {reason}",
|
||||
tenantId, queuedBy, new { sessionId, actionType, reason });
|
||||
|
||||
public Task RecordSessionTimeoutsAsync(string tenantId, int sessionCount, int timeoutHours)
|
||||
=> WriteAsync(OpsEventCategory.Agent, "SessionTimeouts", OpsEventSeverity.Info,
|
||||
$"{sessionCount} session(s) timed out after {timeoutHours}h",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1124,6 +1124,38 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every distinct (tenant, object id) pair a UPN has signed in with — see IMetricsRepository. The Upn
|
||||
/// column is not a key, so this is a filtered table scan; acceptable because it runs once per grant.
|
||||
/// </summary>
|
||||
public async Task<List<UserSignInIdentity>> GetSignInIdentitiesByUpnAsync(string upn)
|
||||
{
|
||||
var result = new Dictionary<(string Tid, string Oid), UserSignInIdentity>();
|
||||
if (string.IsNullOrWhiteSpace(upn))
|
||||
return new List<UserSignInIdentity>();
|
||||
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity);
|
||||
// Rows store the UPN as the token carried it; compare lowercase in memory and pre-filter on the
|
||||
// exact lowercase string (the common case) OR the raw input so a mixed-case row is not missed.
|
||||
var normalized = upn.ToLowerInvariant().Replace("'", "''");
|
||||
var filter = $"Upn eq '{normalized}'";
|
||||
await foreach (var entity in tableClient.QueryAsync<TableEntity>(filter: filter, select: new[] { "PartitionKey", "Upn", "ObjectId", "LoginAt" }))
|
||||
{
|
||||
var tid = entity.PartitionKey.ToLowerInvariant();
|
||||
var oid = (entity.GetString("ObjectId") ?? string.Empty).ToLowerInvariant();
|
||||
var loginAt = entity.GetDateTime("LoginAt") ?? DateTime.MinValue;
|
||||
if (!result.TryGetValue((tid, oid), out var entry))
|
||||
{
|
||||
entry = new UserSignInIdentity { TenantId = tid, ObjectId = oid };
|
||||
result[(tid, oid)] = entry;
|
||||
}
|
||||
entry.LoginCount++;
|
||||
if (loginAt > entry.LastLoginAt)
|
||||
entry.LastLoginAt = loginAt;
|
||||
}
|
||||
return result.Values.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets user activity metrics for a specific tenant
|
||||
/// </summary>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -92,6 +92,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private readonly IConfigBackupRepository _backupRepo;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly OpsEventService _opsEventService;
|
||||
private readonly ILogger<TenantConfigPatchService> _logger;
|
||||
|
||||
public TenantConfigPatchService(
|
||||
@@ -99,12 +100,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
IConfigBackupRepository backupRepo,
|
||||
TenantConfigurationService configService,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
OpsEventService opsEventService,
|
||||
ILogger<TenantConfigPatchService> logger)
|
||||
{
|
||||
_configRepo = configRepo;
|
||||
_backupRepo = backupRepo;
|
||||
_configService = configService;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_opsEventService = opsEventService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -421,6 +424,15 @@ namespace AutopilotMonitor.Functions.Services
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
tenantId, auditAction, "TenantConfiguration", tenantId, updatedBy, auditDetails);
|
||||
|
||||
// Operator visibility for the Collect Logs capability flip — the verified re-read is
|
||||
// the truth of what landed, so detect on it rather than on the candidate.
|
||||
var diagChange = DiagnosticsUploadConfigChange.Detect(initial, reread.Value.Config);
|
||||
if (diagChange != null)
|
||||
{
|
||||
await _opsEventService.RecordDiagnosticsUploadConfigChangedAsync(
|
||||
tenantId, reread.Value.Config.DomainName, diagChange, updatedBy, source);
|
||||
}
|
||||
|
||||
var applied = expected.OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
return new PatchOutcome(true, PatchFailure.None, null, backupId, applied, maskedDiff, 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>
|
||||
|
||||
@@ -76,6 +76,12 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
Task<(int uniqueUsers, int loginCount)> GetUserActivityForDateAsync(string? tenantId, DateTime date);
|
||||
/// <summary>Retention cleanup: deletes UserActivity login rows whose LoginAt is older than the cutoff. Returns the number deleted.</summary>
|
||||
Task<int> DeleteUserActivityOlderThanAsync(DateTime cutoffUtc);
|
||||
/// <summary>
|
||||
/// Every distinct (tenant id, object id) a UPN has signed in with, from the UserActivity login rows
|
||||
/// (cross-partition scan filtered on Upn — grant-time only, never a hot path). Both values came from
|
||||
/// validated tokens, which is what makes them usable as an identity-binding source.
|
||||
/// </summary>
|
||||
Task<List<UserSignInIdentity>> GetSignInIdentitiesByUpnAsync(string upn);
|
||||
|
||||
// --- Live Presence (one row per user, upserted) ---
|
||||
/// <summary>Upserts the caller's presence row (PK=tenantId, RK=SHA-256(lowercase UPN) hex) with LastSeen=now.</summary>
|
||||
@@ -148,6 +154,15 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
public string AppName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>One (tenant, object id) a UPN was seen signing in with, plus the most recent such sign-in.</summary>
|
||||
public class UserSignInIdentity
|
||||
{
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
public string ObjectId { get; set; } = string.Empty;
|
||||
public DateTime LastLoginAt { get; set; }
|
||||
public int LoginCount { get; set; }
|
||||
}
|
||||
|
||||
public class UserActivityMetrics
|
||||
{
|
||||
/// <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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
|
||||
import { isGuid, type IdentityBinding } from "@/lib/identityBinding";
|
||||
|
||||
interface IdentityBindingsSectionProps {
|
||||
tenantId: string;
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
setError: (error: string | null) => void;
|
||||
setSuccessMessage: (message: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsed-by-default view of the identity bindings HOMED in this tenant: every UPN that holds a
|
||||
* cross-tenant role (platform GlobalAdmin/GlobalReader, delegated MSP grants) and signs in from here,
|
||||
* with the Entra tenant + object id the role is bound to. Maintained automatically (resolved from sign-in
|
||||
* history at grant time, object id pinned on the first sign-in); this is the inspection / correction
|
||||
* surface so a re-created account or a re-homed UPN never needs a table edit.
|
||||
*/
|
||||
export function IdentityBindingsSection({
|
||||
tenantId,
|
||||
getAccessToken,
|
||||
setError,
|
||||
setSuccessMessage,
|
||||
}: IdentityBindingsSectionProps) {
|
||||
const canMutate = useCanMutatePlatform();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [bindings, setBindings] = useState<IdentityBinding[]>([]);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [editTenantId, setEditTenantId] = useState("");
|
||||
const [editObjectId, setEditObjectId] = useState("");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const fail = useCallback(
|
||||
(err: unknown, fallback: string) => {
|
||||
if (err instanceof TokenExpiredError) setError(err.message);
|
||||
else setError(err instanceof Error ? err.message : fallback);
|
||||
},
|
||||
[setError],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch(api.identityBindings.list(), getAccessToken);
|
||||
if (!response.ok) throw new Error(`Failed to load identity bindings: ${response.statusText}`);
|
||||
const data = await response.json();
|
||||
const all: IdentityBinding[] = data.bindings ?? [];
|
||||
setBindings(
|
||||
all
|
||||
.filter((b) => b.tenantId.toLowerCase() === tenantId.toLowerCase())
|
||||
.sort((a, b) => a.upn.localeCompare(b.upn)),
|
||||
);
|
||||
setLoaded(true);
|
||||
} catch (err) {
|
||||
fail(err, "Failed to load identity bindings");
|
||||
}
|
||||
}, [tenantId, getAccessToken, fail]);
|
||||
|
||||
// Lazy: the list is fetched the first time the panel is opened (event-driven, no effect).
|
||||
const toggle = () => {
|
||||
const next = !open;
|
||||
setOpen(next);
|
||||
if (next && !loaded) void load();
|
||||
};
|
||||
|
||||
const startEdit = (b: IdentityBinding) => {
|
||||
setEditing(b.upn);
|
||||
setEditTenantId(b.tenantId);
|
||||
setEditObjectId(b.objectId);
|
||||
};
|
||||
|
||||
const save = async (upn: string) => {
|
||||
const tid = editTenantId.trim();
|
||||
const oid = editObjectId.trim();
|
||||
if (!isGuid(tid) || (oid !== "" && !isGuid(oid))) return;
|
||||
try {
|
||||
setBusy(upn);
|
||||
setError(null);
|
||||
const response = await authenticatedFetch(api.identityBindings.put(upn), getAccessToken, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ homeTenantId: tid, objectId: oid || undefined }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Failed to update binding: ${response.statusText}`);
|
||||
}
|
||||
setSuccessMessage(`Identity binding for ${upn} updated.`);
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
fail(err, "Failed to update binding");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (upn: string) => {
|
||||
if (!confirm(`Remove the identity binding for ${upn}? Every platform/delegated role of this UPN stops working until it is granted again.`)) return;
|
||||
try {
|
||||
setBusy(upn);
|
||||
setError(null);
|
||||
const response = await authenticatedFetch(api.identityBindings.remove(upn), getAccessToken, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Failed to remove binding: ${response.statusText}`);
|
||||
}
|
||||
setSuccessMessage(`Identity binding for ${upn} removed.`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
fail(err, "Failed to remove binding");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const editValid = isGuid(editTenantId) && (editObjectId.trim() === "" || isGuid(editObjectId));
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-left"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="font-semibold text-gray-900">
|
||||
Identity bindings
|
||||
{loaded && <span className="ml-2 text-xs font-normal text-gray-500">{bindings.length}</span>}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-500 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-4 pb-4 space-y-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
Cross-tenant roles (platform admins, delegated MSP admins) are bound to the Entra tenant and account they
|
||||
were granted for. Bindings are maintained automatically; correct one here only if an account was re-created.
|
||||
</p>
|
||||
{!loaded ? (
|
||||
<p className="text-sm text-gray-400">Loading…</p>
|
||||
) : bindings.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No cross-tenant role holders are homed in this tenant.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg bg-white">
|
||||
{bindings.map((b) => (
|
||||
<li key={b.upn} className="px-3 py-2 text-sm">
|
||||
{editing === b.upn ? (
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-gray-900 break-all">{b.upn}</div>
|
||||
<input
|
||||
type="text"
|
||||
value={editTenantId}
|
||||
onChange={(e) => setEditTenantId(e.target.value)}
|
||||
placeholder="Home tenant ID (GUID)"
|
||||
spellCheck={false}
|
||||
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm font-mono text-gray-900 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editObjectId}
|
||||
onChange={(e) => setEditObjectId(e.target.value)}
|
||||
placeholder="Object ID (GUID) — leave empty to re-pin on the next sign-in"
|
||||
spellCheck={false}
|
||||
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm font-mono text-gray-900 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => save(b.upn)}
|
||||
disabled={!editValid || busy === b.upn}
|
||||
className="px-3 py-1 text-sm bg-sky-600 text-white rounded-lg hover:bg-sky-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{busy === b.upn ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditing(null)}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="font-medium text-gray-900 break-all">{b.upn}</span>
|
||||
<span className="text-xs font-mono text-gray-500">
|
||||
{b.isObjectIdPinned ? `oid ${b.objectId}` : "object id pinned on next sign-in"}
|
||||
</span>
|
||||
{canMutate && (
|
||||
<span className="ml-auto flex gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => startEdit(b)}
|
||||
disabled={busy === b.upn}
|
||||
className="text-xs text-sky-700 hover:text-sky-900 disabled:opacity-50"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => remove(b.upn)}
|
||||
disabled={busy === b.upn}
|
||||
className="text-xs text-red-600 hover:text-red-800 disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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",
|
||||
@@ -156,6 +160,17 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
|
||||
"WelcomeEmailSent",
|
||||
"WelcomeEmailSkipped",
|
||||
"WelcomeEmailFailed",
|
||||
// A tenant admin turned on-demand diagnostics upload (the Collect Logs capability) on or
|
||||
// off — portal Settings, the Collect Logs quick-config dialog, MCP update_tenant_config,
|
||||
// or a config revert. Fires on the flip only. Backend
|
||||
// RecordDiagnosticsUploadConfigChangedAsync. Dual-register per memory
|
||||
// feedback_ops_event_types_dual_register.
|
||||
"DiagnosticsUploadEnabled",
|
||||
"DiagnosticsUploadDisabled",
|
||||
// Someone clicked Collect Logs on a session and switched on Hosted diagnostics upload
|
||||
// through the quick-config dialog (PUT ?intent=collect-logs). Replaces
|
||||
// DiagnosticsUploadEnabled for that path only — the targeted push signal.
|
||||
"CollectLogsQuickConfigEnabled",
|
||||
],
|
||||
Agent: [
|
||||
"BlobStorageMissing",
|
||||
@@ -178,6 +193,11 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
|
||||
// EventIngestProcessor (RecordCmTraceTimeSkewRegressionAsync). Dual-register per memory
|
||||
// feedback_ops_event_types_dual_register.
|
||||
"CmTraceTimeSkewRegression",
|
||||
// A portal user queued a server action for a live session — Collect Logs
|
||||
// (request_diagnostics), quick-config (rotate_config) or terminate_session. Emitted by
|
||||
// QueueSessionActionFunction (RecordSessionActionQueuedAsync); rule-engine actions are
|
||||
// not included. Dual-register per memory feedback_ops_event_types_dual_register.
|
||||
"SessionActionQueued",
|
||||
],
|
||||
Platform: [
|
||||
// Azure Monitor alerts relayed through POST ops/alert-webhook (Common Alert Schema →
|
||||
|
||||
@@ -8,6 +8,7 @@ import { classifyClientId, legacyConfigured } from "@/lib/authApp";
|
||||
import { appHomingErrorMessage } from "@/lib/appHoming";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import { TenantAdminSection } from "./TenantAdminSection";
|
||||
import { IdentityBindingsSection } from "./IdentityBindingsSection";
|
||||
import { AppHomingConfirmDialog } from "./AppHomingConfirmDialog";
|
||||
import { OffboardTenantConfirmDialog } from "./OffboardTenantConfirmDialog";
|
||||
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
|
||||
@@ -914,6 +915,14 @@ function TenantManagementSectionInner({
|
||||
setSuccessMessage={setSuccessMessage}
|
||||
/>
|
||||
|
||||
{/* Identity bindings homed in this tenant (cross-tenant role holders) — collapsed, inspect/correct only */}
|
||||
<IdentityBindingsSection
|
||||
tenantId={editingTenant.tenantId}
|
||||
getAccessToken={getAccessToken}
|
||||
setError={setError}
|
||||
setSuccessMessage={setSuccessMessage}
|
||||
/>
|
||||
|
||||
{/* Preview Notification Email */}
|
||||
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-indigo-900 mb-3">Preview Notification Email</h3>
|
||||
|
||||
+32
-50
@@ -6,7 +6,7 @@ import { useNotifications } from "@/contexts/NotificationContext";
|
||||
import { useTenantList } from "@/hooks/useTenantList";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { api } from "@/lib/api";
|
||||
import { bindingLabel, bindingsByUpn, isGuid, type IdentityBinding } from "@/lib/identityBinding";
|
||||
import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding";
|
||||
|
||||
/** One delegated-admin assignment as returned by /api/global/delegated-admins (camelCase JSON). */
|
||||
interface DelegatedAssignment {
|
||||
@@ -37,7 +37,6 @@ export function SectionDelegatedAdmins() {
|
||||
const tenants = useTenantList(true);
|
||||
|
||||
const [assignments, setAssignments] = useState<DelegatedAssignment[]>([]);
|
||||
const [bindings, setBindings] = useState<IdentityBinding[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
@@ -45,8 +44,10 @@ export function SectionDelegatedAdmins() {
|
||||
const [newUpn, setNewUpn] = useState("");
|
||||
const [newTenantId, setNewTenantId] = useState("");
|
||||
const [newRole, setNewRole] = useState("DelegatedReader");
|
||||
const [newHomeTenantId, setNewHomeTenantId] = useState("");
|
||||
const [newObjectId, setNewObjectId] = useState("");
|
||||
// Only shown after the backend answered 422 HomeTenantUnresolved (UPN never signed in AND its domain
|
||||
// belongs to no onboarded tenant) — the one case the identity cannot be resolved automatically.
|
||||
const [needHomeTenant, setNeedHomeTenant] = useState(false);
|
||||
const [homeTenantPick, setHomeTenantPick] = useState("");
|
||||
const [granting, setGranting] = useState(false);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
|
||||
@@ -75,7 +76,6 @@ export function SectionDelegatedAdmins() {
|
||||
if (!response.ok) throw new Error(`Failed to load delegated admins: ${response.statusText}`);
|
||||
const data = await response.json();
|
||||
setAssignments(data.assignments ?? []);
|
||||
setBindings(data.bindings ?? []);
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to load delegated admins");
|
||||
} finally {
|
||||
@@ -91,13 +91,9 @@ export function SectionDelegatedAdmins() {
|
||||
void run();
|
||||
}, [fetchAssignments]);
|
||||
|
||||
const bindingOf = useMemo(() => bindingsByUpn(bindings), [bindings]);
|
||||
const homeTenantValid = isGuid(newHomeTenantId);
|
||||
const objectIdValid = newObjectId.trim() === "" || isGuid(newObjectId);
|
||||
|
||||
const handleGrant = useCallback(async () => {
|
||||
const upn = newUpn.trim();
|
||||
if (!upn || !newTenantId || !homeTenantValid || !objectIdValid) return;
|
||||
if (!upn || !newTenantId || (needHomeTenant && !homeTenantPick)) return;
|
||||
try {
|
||||
setGranting(true);
|
||||
setError(null);
|
||||
@@ -108,12 +104,14 @@ export function SectionDelegatedAdmins() {
|
||||
upn,
|
||||
tenantId: newTenantId,
|
||||
role: newRole,
|
||||
homeTenantId: newHomeTenantId.trim(),
|
||||
objectId: newObjectId.trim() || undefined,
|
||||
homeTenantId: needHomeTenant ? homeTenantPick : undefined,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (response.status === 422 && data.code === HOME_TENANT_UNRESOLVED) {
|
||||
setNeedHomeTenant(true);
|
||||
}
|
||||
throw new Error(data.error || `Failed to grant: ${response.statusText}`);
|
||||
}
|
||||
const dom = domainOf(newTenantId);
|
||||
@@ -121,8 +119,8 @@ export function SectionDelegatedAdmins() {
|
||||
setNewUpn("");
|
||||
setNewTenantId("");
|
||||
setNewRole("DelegatedReader");
|
||||
setNewHomeTenantId("");
|
||||
setNewObjectId("");
|
||||
setNeedHomeTenant(false);
|
||||
setHomeTenantPick("");
|
||||
await fetchAssignments();
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to grant");
|
||||
@@ -130,7 +128,7 @@ export function SectionDelegatedAdmins() {
|
||||
setGranting(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [newUpn, newTenantId, newRole, newHomeTenantId, newObjectId, homeTenantValid, objectIdValid, getAccessToken, fetchAssignments, domainOf]);
|
||||
}, [newUpn, newTenantId, newRole, needHomeTenant, homeTenantPick, getAccessToken, fetchAssignments, domainOf]);
|
||||
|
||||
const handleToggle = useCallback(async (a: DelegatedAssignment) => {
|
||||
const key = `${a.upn}|${a.tenantId}`;
|
||||
@@ -264,33 +262,24 @@ export function SectionDelegatedAdmins() {
|
||||
<option value="DelegatedReader">{ROLE_LABELS.DelegatedReader}</option>
|
||||
<option value="DelegatedAdmin">{ROLE_LABELS.DelegatedAdmin}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
value={newHomeTenantId}
|
||||
onChange={(e) => setNewHomeTenantId(e.target.value)}
|
||||
placeholder="Home tenant ID (GUID) — the Entra tenant they sign in from"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`flex-1 min-w-0 px-4 py-2 border rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors ${
|
||||
newHomeTenantId && !homeTenantValid ? "border-red-300" : "border-gray-300"
|
||||
}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newObjectId}
|
||||
onChange={(e) => setNewObjectId(e.target.value)}
|
||||
placeholder="Object ID (GUID, optional — pinned on first sign-in)"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`flex-1 min-w-0 px-4 py-2 border rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors ${
|
||||
newObjectId && !objectIdValid ? "border-red-300" : "border-gray-300"
|
||||
}`}
|
||||
/>
|
||||
{needHomeTenant && (
|
||||
<select
|
||||
value={homeTenantPick}
|
||||
onChange={(e) => setHomeTenantPick(e.target.value)}
|
||||
className="flex-1 min-w-0 px-3 py-2 border border-amber-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
|
||||
aria-label="Home tenant of the person"
|
||||
>
|
||||
<option value="">Home tenant they sign in from…</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.tenantId} value={t.tenantId}>
|
||||
{t.domainName || t.tenantId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
onClick={handleGrant}
|
||||
disabled={granting || !newUpn.trim() || !newTenantId || !homeTenantValid || !objectIdValid}
|
||||
disabled={granting || !newUpn.trim() || !newTenantId || (needHomeTenant && !homeTenantPick)}
|
||||
className="px-6 py-2 bg-sky-600 text-white rounded-lg hover:bg-sky-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center space-x-2"
|
||||
>
|
||||
{granting ? (
|
||||
@@ -305,8 +294,7 @@ export function SectionDelegatedAdmins() {
|
||||
</div>
|
||||
<p className="text-xs text-sky-700">
|
||||
Reader is read-only (secrets redacted). The tenant must be onboarded to appear in the list above.
|
||||
The grant only works from the person’s home tenant and account: a sign-in with the same UPN from
|
||||
another tenant or another account is refused. Leave the object ID empty to pin it on their first sign-in.
|
||||
The grant is bound to the person’s own tenant and account automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -330,17 +318,11 @@ export function SectionDelegatedAdmins() {
|
||||
<div className="space-y-4">
|
||||
{grouped.map(([upn, rows]) => (
|
||||
<div key={upn} className="border border-gray-200 rounded-lg">
|
||||
<div className="px-4 py-2 border-b border-gray-100 bg-gray-50 rounded-t-lg flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<div className="px-4 py-2 border-b border-gray-100 bg-gray-50 rounded-t-lg">
|
||||
<span className="font-medium text-gray-900">{upn}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
{rows.length} tenant{rows.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs border border-gray-300 text-gray-600"
|
||||
title={bindingOf.get(upn.toLowerCase())?.tenantId ?? "No identity binding"}
|
||||
>
|
||||
{bindingLabel(bindingOf.get(upn.toLowerCase()), domainOf)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{rows.map((a) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useNotifications } from "@/contexts/NotificationContext";
|
||||
import { useTenantList } from "@/hooks/useTenantList";
|
||||
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { api } from "@/lib/api";
|
||||
import { bindingLabel, bindingsByUpn, isGuid, type IdentityBinding } from "@/lib/identityBinding";
|
||||
import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding";
|
||||
|
||||
/** One UPN assigned to a group (camelCase JSON from the backend). */
|
||||
interface GroupAssignee {
|
||||
@@ -43,7 +43,6 @@ export function SectionTenantGroups() {
|
||||
const tenants = useTenantList(true);
|
||||
|
||||
const [groups, setGroups] = useState<TenantGroup[]>([]);
|
||||
const [bindings, setBindings] = useState<IdentityBinding[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
@@ -56,8 +55,10 @@ export function SectionTenantGroups() {
|
||||
const [tenantToAdd, setTenantToAdd] = useState<Record<string, string>>({});
|
||||
const [assignUpn, setAssignUpn] = useState<Record<string, string>>({});
|
||||
const [assignRole, setAssignRole] = useState<Record<string, string>>({});
|
||||
const [assignHomeTenantId, setAssignHomeTenantId] = useState<Record<string, string>>({});
|
||||
const [assignObjectId, setAssignObjectId] = useState<Record<string, string>>({});
|
||||
// Per-group: shown only after a 422 HomeTenantUnresolved (UPN never signed in AND its domain belongs to
|
||||
// no onboarded tenant) — the one case the identity binding cannot be resolved automatically.
|
||||
const [needHomeTenant, setNeedHomeTenant] = useState<Record<string, boolean>>({});
|
||||
const [homeTenantPick, setHomeTenantPick] = useState<Record<string, string>>({});
|
||||
|
||||
const domainOf = useMemo(() => {
|
||||
const map = new Map(tenants.map((t) => [t.tenantId.toLowerCase(), t.domainName]));
|
||||
@@ -84,7 +85,6 @@ export function SectionTenantGroups() {
|
||||
if (!response.ok) throw new Error(`Failed to load groups: ${response.statusText}`);
|
||||
const data = await response.json();
|
||||
setGroups(data.groups ?? []);
|
||||
setBindings(data.bindings ?? []);
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to load groups");
|
||||
} finally {
|
||||
@@ -114,6 +114,9 @@ export function SectionTenantGroups() {
|
||||
const response = await authenticatedFetch(url, getAccessToken, init);
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (response.status === 422 && data.code === HOME_TENANT_UNRESOLVED && key.startsWith("assign:")) {
|
||||
setNeedHomeTenant((p) => ({ ...p, [key.slice("assign:".length)]: true }));
|
||||
}
|
||||
throw new Error(data.error || `Request failed: ${response.statusText}`);
|
||||
}
|
||||
flash(ok);
|
||||
@@ -188,19 +191,11 @@ export function SectionTenantGroups() {
|
||||
[mutate, domainOf],
|
||||
);
|
||||
|
||||
const bindingOf = useMemo(() => bindingsByUpn(bindings), [bindings]);
|
||||
|
||||
/** True when the per-group assign form is complete: UPN, GUID home tenant, and an empty-or-GUID object id. */
|
||||
/** True when the per-group assign form is complete (UPN, plus the home tenant if the backend asked for it). */
|
||||
const canAssign = useCallback(
|
||||
(groupId: string) => {
|
||||
const objectId = (assignObjectId[groupId] || "").trim();
|
||||
return (
|
||||
!!(assignUpn[groupId] || "").trim() &&
|
||||
isGuid(assignHomeTenantId[groupId] || "") &&
|
||||
(objectId === "" || isGuid(objectId))
|
||||
);
|
||||
},
|
||||
[assignUpn, assignHomeTenantId, assignObjectId],
|
||||
(groupId: string) =>
|
||||
!!(assignUpn[groupId] || "").trim() && (!needHomeTenant[groupId] || !!homeTenantPick[groupId]),
|
||||
[assignUpn, needHomeTenant, homeTenantPick],
|
||||
);
|
||||
|
||||
const handleAssign = useCallback(
|
||||
@@ -215,18 +210,17 @@ export function SectionTenantGroups() {
|
||||
{
|
||||
upn,
|
||||
role,
|
||||
homeTenantId: (assignHomeTenantId[t.groupId] || "").trim(),
|
||||
objectId: (assignObjectId[t.groupId] || "").trim() || undefined,
|
||||
homeTenantId: needHomeTenant[t.groupId] ? homeTenantPick[t.groupId] : undefined,
|
||||
},
|
||||
`Assigned ${upn} to "${t.name}".`,
|
||||
);
|
||||
if (ok) {
|
||||
setAssignUpn((prev) => ({ ...prev, [t.groupId]: "" }));
|
||||
setAssignHomeTenantId((prev) => ({ ...prev, [t.groupId]: "" }));
|
||||
setAssignObjectId((prev) => ({ ...prev, [t.groupId]: "" }));
|
||||
setNeedHomeTenant((prev) => ({ ...prev, [t.groupId]: false }));
|
||||
setHomeTenantPick((prev) => ({ ...prev, [t.groupId]: "" }));
|
||||
}
|
||||
},
|
||||
[assignUpn, assignRole, assignHomeTenantId, assignObjectId, canAssign, mutate],
|
||||
[assignUpn, assignRole, needHomeTenant, homeTenantPick, canAssign, mutate],
|
||||
);
|
||||
|
||||
const handleUnassign = useCallback(
|
||||
@@ -432,12 +426,6 @@ export function SectionTenantGroups() {
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-sky-100 text-sky-800">
|
||||
{ROLE_LABELS[a.role] ?? a.role}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs border border-gray-300 text-gray-600"
|
||||
title={bindingOf.get(a.upn.toLowerCase())?.tenantId ?? "No identity binding"}
|
||||
>
|
||||
{bindingLabel(bindingOf.get(a.upn.toLowerCase()), domainOf)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleUnassign(t, a.upn)}
|
||||
disabled={busyKey === `unassign:${t.groupId}:${a.upn}`}
|
||||
@@ -481,26 +469,21 @@ export function SectionTenantGroups() {
|
||||
Assign
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={assignHomeTenantId[t.groupId] || ""}
|
||||
onChange={(e) => setAssignHomeTenantId((p) => ({ ...p, [t.groupId]: e.target.value }))}
|
||||
placeholder="Home tenant ID (GUID) — the Entra tenant they sign in from"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="w-full sm:flex-1 min-w-0 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={assignObjectId[t.groupId] || ""}
|
||||
onChange={(e) => setAssignObjectId((p) => ({ ...p, [t.groupId]: e.target.value }))}
|
||||
placeholder="Object ID (optional — pinned on first sign-in)"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="w-full sm:flex-1 min-w-0 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{needHomeTenant[t.groupId] && (
|
||||
<select
|
||||
value={homeTenantPick[t.groupId] || ""}
|
||||
onChange={(e) => setHomeTenantPick((p) => ({ ...p, [t.groupId]: e.target.value }))}
|
||||
className="w-full px-2 py-1.5 border border-amber-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
|
||||
aria-label="Home tenant of the person"
|
||||
>
|
||||
<option value="">Home tenant they sign in from…</option>
|
||||
{tenants.map((tn) => (
|
||||
<option key={tn.tenantId} value={tn.tenantId}>
|
||||
{tn.domainName || tn.tenantId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function CollectLogsButton({
|
||||
: config.diagnosticsUploadMode,
|
||||
};
|
||||
|
||||
const putRes = await authenticatedFetch(api.config.tenant(effectiveTenantId), getAccessToken, {
|
||||
const putRes = await authenticatedFetch(api.config.tenantCollectLogsQuickConfig(effectiveTenantId), getAccessToken, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updated),
|
||||
|
||||
@@ -132,6 +132,10 @@ export const api = {
|
||||
config: {
|
||||
all: () => `${API_BASE_URL}/api/config/all`,
|
||||
tenant: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}`,
|
||||
// Same PUT, labelled: the session-detail Collect Logs quick-config dialog. The backend
|
||||
// maps the intent to write source "portal-collect-logs" and emits the dedicated
|
||||
// CollectLogsQuickConfigEnabled ops event instead of the generic DiagnosticsUploadEnabled.
|
||||
tenantCollectLogsQuickConfig: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}?intent=collect-logs`,
|
||||
/**
|
||||
* PATCH — transactional field-level config write (TenantAdminOrGA, own row):
|
||||
* { fields: { <wireFieldName>: value | null, ... }, reason?: string }. The backend
|
||||
|
||||
@@ -1,33 +1,25 @@
|
||||
/**
|
||||
* The Entra identity (home tenant + object id) a cross-tenant-role UPN is bound to, as returned by
|
||||
* /api/global/delegated-admins, /api/global/tenant-groups and /api/global/identity-bindings (camelCase JSON).
|
||||
* A grant is only usable from this identity: the backend refuses tokens whose tid/oid do not match.
|
||||
* /api/global/identity-bindings (camelCase JSON). A grant is only usable from this identity: the backend
|
||||
* refuses tokens whose tid/oid do not match. Maintained automatically — resolved from sign-in history at
|
||||
* grant time, object id pinned on the first sign-in.
|
||||
*/
|
||||
export interface IdentityBinding {
|
||||
upn: string;
|
||||
tenantId: string;
|
||||
objectId: string; // "" until pinned (grant without objectId ⇒ pinned on the person's first sign-in)
|
||||
objectId: string; // "" until pinned
|
||||
boundBy: string;
|
||||
boundAt: string;
|
||||
objectIdPinnedAt: string | null;
|
||||
isObjectIdPinned: boolean;
|
||||
}
|
||||
|
||||
/** Error code the grant endpoints return (HTTP 422) when the person's home tenant could not be resolved. */
|
||||
export const HOME_TENANT_UNRESOLVED = "HomeTenantUnresolved";
|
||||
|
||||
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** True for a canonical GUID string (what Entra tenant and object ids look like). */
|
||||
export function isGuid(value: string): boolean {
|
||||
return GUID_RE.test(value.trim());
|
||||
}
|
||||
|
||||
/** Indexes a bindings list by lowercase UPN. */
|
||||
export function bindingsByUpn(bindings: IdentityBinding[] | undefined): Map<string, IdentityBinding> {
|
||||
return new Map((bindings ?? []).map((b) => [b.upn.toLowerCase(), b]));
|
||||
}
|
||||
|
||||
/** Short human label for a binding's state, for the per-UPN context pill. */
|
||||
export function bindingLabel(b: IdentityBinding | undefined, domainOf: (tenantId: string) => string): string {
|
||||
if (!b) return "No identity binding — grant is inert";
|
||||
const home = domainOf(b.tenantId) || `tenant …${b.tenantId.slice(-4)}`;
|
||||
return b.isObjectIdPinned ? `${home} · object id pinned` : `${home} · object id pinned on first sign-in`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user