mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a029d69d5e | |||
| daae1ce1fa | |||
| d90cf24508 | |||
| 32f1fd4132 | |||
| ffb09e60fa | |||
| f920b6228e | |||
| 1765773116 | |||
| 5c7295019b | |||
| a0ad769231 | |||
| 64da9f850b | |||
| 5dae21968b | |||
| 59897dfbd8 | |||
| f4551ea1ed | |||
| e816c92b88 | |||
| a16a70fba5 | |||
| 1b2bdd8f35 | |||
| 942bd86238 | |||
| 7a920049ca | |||
| 2eeb4da950 | |||
| 20a80cbdc6 | |||
| c59e65ee0a | |||
| 89aa9e1f10 | |||
| 2d3700cfaf |
+1
-1
Submodule .claude/commands updated: a3bfa8983a...313fe61e98
+1
-1
Submodule internal updated: 22e709ef72...df3667d672
@@ -75,15 +75,17 @@
|
||||
.NOTES
|
||||
Requires:
|
||||
- Microsoft.Graph.Authentication PowerShell module (auto-installs if missing).
|
||||
- Sign-in as Global Administrator OR Privileged Role Administrator OR Cloud Application Administrator.
|
||||
- Sign-in as Global Administrator OR Privileged Role Administrator (with PIM, activate the
|
||||
role first). Only these roles may assign Microsoft Graph application permissions;
|
||||
Application Administrator and Cloud Application Administrator are not enough.
|
||||
- The Autopilot Monitor app must be admin-consented in your tenant first
|
||||
(its service principal must exist before grants can be added).
|
||||
|
||||
Sign-in behaviour:
|
||||
In Azure Cloud Shell the script signs in via the ambient identity endpoint (a delegated
|
||||
token for the already-signed-in user) - no device-code prompt, which Conditional Access
|
||||
policies commonly block. Outside Cloud Shell it falls back to a normal interactive
|
||||
Microsoft Graph sign-in. -TenantId only applies to the interactive fallback.
|
||||
- The Autopilot Monitor app must be admin-consented in your tenant first
|
||||
(its service principal must exist before grants can be added).
|
||||
|
||||
After granting:
|
||||
The Autopilot Monitor backend may cache an older token for up to ~1 hour. Use the
|
||||
@@ -139,6 +141,36 @@ if ($PSCmdlet.ParameterSetName -eq 'ByFeatures') {
|
||||
# ---- Constants ----
|
||||
$MicrosoftGraphAppId = '00000003-0000-0000-c000-000000000000'
|
||||
|
||||
# ---- Graph failure details ----
|
||||
# Invoke-MgGraphRequest puts only "Response status code does not indicate success: ..." into the
|
||||
# exception message. Graph's own error (code + message) is the response body in ErrorDetails,
|
||||
# behind the request line, the status line and the headers. Some headers carry JSON themselves
|
||||
# (x-ms-ags-diagnostic), so the body is taken from behind the blank line that ends the headers.
|
||||
function Get-GraphFailure {
|
||||
param([System.Management.Automation.ErrorRecord]$ErrorRecord)
|
||||
|
||||
$status = 0
|
||||
if ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) {
|
||||
$status = [int]$ErrorRecord.Exception.Response.StatusCode
|
||||
}
|
||||
|
||||
$text = $ErrorRecord.Exception.Message
|
||||
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
|
||||
$body = ($ErrorRecord.ErrorDetails.Message -split '\r?\n\r?\n', 2)[-1].Trim()
|
||||
if ($body.StartsWith('{')) {
|
||||
try {
|
||||
$graphError = ($body | ConvertFrom-Json).error
|
||||
if ($graphError.code) { $text = "$($graphError.code): $($graphError.message)" }
|
||||
}
|
||||
catch {
|
||||
# Not a Graph JSON body - keep the exception message.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[pscustomobject]@{ Status = $status; Text = $text }
|
||||
}
|
||||
|
||||
# ---- Pre-flight: module ----
|
||||
if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) {
|
||||
Write-Host "[Setup] Microsoft.Graph.Authentication not installed. Installing for current user..." -ForegroundColor Yellow
|
||||
@@ -188,7 +220,7 @@ Write-Host "[Auth] Granted scopes: $($ctx.Scopes -join ', ')"
|
||||
# carries a fixed scope set (incl. Directory.AccessAsUser.All) instead of the requested scopes,
|
||||
# and its effective rights follow the signed-in user's directory role.
|
||||
if (-not $identityConnected -and -not ($ctx.Scopes -contains 'AppRoleAssignment.ReadWrite.All')) {
|
||||
Write-Warning "Scope 'AppRoleAssignment.ReadWrite.All' was NOT granted. The signed-in user likely lacks the required role (Global Admin / Privileged Role Admin / Cloud App Admin)."
|
||||
Write-Warning "Scope 'AppRoleAssignment.ReadWrite.All' was NOT granted. The signed-in user likely lacks the required role (Global Administrator or Privileged Role Administrator)."
|
||||
if (-not $VerifyOnly) { return }
|
||||
}
|
||||
|
||||
@@ -262,6 +294,7 @@ $action = if ($Revoke) { 'Revoke' } else { 'Grant' }
|
||||
Write-Host "[4/4] $action mode - processing $($Permissions.Count) permission(s)..." -ForegroundColor Cyan
|
||||
|
||||
$results = @()
|
||||
$graphForbidden = $false
|
||||
foreach ($permName in $Permissions) {
|
||||
$appRole = $graphSp.appRoles | Where-Object {
|
||||
$_.value -eq $permName -and $_.allowedMemberTypes -contains 'Application'
|
||||
@@ -288,7 +321,9 @@ foreach ($permName in $Permissions) {
|
||||
$results += [pscustomobject]@{ Permission = $permName; Status = 'Revoked' }
|
||||
}
|
||||
catch {
|
||||
Write-Warning " [FAIL] '$permName' revoke failed: $($_.Exception.Message)"
|
||||
$failure = Get-GraphFailure $_
|
||||
if ($failure.Status -eq 403) { $graphForbidden = $true }
|
||||
Write-Warning " [FAIL] '$permName' revoke failed: $($failure.Text)"
|
||||
$results += [pscustomobject]@{ Permission = $permName; Status = 'Failed' }
|
||||
}
|
||||
}
|
||||
@@ -315,7 +350,9 @@ foreach ($permName in $Permissions) {
|
||||
$results += [pscustomobject]@{ Permission = $permName; Status = 'Granted' }
|
||||
}
|
||||
catch {
|
||||
Write-Warning " [FAIL] '$permName' grant failed: $($_.Exception.Message)"
|
||||
$failure = Get-GraphFailure $_
|
||||
if ($failure.Status -eq 403) { $graphForbidden = $true }
|
||||
Write-Warning " [FAIL] '$permName' grant failed: $($failure.Text)"
|
||||
$results += [pscustomobject]@{ Permission = $permName; Status = 'Failed' }
|
||||
}
|
||||
}
|
||||
@@ -324,6 +361,9 @@ foreach ($permName in $Permissions) {
|
||||
Write-Host ""
|
||||
Write-Host "=========== Summary ===========" -ForegroundColor Cyan
|
||||
$results | Format-Table -AutoSize | Out-Host
|
||||
if ($graphForbidden) {
|
||||
Write-Warning "Microsoft Graph refused the change (403): the signed-in account may not manage Microsoft Graph application permissions. Sign in as Global Administrator or Privileged Role Administrator (with PIM, activate the role first) and run the script again."
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "Note: token-cached backends may need up to ~1h to see the new permission set." -ForegroundColor Yellow
|
||||
Write-Host " Use 'Refresh permission status' in the admin UI to apply changes immediately." -ForegroundColor Yellow
|
||||
|
||||
+22
@@ -162,6 +162,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
Assert.Equal(SessionStage.AwaitingHello, result.InitialState.Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_snapshot_of_a_rotated_session_adopts_the_current_session_id()
|
||||
{
|
||||
// SESSION-OWNER-BINDING: the backend refused the persisted session id at registration,
|
||||
// the agent rotated, and the snapshot on disk still names the old session. The
|
||||
// enrollment state carries over; the session id must be the current one.
|
||||
using var rig = new Rig();
|
||||
var saved = DecisionState.CreateInitial("S-REFUSED", TenantId)
|
||||
.ToBuilder()
|
||||
.WithStage(SessionStage.EspAccountSetup)
|
||||
.WithStepIndex(7)
|
||||
.WithLastAppliedSignalOrdinal(6)
|
||||
.Build();
|
||||
SeedSnapshot(rig, saved);
|
||||
|
||||
var result = rig.Recover();
|
||||
|
||||
Assert.Equal(SessionId, result.InitialState.SessionId);
|
||||
Assert.Equal(SessionStage.EspAccountSetup, result.InitialState.Stage);
|
||||
Assert.Equal(7, result.InitialState.StepIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_plus_pending_tail_replays_tail_onto_snapshot()
|
||||
{
|
||||
|
||||
+80
@@ -137,6 +137,86 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
|
||||
}
|
||||
|
||||
private static BackendRefusalException TenantConflict()
|
||||
=> new BackendRefusalException("Backend returned 409 Conflict with error code session_owner_mismatch.", 409,
|
||||
AutopilotMonitor.Shared.Constants.AgentErrorCodes.SessionOwnerMismatch);
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_conflict_409_rotates_once_like_an_owner_mismatch()
|
||||
{
|
||||
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 TenantConflict()
|
||||
: 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 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: () => { config.SessionId = rotatedTo; return rotatedTo; });
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
|
||||
Assert.Equal(new[] { originalSessionId, rotatedTo }, seenSessionIds);
|
||||
Assert.Equal(originalSessionId, result.RotatedFromSessionId);
|
||||
Assert.Equal(0, tracker.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Coded_refusal_after_rotation_fails_without_retry_or_auth_failure()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw TenantConflict());
|
||||
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,
|
||||
backoffDelay: _ => throw new InvalidOperationException("a coded refusal must not be retried"),
|
||||
networkLinkWait: _ => Task.CompletedTask,
|
||||
rotateSession: () => { config.SessionId = Guid.NewGuid().ToString(); return config.SessionId; });
|
||||
|
||||
Assert.Equal(SessionRegistrationOutcome.Failed, result.Outcome);
|
||||
Assert.Equal(2, apiClient.CallCount);
|
||||
Assert.Equal(0, tracker.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rotation_hook_failure_on_a_tenant_conflict_fails_without_auth_failure()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = NewLogger(tmp);
|
||||
var config = NewConfig();
|
||||
|
||||
var apiClient = new FakeApiClient(_ => throw TenantConflict());
|
||||
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.Failed, result.Outcome);
|
||||
Assert.Equal(1, apiClient.CallCount);
|
||||
Assert.Equal(0, tracker.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Second_owner_mismatch_after_rotation_is_an_auth_failure()
|
||||
{
|
||||
|
||||
+29
@@ -88,6 +88,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
Assert.Null(ex.ErrorCode);
|
||||
}
|
||||
|
||||
// ── coded 409: a refusal, never an auth failure ─────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Coded_409_is_a_refusal_not_an_auth_failure()
|
||||
{
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Conflict,
|
||||
"{\"success\":false,\"message\":\"Session id is already registered to another tenant\",\"errorCode\":\"session_owner_mismatch\"}",
|
||||
"application/json");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BackendRefusalException>(
|
||||
() => client.RegisterSessionAsync(new AutopilotMonitor.Shared.Models.SessionRegistration()));
|
||||
|
||||
Assert.Equal(409, ex.StatusCode);
|
||||
Assert.Equal("session_owner_mismatch", ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Uncoded_409_stays_a_plain_http_error()
|
||||
{
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Conflict,
|
||||
"{\"success\":false,\"message\":\"A diagnostics package with this name already exists\"}",
|
||||
"application/json");
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => client.GetDiagnosticsUploadUrlAsync("tenant-1", "session-1", "package.zip"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", null)]
|
||||
|
||||
@@ -237,6 +237,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
using (response)
|
||||
{
|
||||
await ThrowOnAuthFailureAsync(response).ConfigureAwait(false);
|
||||
await ThrowOnCodedConflictAsync(response).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
@@ -338,6 +339,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
errorCode: errorCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws <see cref="BackendRefusalException"/> for a 409 whose JSON body carries a
|
||||
/// machine-readable <c>errorCode</c> — register-session answers
|
||||
/// <c>session_owner_mismatch</c> when another tenant claimed the session id, which no retry
|
||||
/// can clear but a session rotation can. A 409 without a code falls through to
|
||||
/// <c>EnsureSuccessStatusCode</c> as before.
|
||||
/// </summary>
|
||||
private static async Task ThrowOnCodedConflictAsync(HttpResponseMessage response)
|
||||
{
|
||||
if (response.StatusCode != System.Net.HttpStatusCode.Conflict)
|
||||
return;
|
||||
|
||||
string body = null;
|
||||
try
|
||||
{
|
||||
body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// No body, no code — the generic 409 handling applies.
|
||||
}
|
||||
|
||||
var errorCode = TryExtractErrorCode(body);
|
||||
if (errorCode == null)
|
||||
return;
|
||||
|
||||
throw new BackendRefusalException(
|
||||
$"Backend returned 409 Conflict with error code {errorCode}.",
|
||||
(int)response.StatusCode,
|
||||
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.
|
||||
@@ -471,4 +504,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when the backend refuses a request on a non-auth status (409) with a machine-readable
|
||||
/// code from Shared <c>Constants.AgentErrorCodes</c>. Unlike <see cref="BackendAuthException"/>
|
||||
/// it says nothing about the device's authorization and must never feed the auth-failure tracker.
|
||||
/// </summary>
|
||||
public class BackendRefusalException : Exception
|
||||
{
|
||||
/// <summary>HTTP status code returned by the backend (409).</summary>
|
||||
public int StatusCode { get; }
|
||||
|
||||
/// <summary>Machine-readable code from the backend's JSON body (never null).</summary>
|
||||
public string ErrorCode { get; }
|
||||
|
||||
public BackendRefusalException(string message, int statusCode, string errorCode) : base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,7 +420,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// Re-stamp AgentBootUtc so the current run's deadlines floor at "now" — the
|
||||
// persisted boot anchor is from the prior run and using it would let replayed
|
||||
// tail signals arm deadlines that are already past-due (premature fire).
|
||||
seed = loadedState.ToBuilder().WithAgentBootUtc(agentBootUtc).Build();
|
||||
// Re-stamp SessionId too: after a session rotation at registration
|
||||
// (SESSION-OWNER-BINDING) the snapshot still names the refused session, and the
|
||||
// timeline emitter takes each event's SessionId from this state.
|
||||
var seedBuilder = loadedState.ToBuilder().WithAgentBootUtc(agentBootUtc);
|
||||
seedBuilder.SessionId = sessionId;
|
||||
seed = seedBuilder.Build();
|
||||
signalsToReplay = CollectSignalLogTailAfter(signalLog, loadedState.LastAppliedSignalOrdinal);
|
||||
branchTag = signalsToReplay.Count > 0 ? "b-tail-replay" : "b-snapshot-current";
|
||||
}
|
||||
|
||||
@@ -34,13 +34,16 @@ 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
|
||||
/// <item>Exception to the above — a 403 or 409 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>
|
||||
/// session that is not this device's — bound to another device identity (403, typically an
|
||||
/// Intune re-enrollment without a wipe, where <c>session.id</c> survived but the certificate
|
||||
/// identity changed) or claimed by another tenant (409). 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 coded 409 (<see cref="BackendRefusalException"/>) → <see cref="SessionRegistrationOutcome.Failed"/>
|
||||
/// without retrying — a refusal with a code does not clear by waiting.</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>
|
||||
@@ -113,14 +116,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
lastError = response?.Message ?? "(null response)";
|
||||
logger.Warning($"Session registration failed: {lastError}");
|
||||
}
|
||||
catch (BackendAuthException ex) when (
|
||||
ex.ErrorCode == Constants.AgentErrorCodes.SessionOwnerMismatch
|
||||
catch (Exception ex) when (
|
||||
IsSessionOwnerMismatch(ex)
|
||||
&& 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.
|
||||
// SESSION-OWNER-BINDING: the id on disk names a session this device does not
|
||||
// own. Become a new session and register once more — immediately, no backoff,
|
||||
// no auth-failure bookkeeping.
|
||||
rotatedFromSessionId = registration.SessionId;
|
||||
string newSessionId;
|
||||
try
|
||||
@@ -130,15 +133,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
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);
|
||||
if (ex is BackendAuthException authEx)
|
||||
{
|
||||
authFailureTracker?.RecordFailure(authEx.StatusCode, "agent/register-session", authEx.EndpointUnavailable);
|
||||
return SessionRegistrationResult.AuthFailed(authEx.StatusCode, authEx.Message);
|
||||
}
|
||||
return SessionRegistrationResult.Failed(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.");
|
||||
$"is not this device's (bound to another device identity after a re-enrollment without wipe, or claimed by another tenant). " +
|
||||
$"Rotated to {newSessionId}; re-registering.");
|
||||
registration = BuildRegistration(agentConfig, agentVersion, deviceHardware);
|
||||
continue;
|
||||
}
|
||||
catch (BackendRefusalException ex)
|
||||
{
|
||||
logger.Error($"Session registration refused ({ex.StatusCode}, {ex.ErrorCode}): {ex.Message}");
|
||||
return SessionRegistrationResult.Failed(ex.Message);
|
||||
}
|
||||
catch (BackendAuthException ex)
|
||||
{
|
||||
logger.Error($"Session registration authentication failed ({ex.StatusCode}): {ex.Message}");
|
||||
@@ -195,6 +208,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
return SessionRegistrationResult.Failed(lastError ?? "max retries exceeded");
|
||||
}
|
||||
|
||||
private static bool IsSessionOwnerMismatch(Exception ex)
|
||||
{
|
||||
var code = (ex as BackendAuthException)?.ErrorCode ?? (ex as BackendRefusalException)?.ErrorCode;
|
||||
return code == Constants.AgentErrorCodes.SessionOwnerMismatch;
|
||||
}
|
||||
|
||||
/// <summary>V1 parity exponential backoff: 2^attempt seconds.</summary>
|
||||
private static Task DefaultBackoffDelay(int attempt)
|
||||
=> Task.Delay(((int)Math.Pow(2, attempt)) * 1000);
|
||||
|
||||
@@ -70,8 +70,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// 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.
|
||||
/// emergency reporter, which snapshotted the id before registration. Everything else that
|
||||
/// holds a session id (orchestrator, emitters, spool) is built in Phase 7 from the rotated
|
||||
/// <c>agentConfig.SessionId</c> — keep it that way: a component built before Phase 6 needs
|
||||
/// its own re-target here.
|
||||
/// </summary>
|
||||
internal static string RotateSession(
|
||||
AgentConfiguration agentConfig,
|
||||
|
||||
+1
-3
@@ -57,8 +57,6 @@ public class AdminConfigurationRateLimitSyncTests
|
||||
It.IsAny<Func<AdminConfiguration, string?>>(), "global-admin@contoso.com", It.IsAny<string?>()), Times.Once);
|
||||
// ...but NO tenant configuration is enumerated or mutated (sync removed).
|
||||
repo.Verify(r => r.GetAllTenantConfigurationsAsync(), Times.Never);
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
repo.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
@@ -32,6 +33,7 @@ public class AppHomingServiceTests
|
||||
|
||||
private readonly Mock<AdminConfigurationService> _adminConfigMock;
|
||||
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
|
||||
private readonly StoredTenantConfig _store;
|
||||
private readonly Mock<GraphTokenService> _graphTokenMock;
|
||||
private readonly Mock<IGraphFeatureDetector> _detectorMock;
|
||||
private readonly Mock<IMaintenanceRepository> _maintenanceMock;
|
||||
@@ -99,13 +101,10 @@ public class AppHomingServiceTests
|
||||
new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true }));
|
||||
|
||||
// Defaults: flag on, legacy-homed config exists, both apps mint a role-less token (the
|
||||
// superset rule passes trivially). FlipAsync reads via the cache-bypassing
|
||||
// GetConfigurationFreshAsync (read-modify-write on the whole entity).
|
||||
// superset rule passes trivially). FlipAsync writes through UpdateAsync (conditional
|
||||
// write on the fresh row), stubbed over one stored row.
|
||||
_adminConfigMock.Setup(x => x.IsSelfServiceAppHomingEnabledAsync()).ReturnsAsync(true);
|
||||
var config = LegacyHomedConfig();
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
||||
_tenantConfigMock.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
_store = _tenantConfigMock.StubWrites(TenantId, LegacyHomedConfig());
|
||||
SetupProbe(GraphTokenResult.Success(Jwt()));
|
||||
SetupLegacyToken(GraphTokenResult.Success(Jwt()));
|
||||
}
|
||||
@@ -143,8 +142,7 @@ public class AppHomingServiceTests
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(result);
|
||||
|
||||
private void VerifyNeverSaved() =>
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
private void VerifyNeverSaved() => Assert.Empty(_store.Writes);
|
||||
|
||||
// ── IsFunnelEligibleAsync ───────────────────────────────────────────────
|
||||
|
||||
@@ -340,9 +338,10 @@ public class AppHomingServiceTests
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.Flipped, outcome.Outcome);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.Is<TenantConfiguration>(c =>
|
||||
string.Equals(c.HomedAppClientId, PrimaryId, StringComparison.OrdinalIgnoreCase)
|
||||
&& c.UpdatedBy == Actor), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
var (written, source, _) = Assert.Single(_store.Writes);
|
||||
Assert.Equal(PrimaryId, written.HomedAppClientId, ignoreCase: true);
|
||||
Assert.Equal(Actor, written.UpdatedBy);
|
||||
Assert.Equal("app-homing", source);
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(TenantId), Times.Once);
|
||||
_maintenanceMock.Verify(m => m.LogAuditEntryAsync(TenantId, "UPDATE", "TenantConfiguration",
|
||||
TenantId, Actor, It.Is<Dictionary<string, string>?>(d =>
|
||||
@@ -359,7 +358,7 @@ public class AppHomingServiceTests
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.ProbeFailed, outcome.Outcome);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
VerifyNeverSaved();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -370,7 +369,7 @@ public class AppHomingServiceTests
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.ProbeTransient, outcome.Outcome);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
VerifyNeverSaved();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -394,7 +393,7 @@ public class AppHomingServiceTests
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.NotEligible, outcome.Outcome);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
VerifyNeverSaved();
|
||||
}
|
||||
|
||||
// ── FlipAsync ───────────────────────────────────────────────────────────
|
||||
@@ -404,7 +403,7 @@ public class AppHomingServiceTests
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.EntraAppRolesEnabled = true;
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
||||
_store.Row = config;
|
||||
|
||||
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga");
|
||||
|
||||
@@ -417,11 +416,11 @@ public class AppHomingServiceTests
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.HomedAppClientId = PrimaryId;
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
||||
_store.Row = config;
|
||||
|
||||
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga");
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
VerifyNeverSaved();
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(It.IsAny<string>()), Times.Never);
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
@@ -430,30 +429,68 @@ public class AppHomingServiceTests
|
||||
public async Task Flip_throws_when_config_row_missing_and_saves_nothing()
|
||||
{
|
||||
// A flip must never materialize a default row (e.g. resurrect an offboarded tenant);
|
||||
// the fresh read returning null is a hard stop.
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId))
|
||||
.ReturnsAsync((TenantConfiguration?)null);
|
||||
// a missing row is a hard stop.
|
||||
_store.Row = null;
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga"));
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
VerifyNeverSaved();
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(It.IsAny<string>()), Times.Never);
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flip_decides_on_the_stored_row_not_the_callers_view()
|
||||
{
|
||||
// The caller's (cached) view is legacy-homed; the stored row was flipped meanwhile.
|
||||
var stored = LegacyHomedConfig();
|
||||
stored.HomedAppClientId = PrimaryId;
|
||||
_store.Row = stored;
|
||||
|
||||
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "consent-auto-flip");
|
||||
|
||||
VerifyNeverSaved();
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flip_skips_a_tenant_being_offboarded()
|
||||
{
|
||||
var tombstone = LegacyHomedConfig();
|
||||
tombstone.Disabled = true;
|
||||
tombstone.DisabledReason = TenantOffboardFunction.OffboardingDisabledReason;
|
||||
_store.Row = tombstone;
|
||||
|
||||
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga");
|
||||
|
||||
VerifyNeverSaved();
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(It.IsAny<string>()), Times.Never);
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flip_throws_when_every_conditional_write_lost()
|
||||
{
|
||||
_store.Force = TenantConfigUpdateStatus.Conflict;
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga"));
|
||||
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flip_back_to_legacy_persists_null_homing()
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.HomedAppClientId = PrimaryId;
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
||||
_store.Row = config;
|
||||
|
||||
await _sut.FlipAsync(TenantId, null, Actor, "manual-ga");
|
||||
|
||||
// The null-homing invariant: legacy is represented as null, never as the legacy GUID.
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.Is<TenantConfiguration>(c =>
|
||||
c.HomedAppClientId == null), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
Assert.Null(Assert.Single(_store.Writes).Row.HomedAppClientId);
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(TenantId), Times.Once);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -306,7 +306,7 @@ public class AppInstallIdentityAndEspBlockingTests
|
||||
};
|
||||
|
||||
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, repo.Object, "Company Portal", days: 30));
|
||||
summaries, repo.Object, "Company Portal", AppInstallSources.Ime, days: 30));
|
||||
|
||||
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
||||
var stats = root.GetProperty("summary");
|
||||
@@ -325,7 +325,7 @@ public class AppInstallIdentityAndEspBlockingTests
|
||||
var summaries = new List<AppInstallSummary> { Summary("Company Portal", collision: true) };
|
||||
|
||||
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, repo.Object, "Company Portal", days: 30));
|
||||
summaries, repo.Object, "Company Portal", AppInstallSources.Ime, days: 30));
|
||||
|
||||
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
||||
Assert.Equal(0, root.GetProperty("summary").GetProperty("totalInstalls").GetInt32());
|
||||
|
||||
@@ -195,6 +195,37 @@ public class AppVersionRegressionRadarTests
|
||||
Assert.Empty(AppVersionRegressionRadar.Evaluate(rows2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_SameNameInTwoChannels_IsTwoApps()
|
||||
{
|
||||
// The RealmJoin package regressed; the Intune app of the same name did not. A shared
|
||||
// group would dilute the RealmJoin medians with the Intune rows and hide the regression.
|
||||
var rows = new List<AppInstallSummary>();
|
||||
rows.AddRange(Batch("1.0", 10, 300, Now.AddDays(-20)).Select(s => { s.Source = AppInstallSources.RealmJoin; return s; }));
|
||||
rows.AddRange(Batch("2.0", 10, 900, Now.AddDays(-5)).Select(s => { s.Source = AppInstallSources.RealmJoin; return s; }));
|
||||
rows.AddRange(Batch("1.0", 30, 300, Now.AddDays(-20)));
|
||||
rows.AddRange(Batch("2.0", 30, 300, Now.AddDays(-5)));
|
||||
|
||||
var finding = Assert.Single(AppVersionRegressionRadar.Evaluate(rows));
|
||||
Assert.Equal(AppInstallSources.RealmJoin, finding.Source);
|
||||
Assert.Equal(App, finding.AppName);
|
||||
Assert.Equal(900, finding.CurrentMedianSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldReArm_ReadsTheAlertsOwnChannel()
|
||||
{
|
||||
// The IME app still regresses; the RealmJoin episode's version has no measured installs.
|
||||
var rows = new List<AppInstallSummary>();
|
||||
rows.AddRange(Batch("1.0", 10, 300, Now.AddDays(-20)));
|
||||
rows.AddRange(Batch("2.0", 10, 900, Now.AddDays(-5)));
|
||||
|
||||
var rjAlert = Alert();
|
||||
rjAlert.Source = AppInstallSources.RealmJoin;
|
||||
Assert.True(AppVersionRegressionRadar.ShouldReArm(rows, rjAlert));
|
||||
Assert.False(AppVersionRegressionRadar.ShouldReArm(rows, Alert()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_DeterministicOrdering_LiftDesc_ThenAppNameOrdinal()
|
||||
{
|
||||
@@ -279,13 +310,25 @@ public class AppVersionRegressionRadarTests
|
||||
{
|
||||
Assert.Equal("appversionregression|contoso app_2|1.0_beta_",
|
||||
TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(
|
||||
" Contoso App/2 ", " 1.0#Beta? "));
|
||||
AppInstallSources.Ime, " Contoso App/2 ", " 1.0#Beta? "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppVersionRegressionRowKey_ImeKeepsPreChannelShape_OtherChannelsPrefixTheApp()
|
||||
{
|
||||
// Episodes written before channels existed must keep resolving (re-arm deletes them by key).
|
||||
Assert.Equal("appversionregression|contoso vpn|2.0",
|
||||
TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(null, App, "2.0"));
|
||||
Assert.Equal("appversionregression|contoso vpn|2.0",
|
||||
TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(string.Empty, App, "2.0"));
|
||||
Assert.Equal("appversionregression|realmjoin:contoso vpn|2.0",
|
||||
TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(AppInstallSources.RealmJoin, App, "2.0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppVersionRegressionRowKey_NoPrefixCollisionWithOtherKeyspaces()
|
||||
{
|
||||
var key = TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(App, "1.0");
|
||||
var key = TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(AppInstallSources.RealmJoin, App, "1.0");
|
||||
Assert.StartsWith("appversionregression|", key, StringComparison.Ordinal);
|
||||
// The prefix range [prefix, "appversionregression}") must not swallow the sibling
|
||||
// keyspaces ("ruleregression|", "tpmpss|", "{mfr}|{model}").
|
||||
@@ -293,6 +336,39 @@ public class AppVersionRegressionRadarTests
|
||||
Assert.True(string.CompareOrdinal("tpmpss|", "appversionregression}") > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppVersionRegressionEntity_RealmJoinChannel_RoundTripsWithPrefixedKey()
|
||||
{
|
||||
var alert = new AppVersionRegressionAlert
|
||||
{
|
||||
TenantId = TenantA,
|
||||
AppName = App,
|
||||
Source = AppInstallSources.RealmJoin,
|
||||
CurrentVersion = "2.4.0",
|
||||
PreviousVersion = "2.3.9",
|
||||
FirstNotifiedAt = Now,
|
||||
LastEvaluatedAt = Now,
|
||||
};
|
||||
|
||||
var entity = TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionEntity(TenantA, alert);
|
||||
Assert.Equal("appversionregression|realmjoin:contoso vpn|2.4.0", entity.RowKey);
|
||||
Assert.Equal(AppInstallSources.RealmJoin,
|
||||
TableHardwareRejectionNotificationTracker.MapToAppVersionRegressionAlert(entity).Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppVersionRegressionEntity_RowWithoutSourceColumn_MapsToIme()
|
||||
{
|
||||
var entity = new Azure.Data.Tables.TableEntity(TenantA.ToLowerInvariant(), "appversionregression|contoso vpn|2.4.0")
|
||||
{
|
||||
["TenantId"] = TenantA,
|
||||
["AppName"] = App,
|
||||
["CurrentVersion"] = "2.4.0",
|
||||
};
|
||||
Assert.Equal(AppInstallSources.Ime,
|
||||
TableHardwareRejectionNotificationTracker.MapToAppVersionRegressionAlert(entity).Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppVersionRegressionEntity_RoundTripsAllFields()
|
||||
{
|
||||
@@ -318,6 +394,7 @@ public class AppVersionRegressionRadarTests
|
||||
var mapped = TableHardwareRejectionNotificationTracker.MapToAppVersionRegressionAlert(entity);
|
||||
Assert.Equal(TenantA, mapped.TenantId);
|
||||
Assert.Equal(App, mapped.AppName);
|
||||
Assert.Equal(AppInstallSources.Ime, mapped.Source);
|
||||
Assert.Equal("2.4.0", mapped.CurrentVersion);
|
||||
Assert.Equal("2.3.9", mapped.PreviousVersion);
|
||||
Assert.Equal(1740, mapped.CurrentMedianSeconds);
|
||||
@@ -371,7 +448,7 @@ public class AppVersionRegressionRadarTests
|
||||
};
|
||||
|
||||
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
rows, sessionRepo.Object, App, days: 30));
|
||||
rows, sessionRepo.Object, App, AppInstallSources.Ime, days: 30));
|
||||
|
||||
var version = root.GetProperty("versionBreakdown")[0];
|
||||
Assert.Equal("1.0", version.GetProperty("appVersion").GetString());
|
||||
@@ -395,12 +472,12 @@ public class AppVersionRegressionRadarTests
|
||||
|
||||
// Empty-data early return still surfaces the block (episodes can outlive window data).
|
||||
var emptyRoot = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
new List<AppInstallSummary>(), sessionRepo.Object, App, days: 30,
|
||||
new List<AppInstallSummary>(), sessionRepo.Object, App, AppInstallSources.Ime, days: 30,
|
||||
new List<AppVersionRegressionAlert> { alert }));
|
||||
Assert.Equal(1, emptyRoot.GetProperty("versionRegressions").GetArrayLength());
|
||||
|
||||
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
new List<AppInstallSummary> { Install("1.0", 100, Recent) }, sessionRepo.Object, App, days: 30,
|
||||
new List<AppInstallSummary> { Install("1.0", 100, Recent) }, sessionRepo.Object, App, AppInstallSources.Ime, days: 30,
|
||||
new List<AppVersionRegressionAlert> { alert }));
|
||||
|
||||
var regression = root.GetProperty("versionRegressions")[0];
|
||||
|
||||
@@ -131,7 +131,7 @@ public class AppsAnalyticsFailureRateTests
|
||||
sessionRepo = mock.Object;
|
||||
}
|
||||
var result = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, sessionRepo, "Contoso App", days: 30);
|
||||
summaries, sessionRepo, "Contoso App", AppInstallSources.Ime, days: 30);
|
||||
return TestWire.SerializeToElement(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,9 +31,9 @@ public class AuditLogDeletionExclusionTests
|
||||
public void Global_fanout_filter_includes_deletion_exclusion_only_when_requested()
|
||||
{
|
||||
var without = TableStorageService.BuildAuditLogFilterWithRowKeyBound(
|
||||
Tenant.ToString(), null, null, lastRowKey: null, excludeDeletions: false);
|
||||
Tenant.ToString(), null, null, rowKeyBound: null, excludeDeletions: false);
|
||||
var with = TableStorageService.BuildAuditLogFilterWithRowKeyBound(
|
||||
Tenant.ToString(), null, null, lastRowKey: null, excludeDeletions: true);
|
||||
Tenant.ToString(), null, null, rowKeyBound: null, excludeDeletions: true);
|
||||
|
||||
Assert.DoesNotContain("deletion_started", without);
|
||||
Assert.Contains("Action ne 'deletion_started'", with);
|
||||
@@ -47,7 +47,7 @@ public class AuditLogDeletionExclusionTests
|
||||
// noise leak back into one of the two views.
|
||||
var tenant = TableStorageService.BuildAuditLogFilter(Tenant.ToString(), null, null, excludeDeletions: true);
|
||||
var global = TableStorageService.BuildAuditLogFilterWithRowKeyBound(
|
||||
Tenant.ToString(), null, null, lastRowKey: null, excludeDeletions: true);
|
||||
Tenant.ToString(), null, null, rowKeyBound: null, excludeDeletions: true);
|
||||
|
||||
var clause = TableStorageService.DeletionExclusionClause();
|
||||
Assert.Contains(clause, tenant);
|
||||
|
||||
@@ -51,7 +51,7 @@ public class AuditLogFieldFilterTests
|
||||
var filters = new AuditLogQueryFilters { Action = "device_blocked", EntityType = "Device" };
|
||||
|
||||
var f = TableStorageService.BuildAuditLogFilterWithRowKeyBound(
|
||||
Tenant, null, null, lastRowKey: null, excludeDeletions: false, filters);
|
||||
Tenant, null, null, rowKeyBound: null, excludeDeletions: false, filters);
|
||||
|
||||
Assert.Contains("Action eq 'device_blocked'", f);
|
||||
Assert.Contains("EntityType eq 'Device'", f);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Functions.Infrastructure;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Tests.Offboarding;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Offboarding;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -32,6 +36,8 @@ public class AuthFunctionSideEffectTests
|
||||
private readonly Mock<IMetricsRepository> _metricsRepoMock;
|
||||
private readonly Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer> _autoApproveEnqueuerMock;
|
||||
private readonly FakeSignalRNotificationService _signalR;
|
||||
private readonly FakeOffboardingAuditRepository _offboardingRepo = new();
|
||||
private readonly StoredTenantConfig _store;
|
||||
private readonly AuthFunction _sut;
|
||||
|
||||
public AuthFunctionSideEffectTests()
|
||||
@@ -108,12 +114,12 @@ public class AuthFunctionSideEffectTests
|
||||
new Mock<AdminIdentityResolver>(
|
||||
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object,
|
||||
_signalR,
|
||||
adminConfigService.Object);
|
||||
adminConfigService.Object,
|
||||
_offboardingRepo);
|
||||
|
||||
// Default: all fire-and-forget calls succeed
|
||||
_tenantConfigMock
|
||||
.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
// Writes go through UpdateAsync / CreateOrUpdateAsync over one stored row (none by default:
|
||||
// a brand-new tenant). Default: all fire-and-forget calls succeed.
|
||||
_store = _tenantConfigMock.StubWrites(TenantId, row: null);
|
||||
_telegramMock
|
||||
.Setup(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
@@ -202,7 +208,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
Assert.True(string.IsNullOrEmpty(config.DomainName));
|
||||
Assert.Null(config.OnboardedBy);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
@@ -222,7 +228,11 @@ public class AuthFunctionSideEffectTests
|
||||
Assert.Equal(Upn, config.OnboardedBy);
|
||||
// The DPA version rides along in the same onboarding write (D-252).
|
||||
Assert.Equal(CurrentDpa, config.DpaVersion);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
var (written, source, _) = Assert.Single(_store.Writes);
|
||||
Assert.Equal("contoso.com", written.DomainName);
|
||||
Assert.Equal(Upn, written.OnboardedBy);
|
||||
Assert.Equal(CurrentDpa, written.DpaVersion);
|
||||
Assert.Equal("auth", source);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once);
|
||||
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
|
||||
"preview_signup", "New Tenant Signup",
|
||||
@@ -237,6 +247,122 @@ public class AuthFunctionSideEffectTests
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Returning tenant (D-288): the offboarding deleted the configuration, the pointer
|
||||
// carries the consumed self-service trial into the first-login write.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void SeedOffboardingPointer(bool trialConsumed) =>
|
||||
_offboardingRepo.Pointers[TenantId] = (new OffboardingByTenantPointer
|
||||
{
|
||||
PartitionKey = Constants.OffboardingPartitionKeys.ByTenant,
|
||||
RowKey = TenantId,
|
||||
TenantId = TenantId,
|
||||
LatestStatus = "Completed",
|
||||
OffboardCount = 1,
|
||||
TrialConsumed = trialConsumed,
|
||||
}, "\"etag\"");
|
||||
|
||||
private static TenantConfiguration ReturningTenantDefault()
|
||||
{
|
||||
// What auth/me hands over once the offboarding deleted the row: an unpersisted default.
|
||||
var config = TenantConfiguration.CreateDefault(TenantId);
|
||||
Assert.False(config.TrialConsumed);
|
||||
return config;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_ReturningTenantThatUsedTheTrial_CarriesItIntoTheOnboardingWrite()
|
||||
{
|
||||
SeedOffboardingPointer(trialConsumed: true);
|
||||
var config = ReturningTenantDefault();
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
Assert.True(config.TrialConsumed);
|
||||
Assert.Equal(Upn, config.OnboardedBy);
|
||||
var written = Assert.Single(_store.Writes).Row;
|
||||
Assert.True(written.TrialConsumed);
|
||||
Assert.Equal(Upn, written.OnboardedBy);
|
||||
// Re-onboarding itself stays unrestricted: same signup path, auto-approve included.
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
|
||||
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
// The verify criterion of the finding: the trial gate refuses the returning tenant.
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, DateTime.UtcNow);
|
||||
Assert.Equal(Constants.ApiErrorCodes.TrialAlreadyConsumed, deny?.Code);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)] // offboarded without ever using the trial
|
||||
[InlineData(null)] // never offboarded
|
||||
public async Task HandleNewTenantDomain_NoConsumedTrialOnRecord_LeavesTheTrialAvailable(bool? pointerTrialConsumed)
|
||||
{
|
||||
if (pointerTrialConsumed is { } consumed)
|
||||
SeedOffboardingPointer(consumed);
|
||||
var config = ReturningTenantDefault();
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
Assert.False(config.TrialConsumed);
|
||||
Assert.Equal("contoso.com", config.DomainName);
|
||||
Assert.False(Assert.Single(_store.Writes).Row.TrialConsumed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_OffboardingLookupFails_WritesNothingAndRetriesOnTheNextLogin()
|
||||
{
|
||||
// An onboarded row without the carry-over would hand the trial back; no write at all instead.
|
||||
_offboardingRepo.ThrowOnPointerRead = new Azure.RequestFailedException(503, "Server busy");
|
||||
var config = ReturningTenantDefault();
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
Assert.True(string.IsNullOrEmpty(config.DomainName));
|
||||
Assert.Null(config.OnboardedBy);
|
||||
Assert.Empty(_store.Writes);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
|
||||
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_ConcurrentFirstLogin_NeverOverwritesOnboardedBy()
|
||||
{
|
||||
// This login's view still has no domain, but another login seeded the stored row meanwhile:
|
||||
// OnboardedBy is write-once (D-061), and the signup side effects belong to the winner.
|
||||
var stored = DefaultConfig();
|
||||
stored.DomainName = "contoso.com";
|
||||
stored.OnboardedBy = "first@contoso.com";
|
||||
_store.Row = stored;
|
||||
var view = DefaultConfig();
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(view, TenantId, "second@contoso.com");
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
Assert.Equal("first@contoso.com", _store.Row!.OnboardedBy);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
|
||||
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_OffboardingTombstone_IsNeverSeeded()
|
||||
{
|
||||
// The offboard click writes a Disabled tombstone even onto a row without a domain.
|
||||
var tombstone = DefaultConfig();
|
||||
tombstone.Disabled = true;
|
||||
tombstone.DisabledReason = AutopilotMonitor.Functions.Functions.Admin.TenantOffboardFunction.OffboardingDisabledReason;
|
||||
_store.Row = tombstone;
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(DefaultConfig(), TenantId, Upn);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// HandleAuthClientIdTrackingAsync — the write must mutate a cache-BYPASSING
|
||||
// read, never the (possibly stale) cached view. Regression for the prod
|
||||
@@ -261,16 +387,16 @@ public class AuthFunctionSideEffectTests
|
||||
fresh.LastAuthClientId = LegacyAppId;
|
||||
fresh.HomedAppClientId = PrimaryAppId; // the concurrent flip that must survive
|
||||
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(fresh);
|
||||
_store.Row = fresh;
|
||||
|
||||
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, $"api://{PrimaryAppId}");
|
||||
|
||||
// The FRESH entity was mutated and saved — the flip survives, the stale view is discarded.
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(fresh, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(cached, It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Equal(PrimaryAppId, fresh.HomedAppClientId);
|
||||
Assert.Equal(PrimaryAppId, fresh.LastAuthClientId);
|
||||
Assert.NotNull(fresh.LastAuthClientIdSince);
|
||||
// The FRESH row was mutated and written — the flip survives, the stale view is discarded.
|
||||
var written = Assert.Single(_store.Writes).Row;
|
||||
Assert.Equal(PrimaryAppId, written.HomedAppClientId);
|
||||
Assert.Equal(PrimaryAppId, written.LastAuthClientId);
|
||||
Assert.NotNull(written.LastAuthClientIdSince);
|
||||
Assert.Null(cached.HomedAppClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -286,11 +412,11 @@ public class AuthFunctionSideEffectTests
|
||||
fresh.DomainName = "contoso.com";
|
||||
fresh.LastAuthClientId = PrimaryAppId;
|
||||
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(fresh);
|
||||
_store.Row = fresh;
|
||||
|
||||
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, PrimaryAppId);
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -303,8 +429,9 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, $"api://{PrimaryAppId}");
|
||||
|
||||
_tenantConfigMock.Verify(x => x.GetConfigurationFreshAsync(It.IsAny<string>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.UpdateAsync(It.IsAny<string>(), It.IsAny<Func<TenantConfiguration, bool>>(),
|
||||
It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
private const string CurrentDpa = AutopilotMonitor.Shared.Constants.CurrentDpaVersion;
|
||||
@@ -321,7 +448,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
Assert.Null(config.DpaVersion);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -332,10 +459,12 @@ public class AuthFunctionSideEffectTests
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = null!;
|
||||
config.OnboardedBy = "original.requester@contoso.com";
|
||||
_store.Row = ConfigRepoMockExtensions.Clone(config);
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
Assert.Equal("original.requester@contoso.com", config.OnboardedBy);
|
||||
Assert.Equal("original.requester@contoso.com", Assert.Single(_store.Writes).Row.OnboardedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -346,7 +475,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
@@ -358,7 +487,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, "");
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -369,7 +498,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, "nodomain");
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -385,8 +514,8 @@ public class AuthFunctionSideEffectTests
|
||||
// Should not throw — Telegram is fire-and-forget
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
// SaveConfig should still have been called before the fire-and-forget
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
// The write still landed before the fire-and-forget
|
||||
Assert.Single(_store.Writes);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -400,14 +529,68 @@ public class AuthFunctionSideEffectTests
|
||||
config.Disabled = true;
|
||||
config.DisabledReason = "Maintenance";
|
||||
config.DisabledUntil = DateTime.UtcNow.AddHours(-1); // expired
|
||||
_store.Row = ConfigRepoMockExtensions.Clone(config);
|
||||
|
||||
await _sut.HandleAutoReEnableAsync(config, TenantId);
|
||||
|
||||
Assert.False(config.Disabled);
|
||||
Assert.Null(config.DisabledReason);
|
||||
Assert.Null(config.DisabledUntil);
|
||||
Assert.Equal("System (auto-re-enable)", config.UpdatedBy);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
var written = Assert.Single(_store.Writes).Row;
|
||||
Assert.False(written.Disabled);
|
||||
Assert.Null(written.DisabledUntil);
|
||||
Assert.Equal("System (auto-re-enable)", written.UpdatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAutoReEnable_SuspensionRenewedMeanwhile_StaysSuspended()
|
||||
{
|
||||
// This instance cached the expired suspension; a Global Admin renewed it since.
|
||||
var view = DefaultConfig();
|
||||
view.Disabled = true;
|
||||
view.DisabledReason = "Maintenance";
|
||||
view.DisabledUntil = DateTime.UtcNow.AddHours(-1);
|
||||
var stored = ConfigRepoMockExtensions.Clone(view);
|
||||
stored.DisabledReason = "Abuse review";
|
||||
stored.DisabledUntil = DateTime.UtcNow.AddDays(2);
|
||||
_store.Row = stored;
|
||||
|
||||
await _sut.HandleAutoReEnableAsync(view, TenantId);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
Assert.True(view.IsCurrentlyDisabled());
|
||||
Assert.Equal("Abuse review", view.DisabledReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAutoReEnable_RowIsTheOffboardingTombstone_StaysSuspended()
|
||||
{
|
||||
var view = DefaultConfig();
|
||||
view.Disabled = true;
|
||||
view.DisabledUntil = DateTime.UtcNow.AddHours(-1);
|
||||
var tombstone = ConfigRepoMockExtensions.Clone(view);
|
||||
tombstone.DisabledReason = AutopilotMonitor.Functions.Functions.Admin.TenantOffboardFunction.OffboardingDisabledReason;
|
||||
tombstone.DisabledUntil = null;
|
||||
_store.Row = tombstone;
|
||||
|
||||
await _sut.HandleAutoReEnableAsync(view, TenantId);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
Assert.True(view.IsCurrentlyDisabled());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAutoReEnable_StorageFails_ThisLoginStillPasses()
|
||||
{
|
||||
var view = DefaultConfig();
|
||||
view.Disabled = true;
|
||||
view.DisabledUntil = DateTime.UtcNow.AddHours(-1);
|
||||
_store.ThrowOnRead = new Azure.RequestFailedException(503, "Server busy");
|
||||
|
||||
await _sut.HandleAutoReEnableAsync(view, TenantId);
|
||||
|
||||
Assert.False(view.Disabled);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -420,7 +603,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandleAutoReEnableAsync(config, TenantId);
|
||||
|
||||
Assert.True(config.Disabled);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -431,7 +614,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleAutoReEnableAsync(config, TenantId);
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -444,7 +627,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandleAutoReEnableAsync(config, TenantId);
|
||||
|
||||
Assert.True(config.Disabled);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -63,7 +63,7 @@ public class AuthenticationMiddlewareTests
|
||||
[InlineData("GET", "/api/progress/sessions/lookup")] // AuthenticatedUserWithRole
|
||||
[InlineData("GET", "/api/sessions")] // MemberRead
|
||||
[InlineData("GET", "/api/audit/logs")] // MemberRead
|
||||
[InlineData("PUT", "/api/config/11111111-1111-1111-1111-111111111111")] // TenantAdminOrGA
|
||||
[InlineData("PATCH", "/api/config/11111111-1111-1111-1111-111111111111/fields")] // TenantAdminOrGA
|
||||
[InlineData("GET", "/api/global/sessions")] // GlobalReadOrDelegatedSubset
|
||||
[InlineData("POST", "/api/global/raw/logs")] // GlobalAdminOnly (KQL proxy)
|
||||
[InlineData("GET", "/api/health/detailed")] // AuthenticatedUser (sub-route is NOT blanket-exempt)
|
||||
|
||||
@@ -152,7 +152,7 @@ public class BusinessTimestampFilterClauseTests
|
||||
|
||||
var plain = TableStorageService.BuildAuditLogFilter(Tenant, from, to);
|
||||
var fanOut = TableStorageService.BuildAuditLogFilterWithRowKeyBound(
|
||||
Tenant, from, to, lastRowKey: "!0123_x", excludeDeletions: false);
|
||||
Tenant, from, to, rowKeyBound: new PerPartitionFanOutMerge.RowKeyBound("!0123_x", Inclusive: false), excludeDeletions: false);
|
||||
|
||||
foreach (var f in new[] { plain!, fanOut })
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
@@ -13,6 +15,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// must validate successfully. This guards against rollouts where the embedded root/intermediate
|
||||
/// bundle drifts from the chain Microsoft is actually issuing.
|
||||
/// - Negative path: self-signed leaf certs (with or without Intune-shaped DNs) must NOT validate.
|
||||
/// - No network I/O: a leaf from an unknown CA must be rejected without fetching its AIA URL.
|
||||
/// </summary>
|
||||
public class CertificateValidatorTests
|
||||
{
|
||||
@@ -131,6 +134,31 @@ public class CertificateValidatorTests
|
||||
Assert.Contains("expired", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCertificate_WithUnknownIssuerAndAiaUrl_NeverFetchesTheIssuer()
|
||||
{
|
||||
// The caller mints the leaf, so the AIA caIssuers URL points wherever they like. A
|
||||
// self-signed leaf never reaches the issuer-download path; it takes a leaf signed by a
|
||||
// CA that is neither embedded nor sent.
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var b64 = CreateLeafFromUnknownCaBase64($"http://127.0.0.1:{port}/{Guid.NewGuid():N}.cer");
|
||||
|
||||
var result = CertificateValidator.ValidateCertificate(b64);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("chain validation failed", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.False(listener.Pending(), "Chain building connected to the certificate's AIA URL");
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveSamplePath()
|
||||
{
|
||||
var assemblyDir = Path.GetDirectoryName(typeof(CertificateValidatorTests).Assembly.Location)!;
|
||||
@@ -165,4 +193,24 @@ public class CertificateValidatorTests
|
||||
|
||||
return Convert.ToBase64String(cert.Export(X509ContentType.Cert));
|
||||
}
|
||||
|
||||
private static string CreateLeafFromUnknownCaBase64(string aiaCaIssuersUrl)
|
||||
{
|
||||
using var caKey = RSA.Create(2048);
|
||||
var caReq = new CertificateRequest("CN=Unknown Test CA", caKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
caReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
using var ca = caReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
|
||||
|
||||
using var leafKey = RSA.Create(2048);
|
||||
var leafReq = new CertificateRequest("CN=test-device.contoso.example", leafKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
leafReq.CertificateExtensions.Add(
|
||||
new X509EnhancedKeyUsageExtension(
|
||||
new OidCollection { new Oid("1.3.6.1.5.5.7.3.2") },
|
||||
critical: false));
|
||||
leafReq.CertificateExtensions.Add(
|
||||
new X509AuthorityInformationAccessExtension(ocspUris: null, caIssuersUris: new[] { aiaCaIssuersUrl }));
|
||||
using var leaf = leafReq.Create(ca, DateTimeOffset.UtcNow.AddHours(-1), DateTimeOffset.UtcNow.AddDays(30), RandomNumberGenerator.GetBytes(16));
|
||||
|
||||
return Convert.ToBase64String(leaf.Export(X509ContentType.Cert));
|
||||
}
|
||||
}
|
||||
|
||||
+18
-9
@@ -40,14 +40,23 @@ public class CollectLogsQuickConfigOpsEventTests
|
||||
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)
|
||||
[InlineData("?intent=collect-logs", "patch", PatchTenantConfigurationFieldsFunction.CollectLogsSource)]
|
||||
[InlineData("?intent=COLLECT-LOGS", "patch", PatchTenantConfigurationFieldsFunction.CollectLogsSource)]
|
||||
[InlineData("", "patch", "api-patch")]
|
||||
[InlineData("?intent=", "patch", "api-patch")]
|
||||
[InlineData("?intent=something-else", "patch", "api-patch")]
|
||||
[InlineData("?intent=collect-logs", "revert", "api-revert")] // a revert is never the quick-config dialog
|
||||
public void ResolveSource_only_honours_the_allow_listed_intent(string query, string operation, string expected)
|
||||
{
|
||||
Assert.Equal(expected, UpdateTenantConfigurationFunction.ResolveWriteSource(intent));
|
||||
var (req, _) = EndpointHarness.Request("t1", queryString: query);
|
||||
Assert.Equal(expected, PatchTenantConfigurationFieldsFunction.ResolveSource(req, operation));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveSource_mcp_header_without_intent_is_mcp()
|
||||
{
|
||||
var (req, _) = EndpointHarness.Request("t1", headers: new Dictionary<string, string> { ["X-Client-Source"] = "mcp" });
|
||||
Assert.Equal("mcp-patch", PatchTenantConfigurationFieldsFunction.ResolveSource(req, "patch"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -56,7 +65,7 @@ public class CollectLogsQuickConfigOpsEventTests
|
||||
var (service, saved) = Rig();
|
||||
|
||||
await service.RecordDiagnosticsUploadConfigChangedAsync(
|
||||
"t1", "contoso.com", EnableFlip(), "admin@contoso.com", UpdateTenantConfigurationFunction.CollectLogsSource);
|
||||
"t1", "contoso.com", EnableFlip(), "admin@contoso.com", PatchTenantConfigurationFieldsFunction.CollectLogsSource);
|
||||
|
||||
var evt = Assert.Single(saved);
|
||||
Assert.Equal("CollectLogsQuickConfigEnabled", evt.EventType);
|
||||
@@ -85,7 +94,7 @@ public class CollectLogsQuickConfigOpsEventTests
|
||||
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);
|
||||
await service.RecordDiagnosticsUploadConfigChangedAsync("t1", null, disable, "ga@x", PatchTenantConfigurationFieldsFunction.CollectLogsSource);
|
||||
|
||||
Assert.Equal("DiagnosticsUploadDisabled", Assert.Single(saved).EventType);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,12 @@ public class ConfigBackupHookTests
|
||||
Upserts.Add(e);
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
Table.Setup(c => c.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, CancellationToken>((e, _) =>
|
||||
{
|
||||
Upserts.Add(e);
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
Table.Setup(c => c.UpdateEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, _, _, _) =>
|
||||
@@ -109,13 +115,13 @@ public class ConfigBackupHookTests
|
||||
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "portal-put", "test change");
|
||||
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "api-patch", "test change");
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts); // the config row itself
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.Equal(TenantId, snapshot!.PartitionKey);
|
||||
Assert.Equal("portal-put", snapshot.Source);
|
||||
Assert.Equal("api-patch", snapshot.Source);
|
||||
Assert.Equal("test change", snapshot.Reason);
|
||||
Assert.Equal("ga@operator.example", snapshot.ChangedBy); // identity about to write
|
||||
// The snapshot holds the STORED row (retention 30), not the incoming one.
|
||||
@@ -130,11 +136,11 @@ public class ConfigBackupHookTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RowCreation_NoExistingRow_NoSnapshot()
|
||||
public async Task RowCreation_ConditionalInsert_NoSnapshot()
|
||||
{
|
||||
var harness = new Harness(stored: null);
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(StoredConfig(), "portal-put", null);
|
||||
var saved = await harness.Sut.TryCreateTenantConfigurationAsync(StoredConfig());
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts);
|
||||
@@ -144,6 +150,16 @@ public class ConfigBackupHookTests
|
||||
b => b.PruneAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RowCreation_RowAlreadyExists_ReturnsFalse()
|
||||
{
|
||||
var harness = new Harness(stored: null);
|
||||
harness.Table.Setup(c => c.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(409, "Conflict", "EntityAlreadyExists", null));
|
||||
|
||||
Assert.False(await harness.Sut.TryCreateTenantConfigurationAsync(StoredConfig()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoiseOnlyChange_AuthClientIdFlip_NoSnapshot()
|
||||
{
|
||||
@@ -157,7 +173,7 @@ public class ConfigBackupHookTests
|
||||
incoming.LastUpdated = DateTime.UtcNow;
|
||||
incoming.UpdatedBy = "System (auth)";
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "auth", null);
|
||||
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "auth", null);
|
||||
|
||||
Assert.True(saved); // the save itself still goes through
|
||||
Assert.Single(harness.Upserts);
|
||||
@@ -171,7 +187,7 @@ public class ConfigBackupHookTests
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(RoundtripClone(stored), "portal-put", null);
|
||||
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(RoundtripClone(stored), "\"etag\"", "api-patch", null);
|
||||
|
||||
Assert.True(saved);
|
||||
harness.Backup.Verify(
|
||||
@@ -190,14 +206,14 @@ public class ConfigBackupHookTests
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.DataRetentionDays = 90;
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "portal-put", null);
|
||||
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "api-patch", null);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts); // the config write went through regardless
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyOneArgOverload_StillSnapshots_WithUnknownSource()
|
||||
public async Task NoSource_StillSnapshots_WithUnknownSource()
|
||||
{
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
@@ -210,7 +226,7 @@ public class ConfigBackupHookTests
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.DataRetentionDays = 90;
|
||||
await harness.Sut.SaveTenantConfigurationAsync(incoming);
|
||||
await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", null, null);
|
||||
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.Equal("unknown", snapshot!.Source);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
internal static class ConfigRepoMockExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Asserts that no tenant configuration row was created or replaced — the only two ways the
|
||||
/// repository can write one (D-290).
|
||||
/// </summary>
|
||||
public static void VerifyNoTenantConfigWrite(this Mock<IConfigRepository> repo)
|
||||
{
|
||||
repo.Verify(r => r.TryCreateTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
repo.Verify(r => r.TryReplaceTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string>()), Times.Never);
|
||||
repo.Verify(r => r.TryReplaceTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backs <see cref="TenantConfigurationService.UpdateAsync"/> and
|
||||
/// <see cref="TenantConfigurationService.CreateOrUpdateAsync"/> of a service mock with one stored row
|
||||
/// and the real contract: the mutation sees a fresh copy (never the caller's object), the offboarding
|
||||
/// tombstone is refused, a declined mutation writes nothing, and every write is recorded.
|
||||
/// </summary>
|
||||
public static StoredTenantConfig StubWrites(this Mock<TenantConfigurationService> service, string tenantId, TenantConfiguration? row)
|
||||
{
|
||||
var store = new StoredTenantConfig { Row = row };
|
||||
|
||||
Task<TenantConfigUpdate> Run(Func<TenantConfiguration, bool> mutate, string source, string reason, bool createIfMissing)
|
||||
{
|
||||
if (store.Force is { } forced)
|
||||
return Task.FromResult(new TenantConfigUpdate(forced, null));
|
||||
if (store.ThrowOnRead is { } ex)
|
||||
throw ex;
|
||||
|
||||
TenantConfiguration fresh;
|
||||
if (store.Row == null)
|
||||
{
|
||||
if (!createIfMissing)
|
||||
return Task.FromResult(new TenantConfigUpdate(TenantConfigUpdateStatus.NotFound, null));
|
||||
fresh = TenantConfiguration.CreateDefault(tenantId);
|
||||
}
|
||||
else
|
||||
{
|
||||
fresh = Clone(store.Row);
|
||||
if (TenantOffboardFunction.IsOffboardingTombstone(fresh))
|
||||
return Task.FromResult(new TenantConfigUpdate(TenantConfigUpdateStatus.OffboardingInProgress, fresh));
|
||||
}
|
||||
|
||||
if (!mutate(fresh))
|
||||
return Task.FromResult(new TenantConfigUpdate(TenantConfigUpdateStatus.Declined, fresh));
|
||||
|
||||
fresh.LastUpdated = DateTime.UtcNow;
|
||||
store.Row = fresh;
|
||||
store.Writes.Add((fresh, source, reason));
|
||||
return Task.FromResult(new TenantConfigUpdate(TenantConfigUpdateStatus.Updated, fresh));
|
||||
}
|
||||
|
||||
service.Setup(x => x.UpdateAsync(tenantId, It.IsAny<Func<TenantConfiguration, bool>>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns((string _, Func<TenantConfiguration, bool> mutate, string source, string reason) => Run(mutate, source, reason, false));
|
||||
service.Setup(x => x.CreateOrUpdateAsync(tenantId, It.IsAny<Func<TenantConfiguration, bool>>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns((string _, Func<TenantConfiguration, bool> mutate, string source, string reason) => Run(mutate, source, reason, true));
|
||||
return store;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backs the tenant-configuration reads and writes of a repository mock with one stored row and
|
||||
/// the storage contract: every read hands back a fresh copy, the replace succeeds only with the
|
||||
/// current ETag, the insert only while no row exists, and every write is recorded.
|
||||
/// </summary>
|
||||
public static StoredTenantConfig BackWithRow(this Mock<IConfigRepository> repo, TenantConfiguration? row)
|
||||
{
|
||||
var store = new StoredTenantConfig { Row = row };
|
||||
var version = 0;
|
||||
string ETag() => $"etag-{version}";
|
||||
|
||||
TenantConfiguration? Read()
|
||||
{
|
||||
if (store.ThrowOnRead is { } ex) throw ex;
|
||||
return store.Row == null ? null : Clone(store.Row);
|
||||
}
|
||||
|
||||
bool Write(TenantConfiguration config, string source, string reason)
|
||||
{
|
||||
if (store.ThrowOnWrite is { } ex) throw ex;
|
||||
if (store.RejectWrites) return false;
|
||||
store.Row = Clone(config);
|
||||
version++;
|
||||
store.Writes.Add((store.Row, source, reason));
|
||||
return true;
|
||||
}
|
||||
|
||||
repo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>())).ReturnsAsync(() => Read());
|
||||
repo.Setup(r => r.GetTenantConfigurationWithEtagAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(() => Read() is { } fresh ? (fresh, ETag()) : null);
|
||||
repo.Setup(r => r.TryReplaceTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync((TenantConfiguration c, string etag, string? source, string? reason) =>
|
||||
store.Row != null && etag == ETag() && Write(c, source ?? "", reason ?? ""));
|
||||
repo.Setup(r => r.TryCreateTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
|
||||
.ReturnsAsync((TenantConfiguration c) => store.Row == null && Write(c, "(create)", ""));
|
||||
return store;
|
||||
}
|
||||
|
||||
/// <summary>A storage round trip: what a fresh read of the stored row hands back.</summary>
|
||||
public static TenantConfiguration Clone(TenantConfiguration config)
|
||||
=> TableConfigRepository.ConvertFromTenantTableEntity(TableConfigRepository.ConvertToTenantTableEntity(config));
|
||||
}
|
||||
|
||||
/// <summary>The stored row behind <see cref="ConfigRepoMockExtensions.StubWrites"/> and what was written to it.</summary>
|
||||
internal sealed class StoredTenantConfig
|
||||
{
|
||||
public TenantConfiguration? Row { get; set; }
|
||||
public List<(TenantConfiguration Row, string Source, string Reason)> Writes { get; } = new();
|
||||
|
||||
/// <summary>When set, every write returns this status without touching the row (e.g. Conflict).</summary>
|
||||
public TenantConfigUpdateStatus? Force { get; set; }
|
||||
|
||||
/// <summary>When set, every read (and, for the service stub, every write) throws it — a storage outage.</summary>
|
||||
public Exception? ThrowOnRead { get; set; }
|
||||
|
||||
/// <summary>Repository double only: when set, every write throws it.</summary>
|
||||
public Exception? ThrowOnWrite { get; set; }
|
||||
|
||||
/// <summary>Repository double only: every conditional write loses (as if a concurrent writer always won).</summary>
|
||||
public bool RejectWrites { get; set; }
|
||||
}
|
||||
@@ -713,31 +713,6 @@ public class ConfigWireParityTests
|
||||
|
||||
// ---- UpdateTenantConfiguration -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void UpdateTenantConfigurationResponse_matches_the_ack_plus_config_shape()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
DomainName = "contoso.com",
|
||||
UpdatedBy = "admin@contoso.com",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = "Configuration updated successfully",
|
||||
config = config
|
||||
},
|
||||
new UpdateTenantConfigurationResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "Configuration updated successfully",
|
||||
Config = config
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
|
||||
@@ -74,8 +74,7 @@ public class CrossTenantAccessTests
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET", "/api/config/{0}")]
|
||||
[InlineData("PUT", "/api/config/{0}")]
|
||||
[InlineData("POST", "/api/config/{0}")]
|
||||
[InlineData("PATCH", "/api/config/{0}/fields")]
|
||||
[InlineData("GET", "/api/config/{0}/feature-flags")]
|
||||
[InlineData("GET", "/api/tenants/{0}/admins")]
|
||||
[InlineData("POST", "/api/tenants/{0}/admins")]
|
||||
@@ -96,8 +95,7 @@ public class CrossTenantAccessTests
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET", "/api/config/{0}")]
|
||||
[InlineData("PUT", "/api/config/{0}")]
|
||||
[InlineData("POST", "/api/config/{0}")]
|
||||
[InlineData("PATCH", "/api/config/{0}/fields")]
|
||||
[InlineData("GET", "/api/config/{0}/feature-flags")]
|
||||
[InlineData("GET", "/api/tenants/{0}/admins")]
|
||||
[InlineData("POST", "/api/tenants/{0}/admins")]
|
||||
@@ -118,7 +116,7 @@ public class CrossTenantAccessTests
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET", "/api/config/{0}")]
|
||||
[InlineData("PUT", "/api/config/{0}")]
|
||||
[InlineData("PATCH", "/api/config/{0}/fields")]
|
||||
[InlineData("GET", "/api/tenants/{0}/admins")]
|
||||
[InlineData("DELETE", "/api/tenants/{0}/offboard")]
|
||||
public void CrossTenant_GlobalAdmin_IsAllowed(string httpMethod, string pathTemplate)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Azure.Core.Serialization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal HTTP endpoint harness: a mocked <see cref="HttpRequestData"/> whose FunctionContext carries the
|
||||
/// <see cref="RequestContext"/> the policy middleware would have resolved, serialized with the production
|
||||
/// wire settings.
|
||||
/// </summary>
|
||||
internal static class EndpointHarness
|
||||
{
|
||||
public static (HttpRequestData Req, FunctionContext Context) Request(
|
||||
string tenantId, string upn = "admin@contoso.com", string? jsonBody = null, string queryString = "",
|
||||
IDictionary<string, string>? headers = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer(ApiJsonOptions.Create()));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var items = new Dictionary<object, object>
|
||||
{
|
||||
["RequestContext"] = new RequestContext
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TargetTenantId = tenantId,
|
||||
UserPrincipalName = upn,
|
||||
IsTenantAdmin = true,
|
||||
UserRole = Constants.TenantRoles.Admin,
|
||||
},
|
||||
};
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(items);
|
||||
context.SetupGet(c => c.InstanceServices).Returns(provider);
|
||||
|
||||
var req = new Mock<HttpRequestData>(context.Object);
|
||||
var headerCollection = new HttpHeadersCollection();
|
||||
foreach (var (name, value) in headers ?? new Dictionary<string, string>())
|
||||
headerCollection.Add(name, value);
|
||||
req.SetupGet(r => r.Headers).Returns(headerCollection);
|
||||
// Query parses the Url in the base class (a bare mock would hand back null).
|
||||
req.SetupGet(r => r.Url).Returns(new Uri("https://localhost/api/test" + queryString));
|
||||
req.SetupGet(r => r.Query).CallBase();
|
||||
req.SetupGet(r => r.Body).Returns(new MemoryStream(Encoding.UTF8.GetBytes(jsonBody ?? "")));
|
||||
req.Setup(r => r.CreateResponse()).Returns(() => new FakeResponse(context.Object));
|
||||
return (req.Object, context.Object);
|
||||
}
|
||||
|
||||
/// <summary>The <c>code</c> of an error envelope; null when the body carries none.</summary>
|
||||
public static string? ErrorCode(HttpResponseData response)
|
||||
{
|
||||
response.Body.Position = 0;
|
||||
using var doc = JsonDocument.Parse(response.Body);
|
||||
return doc.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null;
|
||||
}
|
||||
|
||||
private sealed class FakeResponse : HttpResponseData
|
||||
{
|
||||
public FakeResponse(FunctionContext context) : base(context) { }
|
||||
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
|
||||
public override HttpHeadersCollection Headers { get; set; } = new();
|
||||
public override Stream Body { get; set; } = new MemoryStream();
|
||||
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -156,14 +156,17 @@ public class EndpointPolicyCatalogCompletenessTests
|
||||
public void SameRoute_DifferentMethods_ResolveToDifferentPolicies()
|
||||
{
|
||||
var getConfig = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/config/tenant-1");
|
||||
var putConfig = EndpointAccessPolicyCatalog.FindPolicy("PUT", "/api/config/tenant-1");
|
||||
var patchConfig = EndpointAccessPolicyCatalog.FindPolicy("PATCH", "/api/config/tenant-1/fields");
|
||||
|
||||
Assert.NotNull(getConfig);
|
||||
Assert.NotNull(putConfig);
|
||||
Assert.NotNull(patchConfig);
|
||||
// GET config read is member-tier (Operators/Viewers get the redacted read-only Settings
|
||||
// view; the handler redacts for every non-admin caller); PUT config write stays Admin/GA only.
|
||||
// view; the handler redacts for every non-admin caller); the field PATCH stays Admin/GA only.
|
||||
Assert.Equal(EndpointPolicy.MemberRead, getConfig.Policy);
|
||||
Assert.Equal(EndpointPolicy.TenantAdminOrGA, putConfig.Policy);
|
||||
Assert.Equal(EndpointPolicy.TenantAdminOrGA, patchConfig.Policy);
|
||||
// There is no full-model write of the tenant configuration (D-290).
|
||||
Assert.Null(EndpointAccessPolicyCatalog.FindPolicy("PUT", "/api/config/tenant-1"));
|
||||
Assert.Null(EndpointAccessPolicyCatalog.FindPolicy("POST", "/api/config/tenant-1"));
|
||||
|
||||
var getRules = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/rules/gather");
|
||||
var postRules = EndpointAccessPolicyCatalog.FindPolicy("POST", "/api/rules/gather");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -140,7 +141,7 @@ public class GenericWebhookTests
|
||||
Assert.Empty(config.GetGenericWebhookHeaders());
|
||||
}
|
||||
|
||||
// ── UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders ────
|
||||
// ── TenantConfigValidation.ValidateWebhookCustomHeaders ────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
@@ -149,7 +150,7 @@ public class GenericWebhookTests
|
||||
[InlineData("{\"Authorization\":\"Bearer abc\"}")]
|
||||
public void ValidateWebhookCustomHeaders_Accepts_EmptyOrValid(string? json)
|
||||
{
|
||||
Assert.Null(UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders(json));
|
||||
Assert.Null(TenantConfigValidation.ValidateWebhookCustomHeaders(json));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -160,7 +161,7 @@ public class GenericWebhookTests
|
||||
[InlineData("{\"X-Key\":\"line1\\nline2\"}", "must not contain line breaks")]
|
||||
public void ValidateWebhookCustomHeaders_Rejects_Invalid(string json, string expectedFragment)
|
||||
{
|
||||
var error = UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders(json);
|
||||
var error = TenantConfigValidation.ValidateWebhookCustomHeaders(json);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains(expectedFragment, error);
|
||||
}
|
||||
|
||||
@@ -153,8 +153,7 @@ public class McpUserServiceTenantMcpSwitchTests
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.True(result.IsAllowed);
|
||||
_configRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_configRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
_configRepo.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -218,14 +218,14 @@ public class NotificationChannelsTests
|
||||
Assert.Equal("https://second.example/hook", channels[1].Url);
|
||||
}
|
||||
|
||||
// ── Save-time validation (UpdateTenantConfigurationFunction) ─────────
|
||||
// ── Save-time validation (TenantConfigValidation) ─────────
|
||||
|
||||
[Fact]
|
||||
public void ValidateNotificationChannels_NullEmptyOrValidList_PassesThrough()
|
||||
{
|
||||
Assert.Null(UpdateTenantConfigurationFunction.ValidateNotificationChannels(null));
|
||||
Assert.Null(UpdateTenantConfigurationFunction.ValidateNotificationChannels(""));
|
||||
Assert.Null(UpdateTenantConfigurationFunction.ValidateNotificationChannels(
|
||||
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(null));
|
||||
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(""));
|
||||
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(
|
||||
"[" + ChannelJson("ch-1") + "," + ChannelJson("ch-2", providerType: 10) + "]"));
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ public class NotificationChannelsTests
|
||||
[InlineData("[{\"id\":\"a\",\"providerType\":20,\"url\":\"http://plain.example\"}]", "")] // non-https rejected by SsrfGuard format check
|
||||
public void ValidateNotificationChannels_RejectsInvalidEntries(string json, string expectedFragment)
|
||||
{
|
||||
var error = UpdateTenantConfigurationFunction.ValidateNotificationChannels(json);
|
||||
var error = TenantConfigValidation.ValidateNotificationChannels(json);
|
||||
Assert.NotNull(error);
|
||||
if (expectedFragment.Length > 0)
|
||||
Assert.Contains(expectedFragment, error);
|
||||
@@ -245,7 +245,7 @@ public class NotificationChannelsTests
|
||||
[Fact]
|
||||
public void ValidateNotificationChannels_RejectsDuplicateIds()
|
||||
{
|
||||
var error = UpdateTenantConfigurationFunction.ValidateNotificationChannels(
|
||||
var error = TenantConfigValidation.ValidateNotificationChannels(
|
||||
"[" + ChannelJson("ch-1") + "," + ChannelJson("ch-1") + "]");
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("duplicate", error);
|
||||
@@ -255,7 +255,7 @@ public class NotificationChannelsTests
|
||||
public void ValidateNotificationChannels_RejectsBadPerChannelHeaders()
|
||||
{
|
||||
var json = "[" + ChannelJson("ch-1", extra: ",\"customHeadersJson\":\"not-json\"") + "]";
|
||||
var error = UpdateTenantConfigurationFunction.ValidateNotificationChannels(json);
|
||||
var error = TenantConfigValidation.ValidateNotificationChannels(json);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("headers", error);
|
||||
}
|
||||
@@ -291,7 +291,7 @@ public class NotificationChannelsTests
|
||||
public void ValidateNotificationChannels_RejectsBadPerChannelSigningSecret()
|
||||
{
|
||||
var json = "[" + ChannelJson("ch-1", extra: ",\"signingSecret\":\"too-short\"") + "]";
|
||||
var error = UpdateTenantConfigurationFunction.ValidateNotificationChannels(json);
|
||||
var error = TenantConfigValidation.ValidateNotificationChannels(json);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("signing secret", error);
|
||||
}
|
||||
|
||||
+6
@@ -88,9 +88,14 @@ internal sealed class FakeOffboardingAuditRepository : IOffboardingAuditReposito
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>When set, every pointer read throws — a storage outage on the re-onboarding lookup.</summary>
|
||||
public Exception? ThrowOnPointerRead { get; set; }
|
||||
|
||||
public Task<(OffboardingByTenantPointer? Pointer, string? ETag)> TryGetByTenantPointerAsync(
|
||||
string normalizedTenantId, CancellationToken ct = default)
|
||||
{
|
||||
if (ThrowOnPointerRead is { } ex)
|
||||
throw ex;
|
||||
if (Pointers.TryGetValue(normalizedTenantId, out var entry))
|
||||
{
|
||||
return Task.FromResult<(OffboardingByTenantPointer?, string?)>((Clone(entry.Pointer), entry.ETag));
|
||||
@@ -152,5 +157,6 @@ internal sealed class FakeOffboardingAuditRepository : IOffboardingAuditReposito
|
||||
PartitionKey = p.PartitionKey, RowKey = p.RowKey, TenantId = p.TenantId,
|
||||
LatestHistoryRowKey = p.LatestHistoryRowKey, LatestStatus = p.LatestStatus,
|
||||
LatestUpdatedAt = p.LatestUpdatedAt, OffboardCount = p.OffboardCount,
|
||||
TrialConsumed = p.TrialConsumed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,7 +175,8 @@ public class OpsEventFieldFilterTests
|
||||
// unfiltered rows — the exact bug this test exists to prevent.
|
||||
var filters = new OpsEventQueryFilters { EventType = "AgentEmergencyBreak", MinSeverity = "Error" };
|
||||
|
||||
var fanOut = TableOpsEventRepository.BuildFilterWithRowKeyBound("Agent", null, null, "0009", filters);
|
||||
var fanOut = TableOpsEventRepository.BuildFilterWithRowKeyBound(
|
||||
"Agent", null, null, new PerPartitionFanOutMerge.RowKeyBound("0009", Inclusive: false), filters);
|
||||
|
||||
Assert.Contains("EventType eq 'AgentEmergencyBreak'", fanOut);
|
||||
Assert.Contains("(Severity eq 'Error' or Severity eq 'Critical')", fanOut);
|
||||
|
||||
@@ -1,325 +1,283 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using static AutopilotMonitor.Functions.DataAccess.TableStorage.PerPartitionFanOutMerge;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Cross-partition merge used by both the global all-category ops-events page
|
||||
/// and the global cross-tenant audit-logs page. Without this fan-out, Azure
|
||||
/// pages cross-partition queries by (PK asc, RK asc) — so page 1 would be
|
||||
/// entirely the alphabetically-first partition, masking newer items from
|
||||
/// later-alphabet partitions. The merge picks top-pageSize by timestamp across
|
||||
/// all per-partition fetches.
|
||||
/// Cross-partition paging behind the global all-category ops-events page and the global
|
||||
/// cross-tenant audit-log page. Every page fans out one read per partition and merges by
|
||||
/// (RowKey, Partition); the continuation is the watermark of the last returned row. The tests
|
||||
/// drive the helper against an in-memory partition store that honours the RowKey bound exactly
|
||||
/// like the OData clause does.
|
||||
/// </summary>
|
||||
public class PerPartitionFanOutMergeTests
|
||||
{
|
||||
private static OpsEventEntry Entry(string partition, DateTime ts, string id)
|
||||
=> new() { Category = partition, Id = id, Timestamp = ts, EventType = "synthetic" };
|
||||
|
||||
private static (string RowKey, OpsEventEntry Item) Row(DateTime ts, string id)
|
||||
// Reverse-tick RowKey like the storage layer writes it: a smaller string is a newer row.
|
||||
private static string Rk(int minutesAgo, string suffix = "")
|
||||
{
|
||||
// Mimics the storage-layer revtick rowkey: smaller string = newer timestamp.
|
||||
var rk = $"{(DateTime.MaxValue.Ticks - ts.Ticks):D19}_{id}";
|
||||
return (rk, Entry(partition: "<set-by-caller>", ts, id));
|
||||
var t0 = new DateTime(2026, 9, 28, 12, 0, 0, DateTimeKind.Utc);
|
||||
return $"{DateTime.MaxValue.Ticks - t0.AddMinutes(-minutesAgo).Ticks:D19}{suffix}";
|
||||
}
|
||||
|
||||
private static PartitionFetchResult<OpsEventEntry> Fetched(
|
||||
string partition, params (string RowKey, OpsEventEntry Item)[] rows)
|
||||
private sealed class Store
|
||||
{
|
||||
var aligned = rows
|
||||
.Select(r => (r.RowKey, Item: new OpsEventEntry
|
||||
private readonly Dictionary<string, List<string>> _rows;
|
||||
private readonly Func<string, bool> _matches;
|
||||
public List<(string Partition, RowKeyBound? Bound, int Limit)> Calls { get; } = new();
|
||||
|
||||
public Store(Dictionary<string, List<string>> rows, Func<string, bool>? matches = null)
|
||||
{
|
||||
_rows = rows;
|
||||
_matches = matches ?? (_ => true);
|
||||
}
|
||||
|
||||
public IEnumerable<string> Partitions => _rows.Keys;
|
||||
|
||||
public PartitionReader<string> Reader => (partition, bound, limit, _) =>
|
||||
{
|
||||
lock (Calls) Calls.Add((partition, bound, limit));
|
||||
var read = _rows[partition]
|
||||
.Where(_matches)
|
||||
.Where(rk => bound == null
|
||||
|| (bound.Inclusive
|
||||
? string.CompareOrdinal(rk, bound.RowKey) >= 0
|
||||
: string.CompareOrdinal(rk, bound.RowKey) > 0))
|
||||
.OrderBy(rk => rk, StringComparer.Ordinal)
|
||||
.Take(limit)
|
||||
.Select(rk => (rk, $"{partition}/{rk}"))
|
||||
.ToList();
|
||||
return Task.FromResult(read);
|
||||
};
|
||||
|
||||
/// <summary>The full newest-first order the pages must reproduce.</summary>
|
||||
public List<string> Expected() => _rows
|
||||
.SelectMany(kv => kv.Value.Where(_matches).Select(rk => (Partition: kv.Key, RowKey: rk)))
|
||||
.OrderBy(r => r.RowKey, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.Partition, StringComparer.Ordinal)
|
||||
.Select(r => $"{r.Partition}/{r.RowKey}")
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<List<List<string>>> WalkAsync(Store store, int pageSize)
|
||||
{
|
||||
var pages = new List<List<string>>();
|
||||
string? continuation = null;
|
||||
do
|
||||
{
|
||||
var page = await FetchPageAsync(store.Partitions, pageSize, continuation, store.Reader);
|
||||
pages.Add(page.Items.ToList());
|
||||
continuation = page.NextRawToken;
|
||||
if (pages.Count > 1000) Assert.Fail("Walk did not terminate");
|
||||
}
|
||||
while (continuation != null);
|
||||
return pages;
|
||||
}
|
||||
|
||||
// ────────── Order ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task First_page_is_newest_across_partitions_not_alphabetically_first()
|
||||
{
|
||||
var store = new Store(new()
|
||||
{
|
||||
["Agent"] = new() { Rk(5) },
|
||||
["Security"] = new() { Rk(0) },
|
||||
["Tenant"] = new() { Rk(10) },
|
||||
});
|
||||
|
||||
var page = await FetchPageAsync(store.Partitions, 3, null, store.Reader);
|
||||
|
||||
Assert.Equal(new[] { $"Security/{Rk(0)}", $"Agent/{Rk(5)}", $"Tenant/{Rk(10)}" }, page.Items);
|
||||
Assert.Null(page.NextRawToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task First_page_reads_every_partition_unbounded_with_one_row_of_lookahead()
|
||||
{
|
||||
var store = new Store(new() { ["A"] = new() { Rk(1) }, ["B"] = new(), ["C"] = new() { Rk(2) } });
|
||||
|
||||
await FetchPageAsync(store.Partitions, 15, null, store.Reader);
|
||||
|
||||
Assert.Equal(new[] { "A", "B", "C" }, store.Calls.Select(c => c.Partition).OrderBy(p => p));
|
||||
Assert.All(store.Calls, c => Assert.Null(c.Bound));
|
||||
Assert.All(store.Calls, c => Assert.Equal(16, c.Limit));
|
||||
}
|
||||
|
||||
// ────────── Watermark and bounds ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Bound_is_exclusive_up_to_the_watermark_partition_and_inclusive_after_it()
|
||||
{
|
||||
var w = new Watermark("0000000000000000042", "Maintenance");
|
||||
|
||||
Assert.Null(BoundAfter(null, "Agent"));
|
||||
Assert.Equal(new RowKeyBound(w.RowKey, Inclusive: false), BoundAfter(w, "Agent"));
|
||||
Assert.Equal(new RowKeyBound(w.RowKey, Inclusive: false), BoundAfter(w, "Maintenance"));
|
||||
Assert.Equal(new RowKeyBound(w.RowKey, Inclusive: true), BoundAfter(w, "Security"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bound_renders_as_an_escaped_odata_rowkey_clause()
|
||||
{
|
||||
Assert.Equal("RowKey gt '!0042_a'", new RowKeyBound("!0042_a", false).ToODataClause());
|
||||
Assert.Equal("RowKey ge 'it''s'", new RowKeyBound("it's", true).ToODataClause());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Equal_rowkeys_in_different_partitions_all_survive_a_page_boundary()
|
||||
{
|
||||
// Ops-event RowKeys are bare reverse ticks, so two categories can share one. With a
|
||||
// RowKey-only watermark the second row would be skipped after the cut.
|
||||
var shared = Rk(3);
|
||||
var store = new Store(new()
|
||||
{
|
||||
["Agent"] = new() { shared },
|
||||
["Security"] = new() { shared },
|
||||
["Tenant"] = new() { shared, Rk(4) },
|
||||
});
|
||||
|
||||
var pages = await WalkAsync(store, pageSize: 1);
|
||||
|
||||
Assert.Equal(store.Expected(), pages.SelectMany(p => p));
|
||||
Assert.Equal(4, pages.Count);
|
||||
}
|
||||
|
||||
// ────────── Exact end of list ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task No_continuation_when_the_rows_exactly_fill_the_page()
|
||||
{
|
||||
var store = new Store(new() { ["A"] = new() { Rk(1), Rk(2), Rk(3) }, ["B"] = new() });
|
||||
|
||||
var page = await FetchPageAsync(store.Partitions, 3, null, store.Reader);
|
||||
|
||||
Assert.Equal(3, page.Items.Count);
|
||||
Assert.Null(page.NextRawToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Continuation_when_one_row_is_left_over()
|
||||
{
|
||||
var store = new Store(new() { ["A"] = new() { Rk(1), Rk(2), Rk(3), Rk(4) } });
|
||||
|
||||
var page = await FetchPageAsync(store.Partitions, 3, null, store.Reader);
|
||||
|
||||
Assert.Equal(3, page.Items.Count);
|
||||
Assert.NotNull(page.NextRawToken);
|
||||
}
|
||||
|
||||
// ────────── Full walks ─────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(5)]
|
||||
[InlineData(15)]
|
||||
[InlineData(100)]
|
||||
public async Task Walk_returns_every_row_once_in_global_order_and_never_an_empty_page(int pageSize)
|
||||
{
|
||||
// Uneven partitions: one empty, one dominant, interleaved timestamps and a tie.
|
||||
var store = new Store(new()
|
||||
{
|
||||
["00000000-0000-0000-0000-000000000000"] = new() { Rk(0, "_g0"), Rk(40, "_g1") },
|
||||
["1a"] = new() { Rk(1, "_a"), Rk(2, "_a"), Rk(3, "_a"), Rk(4, "_a"), Rk(5, "_a"), Rk(6, "_a"), Rk(30, "_a") },
|
||||
["2b"] = new(),
|
||||
["3c"] = new() { Rk(2, "_c"), Rk(7, "_c"), Rk(8, "_c") },
|
||||
["4d"] = new() { Rk(9, "_a") },
|
||||
["5e"] = new() { Rk(9, "_a"), Rk(50, "_e") },
|
||||
});
|
||||
|
||||
var pages = await WalkAsync(store, pageSize);
|
||||
|
||||
var expected = store.Expected();
|
||||
Assert.Equal(expected, pages.SelectMany(p => p));
|
||||
Assert.All(pages, p => Assert.NotEmpty(p));
|
||||
Assert.All(pages.Take(pages.Count - 1), p => Assert.Equal(pageSize, p.Count));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Walk_with_a_server_side_filter_returns_exactly_the_matching_rows()
|
||||
{
|
||||
// The reader applies the filter (like `Action ne 'deletion_started'`), so excluded rows
|
||||
// never take a page slot and never become a watermark.
|
||||
var store = new Store(
|
||||
new()
|
||||
{
|
||||
Id = r.Item.Id,
|
||||
Category = partition,
|
||||
EventType = r.Item.EventType,
|
||||
Timestamp = r.Item.Timestamp,
|
||||
}))
|
||||
.ToArray();
|
||||
return new PartitionFetchResult<OpsEventEntry>(partition, aligned);
|
||||
}
|
||||
["A"] = new() { Rk(1, "_keep"), Rk(2, "_drop"), Rk(3, "_drop"), Rk(4, "_keep") },
|
||||
["B"] = new() { Rk(2, "_drop"), Rk(5, "_keep"), Rk(6, "_drop") },
|
||||
},
|
||||
matches: rk => rk.EndsWith("_keep", StringComparison.Ordinal));
|
||||
|
||||
// ────────── Merge picks newest-across-partitions, not within-partition ──
|
||||
var pages = await WalkAsync(store, pageSize: 2);
|
||||
|
||||
[Fact]
|
||||
public void First_page_returns_newest_across_partitions_not_alphabetically_first()
|
||||
{
|
||||
// Without the merge, Azure's (PK asc, RK asc) ordering would surface
|
||||
// "Agent" partition items first (alphabetically), even though "Security"
|
||||
// has a newer event. The merge must pick by timestamp, not partition.
|
||||
var t0 = DateTime.UtcNow;
|
||||
var fetched = new[]
|
||||
{
|
||||
Fetched("Agent", Row(t0.AddMinutes(-5), "agent-1")),
|
||||
Fetched("Security", Row(t0, "sec-1")),
|
||||
Fetched("Tenant", Row(t0.AddMinutes(-10), "tenant-1")),
|
||||
};
|
||||
|
||||
var (items, _) = MergeAndAdvance(
|
||||
fetched,
|
||||
new Dictionary<string, PartitionContinuation>(),
|
||||
pageSize: 3,
|
||||
timestampSelector: e => e.Timestamp);
|
||||
|
||||
Assert.Equal(3, items.Count);
|
||||
Assert.Equal("sec-1", items[0].Id); // newest first
|
||||
Assert.Equal("agent-1", items[1].Id);
|
||||
Assert.Equal("tenant-1", items[2].Id);
|
||||
Assert.Equal(store.Expected(), pages.SelectMany(p => p));
|
||||
Assert.Equal(3, store.Expected().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_fetch_drops_partition_from_continuation_map()
|
||||
public async Task Empty_store_yields_one_empty_page_without_continuation()
|
||||
{
|
||||
// Wire-format v2 invariant: exhausted partitions are absent from the
|
||||
// continuation, not encoded with `x:true`. Consumers treat "missing"
|
||||
// as exhausted on subsequent pages — that's what shrinks the audit
|
||||
// fan-out token from 30+ KB to a few hundred bytes.
|
||||
var t0 = DateTime.UtcNow;
|
||||
var fetched = new[]
|
||||
{
|
||||
Fetched("Agent"), // empty fetch
|
||||
Fetched("Security", Row(t0, "sec-1")),
|
||||
};
|
||||
var store = new Store(new() { ["A"] = new(), ["B"] = new() });
|
||||
|
||||
var (_, conts) = MergeAndAdvance(
|
||||
fetched,
|
||||
new Dictionary<string, PartitionContinuation>(),
|
||||
pageSize: 5,
|
||||
timestampSelector: e => e.Timestamp);
|
||||
var page = await FetchPageAsync(store.Partitions, 15, null, store.Reader);
|
||||
|
||||
Assert.False(conts.ContainsKey("Agent"));
|
||||
Assert.True(conts.ContainsKey("Security"));
|
||||
Assert.Empty(page.Items);
|
||||
Assert.Null(page.NextRawToken);
|
||||
}
|
||||
|
||||
// ────────── Continuation advance: avoid losing leftovers when a partition loses the cut ──
|
||||
// ────────── Token ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Continuation_keeps_position_for_partitions_whose_items_lost_the_merge_cut()
|
||||
public async Task Continuation_size_does_not_grow_with_the_number_of_partitions()
|
||||
{
|
||||
// Page 1: pageSize=3. Agent fetched 5 items, Security fetched 5 items, but
|
||||
// Security's are all newer. Merge takes 3 from Security; Agent's 5 items
|
||||
// all lost the cut. Agent's continuation MUST stay at its previous
|
||||
// position so the items get re-fetched and compete on page 2.
|
||||
var t0 = DateTime.UtcNow;
|
||||
var fetched = new[]
|
||||
{
|
||||
Fetched("Agent",
|
||||
Row(t0.AddMinutes(-100), "a1"),
|
||||
Row(t0.AddMinutes(-101), "a2"),
|
||||
Row(t0.AddMinutes(-102), "a3"),
|
||||
Row(t0.AddMinutes(-103), "a4"),
|
||||
Row(t0.AddMinutes(-104), "a5")),
|
||||
Fetched("Security",
|
||||
Row(t0, "s1"),
|
||||
Row(t0.AddMinutes(-1), "s2"),
|
||||
Row(t0.AddMinutes(-2), "s3"),
|
||||
Row(t0.AddMinutes(-3), "s4"),
|
||||
Row(t0.AddMinutes(-4), "s5")),
|
||||
};
|
||||
// The per-partition map this replaced grew with every partition holding rows until the
|
||||
// nextLink failed with HTTP 414. Every partition here has rows that lose the merge cut.
|
||||
static Store Tenants(int count) => new(Enumerable.Range(0, count).ToDictionary(
|
||||
i => Guid.Parse($"00000000-0000-0000-0000-{i:D12}").ToString(),
|
||||
i => new List<string> { Rk(i, "_" + Guid.Empty.ToString("N")), Rk(i + 1000, "_" + Guid.Empty.ToString("N")) }));
|
||||
|
||||
var (items, conts) = MergeAndAdvance(
|
||||
fetched,
|
||||
new Dictionary<string, PartitionContinuation>(),
|
||||
pageSize: 3,
|
||||
timestampSelector: e => e.Timestamp);
|
||||
var few = await FetchPageAsync(Tenants(3).Partitions, 1, null, Tenants(3).Reader);
|
||||
var many = await FetchPageAsync(Tenants(500).Partitions, 1, null, Tenants(500).Reader);
|
||||
|
||||
// Security wins all three slots.
|
||||
Assert.Equal(new[] { "s1", "s2", "s3" }, items.Select(i => i.Id).ToArray());
|
||||
|
||||
// Agent had no items returned and a non-empty fetch → keep prior continuation
|
||||
// (null = restart). The partition stays in the map (active with no rk),
|
||||
// so the next page will re-fetch its items and they'll be re-considered
|
||||
// at a lower floor.
|
||||
Assert.True(conts.ContainsKey("Agent"));
|
||||
Assert.Null(conts["Agent"].LastRowKey);
|
||||
|
||||
Assert.True(conts.ContainsKey("Security"));
|
||||
Assert.NotNull(conts["Security"].LastRowKey);
|
||||
Assert.NotNull(many.NextRawToken);
|
||||
Assert.Equal(few.NextRawToken!.Length, many.NextRawToken!.Length);
|
||||
Assert.True(many.NextRawToken.Length < 200, $"raw continuation is {many.NextRawToken.Length} characters");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Continuation_advances_to_oldest_returned_rowkey_per_partition()
|
||||
public void Watermark_round_trips()
|
||||
{
|
||||
// When merge takes some-but-not-all from a partition, advance only past the
|
||||
// items we actually returned. The leftover (older) items in that fetch
|
||||
// will be re-fetched and re-considered on the next page.
|
||||
var t0 = DateTime.UtcNow;
|
||||
var (rkNew, _) = Row(t0, "x-new");
|
||||
var (rkMid, _) = Row(t0.AddMinutes(-5), "x-mid");
|
||||
var (rkOld, _) = Row(t0.AddMinutes(-10), "x-old");
|
||||
var fetched = new[]
|
||||
{
|
||||
Fetched("Agent",
|
||||
(rkNew, Entry("Agent", t0, "x-new")),
|
||||
(rkMid, Entry("Agent", t0.AddMinutes(-5), "x-mid")),
|
||||
(rkOld, Entry("Agent", t0.AddMinutes(-10), "x-old"))),
|
||||
// A second partition whose only event is between Agent's mid and old
|
||||
// items, ensuring the merge ends after Agent's mid item.
|
||||
Fetched("Security",
|
||||
Row(t0.AddMinutes(-7), "s-only")),
|
||||
};
|
||||
var w = new Watermark("!2516116980007133310_a4bb51498cad468f89431b15f1e701f7", "tenant-a");
|
||||
|
||||
var (items, conts) = MergeAndAdvance(
|
||||
fetched,
|
||||
new Dictionary<string, PartitionContinuation>(),
|
||||
pageSize: 3,
|
||||
timestampSelector: e => e.Timestamp);
|
||||
|
||||
// Top 3 by timestamp desc: x-new, x-mid, s-only. x-old is dropped.
|
||||
Assert.Equal(new[] { "x-new", "x-mid", "s-only" }, items.Select(i => i.Id).ToArray());
|
||||
|
||||
// Agent's continuation advances to rkMid (the oldest Agent rowkey we returned).
|
||||
// x-old will be re-fetched and re-considered next page (filter: RowKey gt rkMid).
|
||||
Assert.Equal(rkMid, conts["Agent"].LastRowKey);
|
||||
}
|
||||
|
||||
// ────────── Codec round-trip ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Encode_then_decode_round_trips_active_partitions()
|
||||
{
|
||||
var original = new Dictionary<string, PartitionContinuation>
|
||||
{
|
||||
["Agent"] = new PartitionContinuation("0123456789012345678_aaa"),
|
||||
["Consent"] = new PartitionContinuation("0987654321098765432_bbb"),
|
||||
["Tenant"] = new PartitionContinuation(null), // active, restart from top
|
||||
};
|
||||
|
||||
var encoded = EncodeMultiContinuation(original);
|
||||
var decoded = DecodeMultiContinuation(encoded);
|
||||
|
||||
Assert.Equal(3, decoded.Count);
|
||||
Assert.Equal("0123456789012345678_aaa", decoded["Agent"].LastRowKey);
|
||||
Assert.Equal("0987654321098765432_bbb", decoded["Consent"].LastRowKey);
|
||||
Assert.Null(decoded["Tenant"].LastRowKey);
|
||||
Assert.Equal(w, DecodeWatermark(EncodeWatermark(w)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_silently_drops_legacy_v1_exhausted_entries()
|
||||
public void Decode_rejects_anything_that_is_not_a_v3_watermark()
|
||||
{
|
||||
// Wire format v1 (pre-fix) explicitly encoded `x:true` for exhausted
|
||||
// partitions; v2 just omits them. Tokens that were already in flight
|
||||
// when the deploy went out must continue to page through cleanly —
|
||||
// those legacy `x:true` entries are dropped silently so the
|
||||
// consumer's "missing = exhausted" rule applies.
|
||||
var legacyJson = """{"v":1,"c":{"Agent":{"rk":"0123456789012345678_aaa","x":false},"Security":{"rk":null,"x":true},"Consent":{"rk":"0987654321098765432_bbb","x":false}}}""";
|
||||
var legacyToken = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(legacyJson));
|
||||
static string B64(string json) => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(json));
|
||||
|
||||
var decoded = DecodeMultiContinuation(legacyToken);
|
||||
|
||||
Assert.Equal(2, decoded.Count);
|
||||
Assert.True(decoded.ContainsKey("Agent"));
|
||||
Assert.True(decoded.ContainsKey("Consent"));
|
||||
Assert.False(decoded.ContainsKey("Security")); // dropped: was x:true in v1 wire
|
||||
Assert.Null(DecodeWatermark(null));
|
||||
Assert.Null(DecodeWatermark(""));
|
||||
Assert.Null(DecodeWatermark("not-base64-!@#"));
|
||||
Assert.Null(DecodeWatermark(B64("this is not json")));
|
||||
Assert.Null(DecodeWatermark(B64("""{"v":2,"c":{"Agent":{"rk":"0123"}}}""")));
|
||||
Assert.Null(DecodeWatermark(B64("""{"v":3,"rk":"","p":"Agent"}""")));
|
||||
Assert.Null(DecodeWatermark(B64("""{"v":3,"rk":"0123"}""")));
|
||||
Assert.Null(DecodeWatermark(B64("""{"v":"3","rk":"0123","p":"Agent"}""")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encode_omits_partition_state_field_to_keep_token_compact()
|
||||
public async Task A_token_from_an_older_deploy_ends_the_list_without_reading_storage()
|
||||
{
|
||||
// Sanity-check the wire compactness gain: 200 partitions of v1 with
|
||||
// `x:false` per entry produced ~30 KB after base64; v2 should be
|
||||
// dramatically smaller because we drop the redundant flag and only
|
||||
// encode active entries.
|
||||
var many = Enumerable.Range(0, 200)
|
||||
.ToDictionary(i => $"tenant-{i:D3}", i => new PartitionContinuation($"!1234567890123456789_{i:D8}"));
|
||||
var store = new Store(new() { ["Agent"] = new() { Rk(1) } });
|
||||
var v2 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("""{"v":2,"c":{"Agent":{"rk":null}}}"""));
|
||||
|
||||
var encoded = EncodeMultiContinuation(many);
|
||||
var page = await FetchPageAsync(store.Partitions, 15, v2, store.Reader);
|
||||
|
||||
// 200 entries × (~30 chars name + ~30 chars rk + JSON overhead) → JSON
|
||||
// ~12 KB → base64 ~16 KB. The legacy `x:false` flag adds another
|
||||
// ~10 bytes per entry (~2 KB total) which we now save. The hard
|
||||
// assertion below is on absolute size: we must stay well below the
|
||||
// ~30 KB that broke audit Next clicks.
|
||||
Assert.True(encoded.Length < 20_000,
|
||||
$"Encoded continuation should stay under 20 KB even at 200 partitions; got {encoded.Length} bytes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_returns_empty_for_malformed_or_empty_continuation()
|
||||
{
|
||||
Assert.Empty(DecodeMultiContinuation(null));
|
||||
Assert.Empty(DecodeMultiContinuation(""));
|
||||
Assert.Empty(DecodeMultiContinuation("not-base64-!@#"));
|
||||
Assert.Empty(DecodeMultiContinuation("dGhpcyBpcyBub3QganNvbg==")); // valid base64, not JSON
|
||||
}
|
||||
|
||||
// ────────── End-to-end: drain across pages preserves all items, in order ──
|
||||
|
||||
[Fact]
|
||||
public void Multi_page_drain_returns_all_items_globally_newest_first_no_duplicates()
|
||||
{
|
||||
var t0 = new DateTime(2026, 5, 5, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var allItems = new List<(string Part, OpsEventEntry Item, string Rk)>();
|
||||
var partitions = new[] { "Agent", "Security", "Tenant" };
|
||||
var minute = 0;
|
||||
foreach (var part in partitions)
|
||||
{
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
minute++;
|
||||
var ts = t0.AddMinutes(-minute);
|
||||
var (rk, _) = Row(ts, $"{part}-{i}");
|
||||
allItems.Add((part, new OpsEventEntry
|
||||
{
|
||||
Category = part,
|
||||
Id = $"{part}-{i}",
|
||||
Timestamp = ts,
|
||||
EventType = "synthetic",
|
||||
}, rk));
|
||||
}
|
||||
}
|
||||
|
||||
IReadOnlyList<PartitionFetchResult<OpsEventEntry>> SimulateFetch(
|
||||
Dictionary<string, PartitionContinuation> continuations, int pageSize, bool isFirstPage)
|
||||
{
|
||||
var fetched = new List<PartitionFetchResult<OpsEventEntry>>();
|
||||
foreach (var part in partitions)
|
||||
{
|
||||
// Mirrors the consumer rule: on first page query everything; on
|
||||
// subsequent pages only partitions still in the continuation.
|
||||
if (!isFirstPage && !continuations.ContainsKey(part)) continue;
|
||||
continuations.TryGetValue(part, out var prior);
|
||||
var lastRk = prior?.LastRowKey;
|
||||
|
||||
var rows = allItems
|
||||
.Where(t => t.Part == part)
|
||||
.Where(t => lastRk == null || string.Compare(t.Rk, lastRk, StringComparison.Ordinal) > 0)
|
||||
.OrderBy(t => t.Rk, StringComparer.Ordinal)
|
||||
.Take(pageSize)
|
||||
.Select(t => (t.Rk, t.Item))
|
||||
.ToList();
|
||||
fetched.Add(new PartitionFetchResult<OpsEventEntry>(part, rows));
|
||||
}
|
||||
return fetched;
|
||||
}
|
||||
|
||||
const int pageSize = 5;
|
||||
var seen = new List<OpsEventEntry>();
|
||||
var conts = new Dictionary<string, PartitionContinuation>();
|
||||
var loopGuard = 0;
|
||||
bool isFirstPage = true;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var fetched = SimulateFetch(conts, pageSize, isFirstPage);
|
||||
var (page, nextConts) = MergeAndAdvance(fetched, conts, pageSize, e => e.Timestamp);
|
||||
seen.AddRange(page);
|
||||
conts = nextConts;
|
||||
isFirstPage = false;
|
||||
|
||||
// Map only carries active partitions now — empty == drain complete.
|
||||
if (conts.Count == 0) break;
|
||||
if (page.Count == 0) break;
|
||||
if (++loopGuard > 50) Assert.Fail("Drain loop did not terminate");
|
||||
}
|
||||
|
||||
Assert.Equal(12, seen.Count);
|
||||
Assert.Equal(12, seen.Select(e => e.Id).Distinct().Count());
|
||||
for (int i = 1; i < seen.Count; i++)
|
||||
{
|
||||
Assert.True(seen[i - 1].Timestamp >= seen[i].Timestamp,
|
||||
$"Out-of-order at index {i}: {seen[i - 1].Id}({seen[i - 1].Timestamp}) before {seen[i].Id}({seen[i].Timestamp})");
|
||||
}
|
||||
Assert.Empty(page.Items);
|
||||
Assert.Null(page.NextRawToken);
|
||||
Assert.Empty(store.Calls);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +346,7 @@ public class PolicyEnforcementMiddlewareTests
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
|
||||
// PUT config/{tenantId} is TenantAdminOrGA (write). Route tenant == JWT tenant ⇒ own-tenant write.
|
||||
var result = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, upn));
|
||||
var result = await h.Middleware.DecideAsync("PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.True(result.Allowed); // additive: the tenant-admin hat still grants write in the own tenant
|
||||
}
|
||||
@@ -361,7 +361,7 @@ public class PolicyEnforcementMiddlewareTests
|
||||
h.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
|
||||
// No tenant membership ⇒ no write path.
|
||||
|
||||
var result = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, upn));
|
||||
var result = await h.Middleware.DecideAsync("PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(403, result.StatusCode);
|
||||
@@ -700,7 +700,7 @@ public class PolicyEnforcementMiddlewareTests
|
||||
h.AsDelegated(TenantB, Constants.DelegatedRoles.DelegatedAdmin); // even a DelegatedAdmin row…
|
||||
|
||||
// PUT config/{B} is TenantAdminOrGA (write). Phase 1: delegation never satisfies a write tier.
|
||||
var result = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
|
||||
var result = await h.Middleware.DecideAsync("PATCH", $"/api/config/{TenantB}/fields", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(403, result.StatusCode);
|
||||
@@ -986,9 +986,7 @@ public class PolicyEnforcementMiddlewareTests
|
||||
|
||||
Assert.True(result.Allowed); // the delegated read itself is allowed…
|
||||
// …but no config row was persisted for the MSP's home tenant A (nor any tenant).
|
||||
h.ConfigRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
h.ConfigRepo.Verify(r => r.SaveTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
h.ConfigRepo.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
|
||||
// ── Phase 2a: delegated single-tenant access to cross-tenant /api/global/* read endpoints ──
|
||||
@@ -1547,7 +1545,7 @@ public class PolicyEnforcementMiddlewareTests
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
AsSuspended(h, TenantA); // indefinite suspension (no DisabledUntil)
|
||||
|
||||
var result = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, upn));
|
||||
var result = await h.Middleware.DecideAsync("PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal("TenantSuspended", result.ErrorCode);
|
||||
@@ -1576,7 +1574,7 @@ public class PolicyEnforcementMiddlewareTests
|
||||
AsSuspended(h, TenantB);
|
||||
|
||||
var own = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
|
||||
var cross = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
|
||||
var cross = await h.Middleware.DecideAsync("PATCH", $"/api/config/{TenantB}/fields", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.True(own.Allowed);
|
||||
Assert.True(cross.Allowed);
|
||||
|
||||
@@ -43,8 +43,10 @@ public sealed class ProConferralServiceTests
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.Setup(c => c.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((string id) => rows.TryGetValue(id, out var cfg) ? cfg : null);
|
||||
configRepo.Setup(c => c.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync((TenantConfiguration cfg, string? source, string? reason) => { saves.Add((cfg, source, reason)); return true; });
|
||||
configRepo.Setup(c => c.GetTenantConfigurationWithEtagAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((string id) => rows.TryGetValue(id, out var cfg) ? (cfg, "etag") : null);
|
||||
configRepo.Setup(c => c.TryReplaceTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), "etag", It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync((TenantConfiguration cfg, string _, string? source, string? reason) => { saves.Add((cfg, source, reason)); return true; });
|
||||
|
||||
var adminRepo = new Mock<IAdminRepository>();
|
||||
adminRepo.Setup(a => a.GetGroupTenantsAsync(Constants.TenantGroupIds.ForHomeTenant(Home))).ReturnsAsync(members.ToList());
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
using System.Text.Json;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Functions.Apps;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// RealmJoin packages as <c>AppInstallSummaries</c> rows (<see cref="AppInstallSources.RealmJoin"/>):
|
||||
/// the ingest fold of <c>realmjoin_package_started/completed</c>, the scope + package-id row
|
||||
/// identity, the store-side channel columns, and the channel dimension of the per-app aggregates
|
||||
/// (an Intune app and a RealmJoin package with the same display name never merge).
|
||||
/// </summary>
|
||||
public class RealmJoinPackageInstallRowsTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 9, 17, 8, 29, 26, DateTimeKind.Utc);
|
||||
private const string SessionId = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
private static Dictionary<string, AppInstallAggregationState> Aggregate(params EnrollmentEvent[] events)
|
||||
{
|
||||
// Same comparer as the production classification (EventIngestProcessor.Classification).
|
||||
var summaries = new Dictionary<string, AppInstallAggregationState>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var evt in events)
|
||||
EventIngestProcessor.AggregateAppInstallEvent(evt, "tenant", SessionId, summaries);
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private static AppInstallSummary Row(Dictionary<string, AppInstallAggregationState> summaries, string packageId, string scope = "machine")
|
||||
=> summaries[EventIngestProcessor.RealmJoinAggregationKey(scope, packageId)].Summary;
|
||||
|
||||
private static EnrollmentEvent Started(DateTime at, string packageId = "generic-github-desktop",
|
||||
string displayName = "GitHub Desktop", string scope = "machine", string? version = "3.6.5.0")
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["packageId"] = packageId,
|
||||
["displayName"] = displayName,
|
||||
["scope"] = scope,
|
||||
};
|
||||
if (version != null) data["version"] = version;
|
||||
return new EnrollmentEvent { EventType = Constants.EventTypes.RealmJoinPackageStarted, Timestamp = at, Data = data };
|
||||
}
|
||||
|
||||
private static EnrollmentEvent Completed(DateTime at, bool success = true, int exitCode = 0,
|
||||
string packageId = "generic-github-desktop", string displayName = "GitHub Desktop", string scope = "machine")
|
||||
{
|
||||
var evt = Started(at, packageId, displayName, scope);
|
||||
evt.EventType = Constants.EventTypes.RealmJoinPackageCompleted;
|
||||
// Wire form of the agent adapter: strings.
|
||||
evt.Data!["success"] = success ? "true" : "false";
|
||||
evt.Data["lastExitCode"] = exitCode.ToString();
|
||||
return evt;
|
||||
}
|
||||
|
||||
// ── ingest fold: live watcher events ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void StartedThenCompleted_OneRealmJoinRow_WithMeasuredDuration()
|
||||
{
|
||||
var s = Row(Aggregate(Started(T0), Completed(T0.AddSeconds(22))), "generic-github-desktop");
|
||||
|
||||
Assert.Equal(AppInstallSources.RealmJoin, s.Source);
|
||||
Assert.Equal("generic-github-desktop", s.AppId);
|
||||
Assert.Equal("machine", s.InstallScope);
|
||||
Assert.Equal("GitHub Desktop", s.AppName);
|
||||
Assert.Equal("3.6.5.0", s.AppVersion);
|
||||
Assert.Equal("Succeeded", s.Status);
|
||||
Assert.Equal("Installed", s.TerminalState);
|
||||
Assert.Equal(0, s.ExitCode);
|
||||
Assert.Equal(string.Empty, s.FailureCode);
|
||||
Assert.Equal(T0, s.StartedAt);
|
||||
Assert.Equal(T0, s.LastAttemptStartedAt);
|
||||
Assert.Equal(T0.AddSeconds(22), s.CompletedAt);
|
||||
Assert.Equal(22, s.DurationSeconds);
|
||||
Assert.Equal(1, s.InstallPassCount);
|
||||
Assert.True(MetricsMath.HasMeasuredDuration(s));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedPackage_ExitCodeIsTheFailureCode()
|
||||
{
|
||||
var s = Row(Aggregate(Started(T0), Completed(T0.AddSeconds(40), success: false, exitCode: 1603)), "generic-github-desktop");
|
||||
|
||||
Assert.Equal("Failed", s.Status);
|
||||
Assert.Equal("Error", s.TerminalState);
|
||||
Assert.Equal(1603, s.ExitCode);
|
||||
Assert.Equal("1603", s.FailureCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartedAndCompletedInOneRegistryPass_IsUnmeasured()
|
||||
{
|
||||
// Older agents report both events in the same watcher pass, milliseconds apart: that is
|
||||
// no install duration.
|
||||
var s = Row(Aggregate(Started(T0), Completed(T0.AddMilliseconds(4))), "generic-github-desktop");
|
||||
|
||||
Assert.Equal("Succeeded", s.Status);
|
||||
Assert.Equal(0, s.DurationSeconds);
|
||||
Assert.False(MetricsMath.HasMeasuredDuration(s));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingDisplayName_UsesPackageId_UntilANameArrives()
|
||||
{
|
||||
var summaries = Aggregate(Started(T0, displayName: string.Empty));
|
||||
Assert.Equal("generic-github-desktop", Row(summaries, "generic-github-desktop").AppName);
|
||||
|
||||
EventIngestProcessor.AggregateAppInstallEvent(Completed(T0.AddSeconds(5)), "tenant", SessionId, summaries);
|
||||
Assert.Equal("GitHub Desktop", Row(summaries, "generic-github-desktop").AppName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SamePackageIdInBothScopes_IsTwoRows()
|
||||
{
|
||||
var summaries = Aggregate(
|
||||
Completed(T0, scope: "machine"),
|
||||
Completed(T0, success: false, exitCode: 1, scope: "user"));
|
||||
|
||||
Assert.Equal("Succeeded", Row(summaries, "generic-github-desktop", "machine").Status);
|
||||
Assert.Equal("Failed", Row(summaries, "generic-github-desktop", "user").Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealmJoinPackage_AndIntuneApp_WithTheSameName_AreTwoRows()
|
||||
{
|
||||
var ime = new EnrollmentEvent
|
||||
{
|
||||
EventType = "app_install_completed",
|
||||
Timestamp = T0,
|
||||
Data = new Dictionary<string, object> { ["appName"] = "GitHub Desktop", ["state"] = "Installed" },
|
||||
};
|
||||
var summaries = Aggregate(ime, Completed(T0.AddSeconds(1)));
|
||||
|
||||
Assert.Equal(2, summaries.Count);
|
||||
Assert.Equal(string.Empty, summaries["GitHub Desktop"].Summary.Source);
|
||||
Assert.Equal(AppInstallSources.RealmJoin, Row(summaries, "generic-github-desktop").Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventWithoutPackageId_IsIgnored()
|
||||
{
|
||||
var evt = Completed(T0);
|
||||
evt.Data!.Remove("packageId");
|
||||
Assert.Empty(Aggregate(evt));
|
||||
}
|
||||
|
||||
// ── store: row identity, channel columns, reconcile ─────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RowKey_ImeUnchanged_RealmJoinKeyedByScopeAndPackageId()
|
||||
{
|
||||
Assert.Equal($"{SessionId}_Contoso App_2",
|
||||
TableStorageService.BuildAppInstallSummaryRowKey(new AppInstallSummary { SessionId = SessionId, AppName = "Contoso App/2" }));
|
||||
|
||||
var rj = new AppInstallSummary
|
||||
{
|
||||
SessionId = SessionId,
|
||||
Source = AppInstallSources.RealmJoin,
|
||||
InstallScope = "user",
|
||||
AppId = "generic-7zip-usersettings",
|
||||
AppName = "7-Zip Settings",
|
||||
};
|
||||
var key = TableStorageService.BuildAppInstallSummaryRowKey(rj);
|
||||
Assert.Equal($"{SessionId}_realmjoin|user|generic-7zip-usersettings", key);
|
||||
// The per-session range scans (ESP stamp, close step, deletion) match on this prefix.
|
||||
Assert.StartsWith($"{SessionId}_", key, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Entity_ImeRowHasNoChannelColumns_RealmJoinRowRoundTrips()
|
||||
{
|
||||
var ime = TableStorageService.BuildAppInstallSummaryEntity(
|
||||
new AppInstallSummary { TenantId = "t", SessionId = SessionId, AppName = "App" }, "rk");
|
||||
Assert.False(ime.ContainsKey("Source"));
|
||||
Assert.False(ime.ContainsKey("InstallScope"));
|
||||
|
||||
var summary = new AppInstallSummary
|
||||
{
|
||||
TenantId = "t",
|
||||
SessionId = SessionId,
|
||||
AppName = "7-Zip",
|
||||
Source = AppInstallSources.RealmJoin,
|
||||
InstallScope = "machine",
|
||||
AppId = "generic-7zip",
|
||||
};
|
||||
var entity = TableStorageService.BuildAppInstallSummaryEntity(summary, "rk");
|
||||
var sut = new TableStorageService(new Mock<TableServiceClient>().Object, NullLogger<TableStorageService>.Instance);
|
||||
var mapped = sut.MapToAppInstallSummary(entity);
|
||||
|
||||
Assert.Equal(AppInstallSources.RealmJoin, mapped.Source);
|
||||
Assert.Equal("machine", mapped.InstallScope);
|
||||
Assert.Equal("generic-7zip", mapped.AppId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_PackageIdFallbackName_KeepsTheStoredDisplayName()
|
||||
{
|
||||
var existing = new TableEntity("t", "rk") { ["AppName"] = "7-Zip", ["StartedAt"] = T0 };
|
||||
var summary = new AppInstallSummary
|
||||
{
|
||||
Source = AppInstallSources.RealmJoin,
|
||||
AppId = "generic-7zip",
|
||||
AppName = "generic-7zip",
|
||||
StartedAt = T0.AddMinutes(30),
|
||||
};
|
||||
|
||||
TableStorageService.ReconcileAppInstallSummaryWithExisting(summary, existing);
|
||||
Assert.Equal("7-Zip", summary.AppName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EspBlockingStamp_NeverTouchesRealmJoinRows()
|
||||
{
|
||||
var sets = EspBlockingSets.FromEventData(new Dictionary<string, object>
|
||||
{
|
||||
["espTrackedWin32AppIds"] = new[] { "generic-7zip" },
|
||||
})!;
|
||||
var rj = new TableEntity("t", "rk") { ["Source"] = AppInstallSources.RealmJoin, ["AppId"] = "generic-7zip" };
|
||||
var ime = new TableEntity("t", "rk2") { ["AppId"] = "generic-7zip" };
|
||||
|
||||
Assert.False(TableStorageService.ShouldStampEspBlocking(rj, sets));
|
||||
Assert.True(TableStorageService.ShouldStampEspBlocking(ime, sets));
|
||||
}
|
||||
|
||||
// ── per-app aggregates: the channel is part of the app ──────────────────
|
||||
|
||||
private static AppInstallSummary Summary(string appName, string source, string status = "Succeeded", int duration = 60)
|
||||
=> new()
|
||||
{
|
||||
TenantId = "t",
|
||||
SessionId = Guid.NewGuid().ToString(),
|
||||
AppName = appName,
|
||||
Source = source,
|
||||
Status = status,
|
||||
TerminalState = status == "Failed" ? "Error" : "Installed",
|
||||
DurationSeconds = duration,
|
||||
StartedAt = DateTime.UtcNow.AddDays(-1),
|
||||
LastAttemptStartedAt = DateTime.UtcNow.AddDays(-1),
|
||||
};
|
||||
|
||||
private static List<AppInstallSummary> MixedChannelRows() => new()
|
||||
{
|
||||
Summary("GitHub Desktop", string.Empty),
|
||||
Summary("GitHub Desktop", AppInstallSources.RealmJoin, status: "Failed"),
|
||||
Summary("GitHub Desktop", AppInstallSources.RealmJoin),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void AppsList_SameNameInTwoChannels_IsTwoItems()
|
||||
{
|
||||
var root = TestWire.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse(MixedChannelRows(), days: 30));
|
||||
var apps = root.GetProperty("apps").EnumerateArray().ToList();
|
||||
|
||||
Assert.Equal(2, apps.Count);
|
||||
var ime = apps.Single(a => a.GetProperty("source").GetString() == AppInstallSources.Ime);
|
||||
var rj = apps.Single(a => a.GetProperty("source").GetString() == AppInstallSources.RealmJoin);
|
||||
Assert.Equal(1, ime.GetProperty("totalInstalls").GetInt32());
|
||||
Assert.Equal(2, rj.GetProperty("totalInstalls").GetInt32());
|
||||
Assert.Equal(1, rj.GetProperty("failed").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analytics_And_Sessions_FilterByChannel()
|
||||
{
|
||||
var sessionRepo = new Mock<ISessionRepository>();
|
||||
sessionRepo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync((SessionSummary?)null);
|
||||
|
||||
var rj = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
MixedChannelRows(), sessionRepo.Object, "GitHub Desktop", AppInstallSources.RealmJoin, days: 30);
|
||||
Assert.Equal(AppInstallSources.RealmJoin, rj.Source);
|
||||
Assert.Equal(2, rj.Summary.TotalInstalls);
|
||||
Assert.Equal(1, rj.Summary.Failed);
|
||||
|
||||
var ime = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
MixedChannelRows(), sessionRepo.Object, "GitHub Desktop", AppInstallSources.Ime, days: 30);
|
||||
Assert.Equal(AppInstallSources.Ime, ime.Source);
|
||||
Assert.Equal(1, ime.Summary.TotalInstalls);
|
||||
|
||||
var sessions = await AppsAnalyticsHelper.BuildSessionsResponseAsync(
|
||||
MixedChannelRows(), sessionRepo.Object, "GitHub Desktop", AppInstallSources.RealmJoin, days: 30,
|
||||
statusFilter: "all", modelFilter: null, versionFilter: null, offset: 0, limit: 50);
|
||||
Assert.Equal(2, sessions.Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppMetrics_GroupsPerChannel()
|
||||
{
|
||||
var payload = MetricsMath.BuildAppMetricsPayload(MixedChannelRows());
|
||||
|
||||
Assert.Equal(2, payload.TotalApps);
|
||||
var failing = Assert.Single(payload.TopFailingApps);
|
||||
Assert.Equal(AppInstallSources.RealmJoin, failing.Source);
|
||||
Assert.Equal("GitHub Desktop", failing.AppName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, true, "ime")]
|
||||
[InlineData("", true, "ime")]
|
||||
[InlineData("realmjoin", true, "realmjoin")]
|
||||
[InlineData(" RealmJoin ", true, "realmjoin")]
|
||||
[InlineData("office-c2r", false, "office-c2r")]
|
||||
public void SourceQueryParam(string? raw, bool valid, string expected)
|
||||
{
|
||||
Assert.Equal(valid, AppsAnalyticsHelper.TryParseSourceQueryParam(raw, out var source));
|
||||
Assert.Equal(expected, source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// SESSION-OWNER-BINDING call-site inventory. The functions are too dependency-heavy to boot in a
|
||||
/// unit test, and the refusal is a one-line branch at each site — so the guard is structural:
|
||||
/// every file that observes the binding must also act on <c>Decision.Rejected</c>, and the set of
|
||||
/// sites is pinned so a new session-scoped agent endpoint is added here on purpose (and decides
|
||||
/// how it refuses) instead of silently observing without enforcing.
|
||||
/// </summary>
|
||||
public class SessionOwnerBindingCallSiteGuardTests
|
||||
{
|
||||
private const string ObserveCall = "_ownerBinding.Observe(";
|
||||
|
||||
private static readonly string[] ExpectedSites =
|
||||
{
|
||||
"Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs",
|
||||
"Functions/Ingest/IngestTelemetryFunction.cs",
|
||||
"Functions/Ingest/ReportAgentErrorFunction.cs",
|
||||
"Functions/Sessions/RegisterSessionFunction.cs",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Every_observing_site_acts_on_the_refusal()
|
||||
{
|
||||
var functionsRoot = Path.Combine(FindRepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
|
||||
var sites = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(functionsRoot, "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(functionsRoot, file).Replace(Path.DirectorySeparatorChar, '/');
|
||||
if (relative.StartsWith("bin/", StringComparison.OrdinalIgnoreCase) ||
|
||||
relative.StartsWith("obj/", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var text = File.ReadAllText(file);
|
||||
if (text.Contains(ObserveCall, StringComparison.Ordinal))
|
||||
sites[relative] = text;
|
||||
}
|
||||
|
||||
Assert.Equal(ExpectedSites, sites.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray());
|
||||
|
||||
var missing = sites.Where(kv => !kv.Value.Contains(".Rejected", StringComparison.Ordinal)).Select(kv => kv.Key).ToList();
|
||||
Assert.True(missing.Count == 0,
|
||||
"These sites observe the session owner binding but never act on Decision.Rejected:\n " + string.Join("\n ", missing));
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,9 @@ 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.
|
||||
/// SESSION-OWNER-BINDING carriers: the outcome lands on the request row for every request
|
||||
/// (denominator), non-Match outcomes produce a Warning, rejected outcomes raise one throttled
|
||||
/// ops event, stamping is fail-soft, and an evaluation defect fails open.
|
||||
/// </summary>
|
||||
public class SessionOwnerBindingObserverTests
|
||||
{
|
||||
@@ -105,7 +105,7 @@ public class SessionOwnerBindingObserverTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Would_reject_outcome_warns_and_raises_one_ops_event_per_session_and_outcome()
|
||||
public async Task Rejected_outcome_warns_and_raises_one_ops_event_per_session_and_outcome()
|
||||
{
|
||||
var rig = new Rig();
|
||||
var (req, items) = Request("2.0.1500");
|
||||
@@ -115,12 +115,12 @@ public class SessionOwnerBindingObserverTests
|
||||
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.True(first.Rejected);
|
||||
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.All(warnings, w => Assert.Contains("AgentSessionOwnerBinding outcome=MismatchCert enforced=True wouldReject=True", w.Message));
|
||||
Assert.Contains("serialMatch=False", warnings[0].Message);
|
||||
Assert.Contains("ver=2.0.1500", warnings[0].Message);
|
||||
|
||||
@@ -130,11 +130,11 @@ public class SessionOwnerBindingObserverTests
|
||||
Assert.Equal(OpsEventCategory.Security, evt.Category);
|
||||
Assert.Equal(Tenant, evt.TenantId);
|
||||
Assert.Contains("MismatchCert", evt.Message);
|
||||
Assert.Contains("shadow", evt.Message);
|
||||
Assert.Contains("refused", evt.Message);
|
||||
Assert.DoesNotContain(Thumb1, evt.Message);
|
||||
Assert.DoesNotContain(Thumb2, evt.Message);
|
||||
Assert.Contains("\"serialMatch\":false", evt.Details);
|
||||
Assert.Contains("\"enforced\":false", evt.Details);
|
||||
Assert.Contains("\"enforced\":true", evt.Details);
|
||||
_ = second;
|
||||
}
|
||||
|
||||
@@ -154,6 +154,22 @@ public class SessionOwnerBindingObserverTests
|
||||
Assert.Empty(rig.Ops);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluation_defect_fails_open()
|
||||
{
|
||||
var rig = new Rig();
|
||||
var (req, _) = Request();
|
||||
|
||||
// A null validation throws inside the policy — our defect, never evidence of a foreign device.
|
||||
var d = rig.Sut.Observe(req, Tenant, Session, OwnedRow(Thumb1), null!, "agent/telemetry");
|
||||
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.Match, d.Outcome);
|
||||
Assert.False(d.Rejected);
|
||||
Assert.Null(d.OwnerToStamp);
|
||||
Assert.Contains(rig.Logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("request allowed"));
|
||||
Assert.Empty(rig.Ops);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stamp_writes_the_owner_and_swallows_storage_failures()
|
||||
{
|
||||
|
||||
@@ -11,8 +11,8 @@ 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.
|
||||
/// before the binding) and every shape that is refused (foreign cert, foreign bootstrap code,
|
||||
/// bootstrap on a cert-owned row, re-enroll without wipe) is pinned here.
|
||||
/// </summary>
|
||||
public class SessionOwnershipPolicyTests
|
||||
{
|
||||
@@ -65,7 +65,7 @@ public class SessionOwnershipPolicyTests
|
||||
Assert.Equal(Thumb1, d.OwnerToStamp.Thumbprint);
|
||||
Assert.Equal(Dev1, d.OwnerToStamp.DeviceId);
|
||||
Assert.Equal("SN-1", d.OwnerToStamp.Serial);
|
||||
Assert.False(d.WouldReject);
|
||||
Assert.False(d.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -75,7 +75,7 @@ public class SessionOwnershipPolicyTests
|
||||
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);
|
||||
Assert.False(d.Rejected);
|
||||
}
|
||||
|
||||
// ── legacy rows (pre-binding) ────────────────────────────────────────────
|
||||
@@ -87,7 +87,7 @@ public class SessionOwnershipPolicyTests
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.ClaimLegacy, d.Outcome);
|
||||
Assert.NotNull(d.OwnerToStamp);
|
||||
Assert.True(d.SerialMatch);
|
||||
Assert.False(d.WouldReject);
|
||||
Assert.False(d.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -96,7 +96,7 @@ public class SessionOwnershipPolicyTests
|
||||
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);
|
||||
Assert.True(d.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -123,7 +123,7 @@ public class SessionOwnershipPolicyTests
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.RebindCertRotation, d.Outcome);
|
||||
Assert.Equal(Thumb2, d.OwnerToStamp!.Thumbprint);
|
||||
Assert.Equal(Dev1, d.OwnerToStamp.DeviceId);
|
||||
Assert.False(d.WouldReject);
|
||||
Assert.False(d.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -133,7 +133,7 @@ public class SessionOwnershipPolicyTests
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.MismatchCert, d.Outcome);
|
||||
Assert.False(d.SerialMatch);
|
||||
Assert.Null(d.OwnerToStamp);
|
||||
Assert.True(d.WouldReject);
|
||||
Assert.True(d.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -143,7 +143,7 @@ public class SessionOwnershipPolicyTests
|
||||
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);
|
||||
Assert.True(d.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -160,7 +160,7 @@ public class SessionOwnershipPolicyTests
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.RebindBootstrapHandoff, d.Outcome);
|
||||
Assert.Equal(SessionOwner.Kinds.Cert, d.OwnerToStamp!.Kind);
|
||||
Assert.Null(d.OwnerToStamp.BootstrapCode);
|
||||
Assert.False(d.WouldReject);
|
||||
Assert.False(d.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -168,7 +168,7 @@ public class SessionOwnershipPolicyTests
|
||||
{
|
||||
var d = SessionOwnershipPolicy.Evaluate(Row(owner: BootstrapOwner("ABC123")), Cert(Thumb1, Dev1, "OTHER"), Now);
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.MismatchBootstrapOwned, d.Outcome);
|
||||
Assert.True(d.WouldReject);
|
||||
Assert.True(d.Rejected);
|
||||
}
|
||||
|
||||
// ── bootstrap caller ─────────────────────────────────────────────────────
|
||||
@@ -187,7 +187,7 @@ public class SessionOwnershipPolicyTests
|
||||
{
|
||||
var d = SessionOwnershipPolicy.Evaluate(Row(owner: BootstrapOwner("ABC123")), Bootstrap(code, serial), Now);
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.MismatchBootstrap, d.Outcome);
|
||||
Assert.True(d.WouldReject);
|
||||
Assert.True(d.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -195,7 +195,7 @@ public class SessionOwnershipPolicyTests
|
||||
{
|
||||
var d = SessionOwnershipPolicy.Evaluate(Row(owner: CertOwner(Thumb1, Dev1)), Bootstrap("ABC123"), Now);
|
||||
Assert.Equal(SessionOwnershipPolicy.Outcome.DowngradeToBootstrap, d.Outcome);
|
||||
Assert.True(d.WouldReject);
|
||||
Assert.True(d.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -244,7 +244,7 @@ public class SessionOwnershipPolicyTests
|
||||
[InlineData(SessionOwnershipPolicy.Outcome.RebindBootstrapHandoff)]
|
||||
[InlineData(SessionOwnershipPolicy.Outcome.CallerUnidentified)]
|
||||
public void Lifecycle_outcomes_are_deliberately_tolerated(string outcome)
|
||||
=> Assert.False(SessionOwnershipPolicy.WouldRejectUnderEnforcement(outcome));
|
||||
=> Assert.False(SessionOwnershipPolicy.Rejects(outcome));
|
||||
|
||||
[Theory]
|
||||
[InlineData(SessionOwnershipPolicy.Outcome.LegacySerialMismatch)]
|
||||
@@ -252,16 +252,22 @@ public class SessionOwnershipPolicyTests
|
||||
[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));
|
||||
public void Foreign_identity_outcomes_are_rejected(string outcome)
|
||||
=> Assert.True(SessionOwnershipPolicy.Rejects(outcome));
|
||||
|
||||
[Fact]
|
||||
public void Stage1_has_no_Rejects_rule()
|
||||
public void Every_outcome_is_classified_and_the_telemetry_field_reports_the_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);
|
||||
// A new Outcome constant must land in one of the two theories above on purpose, and the
|
||||
// shadow-era wouldReject field (KQL keeps querying it) must say exactly what Rejects does.
|
||||
var outcomes = typeof(SessionOwnershipPolicy.Outcome)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(f => f.IsLiteral)
|
||||
.Select(f => (string)f.GetRawConstantValue()!)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(11, outcomes.Length);
|
||||
Assert.Equal(5, outcomes.Count(SessionOwnershipPolicy.Rejects));
|
||||
Assert.All(outcomes, o => Assert.Equal(SessionOwnershipPolicy.Rejects(o), SessionOwnershipPolicy.WouldRejectUnderEnforcement(o)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ public sealed class SharedManifestParityTests
|
||||
["webhookProviderTypes"] = EnumMap<WebhookProviderType>(),
|
||||
["annotationLanes"] = AnnotationLanes.All,
|
||||
["annotationVerdicts"] = AnnotationVerdicts.All,
|
||||
// Install channels of the app rows (AppInstallSummary.Source and the per-app DTOs).
|
||||
["appInstallSources"] = AppInstallSources.All,
|
||||
// Community rule submissions: kinds, effective statuses (incl. the derived "published"),
|
||||
// attribution modes and reviewer decisions — the MCP tool schemas enumerate these.
|
||||
["ruleSubmissionKinds"] = RuleSubmissionKinds.All,
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Format checks and the address classifier. The connection gate itself is covered on real sockets
|
||||
/// in <see cref="WebhookConnectGateTests"/>.
|
||||
/// </summary>
|
||||
public class SsrfGuardTests
|
||||
{
|
||||
// ── ValidateWebhookUrlFormat: valid URLs ──
|
||||
@@ -145,37 +151,124 @@ public class SsrfGuardTests
|
||||
Assert.False(SsrfGuard.IsBlockedAddress(IPAddress.Parse(ip)));
|
||||
}
|
||||
|
||||
// ── ValidateDestinationAsync: integration ──
|
||||
// ── IsBlockedAddress: IPv6 beyond the classic ranges ──
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateDestination_Localhost_ThrowsSsrfException()
|
||||
[Theory]
|
||||
[InlineData("::")] // unspecified
|
||||
[InlineData("::ffff:8.8.8.8")] // IPv4-mapped, even of a public address
|
||||
[InlineData("::8.8.8.8")] // IPv4-compatible (deprecated)
|
||||
[InlineData("64:ff9b::a00:1")] // NAT64 of 10.0.0.1
|
||||
[InlineData("2002:a00:1::1")] // 6to4 of 10.0.0.1
|
||||
[InlineData("2001::1")] // Teredo
|
||||
[InlineData("2001:db8::1")] // documentation
|
||||
[InlineData("fec0::1")] // deprecated site-local
|
||||
[InlineData("4000::1")] // outside global unicast
|
||||
[InlineData("e000::1")] // outside global unicast
|
||||
public void IsBlocked_Ipv6OutsidePublicUnicast_ReturnsTrue(string ip)
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<SsrfException>(
|
||||
() => SsrfGuard.ValidateDestinationAsync("https://localhost/webhook"));
|
||||
Assert.Contains("private or reserved", ex.Message);
|
||||
Assert.True(SsrfGuard.IsBlockedAddress(IPAddress.Parse(ip)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateDestination_HttpScheme_ThrowsSsrfException()
|
||||
[Theory]
|
||||
[InlineData("fe80::1%5")]
|
||||
[InlineData("2606:4700:4700::1111%5")]
|
||||
public void IsBlocked_Ipv6WithZoneId_ReturnsTrue(string ip)
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<SsrfException>(
|
||||
() => SsrfGuard.ValidateDestinationAsync("http://example.com/webhook"));
|
||||
Assert.Contains("HTTPS", ex.Message);
|
||||
Assert.True(SsrfGuard.IsBlockedAddress(IPAddress.Parse(ip)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateDestination_InvalidUrl_ThrowsSsrfException()
|
||||
[Theory]
|
||||
[InlineData("2606:4700:4700::1111")] // Cloudflare
|
||||
[InlineData("2a00:1450:4001::1")] // Google
|
||||
[InlineData("2001:200::1")] // just above 2001::/23
|
||||
[InlineData("2003::1")] // just above 6to4 2002::/16
|
||||
[InlineData("2001:db9::1")] // just above documentation 2001:db8::/32
|
||||
[InlineData("192.0.1.0")] // just above 192.0.0.0/24
|
||||
[InlineData("198.20.0.0")] // just above benchmarking 198.18.0.0/15
|
||||
[InlineData("223.255.255.255")] // just below multicast
|
||||
public void IsBlocked_PublicNeighbourOfRefusedBlock_ReturnsFalse(string ip)
|
||||
{
|
||||
await Assert.ThrowsAsync<SsrfException>(
|
||||
() => SsrfGuard.ValidateDestinationAsync("not-a-url"));
|
||||
Assert.False(SsrfGuard.IsBlockedAddress(IPAddress.Parse(ip)));
|
||||
}
|
||||
|
||||
// ── IsBlockedAddress: every IANA special-purpose block (tests/fixtures/iana-special-purpose-addresses) ──
|
||||
|
||||
private static readonly string[] RegistryFiles = { "iana-ipv4-special-registry-1.csv", "iana-ipv6-special-registry-1.csv" };
|
||||
|
||||
public static IEnumerable<object[]> RegistryBlocks() =>
|
||||
RegistryFiles.SelectMany(file => ReadRegistryBlocks(file).Select(block => new object[] { file, block }));
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateDestination_UnresolvableHost_ThrowsSsrfException()
|
||||
public void Registry_IsReadCompletely()
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<SsrfException>(
|
||||
() => SsrfGuard.ValidateDestinationAsync("https://this-host-does-not-exist-xyz123.invalid/webhook"));
|
||||
Assert.Contains("resolve", ex.Message);
|
||||
foreach (var file in RegistryFiles)
|
||||
{
|
||||
var blocks = ReadRegistryBlocks(file);
|
||||
Assert.True(blocks.Count >= 20, $"{file}: only {blocks.Count} blocks read");
|
||||
Assert.All(blocks, block => Assert.Matches(@"^[0-9a-f.:]+/\d{1,3}$", block));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RegistryBlocks))]
|
||||
public void IsBlocked_EveryIanaSpecialPurposeBlock_FirstAndLastAddress(string file, string block)
|
||||
{
|
||||
var network = IPNetwork.Parse(block);
|
||||
var first = network.BaseAddress.GetAddressBytes();
|
||||
var last = (byte[])first.Clone();
|
||||
for (var bit = network.PrefixLength; bit < last.Length * 8; bit++)
|
||||
last[bit / 8] |= (byte)(0x80 >> (bit % 8));
|
||||
|
||||
foreach (var ip in new[] { new IPAddress(first), new IPAddress(last) })
|
||||
Assert.True(SsrfGuard.IsBlockedAddress(ip), $"{file}: {block} -> {ip}");
|
||||
}
|
||||
|
||||
private static List<string> ReadRegistryBlocks(string file)
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
var rows = ParseCsv(File.ReadAllText(Path.Combine(dir!.FullName, "tests", "fixtures", "iana-special-purpose-addresses", file)));
|
||||
Assert.Equal("Address Block", rows[0][0]);
|
||||
// Footnote markers ("2002::/16 [3]") dropped, cells that list two blocks split.
|
||||
return rows.Skip(1)
|
||||
.SelectMany(row => row[0].Split(','))
|
||||
.Select(block => Regex.Replace(block, @"\[\d+\]", "").Trim())
|
||||
.Where(block => block.Length > 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>RFC 4180 reader — the registry quotes cells, wraps the RFC column over lines and doubles quotes.</summary>
|
||||
private static List<List<string>> ParseCsv(string text)
|
||||
{
|
||||
var rows = new List<List<string>>();
|
||||
var row = new List<string>();
|
||||
var cell = new StringBuilder();
|
||||
var quoted = false;
|
||||
for (var i = 0; i < text.Length; i++)
|
||||
{
|
||||
var c = text[i];
|
||||
if (quoted)
|
||||
{
|
||||
if (c == '"' && i + 1 < text.Length && text[i + 1] == '"') { cell.Append('"'); i++; }
|
||||
else if (c == '"') quoted = false;
|
||||
else cell.Append(c);
|
||||
}
|
||||
else if (c == '"') quoted = true;
|
||||
else if (c == ',') { row.Add(cell.ToString()); cell.Clear(); }
|
||||
else if (c == '\n' || c == '\r')
|
||||
{
|
||||
if (c == '\r' && i + 1 < text.Length && text[i + 1] == '\n') i++;
|
||||
row.Add(cell.ToString());
|
||||
rows.Add(row);
|
||||
row = new List<string>();
|
||||
cell.Clear();
|
||||
}
|
||||
else cell.Append(c);
|
||||
}
|
||||
if (cell.Length > 0 || row.Count > 0) { row.Add(cell.ToString()); rows.Add(row); }
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ── ValidateAzureBlobSasUrlFormat ──
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="TenantConfigurationService.UpdateAsync"/> and the plan writers built on it: the decision runs
|
||||
/// on the fresh row and the write is conditional on that row's ETag — a cached view (up to 5 minutes stale
|
||||
/// per instance) is never saved, so it can neither rewind another writer's change nor lift the offboarding
|
||||
/// tombstone during the drain barrier, and two concurrent trial starts cannot both see TrialConsumed=false.
|
||||
/// </summary>
|
||||
public class TenantConfigUpdateTests
|
||||
{
|
||||
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
|
||||
private readonly Mock<IConfigRepository> _repo = new();
|
||||
private readonly Queue<(TenantConfiguration Row, string ETag)> _freshReads = new();
|
||||
private readonly List<(TenantConfiguration Config, string ETag, string? Source, string? Reason)> _replaces = new();
|
||||
private readonly Queue<bool> _replaceResults = new();
|
||||
private readonly List<TenantConfiguration> _creates = new();
|
||||
private readonly Queue<bool> _createResults = new();
|
||||
|
||||
public TenantConfigUpdateTests()
|
||||
{
|
||||
_repo.Setup(r => r.GetTenantConfigurationWithEtagAsync(TenantId))
|
||||
.ReturnsAsync(() => _freshReads.Count > 0 ? _freshReads.Dequeue() : null);
|
||||
_repo.Setup(r => r.TryReplaceTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<TenantConfiguration, string, string?, string?>((c, e, s, r) => _replaces.Add((c, e, s, r)))
|
||||
.ReturnsAsync(() => _replaceResults.Count == 0 || _replaceResults.Dequeue());
|
||||
_repo.Setup(r => r.TryCreateTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
|
||||
.Callback<TenantConfiguration>(c => _creates.Add(c))
|
||||
.ReturnsAsync(() => _createResults.Count == 0 || _createResults.Dequeue());
|
||||
}
|
||||
|
||||
private TenantConfigurationService Service(ManagedTenantProIndex? proIndex = null) =>
|
||||
new(_repo.Object, NullLogger<TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()), proIndex ?? ManagedTenantProIndex.None);
|
||||
|
||||
private static TenantConfiguration Row(Action<TenantConfiguration>? shape = null)
|
||||
{
|
||||
var row = TenantConfiguration.CreateDefault(TenantId);
|
||||
row.DomainName = "contoso.com";
|
||||
row.ContactEmail = "it@contoso.com";
|
||||
row.CompanyName = "Contoso";
|
||||
shape?.Invoke(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static TenantConfiguration Tombstone() => Row(r =>
|
||||
{
|
||||
r.Disabled = true;
|
||||
r.DisabledReason = TenantOffboardFunction.OffboardingDisabledReason;
|
||||
});
|
||||
|
||||
/// <summary>Primes the service's cache with <paramref name="cached"/>, the view a warm instance would hold.</summary>
|
||||
private async Task PrimeCacheAsync(TenantConfigurationService service, TenantConfiguration cached)
|
||||
{
|
||||
_repo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(cached);
|
||||
Assert.Same(cached, await service.GetConfigurationIfExistsAsync(TenantId));
|
||||
}
|
||||
|
||||
// ── UpdateAsync ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_MutatesTheFreshRow_UnderItsETag_NeverTheCachedView()
|
||||
{
|
||||
var service = Service();
|
||||
await PrimeCacheAsync(service, Row(r => r.HomedAppClientId = null));
|
||||
_freshReads.Enqueue((Row(r => r.HomedAppClientId = "flipped-meanwhile"), "etag-1"));
|
||||
|
||||
var update = await service.UpdateAsync(TenantId, row => { row.PayingCustomer = true; return true; }, "plan", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.Updated, update.Status);
|
||||
var (written, etag, source, reason) = Assert.Single(_replaces);
|
||||
Assert.Equal("etag-1", etag);
|
||||
Assert.Equal("flipped-meanwhile", written.HomedAppClientId);
|
||||
Assert.True(written.PayingCustomer);
|
||||
Assert.Equal("plan", source);
|
||||
Assert.Equal("why", reason);
|
||||
|
||||
// The cached view is gone: the next read goes to storage.
|
||||
await service.GetConfigurationIfExistsAsync(TenantId);
|
||||
_repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_LostRace_RereadsAndDecidesAgainOnTheNewRow()
|
||||
{
|
||||
var first = Row();
|
||||
var second = Row(r => r.CompanyName = "Contoso after the concurrent write");
|
||||
_freshReads.Enqueue((first, "etag-1"));
|
||||
_freshReads.Enqueue((second, "etag-2"));
|
||||
_replaceResults.Enqueue(false);
|
||||
_replaceResults.Enqueue(true);
|
||||
var seen = new List<TenantConfiguration>();
|
||||
|
||||
var update = await Service().UpdateAsync(TenantId, row => { seen.Add(row); return true; }, "plan", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.Updated, update.Status);
|
||||
Assert.Equal(new[] { first, second }, seen);
|
||||
Assert.Equal(new[] { "etag-1", "etag-2" }, _replaces.Select(r => r.ETag));
|
||||
Assert.Same(second, update.Config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_EveryAttemptLosesTheRace_Conflict()
|
||||
{
|
||||
for (var i = 0; i < TenantConfigurationService.MaxUpdateAttempts; i++)
|
||||
{
|
||||
_freshReads.Enqueue((Row(), $"etag-{i}"));
|
||||
_replaceResults.Enqueue(false);
|
||||
}
|
||||
|
||||
var update = await Service().UpdateAsync(TenantId, _ => true, "plan", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.Conflict, update.Status);
|
||||
Assert.Equal(TenantConfigurationService.MaxUpdateAttempts, _replaces.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_OffboardingTombstone_IsNeverWritten()
|
||||
{
|
||||
_freshReads.Enqueue((Tombstone(), "etag-1"));
|
||||
var mutated = false;
|
||||
|
||||
var update = await Service().UpdateAsync(TenantId, _ => mutated = true, "plan", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.OffboardingInProgress, update.Status);
|
||||
Assert.False(mutated);
|
||||
Assert.Empty(_replaces);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_MutationDeclines_NothingIsWritten()
|
||||
{
|
||||
var row = Row();
|
||||
_freshReads.Enqueue((row, "etag-1"));
|
||||
|
||||
var update = await Service().UpdateAsync(TenantId, _ => false, "plan", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.Declined, update.Status);
|
||||
Assert.Same(row, update.Config);
|
||||
Assert.Empty(_replaces);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_NoRow_NotFound()
|
||||
{
|
||||
var update = await Service().UpdateAsync(TenantId, _ => true, "plan", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.NotFound, update.Status);
|
||||
Assert.Empty(_replaces);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_TheMutationSeesTheConferredProProjection()
|
||||
{
|
||||
// EvaluateTrialStart's AlreadyPro verdict for a managed tenant depends on it.
|
||||
_freshReads.Enqueue((Row(), "etag-1"));
|
||||
string? seen = null;
|
||||
|
||||
await Service(new StubManagedTenantProIndex(_ => "managing-tenant"))
|
||||
.UpdateAsync(TenantId, row => { seen = row.ManagedByProTenantId; return false; }, "plan", "why");
|
||||
|
||||
Assert.Equal("managing-tenant", seen);
|
||||
}
|
||||
|
||||
// ── CreateOrUpdateAsync + the default row ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOrUpdateAsync_NoRow_InsertsTheMutatedDefault()
|
||||
{
|
||||
var update = await Service().CreateOrUpdateAsync(TenantId, row => { row.DomainName = "contoso.com"; return true; }, "auth", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.Updated, update.Status);
|
||||
var created = Assert.Single(_creates);
|
||||
Assert.Equal("contoso.com", created.DomainName);
|
||||
Assert.Equal(TenantId, created.TenantId);
|
||||
Assert.Empty(_replaces);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOrUpdateAsync_RowCreatedConcurrently_DecidesAgainOnTheStoredRow()
|
||||
{
|
||||
// Our read saw no row, the insert lost (409), the re-read finds the concurrent writer's row.
|
||||
var stored = Row(r => r.DomainName = "seeded-by-the-other-login.com");
|
||||
var reads = 0;
|
||||
_repo.Setup(r => r.GetTenantConfigurationWithEtagAsync(TenantId))
|
||||
.ReturnsAsync(() => reads++ == 0 ? null : (stored, "etag-2"));
|
||||
_createResults.Enqueue(false);
|
||||
var seen = new List<string>();
|
||||
|
||||
var update = await Service().CreateOrUpdateAsync(TenantId, row =>
|
||||
{
|
||||
seen.Add(row.DomainName);
|
||||
return string.IsNullOrEmpty(row.DomainName);
|
||||
}, "auth", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.Declined, update.Status);
|
||||
Assert.Equal(new[] { "", "seeded-by-the-other-login.com" }, seen);
|
||||
Assert.Single(_creates);
|
||||
Assert.Empty(_replaces);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateOrUpdateAsync_Tombstone_IsNeverWritten()
|
||||
{
|
||||
_freshReads.Enqueue((Tombstone(), "etag-1"));
|
||||
|
||||
var update = await Service().CreateOrUpdateAsync(TenantId, _ => true, "offboard", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.OffboardingInProgress, update.Status);
|
||||
_repo.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConfigurationAsync_NoRow_CreatesTheDefaultConditionally()
|
||||
{
|
||||
_repo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync((TenantConfiguration?)null);
|
||||
|
||||
var config = await Service().GetConfigurationAsync(TenantId);
|
||||
|
||||
Assert.Same(Assert.Single(_creates), config);
|
||||
_repo.Verify(r => r.TryReplaceTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetConfigurationAsync_DefaultLostTheRace_ReturnsTheRowTheOtherWriterCreated()
|
||||
{
|
||||
// The first-login seed landed between our read and our insert: never overwrite it.
|
||||
var seeded = Row(r => r.OnboardedBy = "first@contoso.com");
|
||||
var reads = 0;
|
||||
_repo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ReturnsAsync(() => reads++ == 0 ? null : seeded);
|
||||
_createResults.Enqueue(false);
|
||||
|
||||
var config = await Service().GetConfigurationAsync(TenantId);
|
||||
|
||||
Assert.Same(seeded, config);
|
||||
Assert.Single(_creates);
|
||||
_repo.Verify(r => r.TryReplaceTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── Endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
private (PlanManagementFunction Sut, Mock<IOpsEventRepository> OpsRepo, Mock<IMaintenanceRepository> Audit) Plan(
|
||||
TenantConfigurationService service)
|
||||
{
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
_repo.Object, NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()))
|
||||
{ CallBase = false };
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
var audit = new Mock<IMaintenanceRepository>();
|
||||
var sut = new PlanManagementFunction(
|
||||
NullLogger<PlanManagementFunction>.Instance, service, adminConfig.Object, audit.Object,
|
||||
new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance,
|
||||
TestNotifications.InertOpsAlertDispatch(adminConfig.Object)),
|
||||
TestProConferral.Inert());
|
||||
return (sut, opsRepo, audit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartTrial_ConcurrentStartWonTheRace_SecondGets409TrialAlreadyConsumed()
|
||||
{
|
||||
_freshReads.Enqueue((Row(), "etag-1"));
|
||||
_freshReads.Enqueue((Row(r => r.TrialConsumed = true), "etag-2"));
|
||||
_replaceResults.Enqueue(false);
|
||||
var (sut, opsRepo, audit) = Plan(Service());
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await sut.StartTrial(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.TrialAlreadyConsumed, EndpointHarness.ErrorCode(response));
|
||||
Assert.Single(_replaces);
|
||||
opsRepo.Verify(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()), Times.Never);
|
||||
audit.Verify(a => a.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartTrial_WarmCacheDuringTheDrainBarrier_CannotLiftTheTombstone()
|
||||
{
|
||||
var service = Service();
|
||||
await PrimeCacheAsync(service, Row()); // this instance still sees the tenant enabled
|
||||
_freshReads.Enqueue((Tombstone(), "etag-1"));
|
||||
var (sut, _, _) = Plan(service);
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await sut.StartTrial(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.TenantSuspended, EndpointHarness.ErrorCode(response));
|
||||
Assert.Empty(_replaces);
|
||||
_repo.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartTrial_Allowed_WritesTheTrialOntoTheFreshRow()
|
||||
{
|
||||
var service = Service();
|
||||
await PrimeCacheAsync(service, Row(r => r.CompanyName = "stale"));
|
||||
_freshReads.Enqueue((Row(r => r.CompanyName = "Contoso"), "etag-1"));
|
||||
var (sut, opsRepo, _) = Plan(service);
|
||||
var (req, _) = EndpointHarness.Request(TenantId, upn: "admin@contoso.com");
|
||||
|
||||
var response = await sut.StartTrial(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var (written, etag, source, reason) = Assert.Single(_replaces);
|
||||
Assert.Equal("etag-1", etag);
|
||||
Assert.Equal("Contoso", written.CompanyName);
|
||||
Assert.True(written.TrialConsumed);
|
||||
Assert.Equal("admin@contoso.com", written.TrialGrantedBy);
|
||||
Assert.Equal(written.TrialStartedUtc!.Value.AddDays(PlanManagementFunction.SelfServiceTrialDays), written.TrialExpiresUtc);
|
||||
Assert.Equal("plan", source);
|
||||
Assert.Equal("self-service trial start", reason);
|
||||
opsRepo.Verify(r => r.SaveOpsEventAsync(It.Is<OpsEventEntry>(e => e.EventType == OpsEventTypes.TenantTrialStarted)), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetPlanTier_WritesOnTheFreshRowUnderItsETag()
|
||||
{
|
||||
var service = Service();
|
||||
await PrimeCacheAsync(service, Row(r => r.CompanyName = "stale"));
|
||||
_freshReads.Enqueue((Row(r => r.CompanyName = "Contoso"), "etag-1"));
|
||||
var (sut, _, audit) = Plan(service);
|
||||
var (req, _) = EndpointHarness.Request(TenantId,
|
||||
jsonBody: $"{{\"{PlanManagementFunction.PlanPatchKeys.PayingCustomer}\":true}}");
|
||||
|
||||
var response = await sut.SetPlanTier(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var (written, etag, _, _) = Assert.Single(_replaces);
|
||||
Assert.Equal("etag-1", etag);
|
||||
Assert.Equal("Contoso", written.CompanyName);
|
||||
Assert.True(written.PayingCustomer);
|
||||
audit.Verify(a => a.LogAuditEntryAsync(TenantId, "UPDATE", "TenantPlan", TenantId, It.IsAny<string>(),
|
||||
It.Is<Dictionary<string, string>>(d => d.ContainsKey("PayingCustomer"))), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetPlanTier_NothingChangesOnTheFreshRow_200WithoutAWrite()
|
||||
{
|
||||
_freshReads.Enqueue((Row(r => r.PayingCustomer = true), "etag-1"));
|
||||
var (sut, _, audit) = Plan(Service());
|
||||
var (req, _) = EndpointHarness.Request(TenantId,
|
||||
jsonBody: $"{{\"{PlanManagementFunction.PlanPatchKeys.PayingCustomer}\":true}}");
|
||||
|
||||
var response = await sut.SetPlanTier(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Empty(_replaces);
|
||||
audit.Verify(a => a.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>>()), Times.Never);
|
||||
}
|
||||
}
|
||||
+37
-33
@@ -100,7 +100,7 @@ public sealed class TenantConfigurationServiceReadSemanticsTests
|
||||
// row can never be Pro, whatever the index says (fail-closed).
|
||||
var (service, repo, _) = BuildProjecting(Owner);
|
||||
repo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync((TenantConfiguration?)null);
|
||||
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>())).ReturnsAsync(true);
|
||||
repo.Setup(r => r.TryCreateTenantConfigurationAsync(It.IsAny<TenantConfiguration>())).ReturnsAsync(true);
|
||||
|
||||
var config = await service.GetConfigurationAsync(TenantId);
|
||||
|
||||
@@ -135,7 +135,7 @@ public sealed class TenantConfigurationServiceReadSemanticsTests
|
||||
.ReturnsAsync((TenantConfiguration?)null);
|
||||
|
||||
Assert.Null(await service.GetConfigurationIfExistsAsync(TenantId));
|
||||
// MockBehavior.Strict: any SaveTenantConfigurationAsync call would have thrown.
|
||||
// MockBehavior.Strict: any write (create or replace) would have thrown.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -186,56 +186,60 @@ public sealed class TenantConfigurationServiceReadSemanticsTests
|
||||
Assert.Equal(TenantId, config.TenantId);
|
||||
}
|
||||
|
||||
// ── Save semantics (Codex finding 2026-07-07): the repository swallows storage exceptions
|
||||
// and reports failure via its bool return — the service must THROW on false so callers
|
||||
// (plan/trial endpoints, config PUT) can never audit + 200 a write that never persisted.
|
||||
// ── Write semantics: UpdateAsync is the write path (D-290). A storage failure propagates, and
|
||||
// the cache is dropped on every attempt — success or failure — so no instance that was read
|
||||
// before the write is served afterwards as if it were current.
|
||||
|
||||
[Fact]
|
||||
public async Task SaveConfigurationAsync_RepoReportsFalse_Throws()
|
||||
private static Mock<IConfigRepository> WithStoredRow(Mock<IConfigRepository> repo)
|
||||
{
|
||||
var (service, repo) = Build();
|
||||
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.SaveConfigurationAsync(new TenantConfiguration { TenantId = TenantId, UpdatedBy = "alice@contoso.com" }));
|
||||
repo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = TenantId, PlanTier = "community", UpdatedBy = "x" });
|
||||
repo.Setup(r => r.GetTenantConfigurationWithEtagAsync(TenantId))
|
||||
.ReturnsAsync((new TenantConfiguration { TenantId = TenantId, PlanTier = "community", UpdatedBy = "x" }, "etag"));
|
||||
return repo;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveConfigurationAsync_FailedSave_InvalidatesCachedInstance()
|
||||
public async Task UpdateAsync_StorageError_Propagates()
|
||||
{
|
||||
// The plan/trial endpoints mutate the CACHED instance in place before saving. A failed
|
||||
// save must drop that instance from the cache — otherwise the unsaved mutation is served
|
||||
// as if persisted for up to 5 minutes.
|
||||
var (service, repo) = Build();
|
||||
var stored = new TenantConfiguration { TenantId = TenantId, PlanTier = "free", UpdatedBy = "x" };
|
||||
repo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(stored);
|
||||
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(false);
|
||||
WithStoredRow(repo).Setup(r => r.TryReplaceTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), "etag", It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ThrowsAsync(new InvalidOperationException("throttled"));
|
||||
|
||||
var cached = await service.GetConfigurationIfExistsAsync(TenantId);
|
||||
cached!.PlanTier = "enterprise"; // in-place mutation, as the plan endpoint does
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.UpdateAsync(TenantId, row => { row.PlanTier = "pro"; return true; }, "plan", "test"));
|
||||
}
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.SaveConfigurationAsync(cached));
|
||||
[Fact]
|
||||
public async Task UpdateAsync_FailedWrite_InvalidatesTheCache()
|
||||
{
|
||||
var (service, repo) = Build();
|
||||
WithStoredRow(repo).Setup(r => r.TryReplaceTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), "etag", It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ThrowsAsync(new InvalidOperationException("throttled"));
|
||||
|
||||
await service.GetConfigurationIfExistsAsync(TenantId); // prime cache
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.UpdateAsync(TenantId, row => { row.PlanTier = "pro"; return true; }, "plan", "test"));
|
||||
await service.GetConfigurationIfExistsAsync(TenantId); // must go back to storage
|
||||
|
||||
// Next read must go back to storage (cache dropped), not serve the mutated instance.
|
||||
await service.GetConfigurationIfExistsAsync(TenantId);
|
||||
repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveConfigurationAsync_Success_InvalidatesCache()
|
||||
public async Task UpdateAsync_Success_InvalidatesTheCache_AndNeverTouchesTheCachedInstance()
|
||||
{
|
||||
var (service, repo) = Build();
|
||||
repo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = TenantId, UpdatedBy = "x" });
|
||||
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
WithStoredRow(repo).Setup(r => r.TryReplaceTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), "etag", It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
await service.GetConfigurationIfExistsAsync(TenantId); // prime cache
|
||||
await service.SaveConfigurationAsync(new TenantConfiguration { TenantId = TenantId, UpdatedBy = "x" });
|
||||
await service.GetConfigurationIfExistsAsync(TenantId); // must re-read after save
|
||||
var cached = await service.GetConfigurationIfExistsAsync(TenantId); // prime cache
|
||||
await service.UpdateAsync(TenantId, row => { row.PlanTier = "pro"; return true; }, "plan", "test");
|
||||
await service.GetConfigurationIfExistsAsync(TenantId); // must re-read after the write
|
||||
|
||||
Assert.Equal("community", cached!.PlanTier);
|
||||
repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Exactly(2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ public class TenantContactEmailTests
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Server-side validation (UpdateTenantConfigurationFunction)
|
||||
// Server-side validation (TenantConfigValidation)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
@@ -51,7 +51,7 @@ public class TenantContactEmailTests
|
||||
[InlineData("first.last+autopilot@sub.fabrikam.co.uk")]
|
||||
[InlineData("UPPER@CONTOSO.COM")]
|
||||
public void ValidateContactEmail_accepts_real_addresses(string email)
|
||||
=> Assert.Null(UpdateTenantConfigurationFunction.ValidateContactEmail(email));
|
||||
=> Assert.Null(TenantConfigValidation.ValidateContactEmail(email));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
@@ -60,7 +60,7 @@ public class TenantContactEmailTests
|
||||
public void ValidateContactEmail_treats_absence_as_valid(string? email)
|
||||
{
|
||||
// No contact address is a legitimate state — it means we cannot reach this tenant.
|
||||
Assert.Null(UpdateTenantConfigurationFunction.ValidateContactEmail(email));
|
||||
Assert.Null(TenantConfigValidation.ValidateContactEmail(email));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -76,7 +76,7 @@ public class TenantContactEmailTests
|
||||
[InlineData("Ops Team <ops@contoso.com>", "single address")] // display-name form
|
||||
public void ValidateContactEmail_rejects_values_that_are_not_a_single_address(string email, string expectedHint)
|
||||
{
|
||||
var error = UpdateTenantConfigurationFunction.ValidateContactEmail(email);
|
||||
var error = TenantConfigValidation.ValidateContactEmail(email);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains(expectedHint, error!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -85,7 +85,7 @@ public class TenantContactEmailTests
|
||||
public void ValidateContactEmail_rejects_header_injection()
|
||||
{
|
||||
// Once this address is actually mailed, an embedded CR/LF would forge mail headers.
|
||||
var error = UpdateTenantConfigurationFunction.ValidateContactEmail(
|
||||
var error = TenantConfigValidation.ValidateContactEmail(
|
||||
$"ops@contoso.com{(char)13}{(char)10}Bcc: attacker@evil.test");
|
||||
|
||||
Assert.NotNull(error);
|
||||
@@ -94,8 +94,8 @@ public class TenantContactEmailTests
|
||||
[Fact]
|
||||
public void ValidateContactEmail_rejects_an_over_long_value()
|
||||
{
|
||||
var local = new string('a', UpdateTenantConfigurationFunction.MaxContactEmailLength);
|
||||
var error = UpdateTenantConfigurationFunction.ValidateContactEmail($"{local}@contoso.com");
|
||||
var local = new string('a', TenantConfigValidation.MaxContactEmailLength);
|
||||
var error = TenantConfigValidation.ValidateContactEmail($"{local}@contoso.com");
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("at most", error!);
|
||||
@@ -189,9 +189,7 @@ public class TenantContactEmailTests
|
||||
|
||||
await service.TrySeedContactEmailAsync(TenantId, "ops@contoso.com");
|
||||
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
repo.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -86,9 +86,7 @@ public class TenantEntitlementServiceTests
|
||||
// auto-create + persist a default config row.
|
||||
var (svc, repo) = Build(config: null);
|
||||
await svc.GetEditionAsync(TenantId);
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
repo.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -52,9 +52,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
alreadyDisabled.Disabled = true;
|
||||
alreadyDisabled.DisabledReason = "Offboarding in progress";
|
||||
alreadyDisabled.LastUpdated = DateTime.UtcNow.AddDays(-30);
|
||||
configRepoMock
|
||||
.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(alreadyDisabled);
|
||||
configRepoMock.BackWithRow(alreadyDisabled);
|
||||
|
||||
var cache = new Microsoft.Extensions.Caching.Memory.MemoryCache(
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions());
|
||||
@@ -196,7 +194,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var sut = Build(repo);
|
||||
var historyRowKey = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}_{TenantId}";
|
||||
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, historyRowKey, DateTime.UtcNow);
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, historyRowKey, DateTime.UtcNow, trialConsumed: false);
|
||||
|
||||
Assert.True(repo.Pointers.TryGetValue(TenantId, out var entry));
|
||||
Assert.Equal(historyRowKey, entry.Pointer.LatestHistoryRowKey);
|
||||
@@ -222,13 +220,108 @@ public sealed class TenantOffboardFunctionTests
|
||||
var sut = Build(repo);
|
||||
var newHistoryRowKey = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}_{TenantId}";
|
||||
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, newHistoryRowKey, DateTime.UtcNow);
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, newHistoryRowKey, DateTime.UtcNow, trialConsumed: false);
|
||||
|
||||
Assert.Equal(newHistoryRowKey, repo.Pointers[TenantId].Pointer.LatestHistoryRowKey);
|
||||
Assert.Equal("Initiated", repo.Pointers[TenantId].Pointer.LatestStatus);
|
||||
Assert.Equal(3, repo.Pointers[TenantId].Pointer.OffboardCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false, false)]
|
||||
[InlineData(false, true, true)]
|
||||
[InlineData(true, false, true)] // a later round without a trial never clears it
|
||||
[InlineData(true, true, true)]
|
||||
public async Task UpsertPointer_TrialConsumed_IsStickyAcrossRounds(bool stored, bool thisRound, bool expected)
|
||||
{
|
||||
var repo = new FakeOffboardingAuditRepository();
|
||||
await repo.InsertByTenantPointerAsync(new OffboardingByTenantPointer
|
||||
{
|
||||
PartitionKey = Constants.OffboardingPartitionKeys.ByTenant,
|
||||
RowKey = TenantId,
|
||||
TenantId = TenantId,
|
||||
LatestHistoryRowKey = "20260101000000000_" + TenantId,
|
||||
LatestStatus = "Completed",
|
||||
OffboardCount = 1,
|
||||
TrialConsumed = stored,
|
||||
});
|
||||
|
||||
await Build(repo).UpsertPointerWithCasAsync(TenantId, "20260201000000000_" + TenantId, DateTime.UtcNow, thisRound);
|
||||
|
||||
Assert.Equal(expected, repo.Pointers[TenantId].Pointer.TrialConsumed);
|
||||
Assert.Equal(2, repo.Pointers[TenantId].Pointer.OffboardCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task UpsertPointer_FreshTenant_StampsTrialConsumed(bool trialConsumed)
|
||||
{
|
||||
var repo = new FakeOffboardingAuditRepository();
|
||||
|
||||
await Build(repo).UpsertPointerWithCasAsync(TenantId, "20260201000000000_" + TenantId, DateTime.UtcNow, trialConsumed);
|
||||
|
||||
Assert.Equal(trialConsumed, repo.Pointers[TenantId].Pointer.TrialConsumed);
|
||||
}
|
||||
|
||||
// ── OffboardTenant (Phase 1): the consumed trial must reach the pointer before anything is deleted ──
|
||||
|
||||
private static (TenantOffboardFunction Sut, FakeOffboardingAuditRepository Repo, RecordingEnqueuer Enqueuer, Mock<IConfigRepository> ConfigRepo)
|
||||
BuildForOffboard(TenantConfiguration? liveConfig, Exception? readFailure = null)
|
||||
{
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.BackWithRow(liveConfig).ThrowOnRead = readFailure;
|
||||
|
||||
var repo = new FakeOffboardingAuditRepository();
|
||||
var enqueuer = new RecordingEnqueuer();
|
||||
var sut = new TenantOffboardFunction(
|
||||
logger: NullLogger<TenantOffboardFunction>.Instance,
|
||||
configRepo: configRepo.Object,
|
||||
tenantConfigService: new TenantConfigurationService(
|
||||
configRepo.Object, NullLogger<TenantConfigurationService>.Instance,
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions())),
|
||||
maintenanceRepo: Mock.Of<IMaintenanceRepository>(),
|
||||
offboardingRepo: repo,
|
||||
offboardingEnqueuer: enqueuer,
|
||||
previewWhitelistService: BuildPreviewWhitelistService(configRepo));
|
||||
return (sut, repo, enqueuer, configRepo);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task OffboardTenant_CarriesTheLiveTrialConsumedOntoThePointer(bool trialConsumed)
|
||||
{
|
||||
var live = TenantConfiguration.CreateDefault(TenantId);
|
||||
live.DomainName = "contoso.com";
|
||||
live.TrialConsumed = trialConsumed;
|
||||
var (sut, repo, enqueuer, _) = BuildForOffboard(live);
|
||||
var (req, context) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await sut.OffboardTenant(req, TenantId, context);
|
||||
|
||||
Assert.Equal(System.Net.HttpStatusCode.Accepted, response.StatusCode);
|
||||
Assert.Equal(trialConsumed, repo.Pointers[TenantId].Pointer.TrialConsumed);
|
||||
Assert.Single(enqueuer.Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OffboardTenant_ConfigReadFails_RefusesBeforeAnyStateIsWritten()
|
||||
{
|
||||
// Proceeding without the read would reset the one-trial rule for the returning tenant.
|
||||
var (sut, repo, enqueuer, configRepo) = BuildForOffboard(null, new RequestFailedException(503, "Server busy"));
|
||||
var (req, context) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await sut.OffboardTenant(req, TenantId, context);
|
||||
|
||||
Assert.Equal(System.Net.HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
Assert.Empty(repo.History);
|
||||
Assert.Empty(repo.Pointers);
|
||||
Assert.Empty(repo.Markers);
|
||||
Assert.Empty(enqueuer.Sent);
|
||||
configRepo.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertPointer_409OnInsert_FallsBackToEtagUpdate()
|
||||
{
|
||||
@@ -273,7 +366,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var sut = Build(repoMock.Object);
|
||||
var newHistoryRowKey = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}_{TenantId}";
|
||||
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, newHistoryRowKey, DateTime.UtcNow);
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, newHistoryRowKey, DateTime.UtcNow, trialConsumed: false);
|
||||
|
||||
Assert.NotNull(updatedPointer);
|
||||
Assert.Equal(newHistoryRowKey, updatedPointer!.LatestHistoryRowKey);
|
||||
@@ -325,7 +418,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var sut = Build(repoMock.Object);
|
||||
var newHistoryRowKey = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}_{TenantId}";
|
||||
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, newHistoryRowKey, DateTime.UtcNow);
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, newHistoryRowKey, DateTime.UtcNow, trialConsumed: false);
|
||||
|
||||
Assert.Equal(2, updateCalls);
|
||||
Assert.Equal(2, readIdx);
|
||||
@@ -484,7 +577,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var historyRowKey = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}_{TenantId}";
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.UpsertPointerWithCasAsync(TenantId, historyRowKey, DateTime.UtcNow));
|
||||
sut.UpsertPointerWithCasAsync(TenantId, historyRowKey, DateTime.UtcNow, trialConsumed: false));
|
||||
}
|
||||
|
||||
// ── Cache-Drain-Barrier (plan v2 §2.2 / §2.3) ─────────────────────────────
|
||||
@@ -638,27 +731,42 @@ public sealed class TenantOffboardFunctionTests
|
||||
var (sut, configRepo, cache, _) = BuildWithConfigService();
|
||||
var existing = TenantConfiguration.CreateDefault(TenantId);
|
||||
existing.Disabled = false;
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(existing);
|
||||
|
||||
TenantConfiguration? saved = null;
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<TenantConfiguration, string?, string?>((c, _, _) => saved = c)
|
||||
.ReturnsAsync(true);
|
||||
existing.DomainName = "contoso.com";
|
||||
var store = configRepo.BackWithRow(existing);
|
||||
|
||||
// Seed cache with stale "Disabled=false" entry so we can prove invalidation.
|
||||
cache.Set($"tenant-config:{TenantId}", existing);
|
||||
|
||||
await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
|
||||
|
||||
Assert.NotNull(saved);
|
||||
Assert.True(saved!.Disabled);
|
||||
var (saved, source, reason) = Assert.Single(store.Writes);
|
||||
Assert.True(saved.Disabled);
|
||||
Assert.Equal("Offboarding in progress", saved.DisabledReason);
|
||||
Assert.Null(saved.DisabledUntil);
|
||||
Assert.Equal("alice@contoso.com", saved.UpdatedBy);
|
||||
Assert.Equal("contoso.com", saved.DomainName); // the rest of the row survives
|
||||
Assert.Equal(("offboard", "Disabled-gate before cascade"), (source, reason));
|
||||
Assert.False(cache.TryGetValue($"tenant-config:{TenantId}", out _),
|
||||
"cache entry must be invalidated so warm reads pick up the new Disabled=true on next access");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTenantDisabled_DecidesOnTheStoredRow_NotAStaleCachedView()
|
||||
{
|
||||
// A cached copy still says "not disabled"; the stored row is already the tombstone.
|
||||
var (sut, configRepo, cache, _) = BuildWithConfigService();
|
||||
var tombstone = TenantConfiguration.CreateDefault(TenantId);
|
||||
tombstone.Disabled = true;
|
||||
tombstone.DisabledReason = "Offboarding in progress";
|
||||
var store = configRepo.BackWithRow(tombstone);
|
||||
cache.Set($"tenant-config:{TenantId}", TenantConfiguration.CreateDefault(TenantId));
|
||||
|
||||
var result = await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
|
||||
|
||||
Assert.Equal(TenantOffboardFunction.EnsureDisabledOutcome.AlreadyTombstone, result.Outcome);
|
||||
Assert.Empty(store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTenantDisabled_AlreadyDisabledWithSameReason_DoesNotRewrite()
|
||||
{
|
||||
@@ -666,12 +774,12 @@ public sealed class TenantOffboardFunctionTests
|
||||
var existing = TenantConfiguration.CreateDefault(TenantId);
|
||||
existing.Disabled = true;
|
||||
existing.DisabledReason = "Offboarding in progress";
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(existing);
|
||||
var store = configRepo.BackWithRow(existing);
|
||||
|
||||
await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
|
||||
|
||||
// Idempotent: no Save thrash on resume-clicks while the marker is already in flight.
|
||||
configRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
// Idempotent: no write thrash on resume-clicks while the marker is already in flight.
|
||||
Assert.Empty(store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -681,21 +789,19 @@ public sealed class TenantOffboardFunctionTests
|
||||
// skip the enqueue. We just assert the exception propagates — the caller's
|
||||
// try/catch + 500-return is exercised by integration tests.
|
||||
var (sut, configRepo, _, _) = BuildWithConfigService();
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ThrowsAsync(new InvalidOperationException("storage unavailable"));
|
||||
configRepo.BackWithRow(TenantConfiguration.CreateDefault(TenantId)).ThrowOnRead =
|
||||
new InvalidOperationException("storage unavailable");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTenantDisabled_StorageThrowsOnSave_PropagatesAsExpected()
|
||||
public async Task EnsureTenantDisabled_StorageThrowsOnWrite_PropagatesAsExpected()
|
||||
{
|
||||
var (sut, configRepo, _, _) = BuildWithConfigService();
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ThrowsAsync(new InvalidOperationException("storage save failed"));
|
||||
configRepo.BackWithRow(TenantConfiguration.CreateDefault(TenantId)).ThrowOnWrite =
|
||||
new InvalidOperationException("storage write failed");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com"));
|
||||
@@ -709,35 +815,25 @@ public sealed class TenantOffboardFunctionTests
|
||||
// The fix creates a fresh CreateDefault row with Disabled=true so the auth-gate is
|
||||
// committed even in this edge case.
|
||||
var (sut, configRepo, _, _) = BuildWithConfigService();
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ReturnsAsync((TenantConfiguration?)null);
|
||||
var store = configRepo.BackWithRow(null);
|
||||
|
||||
TenantConfiguration? saved = null;
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<TenantConfiguration, string?, string?>((c, _, _) => saved = c)
|
||||
.ReturnsAsync(true);
|
||||
var result = await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
|
||||
|
||||
await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
|
||||
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal(TenantId, saved!.TenantId);
|
||||
var saved = Assert.Single(store.Writes).Row;
|
||||
Assert.Equal(TenantId, saved.TenantId);
|
||||
Assert.True(saved.Disabled, "fresh tombstone must carry Disabled=true");
|
||||
Assert.Equal("Offboarding in progress", saved.DisabledReason);
|
||||
Assert.Equal("alice@contoso.com", saved.UpdatedBy);
|
||||
Assert.Equal(TenantOffboardFunction.EnsureDisabledOutcome.WroteNewTombstone, result.Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTenantDisabled_SaveReturnsFalse_ThrowsAsHardFail()
|
||||
public async Task EnsureTenantDisabled_EveryWriteLost_ThrowsAsHardFail()
|
||||
{
|
||||
// PR3.B-revised Finding 2: TableConfigRepository.SaveTenantConfigurationAsync
|
||||
// returns false on transient storage failure (it does NOT throw). Ignoring that
|
||||
// return-value would let the caller proceed to enqueue without the auth-gate
|
||||
// committed. Turn it into a hard exception so the offboard is NOT enqueued.
|
||||
// Without Disabled=true the caller would proceed to enqueue without the auth-gate
|
||||
// committed: every lost conditional write is a hard exception, never a silent skip.
|
||||
var (sut, configRepo, _, _) = BuildWithConfigService();
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(false); // transient storage rejection — NOT a throw.
|
||||
configRepo.BackWithRow(TenantConfiguration.CreateDefault(TenantId)).RejectWrites = true;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com"));
|
||||
@@ -745,15 +841,10 @@ public sealed class TenantOffboardFunctionTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTenantDisabled_NoConfigRow_SaveReturnsFalse_ThrowsAsHardFail()
|
||||
public async Task EnsureTenantDisabled_NoConfigRow_EveryWriteLost_ThrowsAsHardFail()
|
||||
{
|
||||
// Combined edge case: missing row → we'd create a fresh tombstone → Save fails →
|
||||
// hard fail (otherwise the caller would proceed without an auth-gate).
|
||||
var (sut, configRepo, _, _) = BuildWithConfigService();
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
||||
.ReturnsAsync((TenantConfiguration?)null);
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(false);
|
||||
configRepo.BackWithRow(null).RejectWrites = true;
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com"));
|
||||
@@ -766,12 +857,12 @@ public sealed class TenantOffboardFunctionTests
|
||||
var tombstone = TenantConfiguration.CreateDefault(TenantId);
|
||||
tombstone.Disabled = true;
|
||||
tombstone.DisabledReason = "Offboarding in progress";
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(tombstone);
|
||||
var store = configRepo.BackWithRow(tombstone);
|
||||
|
||||
var result = await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
|
||||
|
||||
Assert.Equal(TenantOffboardFunction.EnsureDisabledOutcome.AlreadyTombstone, result.Outcome);
|
||||
configRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Empty(store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -780,13 +871,13 @@ public sealed class TenantOffboardFunctionTests
|
||||
var (sut, configRepo, _, _) = BuildWithConfigService();
|
||||
var existing = TenantConfiguration.CreateDefault(TenantId);
|
||||
existing.Disabled = false;
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(existing);
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(true);
|
||||
var store = configRepo.BackWithRow(existing);
|
||||
|
||||
var result = await sut.EnsureTenantDisabledAsync(TenantId, "alice@contoso.com");
|
||||
|
||||
Assert.Equal(TenantOffboardFunction.EnsureDisabledOutcome.WroteNewTombstone, result.Outcome);
|
||||
// The anchor of the drain-deadline math is the stored write time.
|
||||
Assert.Equal(Assert.Single(store.Writes).Row.LastUpdated, result.TombstoneLastUpdated);
|
||||
}
|
||||
|
||||
// ── Resume-path cache-drain bypass fix (Codex review) ────────────────────
|
||||
@@ -830,10 +921,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var configRepoMock = new Mock<IConfigRepository>();
|
||||
var notYetDisabled = TenantConfiguration.CreateDefault(TenantId);
|
||||
notYetDisabled.Disabled = false;
|
||||
configRepoMock.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(notYetDisabled);
|
||||
configRepoMock.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(true);
|
||||
configRepoMock.BackWithRow(notYetDisabled);
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var tenantConfigService = new TenantConfigurationService(
|
||||
@@ -925,8 +1013,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
tombstone.Disabled = true;
|
||||
tombstone.DisabledReason = "Offboarding in progress";
|
||||
tombstone.LastUpdated = DateTime.UtcNow.AddSeconds(-1); // freshly written ~1s ago
|
||||
configRepoMock.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(tombstone);
|
||||
configRepoMock.BackWithRow(tombstone);
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var tenantConfigService = new TenantConfigurationService(
|
||||
@@ -1000,8 +1087,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
tombstone.Disabled = true;
|
||||
tombstone.DisabledReason = "Offboarding in progress";
|
||||
tombstone.LastUpdated = initiatedAt;
|
||||
configRepoMock.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(tombstone);
|
||||
configRepoMock.BackWithRow(tombstone);
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var tenantConfigService = new TenantConfigurationService(
|
||||
@@ -1043,8 +1129,8 @@ public sealed class TenantOffboardFunctionTests
|
||||
var historyAfter = repo.History[historyRowKey];
|
||||
Assert.Equal(originalEarliestProcessingAt, historyAfter.EarliestProcessingAt);
|
||||
|
||||
// No Save call (the tombstone was already correct).
|
||||
configRepoMock.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
// No write (the tombstone was already correct).
|
||||
configRepoMock.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
|
||||
// ── RetryFailedOffboardingAsync (operator retry of a Failed offboarding) ──────────
|
||||
@@ -1208,7 +1294,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
tombstone.Disabled = true;
|
||||
tombstone.DisabledReason = "Offboarding in progress";
|
||||
tombstone.LastUpdated = DateTime.UtcNow.AddMinutes(-2);
|
||||
configRepoMock.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>())).ReturnsAsync(tombstone);
|
||||
configRepoMock.BackWithRow(tombstone);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var service = new TenantConfigurationService(configRepoMock.Object, NullLogger<TenantConfigurationService>.Instance, cache);
|
||||
var enqueuer = new RecordingEnqueuer();
|
||||
@@ -1227,7 +1313,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var delay = Assert.Single(enqueuer.VisibilityDelays);
|
||||
Assert.NotNull(delay);
|
||||
Assert.InRange(delay!.Value, TimeSpan.FromMinutes(3.5), TimeSpan.FromMinutes(4.1));
|
||||
configRepoMock.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
configRepoMock.VerifyNoTenantConfigWrite();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -191,6 +191,7 @@ public class TypedContractCompletionWireParityTests
|
||||
new
|
||||
{
|
||||
appName = "Contoso App",
|
||||
source = "ime",
|
||||
totalInstalls = 4,
|
||||
succeeded = 2,
|
||||
skipped = 1,
|
||||
@@ -234,6 +235,7 @@ public class TypedContractCompletionWireParityTests
|
||||
new AppMetricsAppGroup
|
||||
{
|
||||
AppName = "Contoso App",
|
||||
Source = AppInstallSources.Ime,
|
||||
TotalInstalls = 4,
|
||||
Succeeded = 2,
|
||||
Skipped = 1,
|
||||
@@ -593,6 +595,7 @@ public class TypedContractCompletionWireParityTests
|
||||
var item = new AppsListItem
|
||||
{
|
||||
AppName = "Contoso App",
|
||||
Source = AppInstallSources.Ime,
|
||||
AppType = "Win32",
|
||||
TotalInstalls = 5,
|
||||
Succeeded = 3,
|
||||
@@ -610,6 +613,7 @@ public class TypedContractCompletionWireParityTests
|
||||
var anonymousItem = new
|
||||
{
|
||||
appName = "Contoso App",
|
||||
source = "ime",
|
||||
appType = "Win32",
|
||||
totalInstalls = 5,
|
||||
succeeded = 3,
|
||||
@@ -677,6 +681,7 @@ public class TypedContractCompletionWireParityTests
|
||||
{
|
||||
success = true,
|
||||
appName = "Contoso App",
|
||||
source = "ime",
|
||||
appType = string.Empty,
|
||||
windowDays = 30,
|
||||
collisionExcluded = 0,
|
||||
@@ -709,6 +714,7 @@ public class TypedContractCompletionWireParityTests
|
||||
{
|
||||
Success = true,
|
||||
AppName = "Contoso App",
|
||||
Source = AppInstallSources.Ime,
|
||||
AppType = string.Empty,
|
||||
WindowDays = 30,
|
||||
CollisionExcluded = 0,
|
||||
|
||||
@@ -62,7 +62,6 @@ public class TypedRequestGuardTests
|
||||
{
|
||||
["Functions/Config/PatchTenantConfigurationFieldsFunction.cs"] = 1,
|
||||
["Functions/Config/PatchAdminConfigurationFunction.cs"] = 1,
|
||||
["Functions/Config/UpdateTenantConfigurationFunction.cs"] = 1,
|
||||
["Functions/Rules/AnalyzeRulesFunction.cs"] = 2,
|
||||
["Functions/Rules/DryRunAnalyzeRuleFunction.cs"] = 1,
|
||||
["Functions/Rules/GatherRulesFunction.cs"] = 2,
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The webhook SSRF gate on real loopback sockets. DNS is injected: <c>rebind.example.test</c> does
|
||||
/// not exist, so a socket reaching a listener proves it used the gate's answer rather than a
|
||||
/// second resolution.
|
||||
/// </summary>
|
||||
public class WebhookConnectGateTests
|
||||
{
|
||||
private const string Host = "rebind.example.test";
|
||||
|
||||
private sealed class CountingResolver
|
||||
{
|
||||
private readonly IPAddress[] _answer;
|
||||
public List<string> Calls { get; } = new();
|
||||
public CountingResolver(params string[] answer) => _answer = answer.Select(IPAddress.Parse).ToArray();
|
||||
|
||||
public Task<IPAddress[]> Resolve(string host, CancellationToken _)
|
||||
{
|
||||
Calls.Add(host);
|
||||
return Task.FromResult(_answer);
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpClient ClientThrough(Func<SocketsHttpConnectionContext, CancellationToken, ValueTask<Stream>> connect,
|
||||
X509Certificate2? trustedServerCertificate = null)
|
||||
{
|
||||
var handler = new SocketsHttpHandler { AllowAutoRedirect = false, ConnectCallback = connect };
|
||||
if (trustedServerCertificate != null)
|
||||
{
|
||||
// Pinned to the test's own self-signed certificate — validation stays on, only the trust anchor differs.
|
||||
var pinned = trustedServerCertificate.GetCertHashString();
|
||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, cert, _, _) => cert?.GetCertHashString() == pinned;
|
||||
}
|
||||
// Short timeout: with a broken gate the TLS handshake waits on a listener that never accepts.
|
||||
return new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) };
|
||||
}
|
||||
|
||||
private static TcpListener StartListener()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
return listener;
|
||||
}
|
||||
|
||||
private static int PortOf(TcpListener listener) => ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
[Fact]
|
||||
public async Task PrivateAnswer_FailsClosed_WithoutOpeningASocket()
|
||||
{
|
||||
var listener = StartListener();
|
||||
try
|
||||
{
|
||||
var resolver = new CountingResolver("127.0.0.1");
|
||||
using var client = ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"https://{Host}:{PortOf(listener)}/hook"));
|
||||
|
||||
var refused = Assert.IsType<SsrfException>(ex.InnerException);
|
||||
Assert.Contains("private or reserved", refused.Message);
|
||||
Assert.Equal(new[] { Host }, resolver.Calls);
|
||||
Assert.False(listener.Pending());
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MixedAnswer_WithOnePrivateAddress_FailsClosed()
|
||||
{
|
||||
var resolver = new CountingResolver("1.1.1.1", "169.254.169.254");
|
||||
using var client = ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"https://{Host}/hook"));
|
||||
|
||||
Assert.IsType<SsrfException>(ex.InnerException);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PrivateIpLiteral_FailsClosed_WithoutResolving()
|
||||
{
|
||||
var listener = StartListener();
|
||||
try
|
||||
{
|
||||
var resolver = new CountingResolver("1.1.1.1");
|
||||
using var client = ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"https://127.0.0.1:{PortOf(listener)}/hook"));
|
||||
|
||||
Assert.IsType<SsrfException>(ex.InnerException);
|
||||
Assert.Empty(resolver.Calls);
|
||||
Assert.False(listener.Pending());
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlainHttp_IsRefused_BeforeResolving()
|
||||
{
|
||||
var resolver = new CountingResolver("1.1.1.1");
|
||||
using var client = ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"http://{Host}/hook"));
|
||||
|
||||
Assert.Contains("HTTPS", Assert.IsType<SsrfException>(ex.InnerException).Message);
|
||||
Assert.Empty(resolver.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnresolvableOrEmptyAnswer_FailsClosed()
|
||||
{
|
||||
Task<IPAddress[]> NotFound(string _, CancellationToken __) => throw new SocketException((int)SocketError.HostNotFound);
|
||||
using (var client = ClientThrough(SsrfGuard.CreateConnectCallback(NotFound)))
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"https://{Host}/hook"));
|
||||
Assert.Contains("resolve", Assert.IsType<SsrfException>(ex.InnerException).Message);
|
||||
}
|
||||
|
||||
using (var client = ClientThrough(SsrfGuard.CreateConnectCallback(new CountingResolver().Resolve)))
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"https://{Host}/hook"));
|
||||
Assert.Contains("no addresses", Assert.IsType<SsrfException>(ex.InnerException).Message);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllowedAnswer_ConnectsToExactlyThatAddress_AndTlsStillTargetsTheRequestHost()
|
||||
{
|
||||
using var certificate = CreateServerCertificate(Host);
|
||||
var listener = StartListener();
|
||||
try
|
||||
{
|
||||
string? serverName = null;
|
||||
var server = Task.Run(async () =>
|
||||
{
|
||||
using var tcp = await listener.AcceptTcpClientAsync();
|
||||
using var tls = new SslStream(tcp.GetStream());
|
||||
await tls.AuthenticateAsServerAsync(new SslServerAuthenticationOptions { ServerCertificate = certificate });
|
||||
serverName = tls.TargetHostName;
|
||||
var buffer = new byte[4096];
|
||||
var request = new StringBuilder();
|
||||
while (!request.ToString().Contains("\r\n\r\n"))
|
||||
{
|
||||
var read = await tls.ReadAsync(buffer);
|
||||
if (read == 0) break;
|
||||
request.Append(Encoding.ASCII.GetString(buffer, 0, read));
|
||||
}
|
||||
await tls.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"));
|
||||
});
|
||||
|
||||
// The classifier is widened to reach the loopback listener; the socket path is the production one.
|
||||
var resolver = new CountingResolver("127.0.0.1");
|
||||
using var client = ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve, _ => false), certificate);
|
||||
|
||||
var response = await client.GetAsync($"https://{Host}:{PortOf(listener)}/hook");
|
||||
await server;
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("ok", await response.Content.ReadAsStringAsync());
|
||||
Assert.Equal(Host, serverName);
|
||||
Assert.Equal(new[] { Host }, resolver.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebhookClient_PrimaryHandler_ConnectsOnlyThroughTheGate()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<ResiliencePolicies>();
|
||||
services.AddWebhookNotificationHttpClient();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
HttpMessageHandler handler = provider.GetRequiredService<IHttpMessageHandlerFactory>().CreateHandler(nameof(WebhookNotificationService));
|
||||
while (handler is DelegatingHandler delegating)
|
||||
handler = delegating.InnerHandler!;
|
||||
|
||||
var primary = Assert.IsType<SocketsHttpHandler>(handler);
|
||||
Assert.Same(SsrfGuard.ConnectToPublicAddress, primary.ConnectCallback);
|
||||
Assert.False(primary.AllowAutoRedirect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotificationPolicy_DoesNotRetryARefusedDestination()
|
||||
{
|
||||
var policies = new ResiliencePolicies(NullLoggerFactory.Instance);
|
||||
var attempts = 0;
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => policies.Notification.ExecuteAsync(() =>
|
||||
{
|
||||
attempts++;
|
||||
throw new HttpRequestException("refused", new SsrfException("Webhook URL targets a private or reserved network."));
|
||||
}));
|
||||
|
||||
Assert.Equal(1, attempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestNotification_ReportsTheRefusal()
|
||||
{
|
||||
var resolver = new CountingResolver("10.0.0.8");
|
||||
var webhook = new WebhookNotificationService(ClientThrough(SsrfGuard.CreateConnectCallback(resolver.Resolve)),
|
||||
NullLogger<WebhookNotificationService>.Instance);
|
||||
|
||||
var result = await webhook.SendNotificationWithResultAsync($"https://{Host}/hook", WebhookProviderType.GenericJson,
|
||||
new NotificationAlert { Title = "T", Summary = "S" });
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("Webhook URL targets a private or reserved network.", result.Message);
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateServerCertificate(string host)
|
||||
{
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var request = new CertificateRequest($"CN={host}", key, HashAlgorithmName.SHA256);
|
||||
var san = new SubjectAlternativeNameBuilder();
|
||||
san.AddDnsName(host);
|
||||
request.CertificateExtensions.Add(san.Build());
|
||||
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false));
|
||||
using var ephemeral = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
|
||||
// Round-trip through PKCS#12: SChannel cannot serve a certificate whose key is ephemeral.
|
||||
return X509CertificateLoader.LoadPkcs12(ephemeral.Export(X509ContentType.Pfx), null);
|
||||
}
|
||||
}
|
||||
+119
-120
@@ -3,159 +3,158 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
|
||||
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Generic helpers driving cross-partition merge-pagination for global views
|
||||
/// where Azure-Tables' native (PK asc, RK asc) ordering would otherwise
|
||||
/// surface partitions one-at-a-time instead of globally newest-first.
|
||||
/// Used by ops-events (categories as partitions) and global audit logs
|
||||
/// (tenants as partitions).
|
||||
/// Newest-first paging across the partitions of one table: global ops events (categories as
|
||||
/// partitions) and the global audit log (tenants as partitions). Azure Tables pages a
|
||||
/// cross-partition query in (PK asc, RK asc) order, which would surface one partition at a time,
|
||||
/// so every page runs one PartitionKey query per partition and merges the results.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Per-partition continuation anchored on RowKey (not Azure's opaque token):
|
||||
/// <c>LastRowKey</c> = the oldest RowKey we have already returned for this
|
||||
/// partition. Next fetch filters <c>RowKey gt LastRowKey</c> — natural
|
||||
/// ordering within the partition (revtick → newest-first) means this skips
|
||||
/// exactly what we showed and re-considers everything older.
|
||||
/// Invariant: every partition uses the same reverse-tick RowKey scheme, so the global order is
|
||||
/// (RowKey asc, Partition asc) and that order is the display order. The merge sorts by exactly
|
||||
/// that key and never by a resolved timestamp column — a merge key that disagrees with the
|
||||
/// RowKey inside one partition would let the watermark skip rows.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Why not Azure's continuation token? Cross-partition merge can drop items
|
||||
/// that were fetched but didn't survive the top-pageSize cut. If we advanced
|
||||
/// an Azure cursor past those leftovers, we'd lose them forever. With a
|
||||
/// RowKey bound we simply don't advance the continuation for partitions whose
|
||||
/// fetched items lost the merge — they get re-fetched next call and compete
|
||||
/// again at a lower floor.
|
||||
/// The continuation is the (RowKey, Partition) of the last returned row, so its size does not
|
||||
/// depend on the number of partitions. The earlier per-partition continuation map kept one entry
|
||||
/// for every partition with rows and outgrew the host's URL limit (HTTP 414) on the tenant
|
||||
/// fan-out.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Continuation shape: only <em>active</em> partitions are encoded. Missing
|
||||
/// from the map = exhausted (or never had any data). For 200-tenant audit
|
||||
/// fan-outs that previously kept exhausted entries around, the encoded
|
||||
/// token would balloon past 30 KB and trip URL-length limits before
|
||||
/// reaching the function (browser <c>net::ERR_FAILED</c> on Next click).
|
||||
/// Now the token shrinks page-over-page as partitions exhaust.
|
||||
/// Each partition reads at most <c>pageSize + 1</c> rows after the watermark. More rows exist
|
||||
/// exactly when more than <c>pageSize</c> were read in total: if no partition reached its limit,
|
||||
/// every partition is exhausted.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class PerPartitionFanOutMerge
|
||||
{
|
||||
internal sealed record PartitionContinuation(string? LastRowKey);
|
||||
/// <summary>The (RowKey, Partition) of the last row a page returned.</summary>
|
||||
internal sealed record Watermark(string RowKey, string Partition);
|
||||
|
||||
internal sealed record PartitionFetchResult<T>(
|
||||
string Partition,
|
||||
IReadOnlyList<(string RowKey, T Item)> Items);
|
||||
|
||||
/// <summary>
|
||||
/// Pure merge-and-advance step. Given pre-fetched per-partition items
|
||||
/// (already newest-first within each list) and the previous per-partition
|
||||
/// continuation map, returns the top-pageSize merged items plus the
|
||||
/// next continuation map. Partitions with nothing left to return are
|
||||
/// dropped from the map entirely — consumers treat "missing" as
|
||||
/// exhausted on subsequent calls (see class remarks).
|
||||
/// </summary>
|
||||
public static (List<T> Items, Dictionary<string, PartitionContinuation> NextContinuations)
|
||||
MergeAndAdvance<T>(
|
||||
IReadOnlyList<PartitionFetchResult<T>> fetched,
|
||||
IReadOnlyDictionary<string, PartitionContinuation> priorContinuations,
|
||||
int pageSize,
|
||||
Func<T, DateTime> timestampSelector)
|
||||
/// <summary>Lower RowKey bound of one partition's read.</summary>
|
||||
internal sealed record RowKeyBound(string RowKey, bool Inclusive)
|
||||
{
|
||||
var allFetched = fetched
|
||||
.SelectMany(r => r.Items.Select(t => (part: r.Partition, rk: t.RowKey, item: t.Item)))
|
||||
.OrderByDescending(t => timestampSelector(t.item))
|
||||
.ToList();
|
||||
|
||||
var merged = allFetched.Take(pageSize).ToList();
|
||||
var returnedItems = merged.Select(t => t.item).ToList();
|
||||
|
||||
var newContinuations = new Dictionary<string, PartitionContinuation>(StringComparer.Ordinal);
|
||||
foreach (var r in fetched)
|
||||
{
|
||||
var returnedFromPart = merged.Where(m => m.part == r.Partition).ToList();
|
||||
if (returnedFromPart.Count > 0)
|
||||
{
|
||||
// Advance to the oldest RowKey we returned for this partition.
|
||||
// Revtick scheme: larger RowKey string = older timestamp, so
|
||||
// OrdinalCompare-max gives us the oldest of the returned set.
|
||||
var oldestRk = returnedFromPart
|
||||
.Select(m => m.rk)
|
||||
.OrderBy(rk => rk, StringComparer.Ordinal)
|
||||
.Last();
|
||||
newContinuations[r.Partition] = new PartitionContinuation(oldestRk);
|
||||
}
|
||||
else if (r.Items.Count == 0)
|
||||
{
|
||||
// No items returned from this partition's query → exhausted.
|
||||
// Drop from map entirely; the consumer's "missing = exhausted"
|
||||
// rule (when continuation was provided) does the right thing
|
||||
// and keeps the encoded token small.
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fetched some but they all lost the merge cut to other partitions.
|
||||
// Keep continuation where it was — they'll be re-fetched and likely
|
||||
// win on the next page when the floor drops. Wasted bandwidth, but
|
||||
// correct (no item silently dropped).
|
||||
priorContinuations.TryGetValue(r.Partition, out var prior);
|
||||
newContinuations[r.Partition] = prior ?? new PartitionContinuation(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (returnedItems, newContinuations);
|
||||
public string ToODataClause() => $"RowKey {(Inclusive ? "ge" : "gt")} '{RowKey.Replace("'", "''")}'";
|
||||
}
|
||||
|
||||
// Wire format v1 (legacy): every partition encoded with `{rk, x:bool}`.
|
||||
// Wire format v2 (current): only ACTIVE partitions, `{rk}` only. Decoder
|
||||
// accepts both and silently drops v1 `x:true` entries so old tokens
|
||||
// emitted by previous deploys still page through cleanly.
|
||||
private const int WireFormatVersion = 2;
|
||||
/// <summary>
|
||||
/// Delegate that reads up to <c>limit</c> rows of one partition, newest first (RowKey
|
||||
/// ascending), restricted by <c>bound</c> when it is set. It must return every matching row
|
||||
/// up to the limit — a short read is taken as "partition exhausted".
|
||||
/// </summary>
|
||||
internal delegate Task<List<(string RowKey, T Item)>> PartitionReader<T>(
|
||||
string partition, RowKeyBound? bound, int limit, CancellationToken cancellationToken);
|
||||
|
||||
public static string EncodeMultiContinuation(IReadOnlyDictionary<string, PartitionContinuation> continuations)
|
||||
/// <summary>
|
||||
/// The rows of <paramref name="partition"/> that come strictly after the watermark in
|
||||
/// (RowKey, Partition) order. A partition that sorts after the watermark's partition may still
|
||||
/// hold rows with the watermark's own RowKey; one that sorts before (or is the same) may not.
|
||||
/// </summary>
|
||||
internal static RowKeyBound? BoundAfter(Watermark? watermark, string partition)
|
||||
{
|
||||
var doc = new Dictionary<string, object?>
|
||||
if (watermark == null) return null;
|
||||
var inclusive = string.CompareOrdinal(partition, watermark.Partition) > 0;
|
||||
return new RowKeyBound(watermark.RowKey, inclusive);
|
||||
}
|
||||
|
||||
public static async Task<RawPage<T>> FetchPageAsync<T>(
|
||||
IEnumerable<string> partitions,
|
||||
int pageSize,
|
||||
string? continuation,
|
||||
PartitionReader<T> readPartition,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||
|
||||
Watermark? watermark = null;
|
||||
if (!string.IsNullOrEmpty(continuation))
|
||||
{
|
||||
watermark = DecodeWatermark(continuation);
|
||||
// The HMAC wrapper already rejected foreign tokens; one that passes it and still does
|
||||
// not decode was minted by an older deploy. End the list rather than restart it.
|
||||
if (watermark == null) return RawPage<T>.Empty;
|
||||
}
|
||||
|
||||
var limit = pageSize + 1;
|
||||
var rows = await BoundedFanOut.RunAsync(partitions, BoundedFanOut.CrossTenantConcurrency, async (partition, ct) =>
|
||||
{
|
||||
var read = await readPartition(partition, BoundAfter(watermark, partition), limit, ct).ConfigureAwait(false);
|
||||
return read.Select(r => (Partition: partition, r.RowKey, r.Item)).ToList();
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Merge(rows, pageSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure merge step: orders the rows read from all partitions by (RowKey, Partition), returns
|
||||
/// the first <paramref name="pageSize"/> and a continuation when rows are left over.
|
||||
/// </summary>
|
||||
internal static RawPage<T> Merge<T>(IEnumerable<(string Partition, string RowKey, T Item)> rows, int pageSize)
|
||||
{
|
||||
var ordered = rows
|
||||
.OrderBy(r => r.RowKey, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.Partition, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var page = ordered.Take(pageSize).ToList();
|
||||
|
||||
string? next = null;
|
||||
if (ordered.Count > pageSize)
|
||||
{
|
||||
var last = page[page.Count - 1];
|
||||
next = EncodeWatermark(new Watermark(last.RowKey, last.Partition));
|
||||
}
|
||||
return new RawPage<T>(page.Select(r => r.Item).ToList(), next);
|
||||
}
|
||||
|
||||
private const int WireFormatVersion = 3;
|
||||
|
||||
public static string EncodeWatermark(Watermark watermark)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new Dictionary<string, object>
|
||||
{
|
||||
["v"] = WireFormatVersion,
|
||||
["c"] = continuations.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => (object?)new Dictionary<string, object?>
|
||||
{
|
||||
["rk"] = kv.Value.LastRowKey,
|
||||
}),
|
||||
};
|
||||
var json = JsonSerializer.Serialize(doc);
|
||||
["rk"] = watermark.RowKey,
|
||||
["p"] = watermark.Partition,
|
||||
});
|
||||
return Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
|
||||
}
|
||||
|
||||
public static Dictionary<string, PartitionContinuation> DecodeMultiContinuation(string? raw)
|
||||
/// <summary>Decodes a watermark; null for anything that is not a v3 watermark.</summary>
|
||||
public static Watermark? DecodeWatermark(string? raw)
|
||||
{
|
||||
var result = new Dictionary<string, PartitionContinuation>(StringComparer.Ordinal);
|
||||
if (string.IsNullOrEmpty(raw)) return result;
|
||||
|
||||
if (string.IsNullOrEmpty(raw)) return null;
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(raw);
|
||||
using var doc = JsonDocument.Parse(bytes);
|
||||
if (!doc.RootElement.TryGetProperty("c", out var entries)) return result;
|
||||
foreach (var prop in entries.EnumerateObject())
|
||||
{
|
||||
// v1 back-compat: skip entries explicitly marked exhausted.
|
||||
// v2 never writes them, but old in-flight tokens may.
|
||||
if (prop.Value.TryGetProperty("x", out var xEl) && xEl.ValueKind == JsonValueKind.True)
|
||||
continue;
|
||||
using var doc = JsonDocument.Parse(Convert.FromBase64String(raw));
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object) return null;
|
||||
if (!root.TryGetProperty("v", out var v) || v.ValueKind != JsonValueKind.Number
|
||||
|| !v.TryGetInt32(out var version) || version != WireFormatVersion)
|
||||
return null;
|
||||
if (!root.TryGetProperty("rk", out var rk) || rk.ValueKind != JsonValueKind.String) return null;
|
||||
if (!root.TryGetProperty("p", out var p) || p.ValueKind != JsonValueKind.String) return null;
|
||||
|
||||
string? rk = prop.Value.TryGetProperty("rk", out var rkEl) && rkEl.ValueKind == JsonValueKind.String
|
||||
? rkEl.GetString()
|
||||
: null;
|
||||
result[prop.Name] = new PartitionContinuation(rk);
|
||||
}
|
||||
var rowKey = rk.GetString();
|
||||
var partition = p.GetString();
|
||||
return string.IsNullOrEmpty(rowKey) || partition == null ? null : new Watermark(rowKey, partition);
|
||||
}
|
||||
catch
|
||||
catch (FormatException)
|
||||
{
|
||||
// Malformed continuation → treat as empty (start over). The wire-
|
||||
// layer fingerprint already rejects tampered tokens before reaching here.
|
||||
return null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-15
@@ -78,27 +78,16 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> SaveTenantConfigurationAsync(TenantConfiguration config)
|
||||
=> SaveTenantConfigurationAsync(config, backupSource: null, backupReason: null);
|
||||
|
||||
public async Task<bool> SaveTenantConfigurationAsync(
|
||||
TenantConfiguration config, string? backupSource, string? backupReason)
|
||||
public async Task<bool> TryCreateTenantConfigurationAsync(TenantConfiguration config)
|
||||
{
|
||||
await TrySnapshotBeforeSaveAsync(
|
||||
_tenantConfigTableClient, config.TenantId, "config",
|
||||
ConvertFromTenantTableEntity, config, TenantBackupNoiseProperties,
|
||||
config.UpdatedBy, backupSource, backupReason);
|
||||
|
||||
try
|
||||
{
|
||||
var entity = ConvertToTenantTableEntity(config);
|
||||
await _tenantConfigTableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace);
|
||||
await _tenantConfigTableClient.AddEntityAsync(ConvertToTenantTableEntity(config));
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (RequestFailedException ex) when (ex.Status == 409)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving tenant configuration for {TenantId}", config.TenantId);
|
||||
return false;
|
||||
return false; // created concurrently — the caller re-reads and updates
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +123,17 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> TryReplaceTenantConfigurationAsync(
|
||||
TenantConfiguration config, string etag, string? backupSource, string? backupReason)
|
||||
{
|
||||
await TrySnapshotBeforeSaveAsync(
|
||||
_tenantConfigTableClient, config.TenantId, "config",
|
||||
ConvertFromTenantTableEntity, config, TenantBackupNoiseProperties,
|
||||
config.UpdatedBy, backupSource, backupReason);
|
||||
|
||||
return await TryReplaceTenantConfigurationAsync(config, etag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-write snapshot hook shared by the tenant- and admin-config save paths.
|
||||
/// Fail-SOFT by design: this is a safety net around long-standing writers
|
||||
|
||||
+12
-4
@@ -18,6 +18,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
/// - F3 rule regression: "ruleregression|{ruleId-lower}" (trimmed; payload-carrying rows
|
||||
/// that are refreshed while the episode is active and deleted on re-arm)
|
||||
/// - app-version duration regression: "appversionregression|{app-lower}|{version-lower}"
|
||||
/// (non-IME channels: "appversionregression|{source}:{app-lower}|{version-lower}")
|
||||
/// (trimmed + table-key-sanitized; same payload-carrying episode semantics — raw
|
||||
/// AppName/CurrentVersion live in columns, the mapper never parses the RowKey)
|
||||
/// Race-safe via AddEntityAsync: Azure Table Storage returns 409 Conflict if the entity already exists.
|
||||
@@ -309,7 +310,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAppVersionRegressionAsync(string tenantId, string appName, string currentVersion)
|
||||
public async Task DeleteAppVersionRegressionAsync(string tenantId, string source, string appName, string currentVersion)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId)
|
||||
|| string.IsNullOrWhiteSpace(appName)
|
||||
@@ -320,7 +321,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
try
|
||||
{
|
||||
await _table.DeleteEntityAsync(
|
||||
tenantId.ToLowerInvariant(), BuildAppVersionRegressionRowKey(appName, currentVersion));
|
||||
tenantId.ToLowerInvariant(), BuildAppVersionRegressionRowKey(source, appName, currentVersion));
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
@@ -576,10 +577,14 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
/// two apps share an episode row — tolerable); the raw values live in columns and the
|
||||
/// mapper reads them, never the RowKey.
|
||||
/// </summary>
|
||||
internal static string BuildAppVersionRegressionRowKey(string appName, string version)
|
||||
internal static string BuildAppVersionRegressionRowKey(string? source, string appName, string version)
|
||||
{
|
||||
var app = SanitizeTableKey((appName ?? string.Empty).Trim().ToLowerInvariant());
|
||||
var ver = SanitizeTableKey((version ?? string.Empty).Trim().ToLowerInvariant());
|
||||
// IME keys predate channels and keep their shape; other channels prefix the app part.
|
||||
var channel = AppInstallSources.Normalize(source);
|
||||
if (channel != AppInstallSources.Ime)
|
||||
app = $"{SanitizeTableKey(channel)}:{app}";
|
||||
return $"{AppVersionRegressionRowKeyPrefix}{app}|{ver}";
|
||||
}
|
||||
|
||||
@@ -598,10 +603,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
internal static TableEntity BuildAppVersionRegressionEntity(string tenantId, AppVersionRegressionAlert alert)
|
||||
{
|
||||
return new TableEntity(
|
||||
tenantId.ToLowerInvariant(), BuildAppVersionRegressionRowKey(alert.AppName, alert.CurrentVersion))
|
||||
tenantId.ToLowerInvariant(), BuildAppVersionRegressionRowKey(alert.Source, alert.AppName, alert.CurrentVersion))
|
||||
{
|
||||
["TenantId"] = tenantId,
|
||||
["AppName"] = alert.AppName,
|
||||
["Source"] = AppInstallSources.Normalize(alert.Source),
|
||||
["CurrentVersion"] = alert.CurrentVersion,
|
||||
["PreviousVersion"] = alert.PreviousVersion ?? string.Empty,
|
||||
["CurrentMedianSeconds"] = alert.CurrentMedianSeconds,
|
||||
@@ -620,6 +626,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
TenantId = entity.GetString("TenantId") ?? entity.PartitionKey,
|
||||
AppName = entity.GetString("AppName") ?? string.Empty,
|
||||
// Rows written before channels existed are IME episodes.
|
||||
Source = AppInstallSources.Normalize(entity.GetString("Source")),
|
||||
CurrentVersion = entity.GetString("CurrentVersion") ?? string.Empty,
|
||||
PreviousVersion = entity.GetString("PreviousVersion") ?? string.Empty,
|
||||
CurrentMedianSeconds = entity.GetInt32("CurrentMedianSeconds") ?? 0,
|
||||
|
||||
+3
@@ -282,6 +282,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
["LatestStatus"] = p.LatestStatus,
|
||||
["LatestUpdatedAt"] = p.LatestUpdatedAt,
|
||||
["OffboardCount"] = p.OffboardCount,
|
||||
["TrialConsumed"] = p.TrialConsumed,
|
||||
};
|
||||
|
||||
private static OffboardingByTenantPointer MapByTenantPointer(TableEntity e) => new()
|
||||
@@ -293,6 +294,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
LatestStatus = e.GetString("LatestStatus") ?? "Initiated",
|
||||
LatestUpdatedAt = e.GetDateTime("LatestUpdatedAt") ?? default,
|
||||
OffboardCount = e.GetInt32("OffboardCount") ?? 1,
|
||||
// Absent on pointers from before the column; false is correct for all of them (D-288).
|
||||
TrialConsumed = e.GetBoolean("TrialConsumed") ?? false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+16
-41
@@ -115,60 +115,35 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
// partitions would just trade certainty for cost — the coverage test guards the list instead.
|
||||
internal static readonly string[] AllCategories = OpsEventCategory.All;
|
||||
|
||||
private async Task<RawPage<OpsEventEntry>> FanOutAcrossCategoriesAsync(
|
||||
private Task<RawPage<OpsEventEntry>> FanOutAcrossCategoriesAsync(
|
||||
DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation,
|
||||
OpsEventQueryFilters? filters = null)
|
||||
{
|
||||
var continuations = PerPartitionFanOutMerge.DecodeMultiContinuation(continuation);
|
||||
// First-page request fans out across every category; subsequent
|
||||
// pages restrict to whatever is still active in the continuation
|
||||
// map. With only 6 fixed categories this never grew to a problem
|
||||
// size, but the convention now matches the audit fan-out so the
|
||||
// shared fan-out helper has one rule.
|
||||
var isFirstPage = string.IsNullOrEmpty(continuation);
|
||||
|
||||
var activeCats = AllCategories
|
||||
.Where(cat => isFirstPage || continuations.ContainsKey(cat))
|
||||
.ToList();
|
||||
if (activeCats.Count == 0)
|
||||
return new RawPage<OpsEventEntry>(new List<OpsEventEntry>(), null);
|
||||
|
||||
var fetchTasks = activeCats.Select(async cat =>
|
||||
{
|
||||
continuations.TryGetValue(cat, out var catContinuation);
|
||||
var filter = BuildFilterWithRowKeyBound(cat, dateFrom, dateTo, catContinuation?.LastRowKey, filters);
|
||||
|
||||
var fetched = new List<(string RowKey, OpsEventEntry Item)>();
|
||||
await foreach (var e in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: pageSize))
|
||||
return PerPartitionFanOutMerge.FetchPageAsync<OpsEventEntry>(
|
||||
AllCategories, pageSize, continuation,
|
||||
async (category, bound, limit, ct) =>
|
||||
{
|
||||
fetched.Add((e.RowKey, MapToEntry(e)));
|
||||
if (fetched.Count >= pageSize) break;
|
||||
}
|
||||
return new PerPartitionFanOutMerge.PartitionFetchResult<OpsEventEntry>(cat, fetched);
|
||||
}).ToList();
|
||||
|
||||
var results = await Task.WhenAll(fetchTasks);
|
||||
|
||||
var (items, nextContinuations) = PerPartitionFanOutMerge.MergeAndAdvance(
|
||||
results, continuations, pageSize, e => e.Timestamp);
|
||||
|
||||
// Map only carries active partitions; empty = pagination complete.
|
||||
string? nextRawToken = nextContinuations.Count > 0
|
||||
? PerPartitionFanOutMerge.EncodeMultiContinuation(nextContinuations)
|
||||
: null;
|
||||
return new RawPage<OpsEventEntry>(items, nextRawToken);
|
||||
var filter = BuildFilterWithRowKeyBound(category, dateFrom, dateTo, bound, filters);
|
||||
var read = new List<(string RowKey, OpsEventEntry Item)>();
|
||||
await foreach (var e in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: limit, cancellationToken: ct))
|
||||
{
|
||||
read.Add((e.RowKey, MapToEntry(e)));
|
||||
if (read.Count >= limit) break;
|
||||
}
|
||||
return read;
|
||||
});
|
||||
}
|
||||
|
||||
internal static string BuildFilterWithRowKeyBound(
|
||||
string category, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey,
|
||||
string category, DateTime? dateFrom, DateTime? dateTo, PerPartitionFanOutMerge.RowKeyBound? rowKeyBound,
|
||||
OpsEventQueryFilters? filters = null)
|
||||
{
|
||||
var clauses = new List<string>
|
||||
{
|
||||
$"PartitionKey eq '{category.Replace("'", "''")}'",
|
||||
};
|
||||
if (!string.IsNullOrEmpty(lastRowKey))
|
||||
clauses.Add($"RowKey gt '{lastRowKey!.Replace("'", "''")}'");
|
||||
if (rowKeyBound != null)
|
||||
clauses.Add(rowKeyBound.ToODataClause());
|
||||
if (dateFrom.HasValue)
|
||||
clauses.Add(BusinessTimestamp.OpsDateFromClause(ToUtc(dateFrom.Value)));
|
||||
if (dateTo.HasValue)
|
||||
|
||||
@@ -100,19 +100,22 @@ public class TenantOffboardFunction
|
||||
"TENANT OFFBOARD initiated for tenant {TenantId} by {Upn}",
|
||||
normalizedTenantId, upn);
|
||||
|
||||
// 2. Capture DomainName from TenantConfiguration so the worker's Completed/Failed
|
||||
// OpsEvents can render "{domain} ({tenantId})" instead of the bare GUID.
|
||||
string? domainName = null;
|
||||
// 2. Capture what must outlive the TenantConfiguration row the cascade deletes: DomainName
|
||||
// for the worker's Completed/Failed OpsEvents ("{domain} ({tenantId})"), and TrialConsumed
|
||||
// for the pointer, which carries it to the returning tenant's first login (D-288).
|
||||
// Fail-loud: without the read the one-trial-per-tenant rule would silently reset.
|
||||
string? domainName;
|
||||
bool trialConsumed;
|
||||
try
|
||||
{
|
||||
var existingConfig = await _configRepo.GetTenantConfigurationAsync(targetTenantId);
|
||||
domainName = existingConfig?.DomainName;
|
||||
trialConsumed = existingConfig?.TrialConsumed ?? false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to read tenant configuration for {TenantId} before offboard; proceeding without domain name",
|
||||
normalizedTenantId);
|
||||
_logger.LogError(ex, "Failed to read tenant configuration for {TenantId} before offboard", normalizedTenantId);
|
||||
return await Build500Async(req, "Failed to read tenant configuration");
|
||||
}
|
||||
|
||||
// 2.b — Capture the Preview-Notification-Email before Phase 2.D wipes the
|
||||
@@ -184,14 +187,14 @@ public class TenantOffboardFunction
|
||||
// 5. Upsert ByTenant pointer with ETag-CAS so OffboardCount increments race-safely.
|
||||
try
|
||||
{
|
||||
await UpsertPointerWithCasAsync(normalizedTenantId, historyRowKey, now);
|
||||
await UpsertPointerWithCasAsync(normalizedTenantId, historyRowKey, now, trialConsumed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// History row is already committed — operator can find it under
|
||||
// PartitionKey="OffboardingHistory" and retry manually. Graceful degradation
|
||||
// (plan §4.4): pointer is the re-onboarding index; without it PR4's auto-wipe
|
||||
// is no-op but the offboarding itself still proceeds.
|
||||
// PartitionKey="OffboardingHistory". Nothing destructive has started: without the
|
||||
// pointer the returning tenant would get its trial back, so refuse and let the
|
||||
// admin re-click.
|
||||
_logger.LogError(ex,
|
||||
"Failed to upsert OffboardingByTenant pointer for {TenantId} after history row inserted",
|
||||
normalizedTenantId);
|
||||
@@ -722,7 +725,7 @@ public class TenantOffboardFunction
|
||||
/// <para>
|
||||
/// Drain-deadline math the caller applies:
|
||||
/// <c>effectiveDeadline = max(history.EarliestProcessingAt, TombstoneLastUpdated + DrainBarrier)</c>.
|
||||
/// Because the tombstone's <c>LastUpdated</c> is part of the same atomic Save as the
|
||||
/// Because the tombstone's <c>LastUpdated</c> is part of the same atomic write as the
|
||||
/// Disabled-flip, it is always present once the auth-gate is committed — even if
|
||||
/// the History-row patch later fails on a fast re-click.
|
||||
/// </para>
|
||||
@@ -734,7 +737,7 @@ public class TenantOffboardFunction
|
||||
internal enum EnsureDisabledOutcome
|
||||
{
|
||||
/// <summary>Row was already a Disabled=true tombstone with the offboarding reason —
|
||||
/// no Save was issued. Existing <c>LastUpdated</c> is returned.</summary>
|
||||
/// nothing was written. Existing <c>LastUpdated</c> is returned.</summary>
|
||||
AlreadyTombstone,
|
||||
/// <summary>We actively wrote the Disabled=true gate (either flipped an existing
|
||||
/// row or created a fresh one). <c>LastUpdated</c> is the timestamp of this write.</summary>
|
||||
@@ -759,70 +762,54 @@ public class TenantOffboardFunction
|
||||
/// <para>
|
||||
/// Behaviour:
|
||||
/// <list type="bullet">
|
||||
/// <item>Existing TenantConfiguration row → read-modify-write so signup data + custom
|
||||
/// rate limits etc. are preserved. Idempotent: if already
|
||||
/// <c>Disabled=true</c> with the offboarding reason, no Save is issued and
|
||||
/// <item>Existing TenantConfiguration row → conditional write on the fresh row
|
||||
/// (<see cref="TenantConfigurationService.CreateOrUpdateAsync"/>) so signup data + custom
|
||||
/// rate limits etc. are preserved and no concurrent write is rewound. Idempotent: if already
|
||||
/// <c>Disabled=true</c> with the offboarding reason, nothing is written and
|
||||
/// <see cref="EnsureDisabledResult.AlreadyTombstone"/> is returned.</item>
|
||||
/// <item>Missing TenantConfiguration row (edge case: very old tenant / manual delete)
|
||||
/// → create a fresh <c>CreateDefault</c> row with <c>Disabled=true</c>. Necessary
|
||||
/// because <c>AuthFunction</c> would otherwise auto-create a <c>Disabled=false</c>
|
||||
/// → create a fresh <c>CreateDefault</c> row with <c>Disabled=true</c> (conditional insert).
|
||||
/// Necessary because <c>AuthFunction</c> would otherwise auto-create a <c>Disabled=false</c>
|
||||
/// default on the next <c>/api/auth/me</c> and slip the offboarded tenant back through.</item>
|
||||
/// <item><c>SaveTenantConfigurationAsync</c> returns <c>false</c> on transient storage
|
||||
/// failure (the production repo does NOT throw; see Codex-finding TableConfigRepository).
|
||||
/// We turn that into <see cref="InvalidOperationException"/> so the caller treats it
|
||||
/// as a hard fail.</item>
|
||||
/// <item>Every conditional write lost to concurrent changes, or storage failed →
|
||||
/// throws, so the caller treats it as a hard fail.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal async Task<EnsureDisabledResult> EnsureTenantDisabledAsync(string targetTenantId, string upn)
|
||||
{
|
||||
var tenantConfig = await _configRepo.GetTenantConfigurationAsync(targetTenantId);
|
||||
|
||||
if (tenantConfig != null)
|
||||
var update = await _tenantConfigService.CreateOrUpdateAsync(targetTenantId, row =>
|
||||
{
|
||||
// Idempotent: if Disabled is already true with our reason, no-op the write so
|
||||
// resume-clicks do not thrash the LastUpdated timestamp on the row.
|
||||
var alreadyDisabled = tenantConfig.Disabled
|
||||
&& string.Equals(tenantConfig.DisabledReason, OffboardingDisabledReason, StringComparison.Ordinal);
|
||||
if (alreadyDisabled)
|
||||
if (string.IsNullOrEmpty(row.DomainName))
|
||||
{
|
||||
_tenantConfigService.InvalidateCache(targetTenantId);
|
||||
return new EnsureDisabledResult(EnsureDisabledOutcome.AlreadyTombstone, tenantConfig.LastUpdated);
|
||||
// Missing-row edge case: the default is written as the tombstone so AuthFunction's
|
||||
// auto-create-default cannot bring the tenant back enabled.
|
||||
_logger.LogWarning(
|
||||
"TenantOffboard: tenant configuration for {TenantId} has no domain (missing or never onboarded) — writing the Disabled=true tombstone onto it",
|
||||
targetTenantId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Missing-row edge case: create a fresh Disabled=true row so AuthFunction's
|
||||
// auto-create-default cannot bring the tenant back enabled.
|
||||
tenantConfig = TenantConfiguration.CreateDefault(targetTenantId);
|
||||
_logger.LogWarning(
|
||||
"TenantOffboard: no TenantConfiguration row found for {TenantId} — creating fresh Disabled=true tombstone",
|
||||
targetTenantId);
|
||||
}
|
||||
row.Disabled = true;
|
||||
row.DisabledReason = OffboardingDisabledReason;
|
||||
row.DisabledUntil = null;
|
||||
row.UpdatedBy = upn;
|
||||
return true;
|
||||
}, "offboard", "Disabled-gate before cascade");
|
||||
|
||||
tenantConfig.Disabled = true;
|
||||
tenantConfig.DisabledReason = OffboardingDisabledReason;
|
||||
tenantConfig.DisabledUntil = null;
|
||||
tenantConfig.UpdatedBy = upn;
|
||||
// Explicit LastUpdated stamp — we bypass TenantConfigurationService.SaveConfigurationAsync
|
||||
// (which would set this) and write to the repo directly, so we own the bookkeeping.
|
||||
// Critical for the resume-path drain-deadline math: this timestamp is the
|
||||
// authoritative "when did the cache-drain barrier start" anchor.
|
||||
var writtenAt = DateTime.UtcNow;
|
||||
tenantConfig.LastUpdated = writtenAt;
|
||||
|
||||
var saved = await _configRepo.SaveTenantConfigurationAsync(tenantConfig, "offboard", "Disabled-gate before cascade");
|
||||
if (!saved)
|
||||
switch (update.Status)
|
||||
{
|
||||
// TableConfigRepository.SaveTenantConfigurationAsync returns false on transient
|
||||
// storage failure instead of throwing. Without throwing here, the caller would
|
||||
// proceed to enqueue without the auth-gate committed — defeating the whole
|
||||
// cache-drain barrier. Surface as a hard failure.
|
||||
throw new InvalidOperationException(
|
||||
$"SaveTenantConfigurationAsync returned false for tenant {targetTenantId}; Disabled-gate NOT committed");
|
||||
case TenantConfigUpdateStatus.OffboardingInProgress:
|
||||
// Idempotent: the tombstone is already in place — nothing written, so LastUpdated
|
||||
// stays the authoritative "when did the cache-drain barrier start" anchor.
|
||||
_tenantConfigService.InvalidateCache(targetTenantId);
|
||||
return new EnsureDisabledResult(EnsureDisabledOutcome.AlreadyTombstone, update.Config!.LastUpdated);
|
||||
case TenantConfigUpdateStatus.Updated:
|
||||
// LastUpdated is stamped by the write itself — the anchor of the drain-deadline math.
|
||||
return new EnsureDisabledResult(EnsureDisabledOutcome.WroteNewTombstone, update.Config!.LastUpdated);
|
||||
default:
|
||||
// Without Disabled=true the cache-drain barrier offers no protection: hard fail.
|
||||
throw new InvalidOperationException(
|
||||
$"Disabled-gate NOT committed for tenant {targetTenantId} ({update.Status})");
|
||||
}
|
||||
_tenantConfigService.InvalidateCache(targetTenantId);
|
||||
return new EnsureDisabledResult(EnsureDisabledOutcome.WroteNewTombstone, writtenAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -869,11 +856,10 @@ public class TenantOffboardFunction
|
||||
}
|
||||
|
||||
// Plan §4.4 read-modify-write CAS-loop. Bounded retry; throws on exhaustion so the
|
||||
// caller turns it into a 500. The pointer is the O(1) re-onboarding index — failure
|
||||
// here is recoverable (graceful degradation in PR4) but we still surface it so the
|
||||
// operator can investigate.
|
||||
// caller turns it into a 500. The pointer is the O(1) re-onboarding index; TrialConsumed
|
||||
// is sticky across rounds (OR), never reset by an offboarding without a trial.
|
||||
internal async Task UpsertPointerWithCasAsync(
|
||||
string normalizedTenantId, string historyRowKey, DateTime now)
|
||||
string normalizedTenantId, string historyRowKey, DateTime now, bool trialConsumed)
|
||||
{
|
||||
for (var attempt = 1; attempt <= PointerCasMaxAttempts; attempt++)
|
||||
{
|
||||
@@ -890,6 +876,7 @@ public class TenantOffboardFunction
|
||||
LatestStatus = "Initiated",
|
||||
LatestUpdatedAt = now,
|
||||
OffboardCount = 1,
|
||||
TrialConsumed = trialConsumed,
|
||||
};
|
||||
|
||||
try
|
||||
@@ -910,6 +897,7 @@ public class TenantOffboardFunction
|
||||
existing.LatestStatus = "Initiated";
|
||||
existing.LatestUpdatedAt = now;
|
||||
existing.OffboardCount = existing.OffboardCount + 1;
|
||||
existing.TrialConsumed |= trialConsumed;
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -30,6 +30,17 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
return Guid.TryParse(raw, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the optional <c>?source=</c> install channel of the per-app endpoints. Absent means
|
||||
/// <see cref="AppInstallSources.Ime"/>, the channel every app link carried before channels
|
||||
/// existed. Returns <c>false</c> for an unknown value (caller should emit a 400).
|
||||
/// </summary>
|
||||
public static bool TryParseSourceQueryParam(string? raw, out string source)
|
||||
{
|
||||
source = string.IsNullOrEmpty(raw) ? AppInstallSources.Ime : raw!.Trim().ToLowerInvariant();
|
||||
return AppInstallSources.IsKnown(source);
|
||||
}
|
||||
|
||||
// ── Opt-in pagination ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>Upper bound for a single <c>apps/list</c> page when the caller opts into pagination.</summary>
|
||||
@@ -141,7 +152,9 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
// they are excluded from every per-app group and disclosed via collisionExcluded.
|
||||
var collisionExcluded = summaries.Count(s => s.AppIdCollision);
|
||||
|
||||
var apps = summaries.Where(s => !s.AppIdCollision).GroupBy(s => s.AppName).Select(g =>
|
||||
// One group per (channel, name): an Intune app and a RealmJoin package that share a
|
||||
// display name are different installs and stay separate rows.
|
||||
var apps = summaries.Where(s => !s.AppIdCollision).GroupBy(s => (Source: AppInstallSources.Normalize(s.Source), s.AppName)).Select(g =>
|
||||
{
|
||||
// PR0 classification (see MetricsMath.IsSkipTerminalState / HasMeasuredDuration):
|
||||
// skips leave the rate + durations; duration stats read measured rows only.
|
||||
@@ -159,7 +172,8 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
|
||||
return new AppsListItem
|
||||
{
|
||||
AppName = g.Key,
|
||||
AppName = g.Key.AppName,
|
||||
Source = g.Key.Source,
|
||||
AppType = g.Select(s => s.AppType).FirstOrDefault(t => !string.IsNullOrEmpty(t)) ?? string.Empty,
|
||||
TotalInstalls = total,
|
||||
Succeeded = installed.Count,
|
||||
@@ -178,6 +192,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
.OrderByDescending(a => a.Failed)
|
||||
.ThenByDescending(a => a.FailureRate)
|
||||
.ThenBy(a => a.AppName, StringComparer.OrdinalIgnoreCase) // deterministic tiebreaker for stable paging cursors
|
||||
.ThenBy(a => a.Source, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Legacy mode: caller did not opt into pagination → full array (web UI pages client-side).
|
||||
@@ -225,6 +240,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
List<AppInstallSummary> allSummaries,
|
||||
ISessionRepository sessionRepo,
|
||||
string appName,
|
||||
string source,
|
||||
int days,
|
||||
IReadOnlyList<AppVersionRegressionAlert>? versionRegressions = null)
|
||||
{
|
||||
@@ -238,6 +254,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
|
||||
var inWindow = allSummaries
|
||||
.Where(s => string.Equals(s.AppName, appName, StringComparison.OrdinalIgnoreCase)
|
||||
&& AppInstallSources.Normalize(s.Source) == source
|
||||
&& s.StartedAt >= cutoff)
|
||||
.ToList();
|
||||
|
||||
@@ -252,6 +269,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
{
|
||||
Success = true,
|
||||
AppName = appName,
|
||||
Source = source,
|
||||
AppType = string.Empty,
|
||||
WindowDays = days,
|
||||
CollisionExcluded = collisionExcluded,
|
||||
@@ -413,6 +431,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
{
|
||||
Success = true,
|
||||
AppName = appName,
|
||||
Source = source,
|
||||
AppType = appType,
|
||||
WindowDays = days,
|
||||
CollisionExcluded = collisionExcluded,
|
||||
@@ -449,6 +468,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
List<AppInstallSummary> allSummaries,
|
||||
ISessionRepository sessionRepo,
|
||||
string appName,
|
||||
string source,
|
||||
int days,
|
||||
string statusFilter,
|
||||
string? modelFilter,
|
||||
@@ -460,6 +480,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
|
||||
var summaries = allSummaries
|
||||
.Where(s => string.Equals(s.AppName, appName, StringComparison.OrdinalIgnoreCase)
|
||||
&& AppInstallSources.Normalize(s.Source) == source
|
||||
&& s.StartedAt >= cutoff)
|
||||
.ToList();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -48,14 +49,19 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
if (!AppsAnalyticsHelper.TryParseSourceQueryParam(query["source"], out var source))
|
||||
{
|
||||
return await req.BadRequestAsync($"source must be one of: {string.Join(", ", AppInstallSources.All)}");
|
||||
}
|
||||
|
||||
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days);
|
||||
// Active duration-regression episodes for this app (fail-soft: empty on error).
|
||||
var versionRegressions = (await _notificationTracker.GetAppVersionRegressionsAsync(tenantId))
|
||||
.Where(a => string.Equals(a.AppName, decodedAppName, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(a => string.Equals(a.AppName, decodedAppName, StringComparison.OrdinalIgnoreCase)
|
||||
&& AppInstallSources.Normalize(a.Source) == source)
|
||||
.ToList();
|
||||
var body = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, _sessionRepo, decodedAppName, days, versionRegressions);
|
||||
summaries, _sessionRepo, decodedAppName, source, days, versionRegressions);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(body);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -47,6 +48,10 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
if (!AppsAnalyticsHelper.TryParseSourceQueryParam(query["source"], out var source))
|
||||
{
|
||||
return await req.BadRequestAsync($"source must be one of: {string.Join(", ", AppInstallSources.All)}");
|
||||
}
|
||||
|
||||
var statusFilter = (query["status"] ?? "all").Trim().ToLowerInvariant();
|
||||
var modelFilter = query["model"];
|
||||
@@ -58,7 +63,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
|
||||
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days);
|
||||
var body = await AppsAnalyticsHelper.BuildSessionsResponseAsync(
|
||||
summaries, _sessionRepo, decodedAppName, days,
|
||||
summaries, _sessionRepo, decodedAppName, source, days,
|
||||
statusFilter, modelFilter, versionFilter, offset, limit);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
+7
-2
@@ -53,6 +53,10 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
return await req.BadRequestAsync("tenantId must be a valid GUID");
|
||||
}
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
if (!AppsAnalyticsHelper.TryParseSourceQueryParam(query["source"], out var source))
|
||||
{
|
||||
return await req.BadRequestAsync($"source must be one of: {string.Join(", ", AppInstallSources.All)}");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Global apps/{App}/analytics requested (user: {User}, tenantId: {TenantId}, days: {Days})",
|
||||
@@ -64,10 +68,11 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
var versionRegressions = string.IsNullOrEmpty(scopedTenantId)
|
||||
? new List<AppVersionRegressionAlert>()
|
||||
: (await _notificationTracker.GetAppVersionRegressionsAsync(scopedTenantId!))
|
||||
.Where(a => string.Equals(a.AppName, decodedAppName, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(a => string.Equals(a.AppName, decodedAppName, StringComparison.OrdinalIgnoreCase)
|
||||
&& AppInstallSources.Normalize(a.Source) == source)
|
||||
.ToList();
|
||||
var body = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, _sessionRepo, decodedAppName, days, versionRegressions);
|
||||
summaries, _sessionRepo, decodedAppName, source, days, versionRegressions);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(body);
|
||||
|
||||
+6
-1
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -52,6 +53,10 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
return await req.BadRequestAsync("tenantId must be a valid GUID");
|
||||
}
|
||||
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
|
||||
if (!AppsAnalyticsHelper.TryParseSourceQueryParam(query["source"], out var source))
|
||||
{
|
||||
return await req.BadRequestAsync($"source must be one of: {string.Join(", ", AppInstallSources.All)}");
|
||||
}
|
||||
|
||||
var statusFilter = (query["status"] ?? "all").Trim().ToLowerInvariant();
|
||||
var modelFilter = query["model"];
|
||||
@@ -67,7 +72,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
|
||||
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, scopedTenantId, days);
|
||||
var body = await AppsAnalyticsHelper.BuildSessionsResponseAsync(
|
||||
summaries, _sessionRepo, decodedAppName, days,
|
||||
summaries, _sessionRepo, decodedAppName, source, days,
|
||||
statusFilter, modelFilter, versionFilter, offset, limit);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
+24
-7
@@ -17,15 +17,25 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
/// <summary>
|
||||
/// Transactional field-level patch of a tenant's configuration (TenantAdminOrGA: a tenant
|
||||
/// admin patches their own row on the stricter TenantAdmin caller tier — GA-only fields are
|
||||
/// an explicit 400 there). Unlike the full-model PUT, this takes ONLY the fields to change,
|
||||
/// and the service verifies after the conditional write that exactly those fields changed —
|
||||
/// rolling back automatically on drift. Every write is preceded by a fail-closed snapshot
|
||||
/// into ConfigurationBackups (revertible via POST config/{tenantId}/revert, GA-only).
|
||||
/// an explicit 400 there). The one client write path of the tenant configuration (the
|
||||
/// full-model PUT is gone, D-290): it takes ONLY the fields to change, and the service
|
||||
/// verifies after the conditional write that exactly those fields changed — rolling back
|
||||
/// automatically on drift. Every write is preceded by a fail-closed snapshot into
|
||||
/// ConfigurationBackups (revertible via POST config/{tenantId}/revert, GA-only).
|
||||
/// </summary>
|
||||
public class PatchTenantConfigurationFieldsFunction
|
||||
{
|
||||
internal const int MaxBodyBytes = 65_536;
|
||||
|
||||
/// <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 (<see cref="OpsEventService"/> raises its own event type for it). Allow-listed,
|
||||
/// never echoed from arbitrary input.
|
||||
/// </summary>
|
||||
internal const string CollectLogsIntent = "collect-logs";
|
||||
internal const string CollectLogsSource = "portal-collect-logs";
|
||||
|
||||
private readonly ILogger<PatchTenantConfigurationFieldsFunction> _logger;
|
||||
private readonly TenantConfigPatchService _patchService;
|
||||
|
||||
@@ -86,12 +96,19 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tags the backup Source with the write path (MCP tools stamp X-Client-Source: mcp).</summary>
|
||||
/// <summary>
|
||||
/// Tags the backup Source with the write path: the Collect Logs intent, else MCP (tools stamp
|
||||
/// X-Client-Source: mcp) or the plain API.
|
||||
/// </summary>
|
||||
internal static string ResolveSource(HttpRequestData req, string operation)
|
||||
=> req.Headers.TryGetValues("X-Client-Source", out var values)
|
||||
&& string.Equals(values.FirstOrDefault(), "mcp", StringComparison.OrdinalIgnoreCase)
|
||||
{
|
||||
if (operation == "patch" && string.Equals(req.Query["intent"], CollectLogsIntent, StringComparison.OrdinalIgnoreCase))
|
||||
return CollectLogsSource;
|
||||
return req.Headers.TryGetValues("X-Client-Source", out var values)
|
||||
&& string.Equals(values.FirstOrDefault(), "mcp", StringComparison.OrdinalIgnoreCase)
|
||||
? $"mcp-{operation}"
|
||||
: $"api-{operation}";
|
||||
}
|
||||
|
||||
internal static async Task<HttpResponseData> WriteOutcome(HttpRequestData req, PatchOutcome outcome)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -198,33 +199,36 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
}
|
||||
}
|
||||
|
||||
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
||||
if (config == null)
|
||||
{
|
||||
return await req.NotFoundAsync("Tenant not found");
|
||||
}
|
||||
|
||||
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
||||
var changes = new Dictionary<string, string>();
|
||||
var wasPermanentPro = false;
|
||||
TenantEdition editionBefore = default, editionAfter = default;
|
||||
|
||||
// Conferred Pro follows the PERMANENT tier of the managing tenant (a trial confers nothing):
|
||||
// remember the standing before the mutation so a permanent-Pro → non-permanent drop can
|
||||
// stamp the managed tenants' grace anchors below.
|
||||
var wasPermanentPro = FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier);
|
||||
var (editionBefore, editionAfter) =
|
||||
ApplyPlanChanges(config, newPlanTier, trialProvided, newTrialExpiresUtc, caller, nowUtc, changes);
|
||||
ApplyDelegatedSlotChange(config, slotsProvided, newMaxDelegatedTenants, changes);
|
||||
ApplyMcpUsagePlanChange(config, mcpPlanProvided, newMcpUsagePlan, changes);
|
||||
ApplyPayingCustomerChange(config, payingProvided, newPayingCustomer, changes);
|
||||
|
||||
if (changes.Count > 0)
|
||||
// Decided on the fresh row and written under its ETag; the mutation re-runs on a lost race.
|
||||
var update = await _configService.UpdateAsync(requestCtx.TargetTenantId, row =>
|
||||
{
|
||||
config.UpdatedBy = caller;
|
||||
// Fail-loud: SaveConfigurationAsync throws when the write did not persist (and
|
||||
// invalidates the cached — possibly mutated — instance in its finally), so a
|
||||
// failed save can never be audited or returned as 200.
|
||||
await _configService.SaveConfigurationAsync(config, "plan", "plan tier change");
|
||||
changes.Clear();
|
||||
// Conferred Pro follows the PERMANENT tier of the managing tenant (a trial confers nothing):
|
||||
// remember the standing before the mutation so a permanent-Pro → non-permanent drop can
|
||||
// stamp the managed tenants' grace anchors below.
|
||||
wasPermanentPro = FeatureEntitlementCatalog.IsPermanentProTier(row.PlanTier);
|
||||
(editionBefore, editionAfter) =
|
||||
ApplyPlanChanges(row, newPlanTier, trialProvided, newTrialExpiresUtc, caller, nowUtc, changes);
|
||||
ApplyDelegatedSlotChange(row, slotsProvided, newMaxDelegatedTenants, changes);
|
||||
ApplyMcpUsagePlanChange(row, mcpPlanProvided, newMcpUsagePlan, changes);
|
||||
ApplyPayingCustomerChange(row, payingProvided, newPayingCustomer, changes);
|
||||
if (changes.Count == 0)
|
||||
return false;
|
||||
row.UpdatedBy = caller;
|
||||
return true;
|
||||
}, "plan", "plan tier change");
|
||||
|
||||
if (await UpdateFailureAsync(req, update) is { } failure)
|
||||
return failure;
|
||||
var config = update.Config!;
|
||||
|
||||
if (update.Status == TenantConfigUpdateStatus.Updated)
|
||||
{
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
"UPDATE",
|
||||
@@ -403,10 +407,27 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
=> definitions.Select(d => TenantEntitlementService.NormalizePlanName(d.Name)).Where(n => n != null).Select(n => n!).OrderBy(n => n, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/config/{tenantId}/trial — TenantAdminOrGA (catalog-enforced). Self-service
|
||||
/// 30-day Pro trial, exactly once per tenant. 409 when the trial was already
|
||||
/// consumed or the tenant is already effectively Pro.
|
||||
/// The response for an <see cref="TenantConfigurationService.UpdateAsync"/> outcome that wrote
|
||||
/// nothing for a reason other than the caller's own decision; null for Updated and Declined.
|
||||
/// </summary>
|
||||
private static async Task<HttpResponseData?> UpdateFailureAsync(HttpRequestData req, TenantConfigUpdate update)
|
||||
{
|
||||
switch (update.Status)
|
||||
{
|
||||
case TenantConfigUpdateStatus.NotFound:
|
||||
return await req.NotFoundAsync("Tenant not found");
|
||||
case TenantConfigUpdateStatus.OffboardingInProgress:
|
||||
// The answer the suspension gate gives once its cache has caught up with the tombstone.
|
||||
return await req.ErrorAsync(HttpStatusCode.Forbidden,
|
||||
Constants.ApiErrorCodes.TenantSuspended, TenantOffboardFunction.OffboardingDisabledReason);
|
||||
case TenantConfigUpdateStatus.Conflict:
|
||||
return await req.ErrorAsync(HttpStatusCode.Conflict,
|
||||
Constants.ApiErrorCodes.Conflict, "The tenant configuration was changed concurrently. Retry shortly.");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure verdict for the self-service trial start; null = allowed. Order matters and is
|
||||
/// test-pinned: the terminal conditions (trial consumed, already Pro) win over the
|
||||
@@ -453,6 +474,12 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
return missing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/config/{tenantId}/trial — TenantAdminOrGA (catalog-enforced). Self-service
|
||||
/// 30-day Pro trial, exactly once per tenant — also across offboarding and re-onboarding
|
||||
/// (the offboarding pointer carries <see cref="TenantConfiguration.TrialConsumed"/> over).
|
||||
/// 409 when the trial was already consumed or the tenant is already effectively Pro.
|
||||
/// </summary>
|
||||
[Function("StartTenantTrial")]
|
||||
public async Task<HttpResponseData> StartTrial(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/trial")] HttpRequestData req,
|
||||
@@ -464,29 +491,31 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
var caller = requestCtx.UserPrincipalName ?? "Unknown";
|
||||
_logger.LogInformation("StartTenantTrial: tenantId={TenantId} by {User}", requestCtx.TargetTenantId, caller);
|
||||
|
||||
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
||||
if (config == null)
|
||||
{
|
||||
return await req.NotFoundAsync("Tenant not found");
|
||||
}
|
||||
|
||||
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
||||
(string Code, string Message)? deny = null;
|
||||
|
||||
if (EvaluateTrialStart(config, nowUtc) is { } deny)
|
||||
// The verdict runs on the fresh row inside the conditional write: two concurrent
|
||||
// starts cannot both see TrialConsumed=false, and a stale cached row is never saved.
|
||||
var update = await _configService.UpdateAsync(requestCtx.TargetTenantId, row =>
|
||||
{
|
||||
return await req.ErrorAsync(HttpStatusCode.Conflict, deny.Code, deny.Message);
|
||||
}
|
||||
deny = EvaluateTrialStart(row, nowUtc);
|
||||
if (deny != null)
|
||||
return false;
|
||||
row.TrialStartedUtc = nowUtc;
|
||||
row.TrialExpiresUtc = nowUtc.AddDays(SelfServiceTrialDays);
|
||||
row.TrialConsumed = true;
|
||||
row.TrialGrantedBy = caller;
|
||||
// Effectively Pro again — a leftover retention grace anchor is obsolete.
|
||||
row.ProDowngradedUtc = null;
|
||||
row.UpdatedBy = caller;
|
||||
return true;
|
||||
}, "plan", "self-service trial start");
|
||||
|
||||
config.TrialStartedUtc = nowUtc;
|
||||
config.TrialExpiresUtc = nowUtc.AddDays(SelfServiceTrialDays);
|
||||
config.TrialConsumed = true;
|
||||
config.TrialGrantedBy = caller;
|
||||
// Effectively Pro again — a leftover retention grace anchor is obsolete.
|
||||
config.ProDowngradedUtc = null;
|
||||
config.UpdatedBy = caller;
|
||||
|
||||
// Fail-loud: throws when the write did not persist (cache invalidated in finally).
|
||||
await _configService.SaveConfigurationAsync(config, "plan", "self-service trial start");
|
||||
if (await UpdateFailureAsync(req, update) is { } failure)
|
||||
return failure;
|
||||
if (deny is { } refused)
|
||||
return await req.ErrorAsync(HttpStatusCode.Conflict, refused.Code, refused.Message);
|
||||
var config = update.Config!;
|
||||
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
@@ -500,8 +529,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{ "TrialExpiresUtc", FormatUtc(config.TrialExpiresUtc) }
|
||||
});
|
||||
|
||||
// The conversion moment. Fired after the write persisted (SaveConfigurationAsync
|
||||
// is fail-loud), so an alert never announces a trial that was not stored.
|
||||
// The conversion moment. Fired only after the conditional write landed (UpdateAsync
|
||||
// returned Updated), so an alert never announces a trial that was not stored.
|
||||
await _opsEvents.RecordTenantTrialStartedAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
config.DomainName,
|
||||
|
||||
-217
@@ -1,217 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
public class UpdateTenantConfigurationFunction
|
||||
{
|
||||
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,
|
||||
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,
|
||||
string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var userIdentifier = requestCtx.UserPrincipalName;
|
||||
|
||||
_logger.LogInformation("UpdateTenantConfiguration: {TenantId} by user {User}", requestCtx.TargetTenantId, userIdentifier);
|
||||
|
||||
// Parse request body
|
||||
// Newtonsoft on purpose: the configuration stack (redaction restore, patch service) is
|
||||
// Newtonsoft-bound — TypedRequestGuardTests baseline.
|
||||
var read = await req.ReadNewtonsoftAsync<TenantConfiguration>(1_048_576);
|
||||
if (read.Error != null) return read.Error;
|
||||
var config = read.Value!;
|
||||
|
||||
// Normalize so the stored value never carries surrounding whitespace, and an
|
||||
// all-whitespace submission clears the field instead of masquerading as a value.
|
||||
config.ContactEmail = string.IsNullOrWhiteSpace(config.ContactEmail) ? null : config.ContactEmail.Trim();
|
||||
config.CompanyName = string.IsNullOrWhiteSpace(config.CompanyName) ? null : config.CompanyName.Trim();
|
||||
|
||||
// Load the stored config up-front so we can (1) restore any redacted secret placeholders
|
||||
// before validation/save and (2) protect GA-only fields below.
|
||||
var existingConfig = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
|
||||
|
||||
// Mid-offboarding freeze: the Disabled tombstone written by TenantOffboardFunction
|
||||
// is the cascade's ONLY auth gate. A PUT that lifts it (or edits anything else)
|
||||
// races the queued wipe — data written afterwards gets destroyed, and agents
|
||||
// would be un-gated against a tenant whose partitions are being deleted. Refuse
|
||||
// for ALL callers, GA included; once the cascade completes the row is deleted
|
||||
// and re-onboarding starts from a fresh default config anyway.
|
||||
if (TenantOffboardFunction.IsOffboardingTombstone(existingConfig))
|
||||
{
|
||||
return await req.ConflictAsync("Tenant offboarding is in progress — the configuration is frozen until the cascade completes.");
|
||||
}
|
||||
|
||||
// Defense-in-depth: a read-only GlobalReader is served a redacted config (secrets replaced
|
||||
// with the ***REDACTED*** sentinel). If such a view is ever round-tripped back on a save,
|
||||
// never persist the placeholder — restore the real secret from the stored config. (Own-tenant
|
||||
// admins are served the FULL config so this is normally a no-op for them.)
|
||||
config.RestoreRedactedSecretsFrom(existingConfig);
|
||||
|
||||
// System-owned fields are never client-writable — for ANY caller, GA included:
|
||||
// identity (DomainName, seeded once from the first login's UPN), onboarding/auth
|
||||
// provenance (OnboardedAt/OnboardedBy/LastAuthClientId*), app-reg homing
|
||||
// (HomedAppClientId — the 2026-07-31 stale-round-trip incident), and plan/trial/
|
||||
// downgrade state (PlanTier/Trial*/ProDowngradedUtc — mutable only via the plan
|
||||
// and trial endpoints; a client-supplied ProDowngradedUtc would refresh the
|
||||
// retention-downgrade grace anchor indefinitely). The set is the PATCH endpoint's
|
||||
// BaseDeniedFields, so the two write paths cannot drift.
|
||||
TenantConfigPatchService.PreserveDeniedFields(config, existingConfig);
|
||||
|
||||
// Shared model validation (rate limits, contact address, webhook/Teams SSRF, custom
|
||||
// headers, notification channels, diagnostics SAS, retention cap) — single source
|
||||
// with the transactional field-patch flow (TenantConfigValidation).
|
||||
var validationError = TenantConfigValidation.ValidateModel(config, existingConfig, requestCtx.IsGlobalAdmin);
|
||||
if (validationError != null)
|
||||
{
|
||||
return await req.BadRequestAsync(validationError);
|
||||
}
|
||||
|
||||
// Ensure tenant ID matches
|
||||
config.TenantId = requestCtx.TargetTenantId;
|
||||
|
||||
// Set the actual user identifier for audit logging
|
||||
config.UpdatedBy = userIdentifier;
|
||||
|
||||
// Protect GA-only fields from non-Global-Admin callers (existingConfig loaded above).
|
||||
if (!requestCtx.IsGlobalAdmin)
|
||||
{
|
||||
if (config.AllowInsecureAgentRequests != existingConfig.AllowInsecureAgentRequests ||
|
||||
config.BootstrapTokenEnabled != existingConfig.BootstrapTokenEnabled ||
|
||||
config.UnrestrictedModeEnabled != existingConfig.UnrestrictedModeEnabled ||
|
||||
config.EntraAppRolesEnabled != existingConfig.EntraAppRolesEnabled ||
|
||||
config.EnableEspContinueAnywayObservation != existingConfig.EnableEspContinueAnywayObservation ||
|
||||
config.CustomRateLimitRequestsPerMinute != existingConfig.CustomRateLimitRequestsPerMinute ||
|
||||
config.CustomUserRateLimitRequestsPerMinute != existingConfig.CustomUserRateLimitRequestsPerMinute ||
|
||||
config.Disabled != existingConfig.Disabled ||
|
||||
config.McpDisabled != existingConfig.McpDisabled ||
|
||||
config.McpClientRegistrationLimit != existingConfig.McpClientRegistrationLimit)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tenant Admin {User} attempted to modify GA-only fields for tenant {TenantId}",
|
||||
userIdentifier, requestCtx.TargetTenantId);
|
||||
}
|
||||
|
||||
config.AllowInsecureAgentRequests = existingConfig.AllowInsecureAgentRequests;
|
||||
config.BootstrapTokenEnabled = existingConfig.BootstrapTokenEnabled;
|
||||
config.UnrestrictedModeEnabled = existingConfig.UnrestrictedModeEnabled;
|
||||
config.EntraAppRolesEnabled = existingConfig.EntraAppRolesEnabled;
|
||||
// Continue-Anyway observation is an operator-set behavioral override —
|
||||
// a tenant admin must not be able to relax their own failure semantics.
|
||||
config.EnableEspContinueAnywayObservation = existingConfig.EnableEspContinueAnywayObservation;
|
||||
config.CustomRateLimitRequestsPerMinute = existingConfig.CustomRateLimitRequestsPerMinute;
|
||||
config.CustomUserRateLimitRequestsPerMinute = existingConfig.CustomUserRateLimitRequestsPerMinute;
|
||||
config.Disabled = existingConfig.Disabled;
|
||||
config.DisabledReason = existingConfig.DisabledReason;
|
||||
config.DisabledUntil = existingConfig.DisabledUntil;
|
||||
config.McpDisabled = existingConfig.McpDisabled;
|
||||
config.McpClientRegistrationLimit = existingConfig.McpClientRegistrationLimit;
|
||||
config.McpDisabledReason = existingConfig.McpDisabledReason;
|
||||
}
|
||||
|
||||
// Safety: if GA gate is off, force UnrestrictedMode to false
|
||||
if (!config.UnrestrictedModeEnabled)
|
||||
{
|
||||
config.UnrestrictedMode = false;
|
||||
}
|
||||
|
||||
// MaxNdjsonPayloadSizeMB is table-only — always preserve existing value
|
||||
config.MaxNdjsonPayloadSizeMB = existingConfig.MaxNdjsonPayloadSizeMB;
|
||||
|
||||
// Save configuration (retention cap already enforced by ValidateModel above)
|
||||
var writeSource = ResolveWriteSource(req.Query["intent"]);
|
||||
await _configService.SaveConfigurationAsync(config, writeSource, null);
|
||||
|
||||
var changes = ConfigDiffHelper.GetChanges(existingConfig, config);
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
"UPDATE",
|
||||
"TenantConfiguration",
|
||||
requestCtx.TargetTenantId,
|
||||
userIdentifier,
|
||||
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 UpdateTenantConfigurationResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "Configuration updated successfully",
|
||||
Config = config
|
||||
});
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "UpdateTenantConfiguration");
|
||||
}
|
||||
}
|
||||
|
||||
// Forwarding shims — the implementations moved to TenantConfigValidation (shared with
|
||||
// the transactional field-patch flow). Kept so existing callers and the validator test
|
||||
// suites keep their call sites; new code should reference TenantConfigValidation.
|
||||
internal const int MaxContactEmailLength = TenantConfigValidation.MaxContactEmailLength;
|
||||
|
||||
internal static string? ValidateContactEmail(string? email)
|
||||
=> TenantConfigValidation.ValidateContactEmail(email);
|
||||
|
||||
internal static string? ValidateNotificationChannels(string? json)
|
||||
=> TenantConfigValidation.ValidateNotificationChannels(json);
|
||||
|
||||
internal static string? ValidateWebhookCustomHeaders(string? json)
|
||||
=> TenantConfigValidation.ValidateWebhookCustomHeaders(json);
|
||||
}
|
||||
}
|
||||
+17
-5
@@ -255,9 +255,16 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
});
|
||||
}
|
||||
|
||||
// SESSION-OWNER-BINDING-SHADOW: same observation as the session-scoped writes, so
|
||||
// stage-2 enforcement covers this endpoint with the same data. Never rejects here.
|
||||
await ObserveSessionOwnerAsync(req, requestBody.TenantId, requestBody.SessionId, validation);
|
||||
// SESSION-OWNER-BINDING: the SAS is bound to the session, so a caller that does not own
|
||||
// it gets no upload slot in it.
|
||||
if (!await IsSessionOwnerAllowedAsync(req, requestBody.TenantId, requestBody.SessionId, validation))
|
||||
{
|
||||
return await req.JsonAsync(HttpStatusCode.Forbidden, new GetDiagnosticsUploadUrlResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = "Session is bound to another device identity"
|
||||
});
|
||||
}
|
||||
|
||||
HostedUploadSasResult sasResult;
|
||||
try
|
||||
@@ -307,18 +314,23 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
});
|
||||
}
|
||||
|
||||
private async Task ObserveSessionOwnerAsync(
|
||||
/// <summary>
|
||||
/// False only when the owner binding refuses the caller for the session. A failed row read
|
||||
/// fails open — our defect is not evidence of a foreign device.
|
||||
/// </summary>
|
||||
private async Task<bool> IsSessionOwnerAllowedAsync(
|
||||
HttpRequestData req, string tenantId, string sessionId, SecurityValidationResult validation)
|
||||
{
|
||||
try
|
||||
{
|
||||
var row = await _sessionRowReader.GetSessionRowAsync(tenantId, sessionId);
|
||||
_ownerBinding.Observe(req, tenantId, sessionId, row, validation, "agent/upload-url");
|
||||
return !_ownerBinding.Observe(req, tenantId, sessionId, row, validation, "agent/upload-url").Rejected;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"GetDiagnosticsUploadUrl: session-owner observation skipped for session {SessionId}", sessionId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Offboarding;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -36,6 +37,7 @@ public class AuthFunction
|
||||
private readonly AdminIdentityResolver _identityResolver;
|
||||
private readonly ISignalRNotificationService _signalRService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly IOffboardingAuditRepository _offboardingRepo;
|
||||
|
||||
public AuthFunction(
|
||||
ILogger<AuthFunction> logger,
|
||||
@@ -52,9 +54,11 @@ public class AuthFunction
|
||||
EntraAppRegistry appRegistry,
|
||||
AdminIdentityResolver identityResolver,
|
||||
ISignalRNotificationService signalRService,
|
||||
AdminConfigurationService adminConfigService)
|
||||
AdminConfigurationService adminConfigService,
|
||||
IOffboardingAuditRepository offboardingRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_offboardingRepo = offboardingRepo;
|
||||
_adminConfigService = adminConfigService;
|
||||
_identityResolver = identityResolver;
|
||||
_signalRService = signalRService;
|
||||
@@ -362,7 +366,9 @@ public class AuthFunction
|
||||
|
||||
/// <summary>
|
||||
/// If the tenant has no domain name yet, extracts it from the UPN, persists it,
|
||||
/// and fires best-effort notifications (Telegram + global notification).
|
||||
/// and fires best-effort notifications (Telegram + global notification). The write lands
|
||||
/// only while the STORED row still has no domain (conditional create or update), and only
|
||||
/// the login that wrote it fires the signup side effects.
|
||||
/// </summary>
|
||||
internal async Task HandleNewTenantDomainAsync(
|
||||
TenantConfiguration tenantConfig, string tenantId, string upn, string? tokenAudience = null)
|
||||
@@ -382,45 +388,85 @@ public class AuthFunction
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Setting domain name for tenant {TenantId}: {Domain}", tenantId, domain);
|
||||
tenantConfig.DomainName = domain;
|
||||
tenantConfig.UpdatedBy = upn;
|
||||
// OnboardedBy is immutable once set: this is the only place that writes it, gated
|
||||
// on the same "DomainName is empty" condition (first-ever user login for the tenant).
|
||||
// Downstream auto-promote (PreviewWhitelistFunction) reads OnboardedBy so background
|
||||
// sync jobs that mutate UpdatedBy cannot leak sentinel strings into TenantAdmins.
|
||||
if (string.IsNullOrWhiteSpace(tenantConfig.OnboardedBy))
|
||||
tenantConfig.OnboardedBy = upn;
|
||||
// The DPA is accepted with the onboarding itself (D-251/D-252): record the version in force,
|
||||
// once, in this same first write — later logins never touch it.
|
||||
if (string.IsNullOrWhiteSpace(tenantConfig.DpaVersion))
|
||||
tenantConfig.DpaVersion = Constants.CurrentDpaVersion;
|
||||
// Dual app-reg window: home a NEW tenant on the primary app ONLY when its first login
|
||||
// actually arrived via the primary app. A first login via the legacy app leaves the field
|
||||
// null (= legacy) — keeps the "null = legacy" invariant clean and never routes a tenant
|
||||
// to an app it hasn't consented to. Seed the last-seen provenance in the same write.
|
||||
var onboardingClientId = EntraAppRegistry.NormalizeAudience(tokenAudience);
|
||||
if (onboardingClientId != null)
|
||||
{
|
||||
if (_appRegistry.IsPrimary(onboardingClientId))
|
||||
tenantConfig.HomedAppClientId = onboardingClientId;
|
||||
tenantConfig.LastAuthClientId = onboardingClientId;
|
||||
tenantConfig.LastAuthClientIdSince = DateTime.UtcNow;
|
||||
}
|
||||
// A returning tenant: the offboarding deleted its configuration, so the pointer is the only
|
||||
// record of a consumed self-service trial. Carried over in this same write as OnboardedBy, so
|
||||
// no onboarded row (and no auto-promoted admin) ever exists without it (D-288). Unreadable ⇒
|
||||
// no write at all; the self-gate above retries on the next login.
|
||||
OffboardingByTenantPointer? offboardingPointer;
|
||||
try
|
||||
{
|
||||
await _tenantConfigService.SaveConfigurationAsync(tenantConfig, "auth", "first-login domain seed");
|
||||
(offboardingPointer, _) = await _offboardingRepo.TryGetByTenantPointerAsync(tenantId.ToLowerInvariant());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Best-effort side-effect: SaveConfigurationAsync now throws on a failed persist
|
||||
// (fail-loud for config endpoints), but a storage blip must not 500 the login.
|
||||
// The handler self-gates on DomainName being empty, so the next login retries —
|
||||
// skip the notifications too so they only fire once the write actually stuck.
|
||||
_logger.LogWarning(ex, "First-login offboarding lookup failed for tenant {TenantId} — will retry on next login", tenantId);
|
||||
return;
|
||||
}
|
||||
|
||||
var onboardingClientId = EntraAppRegistry.NormalizeAudience(tokenAudience);
|
||||
var seededAt = DateTime.UtcNow;
|
||||
void Seed(TenantConfiguration row)
|
||||
{
|
||||
if (offboardingPointer is { TrialConsumed: true })
|
||||
row.TrialConsumed = true;
|
||||
row.DomainName = domain;
|
||||
row.UpdatedBy = upn;
|
||||
// OnboardedBy is immutable once set: this is the only place that writes it, and the write
|
||||
// below lands only while the STORED row has no domain yet (first-ever login for the tenant),
|
||||
// so two concurrent first logins cannot both write it. Downstream auto-promote reads
|
||||
// OnboardedBy so background sync jobs that mutate UpdatedBy cannot leak sentinel strings
|
||||
// into TenantAdmins.
|
||||
if (string.IsNullOrWhiteSpace(row.OnboardedBy))
|
||||
row.OnboardedBy = upn;
|
||||
// The DPA is accepted with the onboarding itself (D-251/D-252): record the version in force,
|
||||
// once, in this same first write — later logins never touch it.
|
||||
if (string.IsNullOrWhiteSpace(row.DpaVersion))
|
||||
row.DpaVersion = Constants.CurrentDpaVersion;
|
||||
// Dual app-reg window: home a NEW tenant on the primary app ONLY when its first login
|
||||
// actually arrived via the primary app. A first login via the legacy app leaves the field
|
||||
// null (= legacy) — keeps the "null = legacy" invariant clean and never routes a tenant
|
||||
// to an app it hasn't consented to. Seed the last-seen provenance in the same write.
|
||||
if (onboardingClientId != null)
|
||||
{
|
||||
if (_appRegistry.IsPrimary(onboardingClientId))
|
||||
row.HomedAppClientId = onboardingClientId;
|
||||
row.LastAuthClientId = onboardingClientId;
|
||||
row.LastAuthClientIdSince = seededAt;
|
||||
}
|
||||
}
|
||||
|
||||
TenantConfigUpdate update;
|
||||
try
|
||||
{
|
||||
update = await _tenantConfigService.CreateOrUpdateAsync(tenantId, row =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(row.DomainName))
|
||||
return false; // another login seeded it since this one read the configuration
|
||||
Seed(row);
|
||||
return true;
|
||||
}, "auth", "first-login domain seed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A storage blip must not 500 the login. The handler self-gates on DomainName being
|
||||
// empty, so the next login retries — skip the notifications too so they only fire once
|
||||
// the write actually stuck.
|
||||
_logger.LogWarning(ex, "First-login domain write failed for tenant {TenantId} — will retry on next login", tenantId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.Status != TenantConfigUpdateStatus.Updated)
|
||||
{
|
||||
// Declined: a concurrent login won the seed and fires the signup side effects itself.
|
||||
// Conflict: the next login retries. Tombstone: the tenant is being offboarded.
|
||||
_logger.LogInformation("First-login domain seed for tenant {TenantId} not written ({Status})", tenantId, update.Status);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Seeded domain name for tenant {TenantId}: {Domain}", tenantId, domain);
|
||||
// This login's view follows what was just stored.
|
||||
Seed(tenantConfig);
|
||||
|
||||
// Fire-and-forget: Telegram
|
||||
_ = _telegramNotificationService.SendNewTenantSignupAsync(tenantId, upn)
|
||||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
||||
@@ -464,26 +510,29 @@ public class AuthFunction
|
||||
var clientId = EntraAppRegistry.NormalizeAudience(tokenAudience);
|
||||
if (clientId == null || string.IsNullOrEmpty(tenantConfig.DomainName))
|
||||
return;
|
||||
// Cheap pre-filter on the cached view; the write decides again on the fresh row.
|
||||
if (string.Equals(tenantConfig.LastAuthClientId, clientId, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Tenant {TenantId} logins now arrive via app registration {ClientId} (was: {Previous})",
|
||||
tenantId, clientId, tenantConfig.LastAuthClientId ?? "(none recorded)");
|
||||
try
|
||||
{
|
||||
// Mutate a cache-BYPASSING read, not the (up to 5 min stale on other instances)
|
||||
// cached view: this write persists the WHOLE entity, and the login that triggers it
|
||||
// is typically the very first one AFTER an app-homing flip — blind-saving the stale
|
||||
// view here would silently revert HomedAppClientId (same failure class as the
|
||||
// 2026-07-31 PUT revert incident). Change-detect above stays on the cached view as a
|
||||
// cheap pre-filter; the fresh read re-checks before writing.
|
||||
var fresh = await _tenantConfigService.GetConfigurationFreshAsync(tenantId);
|
||||
if (fresh == null || string.Equals(fresh.LastAuthClientId, clientId, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
fresh.LastAuthClientId = clientId;
|
||||
fresh.LastAuthClientIdSince = DateTime.UtcNow;
|
||||
await _tenantConfigService.SaveConfigurationAsync(fresh, "auth", "auth client-id tracking");
|
||||
string? previous = null;
|
||||
var update = await _tenantConfigService.UpdateAsync(tenantId, row =>
|
||||
{
|
||||
if (string.Equals(row.LastAuthClientId, clientId, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
previous = row.LastAuthClientId;
|
||||
row.LastAuthClientId = clientId;
|
||||
row.LastAuthClientIdSince = DateTime.UtcNow;
|
||||
return true;
|
||||
}, "auth", "auth client-id tracking");
|
||||
|
||||
if (update.Status == TenantConfigUpdateStatus.Updated)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Tenant {TenantId} logins now arrive via app registration {ClientId} (was: {Previous})",
|
||||
tenantId, clientId, previous ?? "(none recorded)");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -494,7 +543,9 @@ public class AuthFunction
|
||||
|
||||
/// <summary>
|
||||
/// If the tenant's suspension has expired (Disabled=true but DisabledUntil is past),
|
||||
/// clears the disabled state and persists the change.
|
||||
/// clears the disabled state and persists the change. The cached view only triggers the check:
|
||||
/// the decision runs on the FRESH row, so a suspension a Global Admin renewed meanwhile (or the
|
||||
/// offboarding tombstone) is never lifted, and this login's view takes over the stored state.
|
||||
/// MUST run before BuildAuthResult because it mutates tenantConfig.Disabled.
|
||||
/// </summary>
|
||||
internal async Task HandleAutoReEnableAsync(
|
||||
@@ -503,24 +554,44 @@ public class AuthFunction
|
||||
if (!tenantConfig.Disabled || tenantConfig.IsCurrentlyDisabled())
|
||||
return;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Tenant {TenantId} auto-re-enabled: DisabledUntil ({DisabledUntil}) has expired",
|
||||
tenantId, tenantConfig.DisabledUntil?.ToString("o"));
|
||||
|
||||
tenantConfig.Disabled = false;
|
||||
tenantConfig.DisabledReason = null;
|
||||
tenantConfig.DisabledUntil = null;
|
||||
tenantConfig.UpdatedBy = "System (auto-re-enable)";
|
||||
try
|
||||
{
|
||||
await _tenantConfigService.SaveConfigurationAsync(tenantConfig, "auth", "auto-re-enable after suspension expiry");
|
||||
var update = await _tenantConfigService.UpdateAsync(tenantId, row =>
|
||||
{
|
||||
if (!row.Disabled || row.IsCurrentlyDisabled())
|
||||
return false;
|
||||
row.Disabled = false;
|
||||
row.DisabledReason = null;
|
||||
row.DisabledUntil = null;
|
||||
row.UpdatedBy = "System (auto-re-enable)";
|
||||
return true;
|
||||
}, "auth", "auto-re-enable after suspension expiry");
|
||||
|
||||
if (update.Config != null)
|
||||
{
|
||||
if (update.Status == TenantConfigUpdateStatus.Updated)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Tenant {TenantId} auto-re-enabled: DisabledUntil ({DisabledUntil}) has expired",
|
||||
tenantId, tenantConfig.DisabledUntil?.ToString("o"));
|
||||
}
|
||||
tenantConfig.Disabled = update.Config.Disabled;
|
||||
tenantConfig.DisabledReason = update.Config.DisabledReason;
|
||||
tenantConfig.DisabledUntil = update.Config.DisabledUntil;
|
||||
return;
|
||||
}
|
||||
// NotFound / Conflict: storage could not settle it — fall through.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Best-effort: the in-memory flip above already lets THIS login through; a failed
|
||||
// persist just means the next login re-runs the auto-re-enable. Must not 500 auth/me.
|
||||
_logger.LogWarning(ex, "Auto-re-enable persist failed for tenant {TenantId} — will retry on next login", tenantId);
|
||||
}
|
||||
|
||||
// Best-effort: the expired suspension still lets THIS login through; the next login
|
||||
// re-runs the auto-re-enable. Must not 500 auth/me.
|
||||
tenantConfig.Disabled = false;
|
||||
tenantConfig.DisabledReason = null;
|
||||
tenantConfig.DisabledUntil = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -6,6 +6,7 @@ using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -265,12 +266,18 @@ 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.
|
||||
// SESSION-OWNER-BINDING: is the device behind this certificate/token the device this
|
||||
// session belongs to? Same guard row, zero extra reads. A foreign identity gets 403
|
||||
// before anything is stored — covering the Signal-only path too, which is where
|
||||
// pending ServerActions get fetched-and-cleared. An honest agent never gets here:
|
||||
// it picks its certificate once per process and registration (which rotates on
|
||||
// mismatch) precedes its first batch. Tolerated outcomes stamp legacy claims / rebinds.
|
||||
var ownerDecision = _ownerBinding.Observe(req, bodyTenantId, sessionId, guardSessionRow, validation, "agent/telemetry");
|
||||
if (ownerDecision.Rejected)
|
||||
{
|
||||
return AsOutput(await req.ErrorAsync(HttpStatusCode.Forbidden, Constants.ApiErrorCodes.SessionOwnerMismatch,
|
||||
"Session is bound to another device identity; the batch was not stored."));
|
||||
}
|
||||
if (guardSessionRow != null)
|
||||
await _ownerBinding.StampAsync(bodyTenantId, sessionId, ownerDecision);
|
||||
|
||||
|
||||
+17
-9
@@ -186,12 +186,14 @@ 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);
|
||||
// SESSION-OWNER-BINDING: the emergency-break path below writes timeline events into
|
||||
// whatever session the report names (one point-read — this function has no guard row
|
||||
// in hand; no stamping from this path). A report naming a session bound to another
|
||||
// device identity keeps its device-scoped diagnostics above and the binary-integrity
|
||||
// check below, but must not write into that session. Still 200: the channel is never
|
||||
// retried, and the agent ignores its status.
|
||||
if (await IsSessionOwnerAllowedAsync(req, tenantId, report.SessionId, validation))
|
||||
await MaterializeEmergencyBreakArtifactsAsync(report, tenantId, _sessionRepo, _opsEventService, _logger);
|
||||
|
||||
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
||||
await MaterializeIntegrityMismatchAsync(
|
||||
@@ -201,18 +203,24 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
return req.CreateResponse(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
private async Task ObserveSessionOwnerAsync(HttpRequestData req, string tenantId, string? sessionId, SecurityValidationResult validation)
|
||||
/// <summary>
|
||||
/// False only when the owner binding refuses the caller for the named session. Ids that are
|
||||
/// not GUIDs have no row to compare against; a failed row read fails open (our defect is
|
||||
/// not evidence of a foreign device).
|
||||
/// </summary>
|
||||
private async Task<bool> IsSessionOwnerAllowedAsync(HttpRequestData req, string tenantId, string? sessionId, SecurityValidationResult validation)
|
||||
{
|
||||
if (!SecurityValidator.IsValidGuid(sessionId) || !SecurityValidator.IsValidGuid(tenantId))
|
||||
return;
|
||||
return true;
|
||||
try
|
||||
{
|
||||
var row = await _sessionRowReader.GetSessionRowAsync(tenantId, sessionId!);
|
||||
_ownerBinding.Observe(req, tenantId, sessionId!, row, validation, "agent/error");
|
||||
return !_ownerBinding.Observe(req, tenantId, sessionId!, row, validation, "agent/error").Rejected;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ReportAgentError: session-owner observation skipped for session {SessionId}", sessionId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+22
-6
@@ -189,14 +189,15 @@ 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.
|
||||
// SESSION-OWNER-BINDING: compare the caller's validated identity against the owner
|
||||
// already on the row (reusing the guard's read). A foreign identity is refused with
|
||||
// 403 + AgentErrorCodes.SessionOwnerMismatch BEFORE anything is written — the agent
|
||||
// rotates its session id on that code and registers afresh. Tolerated outcomes yield
|
||||
// the owner to stamp (fresh bind, legacy claim, cert rotation, bootstrap→cert handoff).
|
||||
var ownerDecision = _ownerBinding.Observe(
|
||||
req, registration.TenantId, registration.SessionId, guardSessionRow, validation, "agent/register-session");
|
||||
if (ownerDecision.Rejected)
|
||||
return new RegisterSessionOutput { HttpResponse = await WriteSessionOwnerMismatchAsync(req, registration.SessionId) };
|
||||
|
||||
// Store session in Azure Table Storage. The sessionId → tenantId lookup claim inside is
|
||||
// first-writer-wins: a sessionId already owned by another tenant is refused with 409 so
|
||||
@@ -470,6 +471,21 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
ErrorCode = Constants.AgentErrorCodes.SessionOwnerMismatch,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// 403 for a session bound to another device identity (SESSION-OWNER-BINDING). Carries
|
||||
/// <see cref="Constants.AgentErrorCodes.SessionOwnerMismatch"/>, on which the agent rotates its
|
||||
/// SessionId and registers afresh instead of counting an auth failure.
|
||||
/// </summary>
|
||||
private static Task<HttpResponseData> WriteSessionOwnerMismatchAsync(HttpRequestData req, string sessionId)
|
||||
=> req.JsonAsync(HttpStatusCode.Forbidden, new RegisterSessionResponse
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Success = false,
|
||||
Message = "Session is bound to another device identity; register with a new session id.",
|
||||
RegisteredAt = DateTime.UtcNow,
|
||||
ErrorCode = Constants.AgentErrorCodes.SessionOwnerMismatch,
|
||||
});
|
||||
|
||||
private static Task<HttpResponseData> WriteSessionLockedAsync(HttpRequestData req, SessionDeletionLockedException locked)
|
||||
=> req.JsonAsync(HttpStatusCode.Gone, new RegisterSessionResponse
|
||||
{
|
||||
|
||||
@@ -8,6 +8,8 @@ public sealed class AppVersionDurationRegressionFinding
|
||||
{
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
public string AppName { get; init; } = string.Empty;
|
||||
/// <summary>Install channel (<see cref="AppInstallSources"/>); part of the app identity.</summary>
|
||||
public string Source { get; init; } = AppInstallSources.Ime;
|
||||
public string CurrentVersion { get; init; } = string.Empty;
|
||||
public string PreviousVersion { get; init; } = string.Empty;
|
||||
|
||||
@@ -23,7 +25,7 @@ public sealed class AppVersionDurationRegressionFinding
|
||||
/// <summary>
|
||||
/// Deterministic, I/O-free core of the app-version duration regression radar. Detects
|
||||
/// "version X made this app's installs much slower" from the per-(session, app) install
|
||||
/// summaries: per (tenant, app), the newest version's MEDIAN measured install duration is
|
||||
/// summaries: per (tenant, channel, app), the newest version's MEDIAN measured install duration is
|
||||
/// compared against the previous version's median. Medians (nearest-rank, shared with the
|
||||
/// apps dashboard via <see cref="AppsAnalyticsHelper.Percentile"/>) rather than means —
|
||||
/// a single back-stamped straggler must not fire a fleet alert. A regression fires only
|
||||
@@ -90,7 +92,8 @@ public static class AppVersionRegressionRadar
|
||||
findings.Add(new AppVersionDurationRegressionFinding
|
||||
{
|
||||
TenantId = app.Value[0].TenantId,
|
||||
AppName = app.Key,
|
||||
AppName = app.Key.AppName,
|
||||
Source = app.Key.Source,
|
||||
CurrentVersion = current.Version,
|
||||
PreviousVersion = previous.Version,
|
||||
CurrentMedianSeconds = current.MedianSeconds,
|
||||
@@ -103,6 +106,7 @@ public static class AppVersionRegressionRadar
|
||||
return findings
|
||||
.OrderByDescending(f => f.Lift)
|
||||
.ThenBy(f => f.AppName, StringComparer.Ordinal)
|
||||
.ThenBy(f => f.Source, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -116,7 +120,7 @@ public static class AppVersionRegressionRadar
|
||||
/// </summary>
|
||||
public static bool ShouldReArm(IReadOnlyList<AppInstallSummary> tenantSummaries, AppVersionRegressionAlert alert)
|
||||
{
|
||||
var stats = ComputeVersionStatsForApp(tenantSummaries, alert.AppName);
|
||||
var stats = ComputeVersionStatsForApp(tenantSummaries, AppInstallSources.Normalize(alert.Source), alert.AppName);
|
||||
var current = stats.FirstOrDefault(s => string.Equals(s.Version, alert.CurrentVersion, StringComparison.Ordinal));
|
||||
if (current == null || current.MeasuredCount < MinMeasuredInstalls) return true;
|
||||
|
||||
@@ -126,26 +130,23 @@ public static class AppVersionRegressionRadar
|
||||
return current.MedianSeconds < ReArmLiftFactor * previous.MedianSeconds;
|
||||
}
|
||||
|
||||
/// <summary>Per-version stats for one app (measured rows only), or empty when the app has none.</summary>
|
||||
public static List<VersionStats> ComputeVersionStatsForApp(IReadOnlyList<AppInstallSummary> tenantSummaries, string appName)
|
||||
/// <summary>Per-version stats for one app of one channel (measured rows only), or empty when the app has none.</summary>
|
||||
public static List<VersionStats> ComputeVersionStatsForApp(IReadOnlyList<AppInstallSummary> tenantSummaries, string source, string appName)
|
||||
{
|
||||
foreach (var app in MeasuredAppGroups(tenantSummaries))
|
||||
{
|
||||
if (string.Equals(app.Key, appName, StringComparison.OrdinalIgnoreCase))
|
||||
return ComputeVersionStats(app.Value);
|
||||
}
|
||||
return new List<VersionStats>();
|
||||
return MeasuredAppGroups(tenantSummaries).TryGetValue((source, appName), out var rows)
|
||||
? ComputeVersionStats(rows)
|
||||
: new List<VersionStats>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups the MEASURED rows by app name: succeeded, non-skip, plausible duration,
|
||||
/// Groups the MEASURED rows by (channel, app name): succeeded, non-skip, plausible duration,
|
||||
/// no AppId collision (a second app's outcomes must not fire this app's alert),
|
||||
/// non-empty version. Rate/failure signals are deliberately out of scope — this
|
||||
/// radar alerts on duration only.
|
||||
/// </summary>
|
||||
internal static Dictionary<string, List<AppInstallSummary>> MeasuredAppGroups(IReadOnlyList<AppInstallSummary> summaries)
|
||||
internal static Dictionary<(string Source, string AppName), List<AppInstallSummary>> MeasuredAppGroups(IReadOnlyList<AppInstallSummary> summaries)
|
||||
{
|
||||
var groups = new Dictionary<string, List<AppInstallSummary>>(StringComparer.OrdinalIgnoreCase);
|
||||
var groups = new Dictionary<(string Source, string AppName), List<AppInstallSummary>>(AppKeyComparer.Instance);
|
||||
foreach (var summary in summaries)
|
||||
{
|
||||
if (string.IsNullOrEmpty(summary.AppName)) continue;
|
||||
@@ -158,16 +159,30 @@ public static class AppVersionRegressionRadar
|
||||
// enter the duration population, on both sides of every comparison.
|
||||
if (!summary.LastAttemptStartedAt.HasValue) continue;
|
||||
|
||||
if (!groups.TryGetValue(summary.AppName, out var list))
|
||||
var key = (AppInstallSources.Normalize(summary.Source), summary.AppName);
|
||||
if (!groups.TryGetValue(key, out var list))
|
||||
{
|
||||
list = new List<AppInstallSummary>();
|
||||
groups[summary.AppName] = list;
|
||||
groups[key] = list;
|
||||
}
|
||||
list.Add(summary);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// <summary>(channel, app) key: channel ordinal, app name case-insensitive like every per-app lookup.</summary>
|
||||
private sealed class AppKeyComparer : IEqualityComparer<(string Source, string AppName)>
|
||||
{
|
||||
public static readonly AppKeyComparer Instance = new();
|
||||
|
||||
public bool Equals((string Source, string AppName) x, (string Source, string AppName) y) =>
|
||||
string.Equals(x.Source, y.Source, StringComparison.Ordinal) &&
|
||||
string.Equals(x.AppName, y.AppName, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public int GetHashCode((string Source, string AppName) key) =>
|
||||
HashCode.Combine(StringComparer.Ordinal.GetHashCode(key.Source), StringComparer.OrdinalIgnoreCase.GetHashCode(key.AppName));
|
||||
}
|
||||
|
||||
/// <summary>Per-version first/last-seen and nearest-rank median over one app's measured rows.</summary>
|
||||
internal static List<VersionStats> ComputeVersionStats(IReadOnlyList<AppInstallSummary> measuredAppRows)
|
||||
{
|
||||
|
||||
@@ -81,7 +81,8 @@ public static class MetricsMath
|
||||
// keeps every row, since transferred bytes are real regardless of identity mixing.
|
||||
var totalCollisionExcluded = summaryList.Count(s => s.AppIdCollision);
|
||||
|
||||
var appGroups = summaryList.Where(s => !s.AppIdCollision).GroupBy(s => s.AppName).Select(g =>
|
||||
// One group per (channel, name) — see AppsAnalyticsHelper.BuildAppsListResponse.
|
||||
var appGroups = summaryList.Where(s => !s.AppIdCollision).GroupBy(s => (Source: AppInstallSources.Normalize(s.Source), s.AppName)).Select(g =>
|
||||
{
|
||||
// PR0 (2026-07-26) classification — see IsSkipTerminalState / HasMeasuredDuration:
|
||||
// skipped = no real install attempt (TerminalState Skipped/Postponed)
|
||||
@@ -105,7 +106,8 @@ public static class MetricsMath
|
||||
|
||||
return new AppMetricsAppGroup
|
||||
{
|
||||
AppName = g.Key,
|
||||
AppName = g.Key.AppName,
|
||||
Source = g.Key.Source,
|
||||
TotalInstalls = total,
|
||||
Succeeded = installed.Count,
|
||||
Skipped = skipped.Count,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Polly;
|
||||
|
||||
@@ -89,7 +90,8 @@ public sealed class ResiliencePolicies
|
||||
private static IAsyncPolicy<HttpResponseMessage> BuildNotification(ILogger logger)
|
||||
=> Policy<HttpResponseMessage>
|
||||
.HandleResult(r => TransientCodes.Contains(r.StatusCode))
|
||||
.Or<HttpRequestException>()
|
||||
// A destination the SSRF gate refused stays refused; a retry would only resolve it again.
|
||||
.Or<HttpRequestException>(ex => ex.InnerException is not SsrfException)
|
||||
.Or<TaskCanceledException>()
|
||||
.WaitAndRetryAsync(
|
||||
retryCount: 2,
|
||||
|
||||
@@ -9,10 +9,9 @@ using AutopilotMonitor.Shared.Models.Config;
|
||||
namespace AutopilotMonitor.Functions.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Single source for tenant-configuration model validation, shared by the full-model
|
||||
/// PUT (<c>UpdateTenantConfigurationFunction</c>) and the transactional field-patch
|
||||
/// flow (<c>TenantConfigPatchService</c>). The individual validators moved here from
|
||||
/// the PUT function; thin forwarding shims remain there for existing callers/tests.
|
||||
/// Single source for tenant-configuration model validation, used by the transactional
|
||||
/// field-patch flow (<c>TenantConfigPatchService</c>) — the one client write path of the
|
||||
/// tenant configuration since the full-model PUT was removed (D-290).
|
||||
/// </summary>
|
||||
internal static class TenantConfigValidation
|
||||
{
|
||||
|
||||
@@ -184,8 +184,8 @@ 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.
|
||||
// SESSION-OWNER-BINDING — set by SessionOwnerBindingObserver on register / telemetry /
|
||||
// error-report / upload-url 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;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ using AutopilotMonitor.Functions.Functions.Sessions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
@@ -470,14 +471,7 @@ builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.GraphResolutio
|
||||
AutopilotMonitor.Functions.Services.GraphResolution.GraphFeatureDetector>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.GraphResolution.IScriptDisplayNameResolver,
|
||||
AutopilotMonitor.Functions.Services.GraphResolution.ScriptDisplayNameResolver>();
|
||||
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService>()
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(10),
|
||||
})
|
||||
.ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(15))
|
||||
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
|
||||
builder.Services.AddWebhookNotificationHttpClient();
|
||||
builder.Services.AddHttpClient<TelegramNotificationService>()
|
||||
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
|
||||
// Channel-level send API — routes each NotificationChannel to its transport (webhook renderer
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// - X.509 chain trust pinned to embedded Intune root certificate(s) via
|
||||
/// X509ChainTrustMode.CustomRootTrust (OS trust store is ignored)
|
||||
/// - No revocation checking (Intune certificates have no CRL/OCSP endpoints)
|
||||
/// - No network I/O during chain building: issuer downloads (AIA) are disabled
|
||||
/// - Prevents self-signed certificate attacks
|
||||
/// - Validates Enhanced Key Usage for Client Authentication
|
||||
/// - In-memory cache for validated certificates (5 minute TTL)
|
||||
@@ -200,8 +201,11 @@ namespace AutopilotMonitor.Functions.Security
|
||||
chain.ChainPolicy.RevocationFlag = X509RevocationFlag.EntireChain;
|
||||
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
|
||||
chain.ChainPolicy.VerificationTime = DateTime.UtcNow;
|
||||
chain.ChainPolicy.UrlRetrievalTimeout = TimeSpan.FromSeconds(10);
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
// The leaf, and with it any AIA issuer URL, comes from an unauthenticated caller.
|
||||
// Every legitimate issuer is embedded below, so the chain engine must never
|
||||
// download one: a fetch would be an attacker-directed request from the backend.
|
||||
chain.ChainPolicy.DisableCertificateDownloads = true;
|
||||
|
||||
foreach (var root in roots)
|
||||
chain.ChainPolicy.CustomTrustStore.Add(root);
|
||||
|
||||
@@ -339,13 +339,12 @@ public static class EndpointAccessPolicyCatalog
|
||||
// Settings UI. Safe because the handler redacts by default: only a Global Admin or the
|
||||
// tenant's OWN admin gets the unredacted secrets — every other admitted caller (Operator,
|
||||
// Viewer, read-only Global Reader, delegated reader) receives RedactedCopyForReader()
|
||||
// (see GetTenantConfigurationFunction.CanViewSecrets). Writes stay TenantAdminOrGA below.
|
||||
// (see GetTenantConfigurationFunction.CanViewSecrets). Writes go through the field PATCH
|
||||
// (TenantAdminOrGA, below); there is no full-model PUT (D-290).
|
||||
new("GET", "config/{tenantId}", EndpointPolicy.MemberRead, TenantScoping.RouteParam),
|
||||
new("GET", "config/{tenantId}/feature-flags", EndpointPolicy.MemberRead, TenantScoping.RouteParam),
|
||||
|
||||
// ── TenantAdminOrGA ─────────────────────────────────────────────
|
||||
new("PUT", "config/{tenantId}", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("POST", "config/{tenantId}", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("POST", "rules/gather", EndpointPolicy.TenantAdminOrGA),
|
||||
new("PUT", "rules/gather/{ruleId}", EndpointPolicy.TenantAdminOrGA),
|
||||
new("DELETE", "rules/gather/{ruleId}", EndpointPolicy.TenantAdminOrGA),
|
||||
|
||||
@@ -18,19 +18,19 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// </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.
|
||||
/// ENFORCED (stage 2): <see cref="Rejects"/> is the whole rule. It ran as shadow-only first and
|
||||
/// was switched on against four weeks of measured data in which every would-reject outcome was
|
||||
/// an Intune re-enrollment without a wipe. Every outcome is still recorded on the request row,
|
||||
/// non-Match outcomes as Warnings, rejected ones as a throttled <c>SessionOwnerMismatch</c> ops event.
|
||||
/// <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
|
||||
/// A rejected registration answers 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.
|
||||
/// the agent rotates its session id instead of counting an auth failure. That is what turns the
|
||||
/// one legitimate collision — a re-enrollment WITHOUT a wipe, where <c>session.id</c> survives but
|
||||
/// the device gets a new certificate identity — into a fresh session rather than a dead one. It
|
||||
/// only works because the agent picks its certificate once per process, before registration: an
|
||||
/// identity change therefore always surfaces at registration, never on a later ingest.
|
||||
/// Grep marker for the sites involved: <c>SESSION-OWNER-BINDING</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What this does NOT prove: the serial number is a caller-supplied header on both auth paths.
|
||||
@@ -107,7 +107,8 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// <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>Whether the request is refused (<see cref="Rejects"/>).</summary>
|
||||
public bool Rejected => Rejects(Outcome);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -165,8 +166,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
}
|
||||
|
||||
/// <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".
|
||||
/// Whether an outcome blocks the request. This is the enforcement rule, in one place.
|
||||
/// <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
|
||||
@@ -174,13 +174,19 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// normal lifecycle), and <see cref="Outcome.CallerUnidentified"/> (nothing to compare).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static bool WouldRejectUnderEnforcement(string outcome) =>
|
||||
public static bool Rejects(string outcome) =>
|
||||
outcome == Outcome.LegacySerialMismatch
|
||||
|| outcome == Outcome.MismatchBootstrapOwned
|
||||
|| outcome == Outcome.MismatchCert
|
||||
|| outcome == Outcome.MismatchBootstrap
|
||||
|| outcome == Outcome.DowngradeToBootstrap;
|
||||
|
||||
/// <summary>
|
||||
/// Retained name for the <c>wouldReject</c> telemetry field. Identical to <see cref="Rejects"/> —
|
||||
/// kept so KQL written during the shadow phase keeps working.
|
||||
/// </summary>
|
||||
public static bool WouldRejectUnderEnforcement(string outcome) => Rejects(outcome);
|
||||
|
||||
/// <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).
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Prevents Server-Side Request Forgery (SSRF) by validating webhook URLs
|
||||
/// against private/reserved network ranges before allowing outbound requests.
|
||||
/// Prevents Server-Side Request Forgery (SSRF) for customer-configured outbound URLs: format
|
||||
/// checks at config save time, and an address gate on the webhook client's connections.
|
||||
/// </summary>
|
||||
public static class SsrfGuard
|
||||
{
|
||||
@@ -69,129 +73,123 @@ namespace AutopilotMonitor.Functions.Security
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the webhook URL hostname via DNS and validates all resolved IPs
|
||||
/// against blocked ranges. Call before every outbound HTTP request.
|
||||
/// Throws <see cref="SsrfException"/> if the destination is blocked.
|
||||
/// Fails closed: DNS resolution errors are treated as blocked.
|
||||
/// <see cref="SocketsHttpHandler.ConnectCallback"/> of the webhook client — the SSRF gate.
|
||||
/// A refused destination surfaces as an <see cref="HttpRequestException"/> whose
|
||||
/// <see cref="Exception.InnerException"/> is the <see cref="SsrfException"/>.
|
||||
/// </summary>
|
||||
public static async Task ValidateDestinationAsync(string url)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
throw new SsrfException("Webhook URL is not a valid URL.");
|
||||
|
||||
if (uri.Scheme != Uri.UriSchemeHttps)
|
||||
throw new SsrfException("Webhook URL must use HTTPS.");
|
||||
|
||||
IPAddress[] addresses;
|
||||
try
|
||||
{
|
||||
addresses = await Dns.GetHostAddressesAsync(uri.Host);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
throw new SsrfException("Could not resolve webhook hostname.");
|
||||
}
|
||||
|
||||
if (addresses.Length == 0)
|
||||
throw new SsrfException("Webhook hostname resolved to no addresses.");
|
||||
|
||||
foreach (var addr in addresses)
|
||||
{
|
||||
if (IsBlockedAddress(addr))
|
||||
throw new SsrfException("Webhook URL targets a private or reserved network.");
|
||||
}
|
||||
}
|
||||
public static Func<SocketsHttpConnectionContext, CancellationToken, ValueTask<Stream>> ConnectToPublicAddress { get; } =
|
||||
CreateConnectCallback(Dns.GetHostAddressesAsync);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the IP address is private, loopback, link-local, multicast,
|
||||
/// cloud metadata, reserved, or an IPv6-mapped version of any of the above.
|
||||
/// Builds the gate: it resolves the host exactly once per connection and connects the socket
|
||||
/// only to the addresses that resolution returned, after every one of them passed
|
||||
/// <see cref="IsBlockedAddress"/> — so the address checked is the address connected to. A
|
||||
/// separate pre-flight resolution would let a DNS-rebinding host answer the check and the
|
||||
/// connect differently. HTTPS only; TLS then runs on the returned stream against the request
|
||||
/// host as usual. <paramref name="isBlocked"/> is widened only by tests that need to reach a
|
||||
/// loopback listener.
|
||||
/// </summary>
|
||||
internal static bool IsBlockedAddress(IPAddress addr)
|
||||
internal static Func<SocketsHttpConnectionContext, CancellationToken, ValueTask<Stream>> CreateConnectCallback(
|
||||
Func<string, CancellationToken, Task<IPAddress[]>> resolve,
|
||||
Func<IPAddress, bool>? isBlocked = null)
|
||||
{
|
||||
// Normalize IPv6-mapped IPv4 (e.g. ::ffff:10.0.0.1 -> 10.0.0.1)
|
||||
if (addr.IsIPv4MappedToIPv6)
|
||||
addr = addr.MapToIPv4();
|
||||
|
||||
// Loopback: 127.0.0.0/8, ::1
|
||||
if (IPAddress.IsLoopback(addr))
|
||||
return true;
|
||||
|
||||
// IPv6 link-local (fe80::/10)
|
||||
if (addr.IsIPv6LinkLocal)
|
||||
return true;
|
||||
|
||||
// IPv6 multicast (ff00::/8)
|
||||
if (addr.IsIPv6Multicast)
|
||||
return true;
|
||||
|
||||
if (addr.AddressFamily == AddressFamily.InterNetwork)
|
||||
isBlocked ??= IsBlockedAddress;
|
||||
return async (context, cancellationToken) =>
|
||||
{
|
||||
var bytes = addr.GetAddressBytes();
|
||||
if (context.InitialRequestMessage.RequestUri?.Scheme != Uri.UriSchemeHttps)
|
||||
throw new SsrfException("Webhook URL must use HTTPS.");
|
||||
|
||||
// 10.0.0.0/8 (RFC 1918)
|
||||
if (bytes[0] == 10)
|
||||
return true;
|
||||
var host = context.DnsEndPoint.Host;
|
||||
IPAddress[] addresses;
|
||||
if (IPAddress.TryParse(host.Trim('[', ']'), out var literal))
|
||||
{
|
||||
addresses = new[] { literal };
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
addresses = await resolve(host, cancellationToken);
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
throw new SsrfException("Could not resolve webhook hostname.");
|
||||
}
|
||||
}
|
||||
|
||||
// 172.16.0.0/12 (RFC 1918)
|
||||
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31)
|
||||
return true;
|
||||
if (addresses.Length == 0)
|
||||
throw new SsrfException("Webhook hostname resolved to no addresses.");
|
||||
|
||||
// 192.168.0.0/16 (RFC 1918)
|
||||
if (bytes[0] == 192 && bytes[1] == 168)
|
||||
return true;
|
||||
if (addresses.Any(isBlocked))
|
||||
throw new SsrfException("Webhook URL targets a private or reserved network.");
|
||||
|
||||
// 169.254.0.0/16 (link-local, includes Azure IMDS 169.254.169.254)
|
||||
if (bytes[0] == 169 && bytes[1] == 254)
|
||||
return true;
|
||||
|
||||
// 0.0.0.0/8 (current network)
|
||||
if (bytes[0] == 0)
|
||||
return true;
|
||||
|
||||
// 100.64.0.0/10 (Carrier-grade NAT, RFC 6598)
|
||||
if (bytes[0] == 100 && bytes[1] >= 64 && bytes[1] <= 127)
|
||||
return true;
|
||||
|
||||
// 192.0.0.0/24 (IETF Protocol Assignments)
|
||||
if (bytes[0] == 192 && bytes[1] == 0 && bytes[2] == 0)
|
||||
return true;
|
||||
|
||||
// 192.0.2.0/24 (TEST-NET-1)
|
||||
if (bytes[0] == 192 && bytes[1] == 0 && bytes[2] == 2)
|
||||
return true;
|
||||
|
||||
// 198.18.0.0/15 (Benchmark testing)
|
||||
if (bytes[0] == 198 && (bytes[1] == 18 || bytes[1] == 19))
|
||||
return true;
|
||||
|
||||
// 198.51.100.0/24 (TEST-NET-2)
|
||||
if (bytes[0] == 198 && bytes[1] == 51 && bytes[2] == 100)
|
||||
return true;
|
||||
|
||||
// 203.0.113.0/24 (TEST-NET-3)
|
||||
if (bytes[0] == 203 && bytes[1] == 0 && bytes[2] == 113)
|
||||
return true;
|
||||
|
||||
// 224.0.0.0/4 (Multicast) and 240.0.0.0/4 (Reserved)
|
||||
if (bytes[0] >= 224)
|
||||
return true;
|
||||
}
|
||||
|
||||
if (addr.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
{
|
||||
var bytes = addr.GetAddressBytes();
|
||||
|
||||
// fc00::/7 (Unique local address)
|
||||
if ((bytes[0] & 0xFE) == 0xFC)
|
||||
return true;
|
||||
|
||||
// :: (unspecified)
|
||||
if (addr.Equals(IPAddress.IPv6None) || addr.Equals(IPAddress.IPv6Any))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(addresses, context.DnsEndPoint.Port, cancellationToken);
|
||||
return new NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// IPv4: every block of the IANA IPv4 Special-Purpose Address Registry plus multicast and
|
||||
// 240/4 (which holds the limited broadcast).
|
||||
private static readonly IPNetwork[] BlockedIpv4 =
|
||||
{
|
||||
IPNetwork.Parse("0.0.0.0/8"), // "this network"
|
||||
IPNetwork.Parse("10.0.0.0/8"), // private use
|
||||
IPNetwork.Parse("100.64.0.0/10"), // shared address space (CGNAT)
|
||||
IPNetwork.Parse("127.0.0.0/8"), // loopback
|
||||
IPNetwork.Parse("169.254.0.0/16"), // link-local, holds the cloud metadata endpoints
|
||||
IPNetwork.Parse("172.16.0.0/12"), // private use
|
||||
IPNetwork.Parse("192.0.0.0/24"), // IETF protocol assignments
|
||||
IPNetwork.Parse("192.0.2.0/24"), // documentation (TEST-NET-1)
|
||||
IPNetwork.Parse("192.31.196.0/24"), // AS112-v4
|
||||
IPNetwork.Parse("192.52.193.0/24"), // AMT
|
||||
IPNetwork.Parse("192.88.99.0/24"), // deprecated 6to4 relay anycast
|
||||
IPNetwork.Parse("192.168.0.0/16"), // private use
|
||||
IPNetwork.Parse("192.175.48.0/24"), // direct delegation AS112
|
||||
IPNetwork.Parse("198.18.0.0/15"), // benchmarking
|
||||
IPNetwork.Parse("198.51.100.0/24"), // documentation (TEST-NET-2)
|
||||
IPNetwork.Parse("203.0.113.0/24"), // documentation (TEST-NET-3)
|
||||
IPNetwork.Parse("224.0.0.0/4"), // multicast
|
||||
IPNetwork.Parse("240.0.0.0/4"), // reserved, limited broadcast
|
||||
};
|
||||
|
||||
// IPv6: only global unicast 2000::/3 is allowed at all — one rule that refuses loopback,
|
||||
// unspecified, IPv4-mapped/-compatible, NAT64, discard, unique-local, link-/site-local and
|
||||
// multicast — and inside it the IANA special-purpose blocks are refused.
|
||||
private static readonly IPNetwork GlobalUnicastIpv6 = IPNetwork.Parse("2000::/3");
|
||||
|
||||
private static readonly IPNetwork[] BlockedGlobalUnicastIpv6 =
|
||||
{
|
||||
IPNetwork.Parse("2001::/23"), // IETF protocol assignments (Teredo, benchmarking, ORCHID, AMT, AS112, ...)
|
||||
IPNetwork.Parse("2001:db8::/32"), // documentation
|
||||
IPNetwork.Parse("2002::/16"), // 6to4
|
||||
IPNetwork.Parse("2620:4f:8000::/48"), // direct delegation AS112
|
||||
IPNetwork.Parse("3fff::/20"), // documentation
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// True for every address a webhook must not connect to: all IANA special-purpose space, and
|
||||
/// any IPv6 address outside global unicast or carrying a zone id.
|
||||
/// </summary>
|
||||
internal static bool IsBlockedAddress(IPAddress addr) => addr.AddressFamily switch
|
||||
{
|
||||
AddressFamily.InterNetwork => BlockedIpv4.Any(n => n.Contains(addr)),
|
||||
AddressFamily.InterNetworkV6 => addr.ScopeId != 0
|
||||
// Refused before any IPNetwork check: IPNetwork.Contains judges an IPv4-mapped
|
||||
// address by its IPv4 form, so 2000::/3 "contains" ::ffff:8.8.8.8.
|
||||
|| addr.IsIPv4MappedToIPv6
|
||||
|| !GlobalUnicastIpv6.Contains(addr)
|
||||
|| BlockedGlobalUnicastIpv6.Any(n => n.Contains(addr)),
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -221,32 +221,47 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
/// <summary>
|
||||
/// Persists a homing change (<paramref name="targetClientId"/> null = legacy) with the full
|
||||
/// side-effect chain: fresh config re-read (shrinks the read-modify-write window), save,
|
||||
/// side-effect chain: conditional write on the fresh row (<see cref="TenantConfigurationService.UpdateAsync"/>),
|
||||
/// Graph cache invalidation (token layer first, via the single fresh-read entry point),
|
||||
/// audit entry, telemetry, ops event. No-ops when the tenant is already at the target.
|
||||
/// Throws when no configuration row exists: this is a whole-entity read-modify-write, and
|
||||
/// the cached/fail-open reader would let a flip rewind another writer's changes (up to the
|
||||
/// 5-minute TTL) or save a default row over a deleted tenant.
|
||||
/// audit entry, telemetry, ops event. No-ops when the tenant is already at the target or is
|
||||
/// being offboarded. Throws when no configuration row exists (a flip never creates one) or
|
||||
/// when every conditional write lost to a concurrent change.
|
||||
/// </summary>
|
||||
public virtual async Task FlipAsync(string tenantId, string? targetClientId, string actorUpn, string reason, bool forced = false)
|
||||
{
|
||||
var config = await _tenantConfigService.GetConfigurationFreshAsync(tenantId)
|
||||
?? throw new InvalidOperationException(
|
||||
$"App-homing flip aborted for tenant {tenantId} — no tenant configuration row exists");
|
||||
var normalizedTarget = EntraAppRegistry.NormalizeClientId(targetClientId);
|
||||
var oldValue = config.HomedAppClientId;
|
||||
if (string.Equals(oldValue, normalizedTarget, StringComparison.OrdinalIgnoreCase))
|
||||
string? oldValue = null;
|
||||
var update = await _tenantConfigService.UpdateAsync(tenantId, row =>
|
||||
{
|
||||
return;
|
||||
if (string.Equals(row.HomedAppClientId, normalizedTarget, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
oldValue = row.HomedAppClientId;
|
||||
row.HomedAppClientId = normalizedTarget;
|
||||
row.UpdatedBy = actorUpn;
|
||||
return true;
|
||||
}, "app-homing", reason);
|
||||
|
||||
switch (update.Status)
|
||||
{
|
||||
case TenantConfigUpdateStatus.Updated:
|
||||
break;
|
||||
case TenantConfigUpdateStatus.Declined:
|
||||
return; // already at the target
|
||||
case TenantConfigUpdateStatus.OffboardingInProgress:
|
||||
_logger.LogWarning("App-homing flip skipped for tenant {TenantId} — offboarding in progress", tenantId);
|
||||
return;
|
||||
case TenantConfigUpdateStatus.NotFound:
|
||||
throw new InvalidOperationException(
|
||||
$"App-homing flip aborted for tenant {tenantId} — no tenant configuration row exists");
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"App-homing flip for tenant {tenantId} lost every conditional write to concurrent changes");
|
||||
}
|
||||
var config = update.Config!;
|
||||
|
||||
var oldLabel = oldValue ?? "(legacy)";
|
||||
var newLabel = normalizedTarget ?? "(legacy)";
|
||||
|
||||
config.HomedAppClientId = normalizedTarget;
|
||||
config.UpdatedBy = actorUpn;
|
||||
await _tenantConfigService.SaveConfigurationAsync(config, "app-homing", reason);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Tenant {TenantId} app-reg homing flipped: {Old} -> {New} (by {User}, reason {Reason}, forced {Forced})",
|
||||
tenantId, oldLabel, newLabel, actorUpn, reason, forced);
|
||||
|
||||
@@ -5,7 +5,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// <summary>
|
||||
/// Per-app install summary aggregation — folds a batch of <c>app_install_*</c> +
|
||||
/// <c>download_progress</c> + <c>do_telemetry</c> events into an
|
||||
/// <see cref="AppInstallSummary"/> keyed by app name.
|
||||
/// <see cref="AppInstallSummary"/> keyed by app name. RealmJoin package events fold into
|
||||
/// their own rows (<c>.RealmJoinPackages.cs</c>).
|
||||
/// </summary>
|
||||
public sealed partial class EventIngestProcessor
|
||||
{
|
||||
@@ -13,6 +14,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// test seam so the TerminalState / status-fold contract is pinned by unit tests (PR0).
|
||||
internal static void AggregateAppInstallEvent(EnrollmentEvent evt, string tenantId, string sessionId, Dictionary<string, AppInstallAggregationState> summaries)
|
||||
{
|
||||
if (IsRealmJoinPackageEvent(evt.EventType))
|
||||
{
|
||||
AggregateRealmJoinPackageEvent(evt, tenantId, sessionId, summaries);
|
||||
return;
|
||||
}
|
||||
|
||||
bool isRelevant =
|
||||
evt.EventType == "app_install_started" || evt.EventType == "app_install_start" ||
|
||||
evt.EventType == "app_install_completed" || evt.EventType == "app_install_complete" ||
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using SharedConstants = AutopilotMonitor.Shared.Constants;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// RealmJoin package rows of <c>AppInstallSummaries</c> (<see cref="AppInstallSources.RealmJoin"/>),
|
||||
/// folded from the watcher's <c>realmjoin_package_started</c> / <c>_completed</c> events and
|
||||
/// keyed by scope + package id. The watcher reports only packages that appear once RealmJoin
|
||||
/// owns its deployment (phase 100+); packages written to the RealmJoin registry earlier are
|
||||
/// Intune (IME) installs of RealmJoin-wrapped apps and already have their IME row — they must
|
||||
/// not get a second one.
|
||||
/// </summary>
|
||||
public sealed partial class EventIngestProcessor
|
||||
{
|
||||
internal static bool IsRealmJoinPackageEvent(string? eventType) =>
|
||||
eventType == SharedConstants.EventTypes.RealmJoinPackageStarted ||
|
||||
eventType == SharedConstants.EventTypes.RealmJoinPackageCompleted;
|
||||
|
||||
/// <summary>In-batch key of a RealmJoin row; never collides with an IME row's app-name key.</summary>
|
||||
internal static string RealmJoinAggregationKey(string scope, string packageId) =>
|
||||
$"{AppInstallSources.RealmJoin}|{scope}|{packageId}";
|
||||
|
||||
internal static void AggregateRealmJoinPackageEvent(EnrollmentEvent evt, string tenantId, string sessionId, Dictionary<string, AppInstallAggregationState> summaries)
|
||||
{
|
||||
if (evt.Data == null) return;
|
||||
var packageId = ReadRealmJoinString(evt.Data, DecisionEngine.RealmJoinPayloadKeys.PackageId);
|
||||
if (string.IsNullOrEmpty(packageId)) return;
|
||||
var scope = RealmJoinPackageFact.NormalizeScope(ReadRealmJoinString(evt.Data, DecisionEngine.RealmJoinPayloadKeys.Scope));
|
||||
|
||||
var key = RealmJoinAggregationKey(scope, packageId!);
|
||||
if (!summaries.TryGetValue(key, out var state))
|
||||
{
|
||||
state = new AppInstallAggregationState
|
||||
{
|
||||
Summary = new AppInstallSummary
|
||||
{
|
||||
Source = AppInstallSources.RealmJoin,
|
||||
InstallScope = scope,
|
||||
AppId = packageId!,
|
||||
AppName = packageId!,
|
||||
SessionId = sessionId,
|
||||
TenantId = tenantId,
|
||||
StartedAt = evt.Timestamp
|
||||
}
|
||||
};
|
||||
summaries[key] = state;
|
||||
}
|
||||
var summary = state.Summary;
|
||||
|
||||
// RealmJoin does not always write DisplayName; the package id stands in until it does.
|
||||
var displayName = ReadRealmJoinString(evt.Data, DecisionEngine.RealmJoinPayloadKeys.DisplayName);
|
||||
if (!string.IsNullOrEmpty(displayName))
|
||||
summary.AppName = displayName!;
|
||||
var version = ReadRealmJoinString(evt.Data, DecisionEngine.RealmJoinPayloadKeys.Version);
|
||||
if (!string.IsNullOrEmpty(version))
|
||||
summary.AppVersion = version!;
|
||||
|
||||
if (evt.EventType == SharedConstants.EventTypes.RealmJoinPackageStarted)
|
||||
{
|
||||
if (!state.InstallStartedAt.HasValue || evt.Timestamp < state.InstallStartedAt.Value)
|
||||
state.InstallStartedAt = evt.Timestamp;
|
||||
if (!summary.LastAttemptStartedAt.HasValue || evt.Timestamp > summary.LastAttemptStartedAt.Value)
|
||||
summary.LastAttemptStartedAt = evt.Timestamp;
|
||||
summary.InstallPassCount++;
|
||||
if (summary.Status == string.Empty || summary.Status == "InProgress")
|
||||
summary.Status = "InProgress";
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!bool.TryParse(ReadRealmJoinString(evt.Data, DecisionEngine.RealmJoinPayloadKeys.Success), out var success))
|
||||
return;
|
||||
var exitCode = int.TryParse(ReadRealmJoinString(evt.Data, DecisionEngine.RealmJoinPayloadKeys.LastExitCode), out var code)
|
||||
? code : (int?)null;
|
||||
summary.Status = success ? "Succeeded" : "Failed";
|
||||
summary.TerminalState = success ? "Installed" : "Error";
|
||||
summary.ExitCode = exitCode;
|
||||
// RealmJoin reports no error text; the exit code is the failure code.
|
||||
summary.FailureCode = success ? string.Empty : (exitCode?.ToString() ?? string.Empty);
|
||||
summary.CompletedAt = evt.Timestamp;
|
||||
}
|
||||
|
||||
// Duration = completion minus the observed start. A package the watcher first saw
|
||||
// already finished yields 0 s (same registry pass), which the metrics read as unmeasured.
|
||||
RecalculateAppDurations(state);
|
||||
}
|
||||
|
||||
private static string? ReadRealmJoinString(Dictionary<string, object> data, string key) =>
|
||||
data.TryGetValue(key, out var value) ? value?.ToString()?.Trim() : null;
|
||||
}
|
||||
}
|
||||
+17
-11
@@ -84,14 +84,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var findings = AppVersionRegressionRadar.Evaluate(tenantSummaries);
|
||||
var active = await _hardwareRejectionTracker.GetAppVersionRegressionsAsync(tenantId);
|
||||
var activeByKey = active.ToDictionary(
|
||||
a => AppVersionEpisodeKey(a.AppName, a.CurrentVersion), StringComparer.OrdinalIgnoreCase);
|
||||
a => AppVersionEpisodeKey(a.Source, a.AppName, a.CurrentVersion), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
int fired = 0, refreshed = 0, rearmed = 0;
|
||||
var firedKeys = new System.Collections.Generic.HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var finding in findings)
|
||||
{
|
||||
var key = AppVersionEpisodeKey(finding.AppName, finding.CurrentVersion);
|
||||
var key = AppVersionEpisodeKey(finding.Source, finding.AppName, finding.CurrentVersion);
|
||||
firedKeys.Add(key);
|
||||
|
||||
if (activeByKey.TryGetValue(key, out var existing))
|
||||
@@ -109,27 +109,30 @@ namespace AutopilotMonitor.Functions.Services
|
||||
continue; // a concurrent pass won the episode — its bell suffices
|
||||
|
||||
fired++;
|
||||
var isIme = finding.Source == AppInstallSources.Ime;
|
||||
await _tenantNotificationService.CreateNotificationAsync(
|
||||
tenantId,
|
||||
type: "app_version_duration_regression",
|
||||
title: $"Install duration regressed: {finding.AppName}",
|
||||
title: $"Install duration regressed: {finding.AppName}" + (isIme ? string.Empty : " (RealmJoin)"),
|
||||
message: BuildAppVersionRegressionMessage(finding),
|
||||
// Canonical appDetailUrl shape (lib/routes.ts) with the radar's horizon window.
|
||||
href: $"/apps/detail?name={Uri.EscapeDataString(finding.AppName)}&days={AppVersionRegressionRadar.HorizonDays}");
|
||||
// Canonical appDetailUrl shape (lib/routes.ts) with the radar's horizon window;
|
||||
// source is omitted for IME, its default.
|
||||
href: $"/apps/detail?name={Uri.EscapeDataString(finding.AppName)}&days={AppVersionRegressionRadar.HorizonDays}"
|
||||
+ (isIme ? string.Empty : $"&source={Uri.EscapeDataString(finding.Source)}"));
|
||||
await _opsEventService.RecordAppVersionDurationRegressionAsync(
|
||||
tenantId, finding.AppName, finding.CurrentVersion, finding.PreviousVersion,
|
||||
tenantId, finding.AppName, finding.Source, finding.CurrentVersion, finding.PreviousVersion,
|
||||
finding.CurrentMedianSeconds, finding.PreviousMedianSeconds,
|
||||
finding.CurrentMeasuredCount, finding.PreviousMeasuredCount, finding.Lift);
|
||||
}
|
||||
|
||||
foreach (var alert in active)
|
||||
{
|
||||
if (firedKeys.Contains(AppVersionEpisodeKey(alert.AppName, alert.CurrentVersion)))
|
||||
if (firedKeys.Contains(AppVersionEpisodeKey(alert.Source, alert.AppName, alert.CurrentVersion)))
|
||||
continue;
|
||||
|
||||
if (AppVersionRegressionRadar.ShouldReArm(tenantSummaries, alert))
|
||||
{
|
||||
await _hardwareRejectionTracker.DeleteAppVersionRegressionAsync(tenantId, alert.AppName, alert.CurrentVersion);
|
||||
await _hardwareRejectionTracker.DeleteAppVersionRegressionAsync(tenantId, alert.Source, alert.AppName, alert.CurrentVersion);
|
||||
rearmed++;
|
||||
}
|
||||
else
|
||||
@@ -149,8 +152,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return (fired, refreshed, rearmed);
|
||||
}
|
||||
|
||||
private static string AppVersionEpisodeKey(string appName, string version)
|
||||
=> $"{appName}\n{version}";
|
||||
private static string AppVersionEpisodeKey(string source, string appName, string version)
|
||||
=> $"{AppInstallSources.Normalize(source)}\n{appName}\n{version}";
|
||||
|
||||
private static AppVersionRegressionAlert BuildAppVersionAlert(
|
||||
AppVersionDurationRegressionFinding finding, DateTime firstNotifiedAt)
|
||||
@@ -158,6 +161,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
TenantId = finding.TenantId,
|
||||
AppName = finding.AppName,
|
||||
Source = finding.Source,
|
||||
CurrentVersion = finding.CurrentVersion,
|
||||
PreviousVersion = finding.PreviousVersion,
|
||||
CurrentMedianSeconds = finding.CurrentMedianSeconds,
|
||||
@@ -177,7 +181,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private static AppVersionRegressionAlert? TryBuildOngoingAppVersionAlert(
|
||||
System.Collections.Generic.IReadOnlyList<AppInstallSummary> tenantSummaries, AppVersionRegressionAlert existing)
|
||||
{
|
||||
var stats = AppVersionRegressionRadar.ComputeVersionStatsForApp(tenantSummaries, existing.AppName);
|
||||
var source = AppInstallSources.Normalize(existing.Source);
|
||||
var stats = AppVersionRegressionRadar.ComputeVersionStatsForApp(tenantSummaries, source, existing.AppName);
|
||||
var current = stats.FirstOrDefault(s => string.Equals(s.Version, existing.CurrentVersion, StringComparison.Ordinal));
|
||||
var previous = stats.FirstOrDefault(s => string.Equals(s.Version, existing.PreviousVersion, StringComparison.Ordinal));
|
||||
if (current == null || previous == null || previous.MedianSeconds <= 0)
|
||||
@@ -187,6 +192,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
TenantId = existing.TenantId,
|
||||
AppName = existing.AppName,
|
||||
Source = source,
|
||||
CurrentVersion = existing.CurrentVersion,
|
||||
PreviousVersion = existing.PreviousVersion,
|
||||
CurrentMedianSeconds = current.MedianSeconds,
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
{
|
||||
public static class WebhookHttpClientRegistration
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the typed HttpClient of <see cref="WebhookNotificationService"/>. Its primary
|
||||
/// handler connects only through <see cref="SsrfGuard.ConnectToPublicAddress"/>: the webhook
|
||||
/// SSRF gate lives on the connection, so it covers every request this client sends.
|
||||
/// </summary>
|
||||
public static IHttpClientBuilder AddWebhookNotificationHttpClient(this IServiceCollection services) =>
|
||||
services.AddHttpClient<WebhookNotificationService>()
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(10),
|
||||
ConnectCallback = SsrfGuard.ConnectToPublicAddress,
|
||||
})
|
||||
.ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(15))
|
||||
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
|
||||
}
|
||||
}
|
||||
+4
-11
@@ -54,8 +54,6 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
return;
|
||||
}
|
||||
|
||||
await SsrfGuard.ValidateDestinationAsync(webhookUrl);
|
||||
|
||||
var json = renderer.RenderToJson(alert);
|
||||
var response = await PostAsync(webhookUrl, json, customHeaders, signingSecret);
|
||||
|
||||
@@ -90,15 +88,6 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
if (!_renderers.TryGetValue(providerType, out var renderer))
|
||||
return new WebhookTestResult { Success = false, Message = $"Unknown provider type: {providerType}" };
|
||||
|
||||
try
|
||||
{
|
||||
await SsrfGuard.ValidateDestinationAsync(webhookUrl);
|
||||
}
|
||||
catch (SsrfException ex)
|
||||
{
|
||||
return new WebhookTestResult { Success = false, Message = ex.Message };
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = renderer.RenderToJson(alert);
|
||||
@@ -118,6 +107,10 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
Message = $"Webhook returned HTTP {statusCode}: {(body.Length > 200 ? body[..200] : body)}"
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.InnerException is SsrfException refused)
|
||||
{
|
||||
return new WebhookTestResult { Success = false, Message = refused.Message };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new WebhookTestResult { Success = false, Message = $"Connection error: {ex.Message}" };
|
||||
|
||||
@@ -324,14 +324,6 @@ 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>
|
||||
/// <summary>
|
||||
/// A device/bootstrap-authenticated registration named a sessionId whose SessionTenantLookup
|
||||
/// row belongs to ANOTHER tenant (first-writer-wins claim in <c>StoreSessionAsync</c>).
|
||||
@@ -346,13 +338,21 @@ namespace AutopilotMonitor.Functions.Services
|
||||
requestedTenantId, "System.SessionTenantLookup",
|
||||
new { sessionId, owningTenantId, certificateThumbprint, agentVersion, endpoint });
|
||||
|
||||
/// <summary>
|
||||
/// SESSION-OWNER-BINDING: a validated agent request named a session bound to a different
|
||||
/// device identity (see <c>SessionOwnershipPolicy</c>) and its session write was refused.
|
||||
/// Throttled by <c>SessionOwnerBindingObserver</c> to one event per session+outcome per hour
|
||||
/// and Function instance. <c>serialMatch=true</c> on <c>MismatchCert</c> is the
|
||||
/// re-enroll-without-wipe shape, on which the agent rotates to a new session;
|
||||
/// <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, OpsEventTypes.SessionOwnerMismatch, OpsEventSeverity.Warning,
|
||||
$"Session owner mismatch ({outcome}) on {endpoint}: {callerKind} caller vs {ownerKind}-owned session {sessionId} (serialMatch={serialMatch.ToString().ToLowerInvariant()}, shadow - allowed)",
|
||||
$"Session owner mismatch ({outcome}) on {endpoint}: {callerKind} caller vs {ownerKind}-owned session {sessionId} (serialMatch={serialMatch.ToString().ToLowerInvariant()}, refused)",
|
||||
tenantId, "System.SessionOwnerBinding",
|
||||
new { sessionId, outcome, callerKind, ownerKind, serialMatch, agentVersion, endpoint, enforced = false });
|
||||
new { sessionId, outcome, callerKind, ownerKind, serialMatch, agentVersion, endpoint, enforced = true });
|
||||
|
||||
/// <summary>
|
||||
/// Assume-breach signal for the Global-Admin-only surface: an authenticated caller was refused
|
||||
@@ -846,7 +846,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// (memory: feedback_ops_event_types_dual_register).
|
||||
/// </summary>
|
||||
public Task RecordAppVersionDurationRegressionAsync(
|
||||
string tenantId, string appName, string currentVersion, string previousVersion,
|
||||
string tenantId, string appName, string source, string currentVersion, string previousVersion,
|
||||
int currentMedianSeconds, int previousMedianSeconds,
|
||||
int currentMeasuredCount, int previousMeasuredCount, double lift)
|
||||
=> WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.AppVersionDurationRegression, OpsEventSeverity.Warning,
|
||||
@@ -857,6 +857,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
new
|
||||
{
|
||||
appName,
|
||||
source,
|
||||
currentVersion,
|
||||
previousVersion,
|
||||
currentMedianSeconds,
|
||||
@@ -915,7 +916,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
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);
|
||||
var quickConfig = string.Equals(source, Functions.Config.PatchTenantConfigurationFieldsFunction.CollectLogsSource, StringComparison.Ordinal);
|
||||
if (change.Enabled && quickConfig)
|
||||
{
|
||||
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.CollectLogsQuickConfigEnabled, OpsEventSeverity.Info,
|
||||
|
||||
@@ -114,16 +114,19 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
private async Task<bool> StampAsync(string managedTenantId, string homeTenantId, string reason)
|
||||
{
|
||||
// Fresh read: the anchor is a read-modify-write on the whole row; a stale cached instance would
|
||||
// rewind fields another writer just changed (the app-homing failure mode).
|
||||
var config = await _configs.GetConfigurationFreshAsync(managedTenantId);
|
||||
if (config == null)
|
||||
return false;
|
||||
|
||||
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
||||
config.ProDowngradedUtc = nowUtc;
|
||||
config.UpdatedBy = $"delegation:{homeTenantId}";
|
||||
await _configs.SaveConfigurationAsync(config, BackupSource, reason);
|
||||
var update = await _configs.UpdateAsync(managedTenantId, row =>
|
||||
{
|
||||
row.ProDowngradedUtc = nowUtc;
|
||||
row.UpdatedBy = $"delegation:{homeTenantId}";
|
||||
return true;
|
||||
}, BackupSource, reason);
|
||||
|
||||
if (update.Status == TenantConfigUpdateStatus.Conflict)
|
||||
throw new InvalidOperationException(
|
||||
$"Retention grace anchor for tenant {managedTenantId} lost every conditional write to concurrent changes");
|
||||
if (update.Status != TenantConfigUpdateStatus.Updated)
|
||||
return false; // no row, or the tenant is being offboarded
|
||||
_logger.LogInformation(
|
||||
"[ProConferral] {Managed} lost Pro conferred by {Home} ({Reason}); retention grace anchor set to {Anchor:O}",
|
||||
managedTenantId, homeTenantId, reason, nowUtc);
|
||||
|
||||
@@ -12,18 +12,19 @@ using Microsoft.Extensions.Logging;
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// SESSION-OWNER-BINDING-SHADOW — the single side-effect carrier around
|
||||
/// SESSION-OWNER-BINDING — 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"/>).
|
||||
/// ingest, error report, hosted diagnostics upload URL) runs its already-loaded Sessions row and
|
||||
/// validation through <see cref="Observe"/>, which records the outcome and returns the decision.
|
||||
/// The caller refuses when <see cref="SessionOwnershipPolicy.Decision.Rejected"/> is set; callers
|
||||
/// that own a write path otherwise 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.
|
||||
/// rejected outcomes so operators can wire an alert rule.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SessionOwnerBindingObserver
|
||||
@@ -50,7 +51,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// <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.
|
||||
/// with no stamp (fail open: turning it into a rejection would take devices down over a
|
||||
/// defect on our side).
|
||||
/// </summary>
|
||||
/// <param name="req">The request (for the FunctionContext item and the agent-version header).</param>
|
||||
/// <param name="tenantId">Validated tenant.</param>
|
||||
@@ -92,10 +94,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
"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.Outcome, true, decision.Rejected, tenantId, sessionId, callerKind, ownerKind,
|
||||
decision.SerialMatch, endpoint, agentVersion);
|
||||
|
||||
if (decision.WouldReject)
|
||||
if (decision.Rejected)
|
||||
RaiseOpsEventThrottled(tenantId, sessionId, decision, callerKind, ownerKind, agentVersion, endpoint);
|
||||
|
||||
return decision;
|
||||
@@ -112,7 +114,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// <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.
|
||||
/// stamp. Fail-soft — a failed stamp only leaves the row on its previous owner (the next
|
||||
/// request re-evaluates the same tolerated outcome) and must never cost a batch.
|
||||
/// </summary>
|
||||
public async Task StampAsync(string tenantId, string sessionId, SessionOwnershipPolicy.Decision decision)
|
||||
{
|
||||
|
||||
@@ -265,11 +265,13 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var failed = appInstalls.Count(a => a.Status == "Failed");
|
||||
var rate = SlaEvaluationWindow.AppInstallSuccessRate(appInstalls);
|
||||
var target = (double)config.SlaTargetAppInstallSuccessRate.Value;
|
||||
// Grouped per (channel, name) like the SLA page; a RealmJoin package is
|
||||
// named as such in the notification text.
|
||||
var topFailing = appInstalls
|
||||
.Where(a => a.Status == "Failed")
|
||||
.GroupBy(a => a.AppName)
|
||||
.GroupBy(a => (Source: AppInstallSources.Normalize(a.Source), a.AppName))
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Select(g => g.Key)
|
||||
.Select(g => g.Key.Source == AppInstallSources.RealmJoin ? $"{g.Key.AppName} (RealmJoin)" : g.Key.AppName)
|
||||
.FirstOrDefault();
|
||||
var isBreaching = rate < target;
|
||||
if (isBreaching) breaches++;
|
||||
|
||||
@@ -194,15 +194,18 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var appFailed = currentWeekApps.Count(a => a.Status == "Failed");
|
||||
var appRate = SlaEvaluationWindow.AppInstallSuccessRate(currentWeekApps);
|
||||
|
||||
// Per (channel, name): the rate covers every channel, an app group never mixes two.
|
||||
var topFailing = currentWeekApps
|
||||
.Where(a => a.Status == "Failed")
|
||||
.GroupBy(a => a.AppName)
|
||||
.GroupBy(a => (Source: AppInstallSources.Normalize(a.Source), a.AppName))
|
||||
.Select(g =>
|
||||
{
|
||||
var totalForApp = currentWeekApps.Count(a => a.AppName == g.Key);
|
||||
var totalForApp = currentWeekApps.Count(a =>
|
||||
a.AppName == g.Key.AppName && AppInstallSources.Normalize(a.Source) == g.Key.Source);
|
||||
return new TopFailingApp
|
||||
{
|
||||
AppName = g.Key,
|
||||
AppName = g.Key.AppName,
|
||||
Source = g.Key.Source,
|
||||
FailCount = g.Count(),
|
||||
TotalCount = totalForApp,
|
||||
SuccessRate = totalForApp > 0
|
||||
|
||||
+18
-54
@@ -1,5 +1,6 @@
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
@@ -196,9 +197,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation,
|
||||
bool excludeDeletions = false, AuditLogQueryFilters? filters = null)
|
||||
{
|
||||
// Cross-tenant: per-tenant fan-out + merge by RowKey. Without this
|
||||
// fan-out Azure pages by (PK asc, RK asc) cross-partition, surfacing
|
||||
// tenants alphabetically rather than newest-first globally.
|
||||
// Cross-tenant: per-tenant fan-out + merge by RowKey (see PerPartitionFanOutMerge).
|
||||
return FetchAllAuditLogsPageAsync(dateFrom, dateTo, pageSize, continuation, excludeDeletions, filters);
|
||||
}
|
||||
|
||||
@@ -268,16 +267,6 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||
try
|
||||
{
|
||||
var continuations = AutopilotMonitor.Functions.DataAccess.TableStorage.PerPartitionFanOutMerge
|
||||
.DecodeMultiContinuation(continuation);
|
||||
// First-page request (no continuation) fans out across every
|
||||
// tenant in the catalog. Subsequent pages restrict to tenants
|
||||
// still in the continuation map — anything dropped by
|
||||
// MergeAndAdvance (because that partition exhausted on a
|
||||
// prior page) stays dropped, which is what shrinks the
|
||||
// wire-format token from 30+ KB to a few hundred bytes.
|
||||
var isFirstPage = string.IsNullOrEmpty(continuation);
|
||||
|
||||
// Tenants come from TenantConfiguration (1 row per tenant — cheap).
|
||||
// PLUS the synthetic global-tenant partition (Constants.AuditGlobalTenantId)
|
||||
// where platform-action audits are written from TenantOffboardFunction,
|
||||
@@ -293,45 +282,20 @@ namespace AutopilotMonitor.Functions.Services
|
||||
tenantIds.Add(tenantId);
|
||||
}
|
||||
|
||||
var activeTenantIds = tenantIds
|
||||
.Where(t => isFirstPage || continuations.ContainsKey(t))
|
||||
.ToList();
|
||||
if (activeTenantIds.Count == 0)
|
||||
return new RawPage<AuditLogEntry>(new List<AuditLogEntry>(), null);
|
||||
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs);
|
||||
|
||||
// Per-tenant fetch in parallel: filter `PartitionKey eq tenantId` plus
|
||||
// `RowKey gt LastRowKey` (revtick scheme → strictly older than what
|
||||
// we already returned for this tenant), plus the optional date window.
|
||||
var fetchTasks = activeTenantIds.Select(async tid =>
|
||||
{
|
||||
continuations.TryGetValue(tid, out var prior);
|
||||
var tenantFilter = BuildAuditLogFilterWithRowKeyBound(tid, dateFrom, dateTo, prior?.LastRowKey, excludeDeletions, filters);
|
||||
var fetched = new List<(string RowKey, AuditLogEntry Item)>();
|
||||
await foreach (var e in tableClient.QueryAsync<TableEntity>(filter: tenantFilter, maxPerPage: pageSize))
|
||||
return await PerPartitionFanOutMerge.FetchPageAsync<AuditLogEntry>(
|
||||
tenantIds, pageSize, continuation,
|
||||
async (tenantId, bound, limit, ct) =>
|
||||
{
|
||||
fetched.Add((e.RowKey, MapToAuditLogEntry(e)));
|
||||
if (fetched.Count >= pageSize) break;
|
||||
}
|
||||
return new AutopilotMonitor.Functions.DataAccess.TableStorage.PerPartitionFanOutMerge
|
||||
.PartitionFetchResult<AuditLogEntry>(tid, fetched);
|
||||
}).ToList();
|
||||
|
||||
var results = await Task.WhenAll(fetchTasks);
|
||||
|
||||
var (items, nextContinuations) = AutopilotMonitor.Functions.DataAccess.TableStorage.PerPartitionFanOutMerge
|
||||
.MergeAndAdvance(results, continuations, pageSize, e => e.Timestamp);
|
||||
|
||||
// MergeAndAdvance only puts active partitions into the map now
|
||||
// (exhausted ones are dropped, see PerPartitionFanOutMerge.cs).
|
||||
// An empty map therefore means "every partition is done" and we
|
||||
// emit no continuation, ending the pagination cleanly.
|
||||
string? nextRawToken = nextContinuations.Count > 0
|
||||
? AutopilotMonitor.Functions.DataAccess.TableStorage.PerPartitionFanOutMerge
|
||||
.EncodeMultiContinuation(nextContinuations)
|
||||
: null;
|
||||
return new RawPage<AuditLogEntry>(items, nextRawToken);
|
||||
var filter = BuildAuditLogFilterWithRowKeyBound(tenantId, dateFrom, dateTo, bound, excludeDeletions, filters);
|
||||
var read = new List<(string RowKey, AuditLogEntry Item)>();
|
||||
await foreach (var e in tableClient.QueryAsync<TableEntity>(filter: filter, maxPerPage: limit, cancellationToken: ct))
|
||||
{
|
||||
read.Add((e.RowKey, MapToAuditLogEntry(e)));
|
||||
if (read.Count >= limit) break;
|
||||
}
|
||||
return read;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -382,8 +346,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
internal static string BuildAuditLogFilterWithRowKeyBound(
|
||||
string tenantId, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey, bool excludeDeletions,
|
||||
AuditLogQueryFilters? filters = null)
|
||||
string tenantId, DateTime? dateFrom, DateTime? dateTo, PerPartitionFanOutMerge.RowKeyBound? rowKeyBound,
|
||||
bool excludeDeletions, AuditLogQueryFilters? filters = null)
|
||||
{
|
||||
var clauses = new List<string>
|
||||
{
|
||||
@@ -393,8 +357,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (excludeDeletions)
|
||||
clauses.Add(DeletionExclusionClause());
|
||||
AppendAuditFieldFilters(clauses, filters);
|
||||
if (!string.IsNullOrEmpty(lastRowKey))
|
||||
clauses.Add($"RowKey gt '{lastRowKey!.Replace("'", "''")}'");
|
||||
if (rowKeyBound != null)
|
||||
clauses.Add(rowKeyBound.ToODataClause());
|
||||
if (dateFrom.HasValue)
|
||||
clauses.Add(BusinessTimestamp.AuditDateFromClause(ToUtc(dateFrom.Value)));
|
||||
if (dateTo.HasValue)
|
||||
|
||||
@@ -208,14 +208,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// <summary>
|
||||
/// Stores or updates an app install summary.
|
||||
/// Merges with any existing record so StartedAt is never overwritten with a later timestamp.
|
||||
/// PartitionKey: TenantId, RowKey: {SessionId}_{AppName}
|
||||
/// PartitionKey: TenantId, RowKey: <see cref="BuildAppInstallSummaryRowKey"/>.
|
||||
/// </summary>
|
||||
public async Task<bool> StoreAppInstallSummaryAsync(AppInstallSummary summary)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AppInstallSummaries);
|
||||
var rowKey = SanitizeTableKey($"{summary.SessionId}_{summary.AppName}");
|
||||
var rowKey = BuildAppInstallSummaryRowKey(summary);
|
||||
|
||||
// Merge with existing record to preserve StartedAt from a prior batch
|
||||
var existingResult = await tableClient.GetEntityIfExistsAsync<TableEntity>(summary.TenantId, rowKey);
|
||||
@@ -240,6 +240,20 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RowKey of an AppInstallSummaries row; every key starts with "{SessionId}_", which the
|
||||
/// per-session range scans (ESP stamp, close step, deletion) rely on. IME rows stay
|
||||
/// name-keyed (no key migration); RealmJoin rows are keyed by scope + package id, the
|
||||
/// identity the watcher reports — their display name can appear late.
|
||||
/// </summary>
|
||||
internal static string BuildAppInstallSummaryRowKey(AppInstallSummary summary)
|
||||
{
|
||||
var source = AppInstallSources.Normalize(summary.Source);
|
||||
return SanitizeTableKey(source == AppInstallSources.Ime
|
||||
? $"{summary.SessionId}_{summary.AppName}"
|
||||
: $"{summary.SessionId}_{source}|{summary.InstallScope}|{summary.AppId}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconciles an incoming per-batch <see cref="AppInstallSummary"/> against the stored row
|
||||
/// before the Merge-mode upsert. Extracted as an internal static seam so the cross-batch
|
||||
@@ -376,6 +390,16 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
// A RealmJoin row carries its package id as the name until RealmJoin writes
|
||||
// DisplayName; a later batch without the name must not replace a stored one.
|
||||
if (AppInstallSources.Normalize(summary.Source) == AppInstallSources.RealmJoin &&
|
||||
summary.AppName == summary.AppId)
|
||||
{
|
||||
var existingAppName = existing.GetString("AppName");
|
||||
if (!string.IsNullOrEmpty(existingAppName))
|
||||
summary.AppName = existingAppName!;
|
||||
}
|
||||
|
||||
// Preserve app metadata fields: AppVersion, AppType, AttemptNumber come from app_install_started
|
||||
// and must not be wiped by a later _completed/_failed batch that doesn't re-emit them.
|
||||
if (string.IsNullOrEmpty(summary.AppVersion))
|
||||
@@ -484,7 +508,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var filter = $"PartitionKey eq '{tenantId}' and RowKey ge '{sessionId}_' and RowKey lt '{sessionId}`'";
|
||||
var query = tableClient.QueryAsync<TableEntity>(
|
||||
filter: filter,
|
||||
select: new[] { "PartitionKey", "RowKey", "AppId", "EspBlocking", "AppIdCollision" });
|
||||
select: new[] { "PartitionKey", "RowKey", "Source", "AppId", "EspBlocking", "AppIdCollision" });
|
||||
|
||||
var stamped = 0;
|
||||
await foreach (var row in query)
|
||||
@@ -520,6 +544,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// </summary>
|
||||
internal static bool ShouldStampEspBlocking(TableEntity row, EspBlockingSets sets)
|
||||
{
|
||||
// The ESP lists hold Intune identities only; other channels' ids are never members.
|
||||
if (AppInstallSources.Normalize(row.GetString("Source")) != AppInstallSources.Ime) return false;
|
||||
var appId = row.GetString("AppId");
|
||||
if (string.IsNullOrEmpty(appId)) return false;
|
||||
if (row.GetBoolean("AppIdCollision") ?? false) return false;
|
||||
@@ -712,7 +738,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// </summary>
|
||||
internal static readonly string[] AppMetricsProjection =
|
||||
{
|
||||
"PartitionKey", "RowKey", "AppName", "Status", "TerminalState", "StartedAt",
|
||||
"PartitionKey", "RowKey", "AppName", "Source", "Status", "TerminalState", "StartedAt",
|
||||
"DurationSeconds", "DownloadBytes", "FailureCode",
|
||||
"DoDownloadMode", "DoTotalBytesDownloaded", "DoBytesFromPeers", "DoBytesFromHttp",
|
||||
"DoBytesFromLanPeers", "DoBytesFromGroupPeers", "DoBytesFromInternetPeers",
|
||||
@@ -735,7 +761,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// </summary>
|
||||
internal static readonly string[] AppsDashboardProjection =
|
||||
{
|
||||
"PartitionKey", "RowKey", "TenantId", "SessionId", "AppName", "AppType", "AppVersion",
|
||||
"PartitionKey", "RowKey", "TenantId", "SessionId", "AppName", "Source", "AppType", "AppVersion",
|
||||
"Status", "TerminalState", "StartedAt", "CompletedAt", "DurationSeconds", "DownloadBytes",
|
||||
"AttemptNumber", "InstallerPhase", "FailureCode", "FailureMessage", "ExitCode", "DetectionResult",
|
||||
"AppId", "EspBlocking", "AppIdCollision",
|
||||
@@ -852,6 +878,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
AppName = entity.GetString("AppName") ?? string.Empty,
|
||||
SessionId = entity.GetString("SessionId") ?? string.Empty,
|
||||
TenantId = entity.GetString("TenantId") ?? entity.PartitionKey,
|
||||
Source = entity.GetString("Source") ?? string.Empty,
|
||||
InstallScope = entity.GetString("InstallScope") ?? string.Empty,
|
||||
Status = entity.GetString("Status") ?? "InProgress",
|
||||
TerminalState = entity.GetString("TerminalState") ?? string.Empty,
|
||||
DurationSeconds = entity.GetInt32("DurationSeconds") ?? 0,
|
||||
@@ -2111,6 +2139,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// is persisted (Merge cannot un-set an absent column).
|
||||
if (!string.IsNullOrEmpty(summary.AppId))
|
||||
entity["AppId"] = summary.AppId;
|
||||
// Channel columns: absent on IME rows (empty Source reads as IME), so IME entities
|
||||
// keep their pre-channel shape.
|
||||
if (!string.IsNullOrEmpty(summary.Source))
|
||||
entity["Source"] = summary.Source;
|
||||
if (!string.IsNullOrEmpty(summary.InstallScope))
|
||||
entity["InstallScope"] = summary.InstallScope;
|
||||
if (summary.EspBlocking.HasValue)
|
||||
entity["EspBlocking"] = summary.EspBlocking.Value;
|
||||
if (summary.AppIdCollision)
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
@@ -95,34 +96,36 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
try
|
||||
{
|
||||
// Load from repository
|
||||
var config = await _configRepo.GetTenantConfigurationAsync(tenantId);
|
||||
|
||||
if (config != null)
|
||||
if (config == null)
|
||||
{
|
||||
_cache.Set(cacheKey, config, CacheDuration);
|
||||
return (await ProjectAsync(config))!;
|
||||
// No row yet: create the default conditionally, so a row another writer created
|
||||
// since the read (a first-login seed) is never overwritten; a lost race reads it.
|
||||
var defaultConfig = TenantConfiguration.CreateDefault(tenantId);
|
||||
try
|
||||
{
|
||||
if (await _configRepo.TryCreateTenantConfigurationAsync(defaultConfig))
|
||||
{
|
||||
_logger.LogInformation("Default configuration created for tenant {TenantId}", tenantId);
|
||||
// Returned unprojected, like any synthesized default (see the class remarks).
|
||||
_cache.Set(cacheKey, defaultConfig, CacheDuration);
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
config = await _configRepo.GetTenantConfigurationAsync(tenantId);
|
||||
}
|
||||
catch (Exception createEx)
|
||||
{
|
||||
_logger.LogError(createEx, "Failed to create the default configuration for tenant {TenantId}", tenantId);
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
if (config == null)
|
||||
return defaultConfig; // created and deleted again in between — nothing to cache
|
||||
}
|
||||
|
||||
// Configuration not found - create and save default immediately
|
||||
_logger.LogInformation($"Configuration not found for tenant {tenantId}, creating and saving default configuration");
|
||||
var defaultConfig = TenantConfiguration.CreateDefault(tenantId);
|
||||
|
||||
try
|
||||
{
|
||||
// Save the default configuration via repository
|
||||
await _configRepo.SaveTenantConfigurationAsync(defaultConfig);
|
||||
|
||||
_cache.Set(cacheKey, defaultConfig, CacheDuration);
|
||||
|
||||
_logger.LogInformation($"Default configuration created and saved for tenant {tenantId}");
|
||||
}
|
||||
catch (Exception saveEx)
|
||||
{
|
||||
_logger.LogError(saveEx, $"Failed to save default configuration for tenant {tenantId}");
|
||||
}
|
||||
|
||||
return defaultConfig;
|
||||
_cache.Set(cacheKey, config, CacheDuration);
|
||||
return (await ProjectAsync(config))!;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -132,52 +135,107 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves configuration for a tenant. THROWS when the write did not persist — the repository
|
||||
/// swallows storage exceptions and reports failure via its bool return, so ignoring it would
|
||||
/// let callers audit + return 200 for a save that never happened (Codex finding, 2026-07-07).
|
||||
/// The cached row is invalidated on EVERY attempted save (finally): on success it is stale;
|
||||
/// on failure the caller may have mutated the cached instance in place (plan/trial endpoints),
|
||||
/// which must not linger as phantom-saved state for the 5-minute TTL.
|
||||
/// </summary>
|
||||
public virtual Task SaveConfigurationAsync(TenantConfiguration config)
|
||||
=> SaveConfigurationAsync(config, backupSource: null, backupReason: null);
|
||||
internal const int MaxUpdateAttempts = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Overload (not optional parameters — Moq expression trees, CS0854) that tags the
|
||||
/// pre-write backup snapshot with the write path and intent.
|
||||
/// The write path of a tenant configuration (D-289, D-290): callers hand in a mutation, never a
|
||||
/// configuration to save. <paramref name="mutate"/> runs on the FRESH, projected row and the write is
|
||||
/// conditional on that row's ETag. A cached view is up to 5 minutes stale per instance, so saving it
|
||||
/// would rewind whatever another writer changed meanwhile (the 2026-07-31 HomedAppClientId class) —
|
||||
/// including the offboarding tombstone during its drain barrier.
|
||||
/// <para>
|
||||
/// On a lost race the row is re-read and <paramref name="mutate"/> runs again, so it must derive
|
||||
/// everything from the row it is handed. Returning false declines the write. The offboarding tombstone
|
||||
/// is never written: the cascade owns the row until it deletes it. A missing row is
|
||||
/// <see cref="TenantConfigUpdateStatus.NotFound"/>; use <see cref="CreateOrUpdateAsync"/> where the write
|
||||
/// may create it. Storage errors other than a lost race throw; the cache is invalidated on every attempt.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public virtual async Task SaveConfigurationAsync(
|
||||
TenantConfiguration config, string? backupSource, string? backupReason)
|
||||
public virtual Task<TenantConfigUpdate> UpdateAsync(
|
||||
string tenantId, Func<TenantConfiguration, bool> mutate, string backupSource, string backupReason)
|
||||
=> WriteAsync(tenantId, mutate, backupSource, backupReason, createIfMissing: false);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="UpdateAsync"/> that creates a missing row: <paramref name="mutate"/> then runs on
|
||||
/// <see cref="TenantConfiguration.CreateDefault"/> and the row is inserted conditionally. If another
|
||||
/// writer created it first, the row is re-read and the mutation decides again on what is stored.
|
||||
/// </summary>
|
||||
public virtual Task<TenantConfigUpdate> CreateOrUpdateAsync(
|
||||
string tenantId, Func<TenantConfiguration, bool> mutate, string backupSource, string backupReason)
|
||||
=> WriteAsync(tenantId, mutate, backupSource, backupReason, createIfMissing: true);
|
||||
|
||||
private async Task<TenantConfigUpdate> WriteAsync(
|
||||
string tenantId, Func<TenantConfiguration, bool> mutate, string backupSource, string backupReason,
|
||||
bool createIfMissing)
|
||||
{
|
||||
if (config == null || string.IsNullOrEmpty(config.TenantId))
|
||||
for (var attempt = 1; attempt <= MaxUpdateAttempts; attempt++)
|
||||
{
|
||||
throw new ArgumentException("Configuration and TenantId are required");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
config.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
var saved = await _configRepo.SaveTenantConfigurationAsync(config, backupSource, backupReason);
|
||||
if (!saved)
|
||||
var read = await _configRepo.GetTenantConfigurationWithEtagAsync(tenantId);
|
||||
if (read == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Tenant configuration save failed for {config.TenantId} — storage write did not persist (see repository logs)");
|
||||
if (!createIfMissing)
|
||||
return new TenantConfigUpdate(TenantConfigUpdateStatus.NotFound, null);
|
||||
|
||||
// A synthesized row is never projected (see the class remarks).
|
||||
var created = TenantConfiguration.CreateDefault(tenantId);
|
||||
if (!mutate(created))
|
||||
return new TenantConfigUpdate(TenantConfigUpdateStatus.Declined, created);
|
||||
|
||||
created.LastUpdated = DateTime.UtcNow;
|
||||
bool inserted;
|
||||
try
|
||||
{
|
||||
inserted = await _configRepo.TryCreateTenantConfigurationAsync(created);
|
||||
}
|
||||
finally
|
||||
{
|
||||
InvalidateCache(tenantId);
|
||||
}
|
||||
|
||||
if (inserted)
|
||||
{
|
||||
_logger.LogInformation("Configuration created for tenant {TenantId} by {UpdatedBy}", tenantId, created.UpdatedBy);
|
||||
return new TenantConfigUpdate(TenantConfigUpdateStatus.Updated, created);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Configuration create for tenant {TenantId} lost the race (attempt {Attempt}/{Max})",
|
||||
tenantId, attempt, MaxUpdateAttempts);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation($"Configuration saved for tenant {config.TenantId} by {config.UpdatedBy}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Error saving configuration for tenant {config.TenantId}");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Invalidate cache — success AND failure paths (see summary).
|
||||
_cache.Remove($"tenant-config:{config.TenantId}");
|
||||
var (config, etag) = read.Value;
|
||||
await ProjectAsync(config);
|
||||
|
||||
if (TenantOffboardFunction.IsOffboardingTombstone(config))
|
||||
return new TenantConfigUpdate(TenantConfigUpdateStatus.OffboardingInProgress, config);
|
||||
|
||||
if (!mutate(config))
|
||||
return new TenantConfigUpdate(TenantConfigUpdateStatus.Declined, config);
|
||||
|
||||
config.LastUpdated = DateTime.UtcNow;
|
||||
bool replaced;
|
||||
try
|
||||
{
|
||||
replaced = await _configRepo.TryReplaceTenantConfigurationAsync(config, etag, backupSource, backupReason);
|
||||
}
|
||||
finally
|
||||
{
|
||||
InvalidateCache(tenantId);
|
||||
}
|
||||
|
||||
if (replaced)
|
||||
{
|
||||
_logger.LogInformation("Configuration updated for tenant {TenantId} by {UpdatedBy}", tenantId, config.UpdatedBy);
|
||||
return new TenantConfigUpdate(TenantConfigUpdateStatus.Updated, config);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Configuration update for tenant {TenantId} lost the ETag race (attempt {Attempt}/{Max})",
|
||||
tenantId, attempt, MaxUpdateAttempts);
|
||||
}
|
||||
|
||||
return new TenantConfigUpdate(TenantConfigUpdateStatus.Conflict, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -192,7 +250,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// stays invisible for the 5-minute TTL.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Fail-soft by design, unlike <see cref="SaveConfigurationAsync"/>: every caller is a side
|
||||
/// Fail-soft by design, unlike <see cref="UpdateAsync"/>: every caller is a side
|
||||
/// effect of an operation that has already succeeded, and a lost seed is recoverable on the
|
||||
/// next maintenance run. Enforced here rather than left to the repository, so the guarantee
|
||||
/// holds for any implementation.
|
||||
@@ -342,4 +400,24 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum TenantConfigUpdateStatus
|
||||
{
|
||||
/// <summary>Written: replaced, or created by <see cref="TenantConfigurationService.CreateOrUpdateAsync"/>.</summary>
|
||||
Updated,
|
||||
/// <summary>The mutation returned false; nothing was written.</summary>
|
||||
Declined,
|
||||
NotFound,
|
||||
/// <summary>The row is the offboarding tombstone; nothing was written.</summary>
|
||||
OffboardingInProgress,
|
||||
/// <summary>Every conditional write lost the ETag race.</summary>
|
||||
Conflict,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of <see cref="TenantConfigurationService.UpdateAsync"/>. <see cref="Config"/> is the
|
||||
/// row as written (Updated) or as read (Declined, OffboardingInProgress — for a declined create, the
|
||||
/// synthesized default the mutation saw); null otherwise.
|
||||
/// </summary>
|
||||
public sealed record TenantConfigUpdate(TenantConfigUpdateStatus Status, TenantConfiguration? Config);
|
||||
}
|
||||
|
||||
+11
-1
@@ -17,6 +17,7 @@
|
||||
"express": "^5.2.1",
|
||||
"fuse.js": "^7.5.0",
|
||||
"jose": "^6.2.12",
|
||||
"undici": "^8.11.2",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"bin": {
|
||||
@@ -32,7 +33,7 @@
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
@@ -3826,6 +3827,15 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "8.11.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz",
|
||||
"integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "MCP server for Autopilot-Monitor — enables AI agents to query enrollment sessions, device properties, events and vulnerabilities",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"bin": {
|
||||
"autopilot-monitor-mcp": "./dist/index.js"
|
||||
@@ -28,6 +28,7 @@
|
||||
"express": "^5.2.1",
|
||||
"fuse.js": "^7.5.0",
|
||||
"jose": "^6.2.12",
|
||||
"undici": "^8.11.2",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
/**
|
||||
* Unit tests for Client ID Metadata Documents (cimd.ts): URL recognition, the
|
||||
* SSRF gate, document validation, and the cache. All I/O is injected — no DNS,
|
||||
* no network.
|
||||
* SSRF address gate, document validation, and the cache. DNS is always
|
||||
* injected; the only sockets are the loopback listeners of the address-gate
|
||||
* suite, which prove what the gate lets a connection reach.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
import {
|
||||
AddressGateError,
|
||||
CIMD_MAX_DOCUMENT_BYTES,
|
||||
ClientMetadataError,
|
||||
clearClientMetadataCache,
|
||||
createAddressGateLookup,
|
||||
isClientIdMetadataUrl,
|
||||
isPublicAddress,
|
||||
resolveClientMetadata,
|
||||
@@ -44,7 +53,7 @@ const resolve = vi.fn<(h: string) => Promise<string[]>>();
|
||||
beforeEach(() => {
|
||||
clock = 1_000_000;
|
||||
fetchImpl.mockReset();
|
||||
resolve.mockReset().mockResolvedValue(['203.0.113.10', '2001:db8::10']);
|
||||
resolve.mockReset().mockResolvedValue(['1.1.1.1', '2606:4700:4700::1111']);
|
||||
setClientMetadataDepsForTests({ fetchImpl, resolve, now: () => clock });
|
||||
});
|
||||
|
||||
@@ -79,20 +88,233 @@ describe('isClientIdMetadataUrl — what counts as a metadata document URL', ()
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPublicAddress — SSRF address classes', () => {
|
||||
it('rejects every private / special-use range', () => {
|
||||
for (const ip of ['10.1.2.3', '172.16.0.1', '172.31.255.255', '192.168.1.1', '127.0.0.1', '0.0.0.0', '169.254.169.254', '100.64.0.1', '224.0.0.1', '::1', '::', 'fe80::1', 'fd00::1', 'fc00::1', 'ff02::1', '::ffff:10.0.0.1', '::ffff:127.0.0.1']) {
|
||||
// --- IANA special-purpose registries (tests/fixtures/iana-special-purpose-addresses) ---
|
||||
|
||||
function registryDir(): string {
|
||||
let dir = dirname(fileURLToPath(import.meta.url));
|
||||
while (!existsSync(join(dir, 'AutopilotMonitor.sln'))) {
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) throw new Error('repository root (AutopilotMonitor.sln) not found');
|
||||
dir = parent;
|
||||
}
|
||||
return join(dir, 'tests', 'fixtures', 'iana-special-purpose-addresses');
|
||||
}
|
||||
|
||||
/** RFC 4180 reader — the registry quotes cells, wraps the RFC column over lines and doubles quotes. */
|
||||
function parseCsv(text: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let cell = '';
|
||||
let quoted = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (quoted) {
|
||||
if (c === '"' && text[i + 1] === '"') {
|
||||
cell += '"';
|
||||
i++;
|
||||
} else if (c === '"') quoted = false;
|
||||
else cell += c;
|
||||
} else if (c === '"') quoted = true;
|
||||
else if (c === ',') {
|
||||
row.push(cell);
|
||||
cell = '';
|
||||
} else if (c === '\n' || c === '\r') {
|
||||
if (c === '\r' && text[i + 1] === '\n') i++;
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
cell = '';
|
||||
} else cell += c;
|
||||
}
|
||||
if (cell !== '' || row.length > 0) {
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Every block of one registry file: footnote markers ("2002::/16 [3]") dropped, two-block cells split. */
|
||||
function registryBlocks(file: string): string[] {
|
||||
const [header, ...rows] = parseCsv(readFileSync(join(registryDir(), file), 'utf-8'));
|
||||
if (header[0] !== 'Address Block') throw new Error(`${file}: unexpected header ${header[0]}`);
|
||||
return rows.flatMap((row) => row[0].split(',').map((b) => b.replace(/\[\d+\]/g, '').trim())).filter((b) => b !== '');
|
||||
}
|
||||
|
||||
function ipv6ToBigInt(ip: string): bigint {
|
||||
const [head, tail] = ip.split('::');
|
||||
const h = head ? head.split(':') : [];
|
||||
const t = tail === undefined ? [] : tail ? tail.split(':') : [];
|
||||
const groups = tail === undefined ? h : [...h, ...Array<string>(8 - h.length - t.length).fill('0'), ...t];
|
||||
return groups.reduce((acc, g) => (acc << 16n) | BigInt(parseInt(g, 16)), 0n);
|
||||
}
|
||||
|
||||
function bigIntToIpv6(n: bigint): string {
|
||||
return Array.from({ length: 8 }, (_, i) => ((n >> BigInt((7 - i) * 16)) & 0xffffn).toString(16)).join(':');
|
||||
}
|
||||
|
||||
/** First and last address of a CIDR block. */
|
||||
function cidrBounds(cidr: string): [string, string] {
|
||||
const [network, len] = cidr.split('/');
|
||||
const prefix = Number(len);
|
||||
if (net.isIP(network) === 4) {
|
||||
const size = 2 ** (32 - prefix);
|
||||
const first = Math.floor(network.split('.').reduce((acc, o) => acc * 256 + Number(o), 0) / size) * size;
|
||||
const fmt = (v: number) => [24, 16, 8, 0].map((s) => Math.floor(v / 2 ** s) % 256).join('.');
|
||||
return [fmt(first), fmt(first + size - 1)];
|
||||
}
|
||||
const hostBits = BigInt(128 - prefix);
|
||||
const first = (ipv6ToBigInt(network) >> hostBits) << hostBits;
|
||||
return [bigIntToIpv6(first), bigIntToIpv6(first + (1n << hostBits) - 1n)];
|
||||
}
|
||||
|
||||
describe('isPublicAddress — every IANA special-purpose block is refused', () => {
|
||||
for (const file of ['iana-ipv4-special-registry-1.csv', 'iana-ipv6-special-registry-1.csv']) {
|
||||
const blocks = registryBlocks(file);
|
||||
it(`${file} is read completely (${blocks.length} blocks)`, () => {
|
||||
expect(blocks.length).toBeGreaterThanOrEqual(20);
|
||||
for (const block of blocks) expect(block).toMatch(/^[0-9a-f.:]+\/\d{1,3}$/);
|
||||
});
|
||||
it.each(blocks)(`${file}: %s — first and last address refused`, (block) => {
|
||||
for (const ip of cidrBounds(block)) expect(isPublicAddress(ip), `${block} -> ${ip}`).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('isPublicAddress — boundaries and forms', () => {
|
||||
it('refuses multicast, reserved, broadcast and every address outside IPv6 global unicast', () => {
|
||||
for (const ip of ['224.0.0.1', '239.255.255.255', '240.0.0.1', '255.255.255.255', '::ffff:8.8.8.8', '::ffff:10.0.0.1', '::8.8.8.8', '64:ff9b::808:808', 'fec0::1', 'ff02::1', '4000::1', 'e000::1']) {
|
||||
expect(isPublicAddress(ip), ip).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts public addresses', () => {
|
||||
for (const ip of ['203.0.113.10', '8.8.8.8', '172.32.0.1', '100.128.0.1', '2001:db8::10', '::ffff:203.0.113.10']) {
|
||||
it('refuses an address with a zone id, even a global one', () => {
|
||||
for (const ip of ['fe80::1%eth0', 'fe80::1%1', '2606:4700:4700::1111%1']) {
|
||||
expect(isPublicAddress(ip), ip).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses what is not an IP address', () => {
|
||||
for (const ip of ['', 'app.example.test', '1.2.3', '1.2.3.4.5', '::g']) {
|
||||
expect(isPublicAddress(ip), ip).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts public addresses, including the neighbours of refused blocks', () => {
|
||||
for (const ip of ['1.1.1.1', '8.8.8.8', '9.255.255.255', '11.0.0.0', '100.63.255.255', '100.128.0.0', '172.15.255.255', '172.32.0.0', '192.0.1.0', '192.167.255.255', '192.169.0.0', '198.17.255.255', '198.20.0.0', '223.255.255.255', '2606:4700:4700::1111', '2a00:1450:4001::1', '2001:200::1', '2003::1', '2001:db9::1']) {
|
||||
expect(isPublicAddress(ip), ip).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAddressGateLookup — the address gate', () => {
|
||||
type LookupResult = { err: NodeJS.ErrnoException | null; address: unknown; family?: number };
|
||||
function runLookup(lookup: net.LookupFunction, options: Parameters<net.LookupFunction>[1]): Promise<LookupResult> {
|
||||
return new Promise((done) => lookup('app.example.test', options, (err, address, family) => done({ err, address, family })));
|
||||
}
|
||||
|
||||
it('answers the socket with the resolved public addresses, in both callback shapes, resolving once per call', async () => {
|
||||
const gateResolve = vi.fn(async () => ['1.1.1.1', '2606:4700:4700::1111']);
|
||||
const lookup = createAddressGateLookup(gateResolve);
|
||||
expect(await runLookup(lookup, { all: true })).toEqual({
|
||||
err: null,
|
||||
address: [{ address: '1.1.1.1', family: 4 }, { address: '2606:4700:4700::1111', family: 6 }],
|
||||
family: undefined,
|
||||
});
|
||||
expect(await runLookup(lookup, {})).toEqual({ err: null, address: '1.1.1.1', family: 4 });
|
||||
expect(gateResolve).toHaveBeenCalledTimes(2);
|
||||
expect(gateResolve).toHaveBeenCalledWith('app.example.test');
|
||||
});
|
||||
|
||||
it('honours a requested address family', async () => {
|
||||
const lookup = createAddressGateLookup(async () => ['1.1.1.1', '2606:4700:4700::1111']);
|
||||
expect(await runLookup(lookup, { family: 6 })).toEqual({ err: null, address: '2606:4700:4700::1111', family: 6 });
|
||||
expect(await runLookup(lookup, { family: 'IPv4', all: true })).toEqual({ err: null, address: [{ address: '1.1.1.1', family: 4 }], family: undefined });
|
||||
const { err } = await runLookup(createAddressGateLookup(async () => ['1.1.1.1']), { family: 6 });
|
||||
expect(err).toBeInstanceOf(AddressGateError);
|
||||
expect((err as AddressGateError).reason).toBe('unresolvable');
|
||||
});
|
||||
|
||||
it('refuses the connection when ANY resolved address is non-public', async () => {
|
||||
const { err } = await runLookup(createAddressGateLookup(async () => ['1.1.1.1', '169.254.169.254']), { all: true });
|
||||
expect(err).toBeInstanceOf(AddressGateError);
|
||||
expect((err as AddressGateError).reason).toBe('non-public');
|
||||
});
|
||||
|
||||
it('refuses a host that resolves to nothing or fails to resolve, keeping the resolver error as cause', async () => {
|
||||
const empty = await runLookup(createAddressGateLookup(async () => []), { all: true });
|
||||
expect((empty.err as AddressGateError).reason).toBe('unresolvable');
|
||||
const cause = new Error('ENOTFOUND');
|
||||
const failed = await runLookup(createAddressGateLookup(async () => { throw cause; }), { all: true });
|
||||
expect((failed.err as AddressGateError).reason).toBe('unresolvable');
|
||||
expect((failed.err as AddressGateError).cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
|
||||
describe('address gate on real sockets', () => {
|
||||
let listener: net.Server;
|
||||
let listenerPort = 0;
|
||||
let listenerConnections = 0;
|
||||
let httpServer: http.Server;
|
||||
let httpPort = 0;
|
||||
let httpConnections = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
listener = net.createServer((socket) => {
|
||||
listenerConnections++;
|
||||
socket.destroy();
|
||||
});
|
||||
await new Promise<void>((done) => listener.listen(0, '127.0.0.1', done));
|
||||
listenerPort = (listener.address() as net.AddressInfo).port;
|
||||
httpServer = http.createServer((_req, res) => res.end('ok'));
|
||||
httpServer.on('connection', () => {
|
||||
httpConnections++;
|
||||
});
|
||||
await new Promise<void>((done) => httpServer.listen(0, '127.0.0.1', done));
|
||||
httpPort = (httpServer.address() as net.AddressInfo).port;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((done) => listener.close(() => done()));
|
||||
await new Promise<void>((done) => httpServer.close(() => done()));
|
||||
});
|
||||
|
||||
it('a host that resolves to a private address fails closed before any socket is opened (production fetch path)', async () => {
|
||||
listenerConnections = 0;
|
||||
const rebinding = vi.fn(async () => ['127.0.0.1']);
|
||||
setClientMetadataDepsForTests({ resolve: rebinding, now: () => clock });
|
||||
const err = await resolveClientMetadata(`https://rebind.example.test:${listenerPort}/client.json`).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(ClientMetadataError);
|
||||
expect(err).toMatchObject({ code: 'invalid_client', message: 'metadata document host resolves to a non-public address' });
|
||||
expect(rebinding).toHaveBeenCalledTimes(1);
|
||||
expect(rebinding).toHaveBeenCalledWith('rebind.example.test');
|
||||
expect(listenerConnections).toBe(0);
|
||||
});
|
||||
|
||||
it('a host that does not resolve fails closed (production fetch path)', async () => {
|
||||
setClientMetadataDepsForTests({ resolve: async () => { throw new Error('ENOTFOUND'); }, now: () => clock });
|
||||
await expect(resolveClientMetadata(`https://gone.example.test:${listenerPort}/client.json`)).rejects.toMatchObject({
|
||||
code: 'invalid_client',
|
||||
message: 'metadata document host does not resolve',
|
||||
});
|
||||
});
|
||||
|
||||
it('the socket connects to exactly the address the gate handed out — the host is never resolved a second time', async () => {
|
||||
// rebind.example.test does not exist in real DNS: reaching the listener proves the socket used the gate's answer.
|
||||
httpConnections = 0;
|
||||
const gateResolve = vi.fn(async () => ['127.0.0.1']);
|
||||
const dispatcher = new Agent({ connect: { lookup: createAddressGateLookup(gateResolve, () => true) } });
|
||||
try {
|
||||
const res = await undiciFetch(`http://rebind.example.test:${httpPort}/client.json`, { dispatcher });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe('ok');
|
||||
} finally {
|
||||
await dispatcher.close();
|
||||
}
|
||||
expect(gateResolve).toHaveBeenCalledTimes(1);
|
||||
expect(httpConnections).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveClientMetadata — fetch + validation', () => {
|
||||
it('fetches the document without following redirects, with a timeout, and returns the metadata', async () => {
|
||||
fetchImpl.mockResolvedValue(jsonResponse(goodDoc()));
|
||||
@@ -103,23 +325,20 @@ describe('resolveClientMetadata — fetch + validation', () => {
|
||||
redirectUris: ['http://127.0.0.1:3000/callback', 'https://app.example.test/callback'],
|
||||
applicationType: 'native',
|
||||
});
|
||||
expect(resolve).toHaveBeenCalledWith('app.example.test');
|
||||
const [url, init] = fetchImpl.mock.calls[0];
|
||||
expect(String(url)).toBe(CLIENT_ID);
|
||||
expect(init?.redirect).toBe('error');
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('never fetches when the host resolves to a non-public address (SSRF gate)', async () => {
|
||||
resolve.mockResolvedValue(['203.0.113.10', '10.0.0.5']);
|
||||
await expect(resolveClientMetadata(CLIENT_ID)).rejects.toMatchObject({ code: 'invalid_client' });
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never fetches when the host does not resolve', async () => {
|
||||
resolve.mockRejectedValue(new Error('ENOTFOUND'));
|
||||
await expect(resolveClientMetadata(CLIENT_ID)).rejects.toMatchObject({ code: 'invalid_client' });
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
it('refuses a host with any non-public address through the production fetch path (SSRF gate)', async () => {
|
||||
resolve.mockResolvedValue(['1.1.1.1', '10.0.0.5']);
|
||||
setClientMetadataDepsForTests({ resolve, now: () => clock });
|
||||
await expect(resolveClientMetadata(CLIENT_ID)).rejects.toMatchObject({
|
||||
code: 'invalid_client',
|
||||
message: 'metadata document host resolves to a non-public address',
|
||||
});
|
||||
expect(resolve).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects a client_id that is not a metadata URL without any I/O', async () => {
|
||||
|
||||
@@ -48,7 +48,7 @@ afterAll(async () => {
|
||||
|
||||
beforeEach(() => {
|
||||
fetchImpl.mockReset();
|
||||
setClientMetadataDepsForTests({ fetchImpl, resolve: async () => ['203.0.113.10'] });
|
||||
setClientMetadataDepsForTests({ fetchImpl });
|
||||
});
|
||||
|
||||
function authorize(params: Record<string, string>) {
|
||||
@@ -98,7 +98,7 @@ describe('/oauth/authorize with a metadata-document client_id', () => {
|
||||
expect(res.status).toBe(400);
|
||||
expect(((await res.json()) as { error: string }).error).toBe('invalid_client');
|
||||
|
||||
setClientMetadataDepsForTests({ fetchImpl, resolve: async () => ['203.0.113.10'] }); // clears the negative cache
|
||||
setClientMetadataDepsForTests({ fetchImpl }); // clears the negative cache
|
||||
fetchImpl.mockResolvedValueOnce(doc(['http://127.0.0.1:3000/callback'], 'https://app.example.test/oauth/OTHER.json'));
|
||||
res = await authorize({ client_id: CLIENT_ID, redirect_uri: 'http://127.0.0.1:3000/callback' });
|
||||
expect(res.status).toBe(400);
|
||||
@@ -112,11 +112,14 @@ describe('/oauth/authorize with a metadata-document client_id', () => {
|
||||
expect(((await res.json()) as { error: string }).error).toBe('invalid_client_metadata');
|
||||
});
|
||||
|
||||
it('refuses to fetch a client_id whose host resolves privately (SSRF gate) — no request leaves the server', async () => {
|
||||
setClientMetadataDepsForTests({ fetchImpl, resolve: async () => ['10.0.0.8'] });
|
||||
it('refuses a client_id whose host resolves privately (SSRF gate on the production fetch path)', async () => {
|
||||
const resolve = vi.fn(async () => ['10.0.0.8']);
|
||||
setClientMetadataDepsForTests({ resolve });
|
||||
const res = await authorize({ client_id: CLIENT_ID, redirect_uri: 'http://127.0.0.1:3000/callback' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
expect(((await res.json()) as { error: string }).error).toBe('invalid_client');
|
||||
expect(resolve).toHaveBeenCalledTimes(1);
|
||||
expect(resolve).toHaveBeenCalledWith('app.example.test');
|
||||
});
|
||||
|
||||
it('still accepts a dynamically registered (HMAC) client_id next to CIMD', async () => {
|
||||
|
||||
@@ -22,13 +22,16 @@
|
||||
* fetch is therefore: https only, host must not be loopback / an IP literal
|
||||
* / resolve to any non-public address, no redirect following, 5 s budget,
|
||||
* 16 KB body cap, JSON media type required, and nothing from the response
|
||||
* is ever echoed to the caller beyond a generic error code.
|
||||
* is ever echoed to the caller beyond a generic error code. The address
|
||||
* check runs inside the socket's own DNS lookup (createAddressGateLookup),
|
||||
* never as a separate pre-flight resolution.
|
||||
* - Results are cached in-process (positive: per Cache-Control max-age,
|
||||
* clamped to 10–60 min so the /oauth/callback re-check within the 10-min
|
||||
* state window hits the cache; negative: 60 s) with a bounded entry count.
|
||||
*/
|
||||
import { promises as dns } from 'node:dns';
|
||||
import net from 'node:net';
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
import { MAX_CLIENT_NAME_LENGTH, MAX_REDIRECT_URIS_PER_CLIENT, MAX_REDIRECT_URI_LENGTH } from './oauth-limits.js';
|
||||
|
||||
export interface ClientMetadata {
|
||||
@@ -58,16 +61,43 @@ const POSITIVE_TTL_MAX_MS = 60 * 60 * 1000;
|
||||
const NEGATIVE_TTL_MS = 60 * 1000;
|
||||
const CACHE_MAX_ENTRIES = 256;
|
||||
|
||||
/** Injectable I/O — production uses global fetch + dns.lookup; tests substitute both. */
|
||||
/**
|
||||
* The part of a fetch response the metadata fetch reads. Structural so that both
|
||||
* undici's Response (production) and the global one (tests) satisfy it.
|
||||
*/
|
||||
export interface MetadataResponse {
|
||||
status: number;
|
||||
headers: { get(name: string): string | null };
|
||||
body: {
|
||||
getReader(): { read(): Promise<{ done: boolean; value?: Uint8Array }>; cancel(): Promise<void> };
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface MetadataRequestInit {
|
||||
method: 'GET';
|
||||
headers: Record<string, string>;
|
||||
redirect: 'error';
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
/** Injectable I/O — production uses undici fetch through the address gate + dns.lookup; tests substitute either. */
|
||||
export interface CimdDeps {
|
||||
fetchImpl: typeof fetch;
|
||||
/** Resolves a hostname to every address it maps to (A + AAAA). */
|
||||
/** The whole network leg. A substitute bypasses the address gate, which only sees `resolve`. */
|
||||
fetchImpl: (url: URL, init: MetadataRequestInit) => Promise<MetadataResponse>;
|
||||
/** Resolves a hostname to every address it maps to (A + AAAA); feeds the address gate. */
|
||||
resolve: (hostname: string) => Promise<string[]>;
|
||||
now: () => number;
|
||||
}
|
||||
|
||||
// Node never calls `lookup` for an IP-literal host, so isClientIdMetadataUrl
|
||||
// refusing every literal is part of this gate. `activeDeps` is read per
|
||||
// connection so the test seam reaches the gate.
|
||||
const metadataDispatcher = new Agent({
|
||||
connect: { lookup: createAddressGateLookup((hostname) => activeDeps.resolve(hostname)) },
|
||||
});
|
||||
|
||||
const productionDeps: CimdDeps = {
|
||||
fetchImpl: (input, init) => fetch(input, init),
|
||||
fetchImpl: (url, init) => undiciFetch(url, { ...init, dispatcher: metadataDispatcher }),
|
||||
resolve: async (hostname) => (await dns.lookup(hostname, { all: true })).map((a) => a.address),
|
||||
now: () => Date.now(),
|
||||
};
|
||||
@@ -106,33 +136,125 @@ export function isClientIdMetadataUrl(clientId: string | undefined | null): bool
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Non-public address ranges the metadata fetch must never reach (RFC 1918/4193/3927/6598, loopback, multicast, unspecified). */
|
||||
/**
|
||||
* Address space the metadata fetch must never connect to. IPv4: every block of
|
||||
* the IANA IPv4 Special-Purpose Address Registry plus multicast and 240/4 (which
|
||||
* holds the limited broadcast). IPv6: only global unicast 2000::/3 is allowed at
|
||||
* all — one rule that refuses loopback, unspecified, IPv4-mapped/-compatible,
|
||||
* NAT64, discard, unique-local, link-/site-local and multicast — and inside it
|
||||
* the IANA special-purpose blocks are refused.
|
||||
*/
|
||||
const NON_PUBLIC_IPV4: ReadonlyArray<readonly [string, number]> = [
|
||||
['0.0.0.0', 8], // "this network"
|
||||
['10.0.0.0', 8], // private use
|
||||
['100.64.0.0', 10], // shared address space (CGNAT)
|
||||
['127.0.0.0', 8], // loopback
|
||||
['169.254.0.0', 16], // link-local, holds the cloud metadata endpoints
|
||||
['172.16.0.0', 12], // private use
|
||||
['192.0.0.0', 24], // IETF protocol assignments
|
||||
['192.0.2.0', 24], // documentation (TEST-NET-1)
|
||||
['192.31.196.0', 24], // AS112-v4
|
||||
['192.52.193.0', 24], // AMT
|
||||
['192.88.99.0', 24], // deprecated 6to4 relay anycast
|
||||
['192.168.0.0', 16], // private use
|
||||
['192.175.48.0', 24], // direct delegation AS112
|
||||
['198.18.0.0', 15], // benchmarking
|
||||
['198.51.100.0', 24], // documentation (TEST-NET-2)
|
||||
['203.0.113.0', 24], // documentation (TEST-NET-3)
|
||||
['224.0.0.0', 4], // multicast
|
||||
['240.0.0.0', 4], // reserved, limited broadcast
|
||||
];
|
||||
const NON_PUBLIC_IPV6: ReadonlyArray<readonly [string, number]> = [
|
||||
// everything outside global unicast 2000::/3
|
||||
['::', 3],
|
||||
['4000::', 2],
|
||||
['8000::', 1],
|
||||
// special-purpose blocks inside it
|
||||
['2001::', 23], // IETF protocol assignments (Teredo, benchmarking, ORCHID, AMT, AS112, …)
|
||||
['2001:db8::', 32], // documentation
|
||||
['2002::', 16], // 6to4
|
||||
['2620:4f:8000::', 48], // direct delegation AS112
|
||||
['3fff::', 20], // documentation
|
||||
];
|
||||
|
||||
// Two lists on purpose: net.BlockList matches an IPv4 address against IPv6
|
||||
// rules through its mapped form (::/3 would refuse 8.8.8.8), so each address
|
||||
// is checked only against the list of its own family.
|
||||
function blockListOf(ranges: ReadonlyArray<readonly [string, number]>, type: 'ipv4' | 'ipv6'): net.BlockList {
|
||||
const list = new net.BlockList();
|
||||
for (const [network, prefix] of ranges) list.addSubnet(network, prefix, type);
|
||||
return list;
|
||||
}
|
||||
const NON_PUBLIC_V4 = blockListOf(NON_PUBLIC_IPV4, 'ipv4');
|
||||
const NON_PUBLIC_V6 = blockListOf(NON_PUBLIC_IPV6, 'ipv6');
|
||||
|
||||
/** True only for an address the metadata fetch may connect to. Anything unparseable is non-public. */
|
||||
export function isPublicAddress(ip: string): boolean {
|
||||
const family = net.isIP(ip);
|
||||
if (family === 4) return isPublicIpv4(ip);
|
||||
if (family === 6) {
|
||||
const lower = ip.toLowerCase();
|
||||
// IPv4-mapped (::ffff:a.b.c.d) — judge the embedded IPv4.
|
||||
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
|
||||
if (mapped) return isPublicIpv4(mapped[1]);
|
||||
if (lower === '::' || lower === '::1') return false;
|
||||
if (lower.startsWith('fe8') || lower.startsWith('fe9') || lower.startsWith('fea') || lower.startsWith('feb')) return false; // link-local fe80::/10
|
||||
if (lower.startsWith('fc') || lower.startsWith('fd')) return false; // unique-local fc00::/7
|
||||
if (lower.startsWith('ff')) return false; // multicast
|
||||
return true;
|
||||
// A zone id (fe80::1%eth0) passes net.isIP, but BlockList matches no rule for it.
|
||||
if (ip.includes('%')) return false;
|
||||
switch (net.isIP(ip)) {
|
||||
case 4:
|
||||
return !NON_PUBLIC_V4.check(ip, 'ipv4');
|
||||
case 6:
|
||||
return !NON_PUBLIC_V6.check(ip, 'ipv6');
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPublicIpv4(ip: string): boolean {
|
||||
const [a, b] = ip.split('.').map(Number);
|
||||
if (a === 0 || a === 10 || a === 127) return false;
|
||||
if (a === 100 && b >= 64 && b <= 127) return false; // CGNAT 100.64/10
|
||||
if (a === 169 && b === 254) return false; // link-local
|
||||
if (a === 172 && b >= 16 && b <= 31) return false;
|
||||
if (a === 192 && b === 168) return false;
|
||||
if (a >= 224) return false; // multicast + reserved
|
||||
return true;
|
||||
/** Why the address gate refused a connection; travels as the `cause` of the failed fetch. */
|
||||
export class AddressGateError extends Error {
|
||||
constructor(
|
||||
readonly reason: 'unresolvable' | 'non-public',
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(
|
||||
reason === 'unresolvable'
|
||||
? 'metadata document host does not resolve'
|
||||
: 'metadata document host resolves to a non-public address',
|
||||
options,
|
||||
);
|
||||
this.name = 'AddressGateError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The SSRF gate: a `lookup` for the metadata fetch's sockets. It resolves the
|
||||
* host exactly once per connection and hands the socket only addresses that
|
||||
* passed `isAllowed`, so the address checked is the address connected to — a
|
||||
* separate pre-flight resolution would let a DNS-rebinding host answer the
|
||||
* check and the connect differently. Every resolved address must pass, not
|
||||
* only the one used. `isAllowed` is widened only by tests that need to reach a
|
||||
* loopback listener.
|
||||
*/
|
||||
export function createAddressGateLookup(
|
||||
resolve: (hostname: string) => Promise<string[]>,
|
||||
isAllowed: (ip: string) => boolean = isPublicAddress,
|
||||
): net.LookupFunction {
|
||||
return (hostname, options, callback) => {
|
||||
resolve(hostname).then(
|
||||
(addresses) => {
|
||||
if (addresses.length === 0) return callback(new AddressGateError('unresolvable'), '');
|
||||
if (!addresses.every((ip) => isAllowed(ip))) return callback(new AddressGateError('non-public'), '');
|
||||
const family = options.family === 4 || options.family === 'IPv4' ? 4 : options.family === 6 || options.family === 'IPv6' ? 6 : 0;
|
||||
const usable = addresses
|
||||
.map((address) => ({ address, family: net.isIP(address) }))
|
||||
.filter((a) => family === 0 || a.family === family);
|
||||
if (usable.length === 0) return callback(new AddressGateError('unresolvable'), '');
|
||||
if (options.all) callback(null, usable);
|
||||
else callback(null, usable[0].address, usable[0].family);
|
||||
},
|
||||
(err: unknown) => callback(new AddressGateError('unresolvable', { cause: err }), ''),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function addressGateErrorOf(err: unknown): AddressGateError | undefined {
|
||||
let e: unknown = err;
|
||||
for (let depth = 0; e instanceof Error && depth < 5; depth++, e = e.cause) {
|
||||
if (e instanceof AddressGateError) return e;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
@@ -187,24 +309,19 @@ async function fetchAndValidate(clientId: string): Promise<{ value: ClientMetada
|
||||
}
|
||||
const url = new URL(clientId);
|
||||
|
||||
// SSRF gate: every address the host resolves to must be public. A host that
|
||||
// resolves to nothing is refused as well (nothing to fetch safely).
|
||||
let addresses: string[];
|
||||
let res: MetadataResponse;
|
||||
try {
|
||||
addresses = await activeDeps.resolve(url.hostname);
|
||||
} catch {
|
||||
throw new ClientMetadataError('invalid_client', 'metadata document host does not resolve');
|
||||
res = await activeDeps.fetchImpl(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(CIMD_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
const refused = addressGateErrorOf(err);
|
||||
if (refused) throw new ClientMetadataError('invalid_client', refused.message);
|
||||
throw err;
|
||||
}
|
||||
if (addresses.length === 0 || !addresses.every(isPublicAddress)) {
|
||||
throw new ClientMetadataError('invalid_client', 'metadata document host resolves to a non-public address');
|
||||
}
|
||||
|
||||
const res = await activeDeps.fetchImpl(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(CIMD_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
throw new ClientMetadataError('invalid_client', `metadata document responded ${res.status}`);
|
||||
}
|
||||
@@ -223,7 +340,7 @@ async function fetchAndValidate(clientId: string): Promise<{ value: ClientMetada
|
||||
return { value: validateDocument(clientId, doc), ttlMs: ttlFromCacheControl(res.headers.get('cache-control')) };
|
||||
}
|
||||
|
||||
async function readCapped(res: Response, maxBytes: number): Promise<string> {
|
||||
async function readCapped(res: MetadataResponse, maxBytes: number): Promise<string> {
|
||||
const declared = Number(res.headers.get('content-length') ?? '0');
|
||||
if (declared > maxBytes) throw new ClientMetadataError('invalid_client_metadata', 'metadata document exceeds the size limit');
|
||||
if (!res.body) return '';
|
||||
@@ -232,7 +349,7 @@ async function readCapped(res: Response, maxBytes: number): Promise<string> {
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (done || !value) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel().catch(() => {});
|
||||
|
||||
@@ -450,6 +450,8 @@ export interface AppAnalyticsFailureCode {
|
||||
export interface AppAnalyticsResponse {
|
||||
success: boolean;
|
||||
appName: string;
|
||||
/** Install channel (AppInstallSources): "ime" or "realmjoin". */
|
||||
source: string;
|
||||
appType: string;
|
||||
windowDays: number;
|
||||
collisionExcluded: number;
|
||||
@@ -559,6 +561,8 @@ export interface AppInstallerPhaseCount {
|
||||
/** One app's aggregate across its install rows in the window. */
|
||||
export interface AppMetricsAppGroup {
|
||||
appName: string;
|
||||
/** Install channel (AppInstallSources): "ime" or "realmjoin". */
|
||||
source: string;
|
||||
totalInstalls: number;
|
||||
succeeded: number;
|
||||
skipped: number;
|
||||
@@ -661,6 +665,8 @@ export interface AppVersionBreakdownItem {
|
||||
export interface AppVersionRegressionAlert {
|
||||
tenantId: string;
|
||||
appName: string;
|
||||
/** Install channel (AppInstallSources): "ime" or "realmjoin". */
|
||||
source: string;
|
||||
/** The regressed (newer) version — episode key together with the app. */
|
||||
currentVersion: string;
|
||||
/** The comparison version: latest first-seen strictly before the current version's first-seen. */
|
||||
@@ -681,6 +687,8 @@ export interface AppVersionRegressionAlert {
|
||||
/** One app row of the apps list (failed desc, then failure rate, then name). */
|
||||
export interface AppsListItem {
|
||||
appName: string;
|
||||
/** Install channel (AppInstallSources): "ime" or "realmjoin". */
|
||||
source: string;
|
||||
appType: string;
|
||||
totalInstalls: number;
|
||||
succeeded: number;
|
||||
@@ -3367,7 +3375,7 @@ export interface RegisterSessionRequest {
|
||||
registration: SessionRegistration;
|
||||
}
|
||||
|
||||
/** Response from session registration. Also the body of the 409/410 refusals — the agent reads ErrorCode from it, so the error envelope is NOT used on this route. */
|
||||
/** Response from session registration. Also the body of the 403 (session owner mismatch) and 409/410 refusals — the agent reads ErrorCode from it, so the error envelope is NOT used on this route. */
|
||||
export interface RegisterSessionResponse {
|
||||
sessionId: string;
|
||||
success: boolean;
|
||||
@@ -4732,7 +4740,7 @@ export interface TenantConfiguration {
|
||||
companyName?: string | null;
|
||||
/** When this tenant was first onboarded (derived from earliest TenantAdmin AddedDate). Used for feedback eligibility checks (tenant must be old enough before prompting). Backfilled by the maintenance job for existing tenants; set to UtcNow for new tenants. */
|
||||
onboardedAt?: string | null;
|
||||
/** Client id of the Entra app registration this tenant is homed on. Drives which app mints Graph client-credential tokens and admin-consent URLs for the tenant, and which app the portal signs the tenant's users in with (via the auth/me "homedApp" field). Null = the legacy (pre-migration) app registration — the invariant for every tenant onboarded before the C4A8 move. Set to the primary client id at onboarding when the first login arrived via the primary app; flipped by a Global Admin after a tenant re-consents to the new app (GA-only field, see UpdateTenantConfigurationFunction). */
|
||||
/** Client id of the Entra app registration this tenant is homed on. Drives which app mints Graph client-credential tokens and admin-consent URLs for the tenant, and which app the portal signs the tenant's users in with (via the auth/me "homedApp" field). Null = the legacy (pre-migration) app registration — the invariant for every tenant onboarded before the C4A8 move. Set to the primary client id at onboarding when the first login arrived via the primary app; flipped by a Global Admin after a tenant re-consents to the new app. Written only by onboarding and the app-homing flow; never writable through the field patch (TenantConfigPatchService.BaseDeniedFields). */
|
||||
homedAppClientId?: string | null;
|
||||
/** Client id observed in the most recent portal login token's audience — pure observability for the app-reg migration (which app a tenant's users actually arrive through), never used for routing decisions. Written on change only. */
|
||||
lastAuthClientId?: string | null;
|
||||
@@ -4748,9 +4756,9 @@ export interface TenantConfiguration {
|
||||
mcpDisabled: boolean;
|
||||
/** Optional reason why MCP access was disabled. Shown to the caller in the 403 response. */
|
||||
mcpDisabledReason?: string | null;
|
||||
/** How many self-hosted MCP client registrations this tenant may hold. Null = the platform default (one); set by a Global Admin on request, 1-10. Global-Admin-only (UpdateTenantConfigurationFunction GA gate, TenantConfigPatchService.GaOnlyFields). */
|
||||
/** How many self-hosted MCP client registrations this tenant may hold. Null = the platform default (one); set by a Global Admin on request, 1-10. Global-Admin-only (TenantConfigPatchService.GaOnlyFields). */
|
||||
mcpClientRegistrationLimit?: number | null;
|
||||
/** Optional per-tenant override for the device (agent/cert) API rate limit. If null, the effective limit is the global AdminConfiguration.GlobalRateLimitRequestsPerMinute. If set, this value takes precedence. Global-Admin-only (see UpdateTenantConfigurationFunction GA-gate). */
|
||||
/** Optional per-tenant override for the device (agent/cert) API rate limit. If null, the effective limit is the global AdminConfiguration.GlobalRateLimitRequestsPerMinute. If set, this value takes precedence. Global-Admin-only (TenantConfigPatchService.GaOnlyFields). */
|
||||
customRateLimitRequestsPerMinute?: number | null;
|
||||
/** Optional per-tenant override for the user (portal/JWT) API rate limit applied to standard users (Tenant Admins, Operators, Viewers). If null, the effective limit is the global AdminConfiguration.UserRateLimitRequestsPerMinute. Global-Admin-only. Note: Global Admins are rate-limited by the global GlobalAdminRateLimitRequestsPerMinute (cross-tenant), so this override does not apply to them. */
|
||||
customUserRateLimitRequestsPerMinute?: number | null;
|
||||
@@ -5197,6 +5205,8 @@ export interface TopCve {
|
||||
/** An app with a high failure rate. */
|
||||
export interface TopFailingApp {
|
||||
appName: string;
|
||||
/** Install channel (AppInstallSources): "ime" or "realmjoin". */
|
||||
source: string;
|
||||
failCount: number;
|
||||
totalCount: number;
|
||||
successRate: number;
|
||||
@@ -5329,13 +5339,6 @@ export interface UpdateTenantAppHomingResponse {
|
||||
probe: AppHomingProbeWire;
|
||||
}
|
||||
|
||||
/** Response of PUT config/{tenantId}: acknowledgement plus the stored tenant configuration. */
|
||||
export interface UpdateTenantConfigurationResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
config: TenantConfiguration;
|
||||
}
|
||||
|
||||
/** Body of PATCH global/tenant-groups/{groupId} — a rename. */
|
||||
export interface UpdateTenantGroupRequest {
|
||||
/** New display name. */
|
||||
|
||||
@@ -222,6 +222,7 @@ export const API_ERROR_CODES = [
|
||||
"QuotaExceeded",
|
||||
"PayloadTooLarge",
|
||||
"TelemetryItemsRejected",
|
||||
"SessionOwnerMismatch",
|
||||
"HomeTenantUnresolved",
|
||||
"TrialAlreadyConsumed",
|
||||
"AlreadyPro",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user