TenantConfiguration.Disabled/DisabledUntil was checked solely in AuthFunction.BuildAuthResult
(GET /api/auth/me), so a direct REST caller with a valid token kept full read/write access for
the whole suspension window and during an offboarding wipe. DecideAsync now denies 403
TenantSuspended when the caller's home tenant or the request's target tenant is currently
disabled (cached, side-effect-free config read). GA/GlobalReader bypass to administer the
tenant; device/anonymous routes keep their SecurityValidator gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Agent endpoints authenticated per tenant but authorized session writes on a caller-supplied
session id. Sessions rows now carry the creating device identity (cert thumbprint + Intune
device id from the CN, or bootstrap code + serial) in primary-only Owner* columns.
SessionOwnershipPolicy evaluates register / telemetry / error-report requests against it;
SessionOwnerBindingObserver records the outcome on the request row, as a warning and as a
throttled SessionOwnerMismatch ops event. Nothing is rejected in this stage.
Agent: errorCode parsing on 401/403, one-shot session rotation on session_owner_mismatch
without feeding the auth-failure tracker, spool drop, agent_started.sessionRotated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GlobalAdmins, DelegatedAdmins and TenantGroupAssignments resolved their role from the
upn/preferred_username claim alone while the API accepts tokens from every Entra tenant, so a
genuine token minted in a foreign tenant (re-registered domain) or by a recycled UPN in the home
tenant inherited platform admin.
- New AdminIdentityBindings table: RK = UPN -> home TenantId + ObjectId; in TableNames.All,
CriticalBackupTables and the full-restore-forbidden auth set; offboarding wipes bindings homed
in the offboarded tenant
- AdminIdentity (upn + tid + oid) is what GlobalAdminService.GetGlobalRoleAsync and
DelegatedAdminService.GetScopeAsync resolve on; order row -> binding -> Pro gate, fail-closed
(no oid / no binding / tid or oid mismatch => no role), mismatches logged at Warning
- Object id pinned on the first sign-in from the bound tenant (ETag-conditional, verified against
the stored winner); a foreign-tenant token can never claim the pin
- Grants (auth/global-admins, global/delegated-admins, tenant-group assignees) require
homeTenantId (+ optional objectId) and answer 409 instead of re-homing a bound UPN;
GET/PUT/DELETE global/identity-bindings is the audited rebind surface
- RequestContext.ObjectId; throttle identity prefers oid so shared UPN strings across tenants no
longer collide in one rate-limit bucket
- Web: home tenant / object id fields on the grant and assign forms, per-UPN binding pill
- Tests: binding verdict matrix + race semantics, middleware-level denials for foreign-tenant and
recycled-UPN tokens; OKF doc docs/backend/admin-identity-binding.md
Legacy role rows are inert until their binding is seeded - seed before deploying the backend.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post-deploy verification of 249e39bc found zero AgentCertTenantBinding rows
across 32k agent requests. Cause: the Match outcome was emitted with
LogInformation, and worker-side ILogger below Warning never reaches App
Insights (provider default rule is Warning+, host forwarding of worker logs
is off). The bulk outcome was therefore invisible, which is indistinguishable
from the code never running — the shadow telemetry had no denominator.
Stamp every outcome onto the request row instead (FunctionContext.Items ->
RequestTelemetryMiddleware -> CertTenantBinding dimension). That row already
exists per request and is unsampled, so the denominator costs no extra
telemetry. Non-Match outcomes keep their Warning trace with full context.
FromCache existed only to sample the dead Match line and is removed with it.
Backend 4606/4606, 0 warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
True parallel operation, no forced re-consent: existing tenants keep signing
in via the legacy app (1a400946), new tenants land on the C4A8 app (886ab5e2).
Model "default legacy, funnel primary, learn in the background":
- Web: am_auth_app flag -> NEXT_PUBLIC_ENTRA_DEFAULT_APP -> primary; get-started
CTA hands over with ?authapp=primary; auth/me homedApp updates the flag so an
operator tenant flip takes effect on each user's NEXT login; AADSTS90094/65001
one-shot cross-app retry, AADSTS65004 -> failed screen with previous-app link;
am_login_attempt pins redirect returns to the app that started them.
- Backend: TenantConfiguration.HomedAppClientId (null = legacy, GA-only flip via
PUT config) + LastAuthClientId/Since observability; EntraAppRegistry resolves
the acting app per tenant (no-op until the legacy config pair is set); Graph
client-credentials, admin-consent URLs and the surfaced client id follow the
homed app (app-scoped cache keys); JwtBearer [Authorize] pipeline now shares
the middleware audience builders (was single-audience - would 401 legacy
tokens after the swap); middleware trusts EntraId:LegacyClientId implicitly.
- well-known association file now lists both app ids (publisher domain);
deploy-web passes NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID / _DEFAULT_APP;
mcp-server.bicep default client id -> C4A8 app.
Backend xUnit 3730/3730, vitest 625/625 (13 new), tsc/ESLint clean, next build ok.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The portal's Next.js /go/[code] route was the web app's only request-time
server code and blocked the planned static export. The script generation
now lives in the Function App as GET /api/bootstrap/go/{code}
(GetBootstrapScriptFunction), reached by customers as
irm https://go.autopilotmonitor.com/{code} | iex via a Front Door custom
domain whose /* route rewrites onto the API path.
- New Constants.BootstrapGoBaseUrl; CreateBootstrapSessionFunction now
hands out the go URL (previously unguarded — CreateBootstrapSessionUrlShapeTests
pins it now). go host added to both URL-guard enforced lists.
- BootstrapScriptValueValidator ports the bootstrapValidation.ts injection
invariants (exact GUIDs, https + host allowlist + /agent/*.zip path,
expiry <= 14d) as defense-in-depth for in-process values.
- OobeBootstrapScriptGenerator ports the PS template as a Replace-token
raw string; golden test pins byte parity with the TS template
(TestData/oobe-bootstrap-golden.ps1, captured from route.ts).
- Always-HTTP-200 text/plain contract incl. rate limit (own bucket
bootstrap-script:{ip} 20/min); disabled tenant indistinguishable from
unknown code. Catalog: PublicAnonymous; NoStoreCacheMiddleware covers
/api/bootstrap/go/ (script inlines the bearer token).
- Web: BOOTSTRAP_GO_URL registry entry, sessions list shows the go host,
legacy route marked LEGACY (alive for pre-migration URLs <= 168h,
removal in the static-export PR).
- Docs: new OKF backend/oobe-bootstrap-go-flow.md, url-registry go
section, index/log entries.
Backend tests 3611/3611, web vitest 651/651, tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- New EndpointPolicy.TenantAdminOrOperator (Admin/Operator/GA, no Viewer) with
middleware evaluator; POST sessions/{sessionId}/actions moves to this tier so
troubleshooting operators can queue request_diagnostics ("Collect Logs").
- QueueSessionActionFunction re-gates per type: terminate_session and
rotate_config stay Admin/GA-only (403 for operators).
- GET config/{tenantId}/feature-flags exposes diagnosticsUploadConfigured so
members below Admin can see whether an on-demand upload would work.
- Offboarding cascade: hosted diagnostics deletion now sweeps the session
prefix {tenantId}/AgentDiagnostics-{sessionId}- (plus the manifest-named
blob) so on-demand packages overwritten on the Sessions row by the terminal
package no longer survive deletion. CustomerSas branch unchanged.
- Notification dismiss routes deliberately NOT migrated: dismissal is still
tenant-shared; operators would clear admin-tier notifications they cannot see.
Tests: middleware role matrix on the actions route, per-type gate matrix,
feature-flag matrix, prefix-sweep + real hosted-branch cascade tests.
Backend suite 3253/3253.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every well-known host -- own subdomains, both blob accounts, the API origin,
Graph, Entra login -- now lives in exactly one registry per component:
Constants.cs (*BaseUrl) for C#, utils/config.ts for the web app, src/config.ts
for the MCP server. lib/hostRouting.ts derives its hostnames from the registry
and middleware.ts imports from it, retiring what turned out to be a fourth
independent copy of the www/portal host pair.
Three new guard tests (backend xUnit, web vitest, MCP vitest) scan non-test
source and fail on any enforced host outside its registry. This is the lesson
of the EU cutover: the registry existed then too, but LatestVersionsService
(feeds the portal's version badge) and ValidateBootstrapCodeFunction carried
their own literal copies of the legacy blob host and were missed -- kept
working only by the fail-soft legacy mirror in build-agent.yml, whose silent
failure would have pinned badge and self-update to stale bits with a green
release. Both guards proved themselves on their first run, flagging the
middleware host copy and a literal in an MCP tool description. Test files stay
exempt on purpose: literals there are independent oracles, and
ValidateBootstrapCodeResponseShapeTests now pins the full alias URL against
the Constants-built one so registry changes must be acknowledged consciously.
The two legacy readers plus the agent SelfUpdater now use
Constants.AgentDownloadBaseUrl (download.autopilotmonitor.com/agent); the
alias was verified live for version.json, version-v2.json and both ZIPs
before the switch. The portal's bootstrap allow-list accepts the alias AND
the legacy host (AGENT_DOWNLOAD_HOSTNAMES) for already-deployed customer
scripts, which imposes a deploy order: web before or with backend. The
legacy keepalive probes and the release mirror stay untouched -- they exist
for exactly this transition. Also: the bootstrap short-link now targets www
directly instead of riding the registrar's apex 301, and the welcome-mail
docs button points at docs. instead of bouncing through www./docs.
Suites: backend 3194, agent core 1925, DecisionCore 474, web 582, MCP 449 --
all green; next build verified (it, not tsc, proves the next.config.ts
registry import). Docs: docs/url-registry.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CI test job surfaced 10 annotations, but GitHub caps them at 10 per job —
a full rebuild showed 31 nullable warnings plus 26 xUnit analyzer warnings.
Production code:
- CertificateValidator: X509Certificate2(byte[]) is obsolete (SYSLIB0057) ->
X509CertificateLoader.LoadCertificate.
- AuthenticationMiddleware: IsValidTenantId now carries [NotNullWhen(true)], so
the guard above BuildTenantAuthority is visible to flow analysis.
- AgentEndpointMigrationRules: the out params are genuinely nullable on the
false path; netstandard2.0 has no usable [NotNullWhen], so the contract is
documented instead.
- Constants.ClassifyEspAppsFailure: errorCode is optional by design (the
"no HRESULT" branch is the timeout fallback) - signature says so now.
Tests: nullable annotations on helper signatures, Assert.Single/DoesNotContain
overloads instead of pre-filtering with Where, and [InlineData(null)] theories
declare nullable parameters. GatherRule helpers now seed Parameters with the
model default (empty) rather than null, matching a deserialized rule.
No behaviour change. 5616 tests pass; solution builds warning-free apart from
the two pre-existing NU1510 pruning notices on the pinned security packages.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to 479247cd. Publishing only a genuine UPN closed the fleet-wide bucket
collapse but opened a narrower hole: AuthenticationMiddleware validates issuer,
audience, lifetime and signature but requires neither upn nor scp, so an app-only
(client-credentials) token authenticates and reaches every AuthenticatedUser route
(feedback, realtime/negotiate, progress/*, health/detailed, auth/mcp, ...). Such a
token carries no upn, so it resolved to an empty identity and UserRateLimitMiddleware
skipped it entirely — previously it at least landed in the shared anonymous bucket.
Rather than reinstating a placeholder as a bucket key, separate the two concepts:
- RequestContext.UserPrincipalName keeps its meaning — a real UPN or nothing. Audit,
presence and admin flows continue to see only genuine user identities.
- RequestContext.CallerId is the throttle identity: UPN, else the token subject
(oid -> sub -> appid/azp), else a fail-closed shared bucket. Empty means exactly one
thing — no JWT at all (device/anonymous routes, which carry their own limits).
- UserRateLimitMiddleware gates and buckets on CallerId.
This matches how McpQuotaEnforcementMiddleware already keys its quota (oid), so the
subject-based identity is the existing house convention for enforcement.
10 new tests: the oid/sub/appid/azp fallback chain and its precedence, the fail-closed
bucket for a token with no identifying claim (asserting it is never "anonymous"), and
that empty CallerId occurs only on unauthenticated routes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Device and anonymous routes published the "anonymous" placeholder as
RequestContext.UserPrincipalName. UserRateLimitMiddleware skips only on an EMPTY UPN, so
every agent of every tenant was bucketed under user_ratelimit_anonymous and throttled
against the 120/min per-user limit. On 2026-07-21 21:00-21:30 UTC that rejected 1821
agent requests with 429 (/api/agent/telemetry, config, register-session, error); the
7-day trend shows it recurring and growing with fleet size.
- PolicyEnforcementMiddleware: RequestContext.UserPrincipalName carries a genuine JWT
UPN or nothing. decision.UserIdentifier keeps the placeholder for log lines.
- Device/bootstrap routes already hold their own limit in SecurityValidator (per cert
thumbprint / per bootstrap token), so they lose nothing.
- stats/platform and diagnostics/download had NO limiter of their own and were riding on
the accidental shared bucket. Both now limit per trusted client IP (60/min and 30/min)
via ClientIpExtractor, matching the HealthCheck pattern.
- 13 new tests pin the empty-UPN contract across all 12 device/anonymous routes, plus a
guard that an authenticated caller still surfaces its real UPN.
Solution suite 5552 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bucket B (real fixes) + Bucket A (config) of the CodeQL alert triage.
Log-forging (cs/log-forging, 8 sinks): add Helpers/LogSanitizer that strips
CR/LF and other control characters, and apply it to the logged copy of every
user-controlled value in AuthenticationMiddleware, PolicyEnforcementMiddleware
(Invoke + DecideAsync), McpQuotaEnforcementMiddleware and TableUserUsageRepository.
Only the logged value is sanitized — routing, authorization and storage keys are
untouched, so tracing fidelity for legitimate values (paths, UPNs, GUIDs) is
preserved. Structured logging already isolated the fields; this closes the CRLF
vector as defense-in-depth and clears the query.
Three genuine JS/TS fixes:
- guardValidation.ts: replace the no-op `.replace(/%/g, "%")` with a real
escapeRegExp() — the author intended to escape; this also closes the latent
metacharacter gap (js/identity-replacement).
- access-guard.ts: use a `%s` format argument instead of interpolating the UPN
into the console.error format string (js/tainted-format-string).
- docs-corpus.ts: strip HTML tags with a loop-until-stable stripHtmlTags() so the
sanitizer is robust and recognized as complete (js/incomplete-multi-char-sanitization).
CodeQL config: filter the two queries that fire structurally on this codebase,
where the flagged behavior is the feature — cs/exposure-of-sensitive-information
(audit/notification logs legitimately record the actor UPN / recipient email) and
cs/cleartext-storage-of-sensitive-information (rate limiters key on the trusted
client IP). Default suites stay active; genuine one-off false positives from other
queries are dismissed individually so those queries stay sharp.
Tests: Backend xUnit 3110 (+9 LogSanitizer), MCP vitest 19 (+1 tag-strip),
Web vitest 49, all tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expanding the token audience trust set via EntraId:AdditionalClientIds was
completely silent: no format validation, no operator-visible logging. During
the tenant-move window a typo'd GUID would be indistinguishable from "the new
app registration doesn't work" (daily-review follow-up, 2026-07-14).
- ResolveConfiguredClientIds: additional entries must parse as GUIDs; malformed
entries are dropped (fail-closed - they could never match a token aud) and
reported via out param. Accepted entries are normalized to the canonical
lowercase dashed GUID form so brace/uppercase paste variants still match the
ordinal audience comparison. Primary ClientId handling stays verbatim.
- AuditClientIdTrustSet: one Warning per distinct config value per instance for
(a) dropped malformed entries (truncated - never log a full pasted value in
case it is a secret) and (b) trust-set expansion with the accepted ID list.
Warning level because nothing below Warning reaches App Insights.
- Tests: existing ResolveConfiguredClientIds tests moved to GUID values
(signature change, no compat alias) + rejection, primary-verbatim,
normalization and TruncateForLog coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Make JWT audience validation accept the primary EntraId:ClientId PLUS an
optional comma/semicolon/space-separated EntraId:AdditionalClientIds. Each
client ID expands to {id} and api://{id}; the set is trimmed, empties dropped,
and de-duplicated case-insensitively (fail-closed on empty input).
This is the seam that lets the API trust a second/rotated app registration
during a future Entra app-reg tenant move: adding a client ID to
EntraId__AdditionalClientIds is the only backend step needed. When the setting
is unset the accepted audiences are exactly the primary id — today's behaviour,
zero change for production.
BuildTokenValidationParameters now takes params string?[] clientIds (backward
compatible), with two pure, unit-tested helpers ResolveConfiguredClientIds and
BuildValidAudiences. +12 tests (multi-audience accept, primary still valid,
foreign audience rejected, parsing/dedupe/fail-closed). 64/64 auth tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two security-critical paths had zero unit coverage because the logic was inlined
behind infrastructure (a real X509Store / a live OIDC ConfigurationManager +
HttpContext). Extract the decision logic into pure, testable seams — no
behavioural change — and pin it.
CertificateHelper (Agent):
- Extract SelectMdmCertificate(IEnumerable<X509Certificate2>): the issuer
discrimination (CN=Microsoft Intune MDM Device CA only, never the MMP-C CA),
ClientAuth-EKU filter and NotAfter tiebreaker. FindMdmCertificate now calls it.
- 13 tests incl. the dual-stack regression (issuer filter beats the NotAfter
tiebreaker so a longer-lived MMP-C cert can't win over a genuine MDM cert).
AuthenticationMiddleware (Backend):
- Extract BuildTokenValidationParameters(signingKeys, clientId), IsValidTenantId(tid)
and BuildTenantAuthority(issuer, tid). Invoke() now composes them.
- 16 tests validating locally-minted tokens against a test key: alg:none and HS256
rejected (algorithm-confusion defence), expired / wrong-audience / foreign-issuer
/ unknown-signing-key rejected, valid RS256 + api:// audience + v1 issuer accepted,
plus the tid GUID gate and v1/v2 authority mapping.
Backend 2746/2746 and Agent 1615/1615 green; full solution builds clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex follow-up: the previous middleware comment claimed a deterministic limit+1 block, but McpQuotaService caches the per-user decision for 60s - an allowed decision keeps admitting inside that window even after the async increments cross the limit. The soft boundary IS the intended design (bounded overshoot ~TTL x request rate per instance, same posture as the sliding-window rate limiter; one counter read per user per minute instead of per request).
- Middleware doc rewritten: check-then-increment fixes the race/inflation, but the boundary is explicitly documented as SOFT with bounded overshoot; warns against re-documenting as exact without reworking the decision cache.
- Two regression tests pin the contract: cached allowed decision keeps admitting within the TTL even after counters cross the limit (no storage re-read), and after cache expiry (simulated via eviction) the over-limit counters block.
Backend 2658/2658 green.
- High: TenantConfigurationService.SaveConfigurationAsync now THROWS when the repository reports a failed persist (it swallows storage exceptions and returns false) - plan/trial/config writes can no longer be audited and returned as 200 without being persisted. Cache is invalidated on every attempted save (finally), covering in-place mutations of the cached instance; the now-redundant invalidate-on-failure wrapper in PlanManagementFunction is removed. AuthFunction login side-effect saves (first-login domain write, auto-re-enable) are wrapped best-effort so a storage blip cannot 500 auth/me - they self-gate and retry on next login.
- Medium: MCP usage increment moved from AuthenticationMiddleware (fire-and-forget BEFORE the quota decision) into McpQuotaEnforcementMiddleware: strictly check-then-increment and only for requests actually served (GA tracked but quota-exempt; denied 403/429 requests no longer inflate counters; the exact-limit request is now deterministic - 1..limit pass, limit+1 blocks).
- Low: fixed both new nullable warnings (PlanTierDefinitionParser guarded json, McpQuotaEnforcementMiddleware principal null-flow).
Tests: +3 save-semantics regression tests (throw on false, cache invalidation on failed/successful save). Backend 2656/2656 green, build clean.
Fail-closed entitlement layer (FeatureEntitlementCatalog + TenantEntitlementService): edition resolves at read time (PlanTier == 'enterprise' OR active trial; legacy free/pro => Community, no migration).
- Plan/trial endpoints: PATCH config/{tid}/plan (GA: tier + grant/extend/end trial, audited, cache-safe) and POST config/{tid}/trial (tenant-admin self-service 30d, once per tenant, 409 after). Generic PUT preserves plan/trial fields for all callers. Fixes SetPlanTier writing past the config cache without audit/UpdatedBy.
- Rate limits: Enterprise floor 150/min for BOTH user (portal/JWT) and device (agent/cert) paths - floor raises the global default, an explicit per-tenant override still wins; MSP users follow their home tenant.
- Retention cap: Community 90d / Enterprise 365d, clamped in the retention fanout (warn log) + non-GA write validation 7..cap (only on changed values; 0=infinite stays GA-only).
- Delegated admin (MSP): Enterprise gate on the delegated admin's HOME tenant (JWT tid, fail-closed on unknown) - managed target tenants may be any edition. Grant-time target check removed; inert grants are suppressed at resolve time (single choke point in GetScopeAsync, applied after the UPN-keyed cache).
- MCP quota: McpQuotaService + McpQuotaEnforcementMiddleware (X-Client-Source: mcp only, GA exempt) enforce daily/monthly budgets - per-user plan override > tenant edition plan; limits from SectionUsagePlans with catalog fallbacks (community 100/3000, enterprise 1000/20000); 429 with X-MCP-Quota-* headers + Retry-After; fail-open on counter reads, fail-closed on plan resolution. /api/metrics/mcp-usage/me reports effective plan + counters.
- Trial visibility: TrialExpirySweepFunction (daily 03:30 UTC, informational) emits TenantTrialExpired (Warning, 24h look-back) + TenantTrialExpiring (Info, <=3d heads-up); dual-registered in web OPS_EVENT_TYPES.
- Web: EditionBadge + one-time trial CTA in settings header, Plan & Trial card in the GA tenant edit modal (own PATCH save), retention cap hints from entitlements, pure lib/edition.ts (vitest), feature-flags surface edition/trial/entitlements; MCP TENANT_SAFE_FIELDS + trial fields.
Verified: solution build clean; backend 2653/2653 tests; web tsc + 516/516 vitest; MCP server tsc + tests green.
Replace the denormalized per-tenant RateLimitRequestsPerMinute mirror (and its
SaveConfigurationAsync sync job) with compute-at-read resolution: effective limit =
per-tenant override ?? global default. Fixes the clobbering bug where a per-tenant
edit to the base field was overwritten on the next global config save.
- Model: drop TenantConfiguration.RateLimitRequestsPerMinute; add nullable
CustomUserRateLimitRequestsPerMinute (device override CustomRateLimit... kept).
- RateLimitResolver centralizes precedence (device + user); both paths call it.
- SecurityValidator reads the global default (AdminConfigurationService threaded
through ValidateSecurityAsync + all device-auth callers); 5-min cached.
- UserRateLimitMiddleware: per-tenant user override for tenant-scoped callers only
(skipped for HasGlobalScope = GlobalAdmin|GlobalReader); GA -> GA budget, others ->
user default. Uses GetConfigurationIfExistsAsync so an unregistered-tenant JWT can
no longer auto-create a config row via read endpoints.
- Validation: reject rate limits < 1 at the write functions; RateLimitService clamps
< 1 to 1 as a safety net (avoids Min()-on-empty / throttle-everything).
- Web: Global Settings exposes User + GlobalAdmin limits; tenant modal writes the
override fields (blank = inherit); report + usage-plans updated.
Legacy RateLimitRequestsPerMinute Table column is ignored on read and dropped on the
next Replace save (self-cleaning; no migration).
Backend 2577 xUnit green, tsc clean, web 506 vitest green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The V1 agent line is retired: no new V1 sessions since 2026-05-09, only
orphaned straggler traffic remained on /api/agent/ingest (last seen
2026-06-29, events for long-cleaned-up sessions). Bootstrap/Intune/CI
have deployed V2-only since the v2 cutover.
Agent:
- Delete AutopilotMonitor.Agent, .Agent.Core, .Agent.Core.Tests
(projects + sln entries). V2 trio, SummaryDialog, DecisionCore.Tests
and V2Isolation.targets stay.
Backend:
- Relocate the shared statics/DTOs V2 reused from the V1 partials
(StampServerFields, SanitizeEventTimestamps, IsMaxLifetimeAgentShutdown,
RecalculateAppDurations, EventClassification, AppInstallAggregationState)
into EventIngestProcessor; IngestEventsOutput moves next to
IngestTelemetryFunction (same namespace, zero ripple).
- Delete IngestEventsFunction (route agent/ingest), BootstrapIngestEvents
(route bootstrap/ingest), NdjsonParser and their DI registration.
EventDataNormalizer stays (used by TelemetryPayloadParser).
- Drop both routes from EndpointAccessPolicyCatalog and
RequestTelemetryMiddleware.TenantHeaderTrustedPaths.
- Retire the V1 update-hash oracle: LatestAgentV1*/LatestBootstrapV1*
fields + GetAgentLine case 1 (default arm returns empty hashes, so
stragglers gracefully skip the integrity check; ParseAgentMajor keeps
its default of 1 on purpose — defaulting to 2 would feed V2 hashes to
V1 binaries). TableConfigRepository Store+Map updated in lockstep.
- Remove ApiEndpoints.IngestEvents/.BootstrapIngestEvents constants.
Tests: NdjsonParserTests deleted; timestamp/critical-path/max-lifetime
tests repointed to EventIngestProcessor; the two NDJSON pipeline tests
removed (coverage retained by direct-list siblings); catalog/no-store
InlineData rows updated.
Docs: architecture.md (endpoint tables, ingest flow diagram, solution
layout, test matrix), web SectionAgentInternals endpoint tables,
build.ps1 help text.
Azure: no config change needed — /api/agent is not in the client-cert
exclusion paths (agent/ingest was always cert-enforced) and the
/api/bootstrap prefix exclusion remains for the surviving bootstrap
routes (verified via ARM on autopilotmonitor-api).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The delegated rescue treats every GlobalReadOrAdmin + QueryParam/RouteParam route
as delegated-reachable, which had silently opened several platform-OPERATIONAL
global reads to delegated ("MSP") admins. Those should be Global Admin + read-only
Global Reader only - the platform operator's view, not the managed tenant's data.
- global/ops-events -> TenantScoping.None (Consent/Maintenance/Security/Tenant/
Agent/SLA - operator data). Its in-memory FilterByTenant is reframed as a
GA/Reader ?tenantId= drill (correctness, NOT a delegated-isolation boundary)
and extracted internal-static for direct test coverage.
- New EndpointPolicyEntry.ExcludeDelegated flag: the None-equivalent for routes
whose {tenantId} template forces RouteParam (the {tenantId}-convention test
forbids None there). The middleware ANDs !ExcludeDelegated into the delegated
rescue, so it can only RESTRICT access, never grant it. Applied to:
- global/customs-archive (+ the two drill reads)
- global/tenants/{tenantId}/deletion-manifests
- preview/notification-email/{tenantId}
GA + read-only Global Reader keep all of these.
Full reclassification of every delegated-reachable global/* route: customer
telemetry (sessions / metrics / apps / rules / audit-logs / mcp-usage / ...) stays
delegated-reachable; platform-operational + offboarding reads are now excluded.
Tests: Delegated_{OpsEvents,CustomsArchive,DeletionManifests,PreviewNotificationEmail}_*
+ GlobalReader_*_Allowed + catalog Theory PlatformOperationalReads_ExcludeDelegated.
Backend filtered tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make a delegated ("MSP") admin a "scoped Global Reader": the same pages a Global
Admin sees, but every tenant switcher/filter is bounded to the managed allow-list.
Reader path only (readWrite deferred). MCP stays read-only (Phase 6 already covers
delegated reads).
Backend (bounded aggregate — reuses the GA per-tenant fan-out):
- global/sessions + global/stats/sessions: GlobalReadOrAdmin -> GlobalReadOrDelegatedSubset
(keep QueryParam). A delegated caller WITHOUT ?tenantId= is admitted as a bounded
aggregate; the handler restricts the cross-tenant fan-out to RequestContext.AllowedTenantIds.
With ?tenantId=X it drills into one managed tenant (middleware validates X in scope).
- TableStorageService: GetAllSessions{,Page,Stats}Async gain an optional allowedTenantIds
set; FetchAllSessionsPageInternalAsync swaps its Step-1 tenant source to that subset
(everything downstream — per-tenant query, inverted-tick merge, cursor — is reused).
- IsDelegatedReadTier += GlobalReadOrDelegatedSubset so the single-tenant drill rescue fires.
- SignalR AddToGroup/RemoveFromGroup: admit a delegated caller to a managed tenant-{id}
group (in scope); global-admins stays platform-scope-only.
Web:
- useAggregatedAdminScope / useGlobalAdminScope: cross-tenant mode is now on for a delegated
admin too (isGlobalAdmin = crossTenant; new isDelegatedScope). Tenant list bounded to the
managed subset; always a concrete tenant (no "All tenants" aggregate on analytics pages).
- TenantScopeSelector hides the aggregate option for delegated; GlobalAdminBanner gains a
blue "Delegated Admin View" variant (wired on all analytics pages + dashboard).
- Nav: Fleet group exposes Dashboard + Fleet Health + Software + Geographic + SLA + Usage;
showDashboard now includes delegated.
- Dashboard delegated-ready: crossTenant drives the /global endpoints + filter UI; empty
filter = bounded aggregate over managed tenants, a selected tenant = drill-in; global-admins
SignalR join stays real-GA-only; read-only (mutations already GA-gated). `?tenant=` deep-link
seeds the filter.
- Fleet is now a pure stats card grid; the per-tenant session browser /fleet/[tenantId] is
removed and cards link to /dashboard?tenant=<id>.
Deferred follow-ups: Audit Log + Geographic session drill-in for delegated (both diverge from
the scope-hook pattern / need tenantId threading).
Tests: backend 2484 green (catalog/middleware contract updated for the new subset tier),
web 481 green, MCP 292 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduces the GlobalReadOrDelegatedSubset policy tier — a cross-tenant READ that
is BOUNDABLE to a subset — and applies it to config/all (the tenant list). A
delegated ("MSP") admin can now list ITS managed tenants in one call, bounded to
its allowed set; a Global Admin / Reader still gets all tenants (unbounded).
This is the server-side linchpin for the MSP fleet view + tenant switcher: the
frontend gets the managed-tenant list (id + domain) here, then builds the
per-tenant fleet grid by fanning out over that subset using the Phase-2a
single-tenant /api/global/*?tenantId= endpoints (subset fan-out, client-orchestrated
over already-bounded per-tenant reads — no all-tenants code path for delegated).
- Middleware: new EvaluateGlobalReadOrDelegatedSubsetAsync — GA/Reader admitted
unbounded; a delegated caller with a non-empty scope admitted with its managed
set carried on the decision and published as RequestContext.AllowedTenantIds.
DecideAsync merges this into the delegated flags + AllowedTenantIds.
- GetAllTenantConfigurationsFunction: when AllowedTenantIds is present, BIND the
response to that subset (both the bare-array and paginated shapes) and always
redact secrets (a delegated caller is never a Global Admin). The subset is small,
so it is returned in one shot.
- Catalog: config/all -> GlobalReadOrDelegatedSubset. Aggregates that cannot be
bounded stay GlobalReadOrAdmin + None (delegated-unreachable).
- Tests: delegated config/all admitted with bounded AllowedTenantIds; GA unbounded
(null bound); non-delegated non-global denied; tier-assignment guard. Full suite green (2464).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lets a delegated ("MSP") admin read its managed tenants through the cross-tenant
/api/global/* endpoints — but ONLY on the single-tenant ?tenantId=X path, bounded
by the middleware to a tenant in its allowed set. This is what makes the tenant
SWITCHER work end-to-end (drill into a managed tenant, see its normal dashboard).
The dangerous no-tenantId AGGREGATE path (fan-out over ALL tenants) stays
GA/Reader-only and is untouched here; bounded fleet aggregation is a separate step.
- Middleware: GlobalReadOrAdmin added to the delegated read tier. The existing
`isScopedRoute && crossTenant` guard means a delegated grant still fires ONLY on
a tenant-scoped route for an in-scope target — so a delegated caller reaches a
global endpoint only via its single-tenant path, never the aggregate path.
- Catalog: the 27 GlobalReadOrAdmin routes whose handlers strictly restrict to the
named ?tenantId= (verified by an exhaustive per-endpoint audit) recataloged
None -> QueryParam. QueryParam/RouteParam on a GlobalReadOrAdmin route now
formally means "handler restricts to the named tenant => delegated-reachable";
aggregate-only routes (presence, platform metrics, distress-reports, config/all,
…) stay None and remain unreachable by delegated callers. Documented inline.
- Tests: delegated single-tenant global read allowed in-scope / denied out-of-scope
/ denied on the no-tenantId aggregate path; an aggregate-only route stays blocked
even with an in-scope ?tenantId=; Global Reader aggregate path regression; and a
catalog-contract theory pinning the SAFE(QueryParam) vs AGGREGATE-ONLY(None) split.
Updated GlobalAppsRoutes completeness assertion to QueryParam. Full suite green (2461).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses a Codex review finding: the normal policy evaluator runs before the
delegated rescue, so an external MSP user's first delegated cross-tenant read
still touches the own-home-tenant role path. That path called
TenantConfigurationService.GetConfigurationAsync, which AUTO-CREATES and persists
a default TenantConfiguration row for a missing tenant — writing a phantom config
row for the MSP's (possibly non-onboarded) home tenant.
Authorization role resolution must be side-effect-free. ResolveEffectiveRoleAsync
now uses the existing non-creating TryGetConfigurationAsync (returns the in-memory
default without persisting). A missing config means EntraAppRolesEnabled = false
either way, so the resolved role is unchanged — only the phantom write is gone.
This also removes the same latent write for ANY authenticated non-member.
Regression test: "delegated read does not create config for MSP home tenant"
(verifies SaveTenantConfigurationAsync is never called). Full Functions suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the delegated ("scoped global" / MSP) tier into the auth middleware. A
delegated admin signs into their OWN home tenant but may now READ a SUBSET of
tenants — exactly the tenants in its DelegatedAdmins scope — without holding
full platform (GlobalAdmin/GlobalReader) scope.
Mechanism (PolicyEnforcementMiddleware.DecideAsync):
- Resolve the target tenant up-front (ResolveTarget), then resolve delegated
scope LAZILY — only when the caller lacks full platform scope AND the route is
a cross-tenant, tenant-scoped route. Keeps the common authenticated path free
of the extra (cached) table read. Service errors still fail closed to 503.
- A delegated grant applies ONLY to read tiers {MemberRead, TenantAdminOrGlobalReader}
on RouteParam/QueryParam routes, and ONLY for a target in the caller's scope.
It rescues a tier-denial for a delegated-only caller and lifts the cross-tenant
block for that target. Write tiers never admit a delegated caller (even a
DelegatedAdmin row grants read-only this phase), so no read->write escalation.
- Aggregate /api/global/* endpoints (GlobalReadOrAdmin) fan out over ALL tenants
and are deliberately NOT reachable by delegated callers until Phase 2 bounds
them to AllowedTenantIds.
RequestContext gains IsDelegatedReader/IsDelegatedAdmin/IsDelegated, HasFleetScope
and AllowedTenantIds (the delegated subset, for Phase 2 aggregation).
Secret-leak hardening: GetTenantConfigurationFunction now redacts by default —
only a Global Admin or the tenant's OWN admin sees unredacted secrets
(CanViewSecrets, extracted as a testable static). Behavior is identical for all
pre-existing roles; it closes the path where a cross-tenant delegated/global
reader would otherwise receive SAS/webhook secrets.
12 new tests (delegated allow/deny matrix + CanViewSecrets clearance). Full
Functions suite green aside from two pre-existing timing-flaky tests (unrelated
code, pass in isolation).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex review #7 (MEDIUM): AuthenticationMiddleware read the unvalidated
`tid` claim and built a tenant-specific OIDC authority before signature
validation. A garbage tid would drive an outbound metadata fetch to a bogus
authority - a DoS/cost surface on malformed-token floods (not an auth bypass;
signature/audience/issuer are still validated afterward).
- Guard `tid` with Guid.TryParse before constructing the authority; missing/
non-GUID throws SecurityTokenValidationException -> 401, no fetch. Loss-free:
every legitimate Entra token (incl. consumer MSA 9188040d-...) has a GUID tid.
- Drop the now-unreachable null -> common/v2.0 fallback.
- Pass context.CancellationToken into GetConfigurationAsync (was None).
Negative cache for invalid/unreachable authorities deferred (LRU already
bounds memory; revisit only if floods observed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Global-Admin cross-tenant view of web users active "now" (made an authenticated
request within a window, default 5min), backed by a new self-maintaining
UserPresence table. Not App Insights (sampling) / not SignalR.
Backend:
- UserPresence table (one row/user, PK=tenantId, RK=SHA-256(lowercase UPN));
upserted fire-and-forget by UserPresenceMiddleware (after PolicyEnforcement,
60s per-process throttle, UPN-gated so agent cert calls are skipped).
- GET /api/global/presence (GlobalReadOrAdmin) via GetActiveUsersFunction; read
path propagates storage errors (5xx) instead of masking them as "0 active".
- 1-day presence retention + 90-day UserActivity retention in maintenance;
PlatformStats.TotalUsers clamped monotonic so the public "Users Seen" figure
cannot regress after a prune.
Web:
- /admin/presence page (live counter + table), nav entry under GA > Metrics.
- "Total Users" relabeled "Unique Users (90d)" to match the windowed metric.
Backend+web only, no ConfigVersion bump, inert until redeploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ticket-gated diagnostics/download route (PublicAnonymous, MCP session_debug)
401'd on the deployed backend: AuthenticationMiddleware kept its OWN hardcoded
_anonymousRoutes allowlist, parallel to EndpointAccessPolicyCatalog, and the new
route was added only to the catalog. Since AuthenticationMiddleware runs before
PolicyEnforcementMiddleware, the missing entry rejected the request with a JWT
401 before the PublicAnonymous policy was ever honored. Classic hand-synced
parallel-catalog drift.
Delete _anonymousRoutes / _anonymousPrefixes and derive the JWT-exempt decision
from the catalog: a route skips JWT iff its policy is PublicAnonymous or
DeviceOrBootstrapAuth (authenticated in-function via cert/bootstrap token, not
JWT). Now method-aware and fail-closed on unregistered routes. Behavior-
preserving: the old 14 routes + 1 prefix equal exactly the catalog's
PublicAnonymous u DeviceOrBootstrapAuth set.
Add drift guard in EndpointPolicyCatalogCompletenessTests:
AnonymousAndDeviceRoutes_AreJwtExemptInAuthMiddleware loops every such catalog
entry and asserts the middleware exempts it (would have caught this bug);
SkipsJwtValidation_MatchesPolicyTier locks the positive/negative tiers incl. the
diagnostics/download vs diagnostics/download-url contrast. Full suite 2393 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lets tenant-admins and GAs run end-to-end session debugging over MCP: backend
telemetry plus the agent diagnostics ZIP, downloaded and analyzed locally by the
AI client. The backend never unzips/parses the archive (no parse CPU); it only
mints a short-lived signed download ticket and proxy-streams bytes.
Backend (no ConfigVersion bump, no agent change):
- DiagnosticsDownloadTicket: HMAC codec (reuses PaginationTokenSigningKey,
purpose-tagged diag-dl-v1, 10-min TTL) so a no-JWT client can download.
- DiagnosticsBlobStreamer: shared proxy-stream engine; DiagnosticsDownloadFunction
(web/JWT) refactored onto it. ClassifyBlobName/BlobDestination stay put (xUnit).
- POST diagnostics/download-ticket (MemberRead, QueryParam, GA-bypass) +
GET diagnostics/download?t= (anonymous; tenantId/blobName read only from the
signed ticket -> no retargeting). Both added to NoStoreCacheMiddleware.
MCP:
- get_session_diagnostics tool (all roles) -> absolute downloadUrl + static zipMap
+ instructions; unwraps the {success,session} envelope.
- diag_zip_layout catalog (get_resource + protocol resource).
- debug-session prompt (tenant + GA variants).
- Tool descriptions flag the client requirement (needs local file/shell tools;
pure chat clients cannot unzip).
Tests: backend 2380/2380 (ticket codec, NoStore, classify, catalog completeness);
MCP 261 passed (incl. envelope-unwrap regression). Inert until redeploy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GlobalReader = cross-tenant READ visibility like a Global Admin, but no mutating
operations and no platform settings. Provisioned exactly like a GA: a GlobalAdmins
table row with Role=GlobalReader (empty/legacy Role = GlobalAdmin, back-compat).
Semantics are ADDITIVE — a user who is also their own tenant's Admin keeps edit
rights on that tenant; a pure GlobalReader is read-only everywhere.
Backend:
- GlobalAdmins.Role column + GlobalAdminService.GetGlobalRoleAsync; IsGlobalAdminAsync
(incl. TableAdminRepository) delegates so a GlobalReader row never reads back as admin
- RequestContext.IsGlobalReader + HasGlobalScope
- Split GlobalAdminOnly into GlobalReadOrAdmin (cross-tenant reads) vs GlobalAdminOnly
(writes/settings) + TenantAdminOrGlobalReader for config-read
- PolicyEnforcementMiddleware: extracted testable DecideAsync seam; cross-tenant gate keys
off HasGlobalScope; additive own-tenant role carried so IsTenantAdmin stays correct
- Config secret redaction for readers (RedactedCopyForReader) + save-time placeholder
restore (RestoreRedactedSecretsFrom) so a redacted view can't blank real secrets
- raw table/log tools stay GlobalAdminOnly; SignalR global-admins read group admits HasGlobalScope
- /auth/me emits isGlobalReader (+ preview-gate bypass, no auto-admin); /auth/mcp emits globalRole
MCP: hasGlobalScope (GA or Reader) drives routing + tool catalog; raw secret-bearing tools
(query_table/list_tables/query_backend_logs) gated strict-GA only.
Web: AuthContext isGlobalReader + hasGlobalScope; reader reaches /dashboard + cross-tenant
views; platform Settings/Ops/Backups/Software GA-only (nav-hidden + requireGlobalAdmin route
layouts); mutating controls on mixed data pages disabled via useCanMutatePlatform; rules pages
read-only on foreign tenants; additive own-tenant edit preserved.
Tests: backend 2369, MCP 252, web 469 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two related fixes for the end-user Progress Portal experience:
Live updates / permissions: realtime/groups/join+leave were elevated to
MemberRead (regression c4dabeee), which 403'd roleless end users tracking
their own device - the page loaded initial status but never received live
eventStream pushes (looked frozen). Introduce a new EndpointPolicy tier
AuthenticatedUserWithRole that admits any authenticated caller but still
resolves IsGlobalAdmin/IsTenantAdmin/UserRole onto RequestContext, so the
SignalR functions can do per-group authorization. Real gating now lives in
SignalRAddToGroup/RemoveFromGroupFunction: tenant match, global-admins group
on GA, admin-tier notify group on TenantAdmin, member-tier notify group on
tenant membership. Plain AuthenticatedUser was rejected - it would leave the
role flags always-false and break GA/admin group joins.
Sidebar: hide the global Dashboard nav item for regular users (no
admin/operator access); they only see the Progress Portal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Grant tenant member roles via Entra ID app-role assignments on the
Enterprise App (the token's "roles" claim), in addition to the
TenantAdmins table. Per-tenant opt-in via the new GA-only
EntraAppRolesEnabled flag (backend-only, no ConfigVersion bump).
Resolution is table-first and tri-state (NotPresent/Disabled/Enabled):
an enabled table row wins, a disabled row is an explicit deny that
suppresses the claim entirely (a disabled member can never be
re-authorized through their app-role assignment), and only a missing
row falls back to the claim. Only Admin and Operator are claim-mappable
(Admin outranks Operator); Viewer and the platform GlobalAdmin role are
never claim-derived.
All effective-role consumers share one pure decision authority
(EntraAppRoleResolver.Resolve): PolicyEnforcementMiddleware, auth/me,
and feedback/status — the latter previously used a table-only check, so
Entra-only members did not see the feedback bubble.
GA toggle wired through the BootstrapToken pattern: TableConfigRepository
persistence, UpdateTenantConfigurationFunction GA-only protection, and a
checkbox in the admin tenant editor.
Tests: EntraAppRoleResolverTests (incl. disabled-row deny) + an
auto-promote regression guard; 2308/2308 backend green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Setting requestTelemetry.Context.Location.Ip never reached the requests
table: App Insights uses the IP only for geo-lookup then masks the stored
client_IP to 0.0.0.0 (verified — every request in the resource shows
0.0.0.0). On top of that, the only IP reaching the isolated worker is an
Azure-infra hop (West Europe / Amsterdam), not the device's real egress IP
(device geo = Falkenstein). A usable device IP would need a non-masked
custom property AND the correct forwarded header (Front-Door-topology
dependent) — not worth it: TenantId + CorrelationId already isolate a
device (they dissolved the IngestTelemetry "multiplication" illusion).
Remove the dead Context.Location.Ip block + now-unused Security using,
replace with a NOTE documenting why client_IP is intentionally absent so it
is not re-added. ClientIpExtractor.GetClientEgressIp stays (still used by
ReportDistressFunction's stored SourceIp, which is not App-Insights-masked).
Full backend suite 2249/2249 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Makes the requests table actually usable for diagnosing agent traffic and rate
limits, without adding any new telemetry items — only properties on the already-
emitted, sampling-excluded Request telemetry (host.json excludedTypes "Request;
Event"), consistent with StorageDependencyFilterProcessor.
RequestTelemetryMiddleware:
- client_IP: the isolated worker behind Front Door/App Service left it 0.0.0.0.
Set Context.Location.Ip from X-Azure-ClientIP (Front Door's true client egress
IP) else trusted rightmost X-Forwarded-For. Header values are joined via
StringValues.ToString() so the rightmost trusted hop wins (not a spoofable
left entry). Diagnostic only — the rate limiter independently uses the trusted
hop, so a spoofed value here cannot affect throttling.
- TenantId: agent ingest skips PolicyEnforcementMiddleware so reqCtx.TenantId was
empty. Fall back to X-Tenant-Id, but ONLY on an exact allowlist of device
endpoints that actually validate it (agent telemetry/ingest/error/distress,
bootstrap ingest/error) and only when it is a well-formed GUID — a prefix match
would let anonymous/management routes (bootstrap/validate, bootstrap/sessions,
agent/config, …) be tagged with a spoofed tenant.
RateLimitService: 429 warning is now structured ({RequestsInWindow}/{MaxRequests}
/{WindowSeconds}/{RateLimitKeyPrefix}) so it's queryable; privacy-safe (8-char
prefix only — the key may be a cert thumbprint, bootstrap token, or UPN).
ReportDistressFunction: store the real device egress IP in SourceIp (table entry
+ AgentDistressReport event) via new ClientIpExtractor.GetClientEgressIp; the
rate-limit key keeps the un-spoofable trusted hop.
Full backend suite 2249/2249 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After the MCP search tools moved to the paginated /api/raw/events fan-out
(prev commit), /api/raw/events/search had zero callers anywhere in the repo
(no MCP tool, no web). Remove the now-dead surface end to end:
- Delete SearchEventsFunction (raw/events/search + global/raw/events/search).
- Drop both routes from EndpointAccessPolicyCatalog and the no-store cache
middleware allowlist (+ its test InlineData). EndpointPolicyCatalog
completeness test stays green (route and entry removed together).
- Remove the now-orphaned repo method SearchEventsByTypesAsync (its only
caller was the deleted endpoint) from ISessionRepository + TableSessionRepository
delegation + TableStorageService impl.
- Remove the non-paginated SearchSessionsByEventAsync: its only runtime caller
was SearchEventsByTypesAsync, and it is strictly redundant with the resumable
SearchSessionsByEventPageAsync (used by /api/raw/events + search/sessions-by-event).
Reword the surviving paged method's xmldoc/comment that referenced it.
No capability lost: multi-type cross-session event search still runs via the
MCP fan-out over the paginated single-type endpoint; single-type primitives
remain exposed via query_raw_events + search_sessions_by_event. Full solution
builds clean (0 errors); catalog-completeness + cache-middleware tests 77/77.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the full backup pipeline that snapshots 15 critical tables (Admin/Tenant
config, rules, identities, audit, preview) into a logically-isolated blob
container, daily at 04:00 UTC and on operator demand. Restore endpoints follow
in PR2/PR3.
Architecture:
* Daily TimerTrigger + manual POST /api/global/backups/trigger (GA-only,
fail-hard producer). HTTP returns 202 + jobId on enqueue, 500 if the queue
write fails (and the BackupJobs row is rolled to Failed).
* BackgroundService queue worker (not QueueTrigger — matches the Analyze/
IndexReconcile pattern that avoids the Functions-host connection-binding
app-setting). Worker holds the maintenance lease for the whole run; service
is intentionally lease-unaware so it stays unit-testable.
* BlobBackupStore wraps the new 'critical-table-backups' container with a
shared maintenance-lease sentinel at '_lock/maintenance.lock' that
serialises every backup or restore op across worker/timer/single-row paths.
* CriticalTableBackupService streams each table through CryptoStream(SHA256)
into per-table NDJSON blobs, then writes manifest.json LAST as the
durability anchor. Per-table failures degrade Outcome to Partial without
aborting; per-run budget (50 min) → Skipped status.
* BackupJobsRepository persists the six-state machine
(Queued/Running/Completed/Failed/Skipped/BlockedTerminal) with ETag-CAS
updates. Enum→string mapping rejects unknown values on read so refactors
surface loudly instead of silently shifting state.
* BackupJobWatchdog (30 min timer) heals stuck rows via a probe lease on the
shared sentinel: Queued > 60 min → Failed, Running with stale heartbeat
AND free lease → Failed (lease-busy means a live worker — leave alone).
* OpsEvents: distinct CriticalTableBackupPartial (Warning) vs Failed (Error)
so operators can wire softer Telegram rules for partial captures.
* MaintenanceService.MonitoredPoisonQueues + OPS_EVENT_TYPES catalog
registered (dual-register memory).
Test coverage (15 new tests, 1952/1952 backend xUnit green):
* BackupManifestJsonTests: enum-as-string (allowIntegerValues=false rejects
numeric values), DictionaryKeyPolicy unset (Azure column names preserved
case-faithfully), manifest field round-trip.
* BackupJobsRepositoryMappingTests: full entity↔domain round-trip, unknown
Kind/State/BackupOutcome rejected, legacy BackupOutcome="Failed" rejected.
* CriticalTableBackupServiceTests: happy/partial/empty/budget-exceeded paths
exercise the per-table loop with in-memory table + blob fakes.
Setup: scripts/Backup/setup-backup-container.ps1 — idempotent az-CLI bootstrap
for container + lifecycle rule (90 d delete on prefix). Soft-Delete and
Versioning are NOT applied automatically (storage-account-wide blast radius
— operator must validate per separate doc note).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Post-deploy re-validation in the browser surfaced two issues that the
previous commit did not actually solve:
- Timing-Allow-Origin was still missing on every API response (curl
confirmed) and X-Correlation-ID was missing too — a preexisting bug.
Both middlewares used httpContext.Response.OnStarting() to attach the
header, but that callback is not reliably triggered in the .NET 8
isolated worker: the host bridges the worker's response to the wire,
so OnStarting fires on a shadow object that never reaches the client.
UserRateLimitMiddleware already writes X-RateLimit-* directly on
Response.Headers before calling next(), and those headers do come
through in production. Switching both header-emitting middlewares
to that pattern.
- /dashboard kept firing /api/config/<t>/feature-flags twice. The
earlier dep-stabilization on useTenantSecurityConfig was incomplete:
the effect fires once on initial render with user=null (deps
[tenantId, undefined, undefined, undefined]) and once after AuthContext
resolves user (deps [tenantId, bool, bool, role]) — two distinct dep
tuples, two fetches. The user=null path also bypassed the role gate
(the guard was `if (user && ...) return`). Adding `!user` to the
early-return at the top of the effect collapses to one fetch and
also drops the would-be 401 we were producing during the loading
window.
Backend tests 1058/1058, web vitest 249/249, tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Browser RUM on portal/dashboard surfaced ~1.7s time-to-data driven by
sequential backend reads, two stray duplicate fetches, and a 2s SignalR
defer. Five focused changes:
- AuthContext: store the auth/me prefetch as a Promise instead of its
resolved value so fetchUserInfo awaits the in-flight request rather
than racing a second fetch when the prefetch hasn't settled yet.
- useTenantSecurityConfig: depend on user.isTenantAdmin/isGlobalAdmin/
role primitives instead of the whole user object so the effect stops
refiring when AuthContext swaps prefetched user for fresh user (the
duplicate /config/<t>/feature-flags call observed on /dashboard).
- SignalRContext: drop the hardcoded 2000ms setTimeout before
startConnection. The original "avoid competing with critical API
calls" rationale doesn't apply across origins (web is HTTP/2, API is
a different host); the delay only pushed live updates ~2s into the
load for no benefit.
- FeedbackFunction: keep the kill-switch check sequential, then run the
remaining four Table reads (role, tenant config, sessions existence,
feedback cooldown) in parallel via Task.WhenAll. Endpoint dropped
from ~1285ms (6 sequential reads on Flex Consumption) toward the
slowest single read.
- TimingAllowOriginMiddleware: emit Timing-Allow-Origin: * on every
function response so the portal's Resource Timing API can read API
payload sizes (transferSize / nextHopProtocol) for our own RUM —
cross-origin entries currently report 0/unknown.
Backend tests 1058/1058, Web tsc clean, vitest 249/249.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Track MCP tool invocations in App Insights and UserUsageLog:
- Add ClientSource + McpToolName custom dimensions to RequestTelemetry
- Propagate tool name via X-MCP-Tool-Name header from MCP server
- Wrap all 26 tool handlers with withToolTelemetry for context propagation
- Add MCP_TOOL_LOGGING env var (default false) for optional stderr logging
- Add toolLoggingEnabled param to Bicep template for persistence
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>