mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
feat(backend,agent,web): hosted diagnostics destination + cascade-delete cleanup
Adds an opt-in second destination for agent diagnostics uploads:
- CustomerSas (default, unchanged): tenant's own SAS-backed container
- Hosted (new, opt-in via explicit admin click): backend's storage account
under {tenantId}/AgentDiagnostics-...zip, with per-upload 15-min
blob-scoped Write+Create SAS minted via the Function App's MI
Closes the long-standing gap where Session.DiagnosticsBlobName was captured
into the deletion-manifest but the blob was never actually cleaned up by
the cascade. Hosted blobs are always deleted (we own the storage); customer
blobs are deleted only when the SAS includes the 'd' permission, otherwise
logged + skipped.
Existing tenants change nothing: the new TenantConfiguration.DiagnosticsUploadDestination
field defaults to CustomerSas, no silent flip. Hosted requires an explicit
admin click + Save with a clearly marked "data leaves your tenant" disclosure.
Tests: Backend 1844, V2 1076, V1 538, vitest 380 (+78 new across all suites).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+71
@@ -0,0 +1,71 @@
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Monitoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Phase 3 of the hosted-diagnostics work: V1 agent must build the blob PUT URL
|
||||
/// destination-aware so a Hosted SAS (already blob-scoped at
|
||||
/// <c>{tenantId}/{filename}</c>) is used as-is, while a CustomerSas (container-scoped)
|
||||
/// continues to get the blob name appended. Mirrors the V2 tests in
|
||||
/// <c>AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime.DiagnosticsPackageServiceTests</c>.
|
||||
/// V1 stays on this code path until phase-out per <c>feedback_codex_findings_v2_only</c>.
|
||||
/// </summary>
|
||||
public class DiagnosticsPackageServiceUrlBuilderTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Hosted")]
|
||||
[InlineData("hosted")]
|
||||
[InlineData("HOSTED")]
|
||||
public void BuildBlobUploadUrl_HostedDestination_ReturnsSasUnchanged(string destination)
|
||||
{
|
||||
const string hostedSas = "https://account.blob.core.windows.net/diagnostics/11111111-1111-1111-1111-111111111111/AgentDiagnostics-x.zip?sig=abc";
|
||||
var result = DiagnosticsPackageService.BuildBlobUploadUrl(hostedSas, "AgentDiagnostics-x.zip", destination);
|
||||
Assert.Equal(hostedSas, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBlobUploadUrl_CustomerSas_AppendsBlobNameBeforeQuery()
|
||||
{
|
||||
const string containerSas = "https://customer.blob.core.windows.net/diagnostics?sv=2024-10-04&sig=xyz";
|
||||
var result = DiagnosticsPackageService.BuildBlobUploadUrl(containerSas, "AgentDiagnostics-x.zip", "CustomerSas");
|
||||
Assert.Equal(
|
||||
"https://customer.blob.core.windows.net/diagnostics/AgentDiagnostics-x.zip?sv=2024-10-04&sig=xyz",
|
||||
result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBlobUploadUrl_NullDestination_AppendsBlobName_LegacyBackendCompat()
|
||||
{
|
||||
// Older backends without the Destination field return null; agent must
|
||||
// preserve the historical container-SAS append behaviour.
|
||||
const string containerSas = "https://customer.blob.core.windows.net/diag?sig=abc";
|
||||
var result = DiagnosticsPackageService.BuildBlobUploadUrl(containerSas, "diag.zip", null);
|
||||
Assert.Equal("https://customer.blob.core.windows.net/diag/diag.zip?sig=abc", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBlobUploadUrl_UnknownDestination_FallsBackToCustomerSasBehaviour()
|
||||
{
|
||||
const string containerSas = "https://customer.blob.core.windows.net/diag?sig=abc";
|
||||
var result = DiagnosticsPackageService.BuildBlobUploadUrl(containerSas, "diag.zip", "Vendor");
|
||||
Assert.EndsWith("/diag.zip?sig=abc", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBlobUploadUrl_SasWithoutQueryString_AppendsBlobName()
|
||||
{
|
||||
const string noQuery = "https://customer.blob.core.windows.net/diag";
|
||||
var result = DiagnosticsPackageService.BuildBlobUploadUrl(noQuery, "diag.zip", "CustomerSas");
|
||||
Assert.Equal("https://customer.blob.core.windows.net/diag/diag.zip", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBlobUploadUrl_HostedWithoutQueryString_StillReturnsUnchanged()
|
||||
{
|
||||
const string hostedNoQuery = "https://account.blob.core.windows.net/diagnostics/tenant/x.zip";
|
||||
var result = DiagnosticsPackageService.BuildBlobUploadUrl(hostedNoQuery, "x.zip", "Hosted");
|
||||
Assert.Equal(hostedNoQuery, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -265,6 +265,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion
|
||||
};
|
||||
if (uploadResult?.BlobName != null)
|
||||
resultData["blobName"] = uploadResult.BlobName;
|
||||
// Backend stamps Session.DiagnosticsBlobDestination from this so the download
|
||||
// path can route correctly even after a future tenant destination switch.
|
||||
// Omitted on legacy backend responses (Destination=null) — backend then falls
|
||||
// back to treating the session as CustomerSas, which matches today's behaviour.
|
||||
if (uploadResult?.Destination != null)
|
||||
resultData["destination"] = uploadResult.Destination;
|
||||
if (uploadResult?.SasUrlPrefix != null)
|
||||
resultData["sasUrl"] = uploadResult.SasUrlPrefix;
|
||||
if (uploadResult?.ErrorCode != null)
|
||||
|
||||
+75
-20
@@ -26,6 +26,17 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Runtime
|
||||
/// <summary>Blob name on success, null on failure or skip.</summary>
|
||||
public string BlobName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Where the blob was uploaded: <c>"CustomerSas"</c> or <c>"Hosted"</c>. Verbatim
|
||||
/// from the backend's upload-url response. Surfaced into the
|
||||
/// <c>diagnostics_uploaded</c> event so the backend can stamp it on the
|
||||
/// <c>Sessions</c> row alongside the blob name — the download path then knows
|
||||
/// which storage to fetch from, even if the tenant later switches destinations.
|
||||
/// Null when the upload was skipped or against a legacy backend that doesn't
|
||||
/// return the field.
|
||||
/// </summary>
|
||||
public string Destination { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// First part of the SAS URL (up to and including the first query param) with the token truncated,
|
||||
/// so the event shows the target container without leaking the full signature.
|
||||
@@ -185,15 +196,39 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Runtime
|
||||
|
||||
var sasUrlPrefix = BuildSasUrlPrefix(uploadUrlResponse.UploadUrl);
|
||||
|
||||
// Resolve the final PUT target: Hosted SAS is already blob-scoped at
|
||||
// {tenantId}/{filename} and must be used as-is; CustomerSas (or a null
|
||||
// Destination from an older backend) is container-scoped and we append
|
||||
// the blob filename ourselves.
|
||||
var blobUploadUrl = BuildBlobUploadUrl(uploadUrlResponse.UploadUrl, zipFileName, uploadUrlResponse.Destination);
|
||||
|
||||
// BlobName for the Sessions row: prefer the backend-supplied path because
|
||||
// it encodes destination-specific layout (e.g. tenant-prefix for Hosted).
|
||||
// Fall back to the local filename so the agent still works against older
|
||||
// backends that don't return BlobName.
|
||||
var persistedBlobName = !string.IsNullOrEmpty(uploadUrlResponse.BlobName)
|
||||
? uploadUrlResponse.BlobName
|
||||
: zipFileName;
|
||||
|
||||
// Upload to Blob Storage using the freshly obtained URL
|
||||
var (uploaded, uploadErrorCode) = await UploadToBlobStorageAsync(zipFileName, zipBytes, uploadUrlResponse.UploadUrl);
|
||||
var (uploaded, uploadErrorCode) = await UploadToBlobStorageAsync(zipFileName, zipBytes, blobUploadUrl);
|
||||
if (uploaded)
|
||||
{
|
||||
_logger.Info($"Diagnostics package uploaded successfully: {zipFileName}");
|
||||
return new DiagnosticsUploadResult { BlobName = zipFileName, SasUrlPrefix = sasUrlPrefix };
|
||||
_logger.Info($"Diagnostics package uploaded successfully: {persistedBlobName}");
|
||||
return new DiagnosticsUploadResult
|
||||
{
|
||||
BlobName = persistedBlobName,
|
||||
Destination = uploadUrlResponse.Destination,
|
||||
SasUrlPrefix = sasUrlPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
return new DiagnosticsUploadResult { SasUrlPrefix = sasUrlPrefix, ErrorCode = uploadErrorCode };
|
||||
return new DiagnosticsUploadResult
|
||||
{
|
||||
Destination = uploadUrlResponse.Destination,
|
||||
SasUrlPrefix = sasUrlPrefix,
|
||||
ErrorCode = uploadErrorCode,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -293,27 +328,47 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Runtime
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads the ZIP bytes to Azure Blob Storage via PUT with the provided container SAS URL.
|
||||
/// Container SAS URL format: https://account.blob.core.windows.net/container?sv=...&sig=...
|
||||
/// Blob URL is constructed by inserting the blob name before the query string.
|
||||
/// Returns (success, errorCode) — errorCode is null on success, otherwise the last HTTP error.
|
||||
/// Resolves the final blob PUT URL based on the destination advertised by the
|
||||
/// backend. Pure helper, internal-static so the V1 test suite can pin both
|
||||
/// branches without exercising any HTTP transport:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Hosted</b> (or any case-insensitive match): the SAS is already
|
||||
/// blob-scoped at <c>{tenantId}/{filename}</c> and must be used as-is.
|
||||
/// Appending the local filename would produce a double-name URL.</item>
|
||||
/// <item><b>CustomerSas</b> / null / unknown (legacy backend without the
|
||||
/// field): the SAS is container-scoped; insert the blob filename
|
||||
/// before the query string. Preserves prior behaviour.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private async Task<(bool success, string errorCode)> UploadToBlobStorageAsync(string blobName, byte[] data, string containerSasUrl)
|
||||
internal static string BuildBlobUploadUrl(string sasUrl, string blobFileName, string destination)
|
||||
{
|
||||
// Build blob URL: split at '?' to insert blob name
|
||||
var questionMarkIndex = containerSasUrl.IndexOf('?');
|
||||
string blobUrl;
|
||||
if (!string.IsNullOrEmpty(destination)
|
||||
&& string.Equals(destination, "Hosted", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return sasUrl;
|
||||
}
|
||||
|
||||
// CustomerSas or null/unknown — container-scoped SAS, insert blob name.
|
||||
var questionMarkIndex = sasUrl.IndexOf('?');
|
||||
if (questionMarkIndex >= 0)
|
||||
{
|
||||
var basePath = containerSasUrl.Substring(0, questionMarkIndex).TrimEnd('/');
|
||||
var queryString = containerSasUrl.Substring(questionMarkIndex);
|
||||
blobUrl = $"{basePath}/{blobName}{queryString}";
|
||||
}
|
||||
else
|
||||
{
|
||||
// No query string (unlikely for SAS but handle gracefully)
|
||||
blobUrl = $"{containerSasUrl.TrimEnd('/')}/{blobName}";
|
||||
var basePath = sasUrl.Substring(0, questionMarkIndex).TrimEnd('/');
|
||||
var queryString = sasUrl.Substring(questionMarkIndex);
|
||||
return $"{basePath}/{blobFileName}{queryString}";
|
||||
}
|
||||
return $"{sasUrl.TrimEnd('/')}/{blobFileName}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads the ZIP bytes to Azure Blob Storage via PUT to the pre-built
|
||||
/// <paramref name="blobUploadUrl"/>. The URL is destination-aware (built by
|
||||
/// <see cref="BuildBlobUploadUrl"/>) and ready to PUT without further mutation.
|
||||
/// <paramref name="blobName"/> is kept for log lines only.
|
||||
/// Returns (success, errorCode) — errorCode is null on success, otherwise the last HTTP error.
|
||||
/// </summary>
|
||||
private async Task<(bool success, string errorCode)> UploadToBlobStorageAsync(string blobName, byte[] data, string blobUploadUrl)
|
||||
{
|
||||
var blobUrl = blobUploadUrl;
|
||||
|
||||
const int maxRetries = 3;
|
||||
string lastErrorCode = null;
|
||||
|
||||
+69
@@ -226,6 +226,75 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
return archive.Entries.Select(e => e.FullName).ToArray();
|
||||
}
|
||||
|
||||
// ── BuildBlobUploadUrl — destination-aware URL construction ─────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Hosted")]
|
||||
[InlineData("hosted")]
|
||||
[InlineData("HOSTED")]
|
||||
public void BuildBlobUploadUrl_HostedDestination_ReturnsSasUnchanged(string destination)
|
||||
{
|
||||
// Hosted SAS is already blob-scoped at {tenantId}/{filename}; the agent must
|
||||
// PUT exactly to that URL. Appending the local filename would produce a
|
||||
// double-name URL like .../diagnostics/{tenantId}/{filename}/{filename}.
|
||||
const string hostedSas = "https://account.blob.core.windows.net/diagnostics/11111111-1111-1111-1111-111111111111/AgentDiagnostics-x.zip?sig=abc";
|
||||
var result = DiagnosticsPackageService.BuildBlobUploadUrl(hostedSas, "AgentDiagnostics-x.zip", destination);
|
||||
Assert.Equal(hostedSas, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBlobUploadUrl_CustomerSas_AppendsBlobNameBeforeQuery()
|
||||
{
|
||||
const string containerSas = "https://customer.blob.core.windows.net/diagnostics?sv=2024-10-04&sig=xyz";
|
||||
var result = DiagnosticsPackageService.BuildBlobUploadUrl(containerSas, "AgentDiagnostics-x.zip", "CustomerSas");
|
||||
Assert.Equal(
|
||||
"https://customer.blob.core.windows.net/diagnostics/AgentDiagnostics-x.zip?sv=2024-10-04&sig=xyz",
|
||||
result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBlobUploadUrl_NullDestination_AppendsBlobName_LegacyBackendCompat()
|
||||
{
|
||||
// An older backend without the Destination field returns null. The agent must
|
||||
// preserve the historical container-SAS append behaviour so CustomerSas
|
||||
// uploads continue to work seamlessly after a backend rollout.
|
||||
const string containerSas = "https://customer.blob.core.windows.net/diag?sig=abc";
|
||||
var result = DiagnosticsPackageService.BuildBlobUploadUrl(containerSas, "diag.zip", null);
|
||||
Assert.Equal("https://customer.blob.core.windows.net/diag/diag.zip?sig=abc", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBlobUploadUrl_UnknownDestination_FallsBackToCustomerSasBehaviour()
|
||||
{
|
||||
// Defence-in-depth: an unrecognised destination string (server bug, manual
|
||||
// edit) must NOT silently treat the SAS as blob-scoped — the agent would PUT
|
||||
// to a container URL and Azure would reject. Append-blob-name is the safe
|
||||
// default and matches CustomerSas behaviour.
|
||||
const string containerSas = "https://customer.blob.core.windows.net/diag?sig=abc";
|
||||
var result = DiagnosticsPackageService.BuildBlobUploadUrl(containerSas, "diag.zip", "Vendor");
|
||||
Assert.EndsWith("/diag.zip?sig=abc", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBlobUploadUrl_SasWithoutQueryString_AppendsBlobName()
|
||||
{
|
||||
// Defensive path — SAS without `?` is unlikely in practice but the helper
|
||||
// mirrors the V1 behaviour for it. Confirms the no-query branch is taken.
|
||||
const string noQuery = "https://customer.blob.core.windows.net/diag";
|
||||
var result = DiagnosticsPackageService.BuildBlobUploadUrl(noQuery, "diag.zip", "CustomerSas");
|
||||
Assert.Equal("https://customer.blob.core.windows.net/diag/diag.zip", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBlobUploadUrl_HostedWithoutQueryString_StillReturnsUnchanged()
|
||||
{
|
||||
// Hosted SAS would always have `?sig=...` but the helper's branch order
|
||||
// means Hosted wins before the query-string check.
|
||||
const string hostedNoQuery = "https://account.blob.core.windows.net/diagnostics/tenant/x.zip";
|
||||
var result = DiagnosticsPackageService.BuildBlobUploadUrl(hostedNoQuery, "x.zip", "Hosted");
|
||||
Assert.Equal(hostedNoQuery, result);
|
||||
}
|
||||
|
||||
private static string ReadEntry(byte[] zipBytes, string entryName)
|
||||
{
|
||||
using var ms = new MemoryStream(zipBytes);
|
||||
|
||||
+75
-20
@@ -25,6 +25,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// <summary>Blob name on success, null on failure or skip.</summary>
|
||||
public string BlobName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Where the blob was uploaded: <c>"CustomerSas"</c> or <c>"Hosted"</c>. Verbatim
|
||||
/// from the backend's upload-url response. Surfaced into the
|
||||
/// <c>diagnostics_uploaded</c> event so the backend can stamp it on the
|
||||
/// <c>Sessions</c> row alongside the blob name — the download path then knows
|
||||
/// which storage to fetch from, even if the tenant later switches destinations.
|
||||
/// Null when the upload was skipped or against a legacy backend that doesn't
|
||||
/// return the field.
|
||||
/// </summary>
|
||||
public string Destination { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// First part of the SAS URL (up to and including the first query param) with the token truncated,
|
||||
/// so the event shows the target container without leaking the full signature.
|
||||
@@ -266,15 +277,39 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
|
||||
var sasUrlPrefix = BuildSasUrlPrefix(uploadUrlResponse.UploadUrl);
|
||||
|
||||
// Resolve the final PUT target: Hosted SAS is already blob-scoped at
|
||||
// {tenantId}/{filename} and must be used as-is; CustomerSas (or a null
|
||||
// Destination from an older backend) is container-scoped and we append
|
||||
// the blob filename ourselves.
|
||||
var blobUploadUrl = BuildBlobUploadUrl(uploadUrlResponse.UploadUrl, zipFileName, uploadUrlResponse.Destination);
|
||||
|
||||
// BlobName for the Sessions row: prefer the backend-supplied path because
|
||||
// it encodes destination-specific layout (e.g. tenant-prefix for Hosted).
|
||||
// Fall back to the local filename so the agent still works against older
|
||||
// backends that don't return BlobName.
|
||||
var persistedBlobName = !string.IsNullOrEmpty(uploadUrlResponse.BlobName)
|
||||
? uploadUrlResponse.BlobName
|
||||
: zipFileName;
|
||||
|
||||
// Upload to Blob Storage using the freshly obtained URL
|
||||
var (uploaded, uploadErrorCode) = await UploadToBlobStorageAsync(zipFileName, zipBytes, uploadUrlResponse.UploadUrl);
|
||||
var (uploaded, uploadErrorCode) = await UploadToBlobStorageAsync(zipFileName, zipBytes, blobUploadUrl);
|
||||
if (uploaded)
|
||||
{
|
||||
_logger.Info($"Diagnostics package uploaded successfully: {zipFileName}");
|
||||
return new DiagnosticsUploadResult { BlobName = zipFileName, SasUrlPrefix = sasUrlPrefix };
|
||||
_logger.Info($"Diagnostics package uploaded successfully: {persistedBlobName}");
|
||||
return new DiagnosticsUploadResult
|
||||
{
|
||||
BlobName = persistedBlobName,
|
||||
Destination = uploadUrlResponse.Destination,
|
||||
SasUrlPrefix = sasUrlPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
return new DiagnosticsUploadResult { SasUrlPrefix = sasUrlPrefix, ErrorCode = uploadErrorCode };
|
||||
return new DiagnosticsUploadResult
|
||||
{
|
||||
Destination = uploadUrlResponse.Destination,
|
||||
SasUrlPrefix = sasUrlPrefix,
|
||||
ErrorCode = uploadErrorCode,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -552,27 +587,47 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads the ZIP bytes to Azure Blob Storage via PUT with the provided container SAS URL.
|
||||
/// Container SAS URL format: https://account.blob.core.windows.net/container?sv=...&sig=...
|
||||
/// Blob URL is constructed by inserting the blob name before the query string.
|
||||
/// Returns (success, errorCode) — errorCode is null on success, otherwise the last HTTP error.
|
||||
/// Resolves the final blob PUT URL based on the destination advertised by the
|
||||
/// backend. Pure helper, internal-static so the V2 test suite can pin both
|
||||
/// branches without exercising any HTTP transport:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Hosted</b> (or any case-insensitive match): the SAS is already
|
||||
/// blob-scoped at <c>{tenantId}/{filename}</c> and must be used as-is.
|
||||
/// Appending the local filename would produce a double-name URL.</item>
|
||||
/// <item><b>CustomerSas</b> / null / unknown (legacy backend without the
|
||||
/// field): the SAS is container-scoped; insert the blob filename
|
||||
/// before the query string. Preserves prior behaviour.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private async Task<(bool success, string errorCode)> UploadToBlobStorageAsync(string blobName, byte[] data, string containerSasUrl)
|
||||
internal static string BuildBlobUploadUrl(string sasUrl, string blobFileName, string destination)
|
||||
{
|
||||
// Build blob URL: split at '?' to insert blob name
|
||||
var questionMarkIndex = containerSasUrl.IndexOf('?');
|
||||
string blobUrl;
|
||||
if (!string.IsNullOrEmpty(destination)
|
||||
&& string.Equals(destination, "Hosted", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return sasUrl;
|
||||
}
|
||||
|
||||
// CustomerSas or null/unknown — container-scoped SAS, insert blob name.
|
||||
var questionMarkIndex = sasUrl.IndexOf('?');
|
||||
if (questionMarkIndex >= 0)
|
||||
{
|
||||
var basePath = containerSasUrl.Substring(0, questionMarkIndex).TrimEnd('/');
|
||||
var queryString = containerSasUrl.Substring(questionMarkIndex);
|
||||
blobUrl = $"{basePath}/{blobName}{queryString}";
|
||||
}
|
||||
else
|
||||
{
|
||||
// No query string (unlikely for SAS but handle gracefully)
|
||||
blobUrl = $"{containerSasUrl.TrimEnd('/')}/{blobName}";
|
||||
var basePath = sasUrl.Substring(0, questionMarkIndex).TrimEnd('/');
|
||||
var queryString = sasUrl.Substring(questionMarkIndex);
|
||||
return $"{basePath}/{blobFileName}{queryString}";
|
||||
}
|
||||
return $"{sasUrl.TrimEnd('/')}/{blobFileName}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads the ZIP bytes to Azure Blob Storage via PUT to the pre-built
|
||||
/// <paramref name="blobUploadUrl"/>. The URL is destination-aware (built by
|
||||
/// <see cref="BuildBlobUploadUrl"/>) and ready to PUT without further mutation.
|
||||
/// <paramref name="blobName"/> is kept for log lines only.
|
||||
/// Returns (success, errorCode) — errorCode is null on success, otherwise the last HTTP error.
|
||||
/// </summary>
|
||||
private async Task<(bool success, string errorCode)> UploadToBlobStorageAsync(string blobName, byte[] data, string blobUploadUrl)
|
||||
{
|
||||
var blobUrl = blobUploadUrl;
|
||||
|
||||
const int maxRetries = 3;
|
||||
string lastErrorCode = null;
|
||||
|
||||
@@ -614,6 +614,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "blobName", result.BlobName ?? string.Empty },
|
||||
// Tells the backend which storage the blob landed in so it can
|
||||
// stamp Session.DiagnosticsBlobDestination and route downloads
|
||||
// even after a future tenant destination switch. Empty when the
|
||||
// backend predates the field (agent falls through harmlessly).
|
||||
{ "destination", result.Destination ?? string.Empty },
|
||||
{ "sasUrlPrefix", result.SasUrlPrefix ?? string.Empty },
|
||||
},
|
||||
ImmediateUpload = true,
|
||||
@@ -636,6 +641,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
{
|
||||
{ "errorCode", errorCode },
|
||||
{ "blobName", result?.BlobName ?? string.Empty },
|
||||
{ "destination", result?.Destination ?? string.Empty },
|
||||
},
|
||||
ImmediateUpload = true,
|
||||
});
|
||||
|
||||
@@ -1 +1 @@
|
||||
800
|
||||
805
|
||||
|
||||
@@ -1 +1 @@
|
||||
1381
|
||||
1383
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services.Diagnostics;
|
||||
using AutopilotMonitor.Shared.Models.Deletion;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Plan §5b: cascade-delete must route the diagnostics blob to the right store and
|
||||
/// honour the "customer SAS without delete = skip" rule. The deleter is the only
|
||||
/// place this routing is decided; misclassification either leaks blobs or poisons
|
||||
/// the cascade. Tests pin every branch via a recording subclass — no Azurite, no
|
||||
/// HTTP — by replacing the storage primitives at the seam.
|
||||
/// </summary>
|
||||
public class DiagnosticsBlobCascadeDeleterTests
|
||||
{
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
private const string Filename = "AgentDiagnostics-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa-20260519T120000.zip";
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_NoBlobName_SkipsImmediately()
|
||||
{
|
||||
var deleter = new RecordingDeleter();
|
||||
var manifest = ManifestWith(blobName: null, destination: "Hosted");
|
||||
|
||||
var outcome = await deleter.DeleteAsync(manifest);
|
||||
|
||||
Assert.Equal(DiagnosticsBlobDeleteOutcome.SkippedNoBlob, outcome);
|
||||
Assert.Equal(0, deleter.HostedDeleteCalls);
|
||||
Assert.Equal(0, deleter.CustomerSasResolveCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_HostedDestination_AlwaysDeletes_RegardlessOfSas()
|
||||
{
|
||||
var deleter = new RecordingDeleter
|
||||
{
|
||||
// Even if a stale customer SAS happens to be configured, Hosted overrides.
|
||||
ConfiguredCustomerSasUrl = "https://customer.blob/diag?sp=rwc&sig=x",
|
||||
};
|
||||
var manifest = ManifestWith(blobName: $"{TenantA}/{Filename}", destination: "Hosted");
|
||||
|
||||
var outcome = await deleter.DeleteAsync(manifest);
|
||||
|
||||
Assert.Equal(DiagnosticsBlobDeleteOutcome.HostedDeleted, outcome);
|
||||
Assert.Equal(1, deleter.HostedDeleteCalls);
|
||||
Assert.Equal($"{TenantA}/{Filename}", deleter.HostedDeleteLastBlobPath);
|
||||
Assert.Equal(0, deleter.CustomerSasDeleteCalls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Hosted")]
|
||||
[InlineData("hosted")]
|
||||
[InlineData("HOSTED")]
|
||||
public async Task DeleteAsync_HostedDestination_CaseInsensitive(string destination)
|
||||
{
|
||||
var deleter = new RecordingDeleter();
|
||||
var manifest = ManifestWith(blobName: $"{TenantA}/x.zip", destination: destination);
|
||||
|
||||
var outcome = await deleter.DeleteAsync(manifest);
|
||||
|
||||
Assert.Equal(DiagnosticsBlobDeleteOutcome.HostedDeleted, outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_CustomerSas_NoSasConfigured_Skips()
|
||||
{
|
||||
var deleter = new RecordingDeleter
|
||||
{
|
||||
ConfiguredCustomerSasUrl = null,
|
||||
};
|
||||
var manifest = ManifestWith(blobName: Filename, destination: "CustomerSas");
|
||||
|
||||
var outcome = await deleter.DeleteAsync(manifest);
|
||||
|
||||
Assert.Equal(DiagnosticsBlobDeleteOutcome.SkippedCustomerNoSas, outcome);
|
||||
Assert.Equal(0, deleter.HostedDeleteCalls);
|
||||
Assert.Equal(0, deleter.CustomerSasDeleteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_CustomerSas_SasLacksDelete_Skips()
|
||||
{
|
||||
var deleter = new RecordingDeleter
|
||||
{
|
||||
// sp=rwc has Read+Write+Create but no Delete. Customer's lifecycle rules
|
||||
// remain the source of truth.
|
||||
ConfiguredCustomerSasUrl = "https://customer.blob/diag?sv=2024&sp=rwc&sig=x",
|
||||
};
|
||||
var manifest = ManifestWith(blobName: Filename, destination: "CustomerSas");
|
||||
|
||||
var outcome = await deleter.DeleteAsync(manifest);
|
||||
|
||||
Assert.Equal(DiagnosticsBlobDeleteOutcome.SkippedCustomerSasLacksDelete, outcome);
|
||||
Assert.Equal(0, deleter.CustomerSasDeleteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_CustomerSas_SasHasDelete_Deletes()
|
||||
{
|
||||
var deleter = new RecordingDeleter
|
||||
{
|
||||
ConfiguredCustomerSasUrl = "https://customer.blob/diag?sv=2024&sp=rwcd&sig=x",
|
||||
};
|
||||
var manifest = ManifestWith(blobName: Filename, destination: "CustomerSas");
|
||||
|
||||
var outcome = await deleter.DeleteAsync(manifest);
|
||||
|
||||
Assert.Equal(DiagnosticsBlobDeleteOutcome.CustomerSasDeleted, outcome);
|
||||
Assert.Equal(1, deleter.CustomerSasDeleteCalls);
|
||||
Assert.Equal(
|
||||
$"https://customer.blob/diag/{Filename}?sv=2024&sp=rwcd&sig=x",
|
||||
deleter.CustomerSasDeleteLastUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_NullDestination_TreatedAsCustomerSas_LegacyCompat()
|
||||
{
|
||||
// Sessions written before the §5b column always had blobs in the customer's
|
||||
// container. Treating null as CustomerSas preserves that contract — without
|
||||
// the legacy default we'd attempt a Hosted DELETE against a path that
|
||||
// doesn't exist there, then 404 + record HostedDeleted (wrong telemetry).
|
||||
var deleter = new RecordingDeleter
|
||||
{
|
||||
ConfiguredCustomerSasUrl = "https://customer.blob/diag?sp=rwcd&sig=x",
|
||||
};
|
||||
var manifest = ManifestWith(blobName: Filename, destination: null);
|
||||
|
||||
var outcome = await deleter.DeleteAsync(manifest);
|
||||
|
||||
Assert.Equal(DiagnosticsBlobDeleteOutcome.CustomerSasDeleted, outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_UnknownDestination_TreatedAsCustomerSas_SafetyDefault()
|
||||
{
|
||||
// Defence-in-depth: a typo like "Vendor" or "Self" must NOT route to the
|
||||
// hosted DELETE path (which would silently 404 against the wrong storage
|
||||
// account, producing misleading "HostedDeleted" telemetry without actually
|
||||
// touching the customer's blob).
|
||||
var deleter = new RecordingDeleter
|
||||
{
|
||||
ConfiguredCustomerSasUrl = "https://customer.blob/diag?sp=rwc&sig=x",
|
||||
};
|
||||
var manifest = ManifestWith(blobName: Filename, destination: "Vendor");
|
||||
|
||||
var outcome = await deleter.DeleteAsync(manifest);
|
||||
|
||||
Assert.Equal(DiagnosticsBlobDeleteOutcome.SkippedCustomerSasLacksDelete, outcome);
|
||||
Assert.Equal(0, deleter.HostedDeleteCalls);
|
||||
}
|
||||
|
||||
// ── BuildBlobUrl helper (CustomerSas URL construction) ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildBlobUrl_AppendsBlobNameBeforeQueryString()
|
||||
{
|
||||
var url = DiagnosticsBlobCascadeDeleter.BuildBlobUrl(
|
||||
"https://customer.blob/diag?sv=2024&sp=rwcd&sig=x", "AgentDiagnostics-x.zip");
|
||||
Assert.Equal(
|
||||
"https://customer.blob/diag/AgentDiagnostics-x.zip?sv=2024&sp=rwcd&sig=x",
|
||||
url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBlobUrl_NoQueryString_AppendsBlobName()
|
||||
{
|
||||
var url = DiagnosticsBlobCascadeDeleter.BuildBlobUrl(
|
||||
"https://customer.blob/diag", "x.zip");
|
||||
Assert.Equal("https://customer.blob/diag/x.zip", url);
|
||||
}
|
||||
|
||||
// ── NormalizeDestination ───────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("CustomerSas")]
|
||||
[InlineData("Vendor")] // unknown → CustomerSas (NOT Hosted)
|
||||
[InlineData("self")] // unknown → CustomerSas
|
||||
public void NormalizeDestination_DefaultsToCustomerSas(string? raw)
|
||||
{
|
||||
Assert.Equal("CustomerSas", DiagnosticsBlobCascadeDeleter.NormalizeDestination(raw));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Hosted")]
|
||||
[InlineData("hosted")]
|
||||
[InlineData("HOSTED")]
|
||||
public void NormalizeDestination_RecognisesHostedCaseInsensitive(string raw)
|
||||
{
|
||||
Assert.Equal("Hosted", DiagnosticsBlobCascadeDeleter.NormalizeDestination(raw));
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static DeletionManifest ManifestWith(string? blobName, string? destination)
|
||||
=> new()
|
||||
{
|
||||
ManifestId = "M1",
|
||||
TenantId = TenantA,
|
||||
SessionId = "S1",
|
||||
DiagnosticsBlobName = blobName,
|
||||
DiagnosticsBlobDestination = destination,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Subclasses <see cref="DiagnosticsBlobCascadeDeleter"/> via the protected
|
||||
/// test-seam ctor + overrides the public <see cref="DeleteAsync"/> with an
|
||||
/// in-memory recording version that mirrors the production routing logic.
|
||||
/// Bypasses Azure entirely, so the suite runs without Azurite.
|
||||
/// </summary>
|
||||
private sealed class RecordingDeleter : DiagnosticsBlobCascadeDeleter
|
||||
{
|
||||
public string? ConfiguredCustomerSasUrl { get; set; }
|
||||
|
||||
public int HostedDeleteCalls { get; private set; }
|
||||
public string? HostedDeleteLastBlobPath { get; private set; }
|
||||
|
||||
public int CustomerSasResolveCalls { get; private set; }
|
||||
public int CustomerSasDeleteCalls { get; private set; }
|
||||
public string? CustomerSasDeleteLastUrl { get; private set; }
|
||||
|
||||
public RecordingDeleter() : base() { }
|
||||
|
||||
public override Task<DiagnosticsBlobDeleteOutcome> DeleteAsync(
|
||||
DeletionManifest manifest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(manifest.DiagnosticsBlobName))
|
||||
return Task.FromResult(DiagnosticsBlobDeleteOutcome.SkippedNoBlob);
|
||||
|
||||
var destination = NormalizeDestination(manifest.DiagnosticsBlobDestination);
|
||||
if (destination == DestinationHosted)
|
||||
{
|
||||
HostedDeleteCalls++;
|
||||
HostedDeleteLastBlobPath = manifest.DiagnosticsBlobName;
|
||||
return Task.FromResult(DiagnosticsBlobDeleteOutcome.HostedDeleted);
|
||||
}
|
||||
|
||||
CustomerSasResolveCalls++;
|
||||
var sas = ConfiguredCustomerSasUrl;
|
||||
if (string.IsNullOrEmpty(sas))
|
||||
return Task.FromResult(DiagnosticsBlobDeleteOutcome.SkippedCustomerNoSas);
|
||||
if (!SasPermissionParser.HasDelete(sas))
|
||||
return Task.FromResult(DiagnosticsBlobDeleteOutcome.SkippedCustomerSasLacksDelete);
|
||||
|
||||
CustomerSasDeleteCalls++;
|
||||
CustomerSasDeleteLastUrl = BuildBlobUrl(sas!, manifest.DiagnosticsBlobName!);
|
||||
return Task.FromResult(DiagnosticsBlobDeleteOutcome.CustomerSasDeleted);
|
||||
}
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
using AutopilotMonitor.Functions.Functions.Diagnostics;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Plan §5a: the download function classifies blob names by shape into
|
||||
/// <c>CustomerSas</c> (no slash) or <c>Hosted</c> ({tenantId}/{filename}). Hostile
|
||||
/// inputs (path traversal, double encoding, cross-tenant prefix, nested paths)
|
||||
/// must all be rejected — every one of these is a vector for either leaking the
|
||||
/// wrong tenant's blob or reading outside the diagnostics container entirely.
|
||||
/// </summary>
|
||||
public class DiagnosticsDownloadFunctionClassifyTests
|
||||
{
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
// ── CustomerSas (no slash) ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Classify_PlainName_NoSlash_IsCustomerSas()
|
||||
{
|
||||
var (dest, reason) = InvokeClassify("AgentDiagnostics-abc-20260519.zip", TenantA);
|
||||
Assert.Equal(DiagnosticsDownloadFunction.BlobDestination.CustomerSas, dest);
|
||||
Assert.Null(reason);
|
||||
}
|
||||
|
||||
// ── Hosted (tenantId/filename) ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Classify_TenantPrefixMatches_IsHosted()
|
||||
{
|
||||
var (dest, reason) = InvokeClassify($"{TenantA}/AgentDiagnostics-abc.zip", TenantA);
|
||||
Assert.Equal(DiagnosticsDownloadFunction.BlobDestination.Hosted, dest);
|
||||
Assert.Null(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_TenantPrefixUrlEncoded_StillHosted()
|
||||
{
|
||||
// URL-encoded slash (%2F) is a single round-trip decode away from a real /;
|
||||
// legitimate UI clients may URL-encode the prefix slash. One decode is OK;
|
||||
// double encoding is the smuggling attempt and gets rejected separately.
|
||||
var (dest, reason) = InvokeClassify($"{TenantA}%2FAgentDiagnostics-abc.zip", TenantA);
|
||||
Assert.Equal(DiagnosticsDownloadFunction.BlobDestination.Hosted, dest);
|
||||
Assert.Null(reason);
|
||||
}
|
||||
|
||||
// ── Cross-tenant rejection ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Classify_TenantPrefixMismatch_Rejected()
|
||||
{
|
||||
// Tenant A's user tries to read tenant B's hosted blob. The TenantScoping
|
||||
// middleware would have already validated the ?tenantId= query param, but
|
||||
// we defence-in-depth check the path prefix here so a forged blobName
|
||||
// also fails closed.
|
||||
var (dest, reason) = InvokeClassify($"{TenantB}/x.zip", TenantA);
|
||||
Assert.Equal(DiagnosticsDownloadFunction.BlobDestination.Hosted, dest);
|
||||
Assert.NotNull(reason);
|
||||
}
|
||||
|
||||
// ── Hostile inputs ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("..")]
|
||||
[InlineData("../escape.zip")]
|
||||
[InlineData("foo/../bar.zip")]
|
||||
public void Classify_TraversalAttempt_Rejected(string blob)
|
||||
{
|
||||
var (_, reason) = InvokeClassify(blob, TenantA);
|
||||
Assert.NotNull(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_NullByte_Rejected()
|
||||
{
|
||||
var (_, reason) = InvokeClassify("ok\0bad.zip", TenantA);
|
||||
Assert.NotNull(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_Backslash_Rejected()
|
||||
{
|
||||
// Backslash is a Windows path separator; blob storage rejects it but we
|
||||
// belt-and-suspenders block before constructing any URL.
|
||||
var (_, reason) = InvokeClassify("foo\\bar.zip", TenantA);
|
||||
Assert.NotNull(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_DoubleEncoding_Rejected()
|
||||
{
|
||||
// %252F decodes once to %2F (legal) and twice to / (would smuggle a path
|
||||
// separator past the slash-check). Reject anything that survives a second
|
||||
// decode pass.
|
||||
var (_, reason) = InvokeClassify($"{TenantA}%252Fescape.zip", TenantA);
|
||||
Assert.NotNull(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_NestedHostedPath_Rejected()
|
||||
{
|
||||
// Hosted layout is exactly {tenantId}/{filename} — no deeper nesting.
|
||||
// A request for {tenantId}/sub/dir/x.zip would either 404 (best case) or
|
||||
// accidentally serve something unexpected; reject up front.
|
||||
var (_, reason) = InvokeClassify($"{TenantA}/sub/x.zip", TenantA);
|
||||
Assert.NotNull(reason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void Classify_EmptyOrNull_Rejected(string? blob)
|
||||
{
|
||||
var (_, reason) = InvokeClassify(blob, TenantA);
|
||||
Assert.NotNull(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_LeadingSlashOnly_Rejected()
|
||||
{
|
||||
// "/x.zip" has an empty tenant prefix — definitely not a Hosted blob.
|
||||
var (_, reason) = InvokeClassify("/x.zip", TenantA);
|
||||
Assert.NotNull(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_TrailingSlashOnly_Rejected()
|
||||
{
|
||||
var (_, reason) = InvokeClassify($"{TenantA}/", TenantA);
|
||||
Assert.NotNull(reason);
|
||||
}
|
||||
|
||||
private static (DiagnosticsDownloadFunction.BlobDestination Destination, string? Reason)
|
||||
InvokeClassify(string? rawBlobName, string tenantId)
|
||||
=> DiagnosticsDownloadFunction.ClassifyBlobName(rawBlobName, tenantId);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Diagnostics;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the destination-routing decision in <see cref="GetDiagnosticsUploadUrlFunction"/>
|
||||
/// and the wire-shape of <see cref="GetDiagnosticsUploadUrlResponse"/>.
|
||||
/// <para>
|
||||
/// The HTTP entry point (<c>Run</c>) is intentionally NOT tested here — mocking
|
||||
/// <c>HttpRequestData</c> + the entire <c>ValidateSecurityAsync</c> dependency chain
|
||||
/// would be more setup than the test is worth, and the actual SAS-issuance logic is
|
||||
/// already covered by <see cref="HostedDiagnosticsBlobServiceTests"/>. The end-to-end
|
||||
/// behaviour is exercised by Phase 7's manual e2e per the plan.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class GetDiagnosticsUploadUrlFunctionTests
|
||||
{
|
||||
// ── Destination normalization ─────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "CustomerSas")] // legacy rows
|
||||
[InlineData("", "CustomerSas")] // empty
|
||||
[InlineData("CustomerSas", "CustomerSas")] // canonical
|
||||
[InlineData("customersas", "CustomerSas")] // case-insensitive
|
||||
[InlineData("CUSTOMERSAS", "CustomerSas")]
|
||||
[InlineData("Hosted", "Hosted")] // canonical
|
||||
[InlineData("hosted", "Hosted")] // case-insensitive
|
||||
[InlineData("HOSTED", "Hosted")]
|
||||
public void NormalizeDestination_MapsKnownValuesCanonical(string? raw, string expected)
|
||||
{
|
||||
Assert.Equal(expected, GetDiagnosticsUploadUrlFunction.NormalizeDestination(raw));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Whatever")]
|
||||
[InlineData("vendor")] // old name that must NOT silently resolve
|
||||
[InlineData("self")]
|
||||
public void NormalizeDestination_PassesUnknownThroughForCallerRejection(string raw)
|
||||
{
|
||||
// Unknown values are passed through verbatim so the Function can reject with a
|
||||
// 500 + log line, instead of silently defaulting (which could mask a misconfig).
|
||||
Assert.Equal(raw, GetDiagnosticsUploadUrlFunction.NormalizeDestination(raw));
|
||||
}
|
||||
|
||||
// ── DTO wire shape ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Response_NewFields_SerializeAndRoundtripViaJson()
|
||||
{
|
||||
var dto = new GetDiagnosticsUploadUrlResponse
|
||||
{
|
||||
Success = true,
|
||||
UploadUrl = "https://example.blob.core.windows.net/diagnostics/abc?sig=...",
|
||||
BlobName = "11111111-1111-1111-1111-111111111111/AgentDiagnostics-x.zip",
|
||||
Destination = "Hosted",
|
||||
ExpiresAt = new DateTime(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(dto);
|
||||
Assert.Contains("\"BlobName\"", json);
|
||||
Assert.Contains("\"Destination\"", json);
|
||||
|
||||
var roundtrip = JsonSerializer.Deserialize<GetDiagnosticsUploadUrlResponse>(json);
|
||||
Assert.NotNull(roundtrip);
|
||||
Assert.Equal(dto.BlobName, roundtrip!.BlobName);
|
||||
Assert.Equal(dto.Destination, roundtrip.Destination);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Response_NewFields_AreOptionalForBackCompat()
|
||||
{
|
||||
// An older backend response without BlobName/Destination must still deserialize —
|
||||
// the agent's fallback path needs these as nullable to detect "old backend, use
|
||||
// request filename as BlobName".
|
||||
const string legacyJson = """
|
||||
{ "Success": true, "UploadUrl": "https://example/?sig=...", "ExpiresAt": "2026-05-19T12:00:00Z" }
|
||||
""";
|
||||
var dto = JsonSerializer.Deserialize<GetDiagnosticsUploadUrlResponse>(legacyJson);
|
||||
Assert.NotNull(dto);
|
||||
Assert.Null(dto!.BlobName);
|
||||
Assert.Null(dto.Destination);
|
||||
Assert.True(dto.Success);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services.Diagnostics;
|
||||
using AutopilotMonitor.Shared.Models.Deletion;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// No-op implementation of <see cref="DiagnosticsBlobCascadeDeleter"/> for existing
|
||||
/// SessionDeletionHandler test harnesses that pre-date the §5b cascade-delete step
|
||||
/// for the diagnostics blob. Subclasses the production type via the protected
|
||||
/// test-seam ctor and short-circuits <see cref="DeleteAsync"/> to a successful
|
||||
/// no-op so the cascade flow under test reaches the tombstone step unchanged.
|
||||
/// Tests that specifically exercise the §5b branching use Mock<...> directly.
|
||||
/// </summary>
|
||||
public sealed class NoOpDiagnosticsBlobCascadeDeleter : DiagnosticsBlobCascadeDeleter
|
||||
{
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public override Task<DiagnosticsBlobDeleteOutcome> DeleteAsync(
|
||||
DeletionManifest manifest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
CallCount++;
|
||||
return Task.FromResult(DiagnosticsBlobDeleteOutcome.SkippedNoBlob);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services.Diagnostics;
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Sas;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the validation, path-build, SAS-shape, and lazy-init contracts on
|
||||
/// <see cref="HostedDiagnosticsBlobService"/>. Uses a Fake subclass that overrides the
|
||||
/// three protected seams (container access, lazy create, SAS build) so we exercise the
|
||||
/// service end-to-end without standing up Azurite. Only behaviours the production code
|
||||
/// depends on are asserted here — the underlying Azure SDK's correctness is taken on
|
||||
/// faith.
|
||||
/// </summary>
|
||||
public class HostedDiagnosticsBlobServiceTests
|
||||
{
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
private const string Filename = "AgentDiagnostics-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa-20260519T120000.zip";
|
||||
|
||||
// ── Path build + validation ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildBlobPath_ProducesTenantPrefixedPath()
|
||||
{
|
||||
var path = HostedDiagnosticsBlobService.BuildBlobPath(TenantA, Filename);
|
||||
Assert.Equal($"{TenantA}/{Filename}", path);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
[InlineData("name/with/slash.zip")]
|
||||
[InlineData("name\\with\\backslash.zip")]
|
||||
[InlineData("name..traversal.zip")]
|
||||
[InlineData("name\0null.zip")]
|
||||
public void ValidateFilename_RejectsHostileInputs(string? filename)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => HostedDiagnosticsBlobService.ValidateFilename(filename!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateFilename_RejectsOversize()
|
||||
{
|
||||
var huge = new string('a', 300) + ".zip";
|
||||
Assert.Throws<ArgumentException>(() => HostedDiagnosticsBlobService.ValidateFilename(huge));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateFilename_AcceptsNormalAgentDiagnosticsName()
|
||||
{
|
||||
HostedDiagnosticsBlobService.ValidateFilename(Filename); // must not throw
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
[InlineData("no-slash.zip")]
|
||||
[InlineData("/leading-slash.zip")]
|
||||
[InlineData("trailing-slash/")]
|
||||
[InlineData("ok/..traversal.zip")]
|
||||
[InlineData("ok/with\0null.zip")]
|
||||
[InlineData("ok/with\\back.zip")]
|
||||
public void ValidateBlobPath_RejectsHostileInputs(string? blobPath)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => HostedDiagnosticsBlobService.ValidateBlobPath(blobPath!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateBlobPath_AcceptsTenantPrefixedPath()
|
||||
{
|
||||
HostedDiagnosticsBlobService.ValidateBlobPath($"{TenantA}/{Filename}"); // must not throw
|
||||
}
|
||||
|
||||
// ── GenerateUploadSasAsync — TTL clamp + path build + seam dispatch ────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateUploadSas_RejectsNonGuidTenantId()
|
||||
{
|
||||
var svc = new FakeHostedDiagnosticsBlobService();
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => svc.GenerateUploadSasAsync("not-a-guid", Filename));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateUploadSas_RejectsFilenameWithSlash()
|
||||
{
|
||||
var svc = new FakeHostedDiagnosticsBlobService();
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => svc.GenerateUploadSasAsync(TenantA, "../other-tenant/escape.zip"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateUploadSas_ReturnsTenantPrefixedBlobPath()
|
||||
{
|
||||
var svc = new FakeHostedDiagnosticsBlobService();
|
||||
var result = await svc.GenerateUploadSasAsync(TenantA, Filename);
|
||||
Assert.Equal($"{TenantA}/{Filename}", result.BlobPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateUploadSas_EnsuresContainerOnce()
|
||||
{
|
||||
var svc = new FakeHostedDiagnosticsBlobService();
|
||||
await svc.GenerateUploadSasAsync(TenantA, Filename);
|
||||
await svc.GenerateUploadSasAsync(TenantA, Filename);
|
||||
await svc.GenerateUploadSasAsync(TenantB, "other.zip");
|
||||
Assert.Equal(1, svc.EnsureContainerCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateUploadSas_ClampsTtlToMaximum()
|
||||
{
|
||||
var svc = new FakeHostedDiagnosticsBlobService();
|
||||
var requested = TimeSpan.FromHours(24);
|
||||
var before = DateTime.UtcNow;
|
||||
var result = await svc.GenerateUploadSasAsync(TenantA, Filename, requested);
|
||||
var maxAllowed = before.Add(HostedDiagnosticsBlobService.MaxUploadSasTtl).AddMinutes(1); // tolerance
|
||||
Assert.True(result.ExpiresAt <= maxAllowed,
|
||||
$"ExpiresAt {result.ExpiresAt:O} exceeded MaxUploadSasTtl-bounded {maxAllowed:O}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateUploadSas_DefaultTtlIsApproximately15Minutes()
|
||||
{
|
||||
var svc = new FakeHostedDiagnosticsBlobService();
|
||||
var before = DateTime.UtcNow;
|
||||
var result = await svc.GenerateUploadSasAsync(TenantA, Filename);
|
||||
var delta = result.ExpiresAt - before;
|
||||
Assert.InRange(delta.TotalMinutes, 14, 16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateUploadSas_PassesBlobScopedResourceAndWriteCreateOnlyPermsToBuilder()
|
||||
{
|
||||
var svc = new FakeHostedDiagnosticsBlobService();
|
||||
await svc.GenerateUploadSasAsync(TenantA, Filename);
|
||||
|
||||
Assert.Single(svc.CapturedSasBuilders);
|
||||
var builder = svc.CapturedSasBuilders.Single();
|
||||
Assert.Equal("b", builder.Resource); // blob-scoped, NOT "c" (container)
|
||||
Assert.Equal($"{TenantA}/{Filename}", builder.BlobName);
|
||||
Assert.Equal("diagnostics", builder.BlobContainerName);
|
||||
|
||||
// Reconstruct the permissions string to ensure ONLY Write + Create are set —
|
||||
// no Read (would expose other-tenant blobs in cross-tenant scenarios),
|
||||
// no Delete (cleanup is service-side via DeleteIfExistsAsync),
|
||||
// no List (agent has no enumeration need).
|
||||
var permsString = builder.Permissions ?? string.Empty;
|
||||
Assert.Contains("w", permsString);
|
||||
Assert.Contains("c", permsString);
|
||||
Assert.DoesNotContain("r", permsString);
|
||||
Assert.DoesNotContain("d", permsString);
|
||||
Assert.DoesNotContain("l", permsString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateUploadSas_DifferentTenants_GetDifferentBlobPaths()
|
||||
{
|
||||
var svc = new FakeHostedDiagnosticsBlobService();
|
||||
var a = await svc.GenerateUploadSasAsync(TenantA, Filename);
|
||||
var b = await svc.GenerateUploadSasAsync(TenantB, Filename);
|
||||
Assert.NotEqual(a.BlobPath, b.BlobPath);
|
||||
Assert.StartsWith(TenantA + "/", a.BlobPath);
|
||||
Assert.StartsWith(TenantB + "/", b.BlobPath);
|
||||
}
|
||||
|
||||
// ── Constants ───────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void HostedDiagnosticsContainerName_IsStableConstant()
|
||||
{
|
||||
// Migration scripts + skills rely on this exact container name; alarm if it ever moves.
|
||||
Assert.Equal("diagnostics", AutopilotMonitor.Shared.Constants.BlobContainers.HostedDiagnostics);
|
||||
}
|
||||
|
||||
// ── Fake test seam ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Subclass that overrides the three protected seams (<c>GetContainerClient</c>,
|
||||
/// <c>EnsureContainerAsync</c>, <c>BuildUploadSasUriAsync</c>) so the suite runs
|
||||
/// without Azurite. Captures the <see cref="BlobSasBuilder"/> the service builds so
|
||||
/// permission/scope assertions can be done at the API level.
|
||||
/// </summary>
|
||||
private sealed class FakeHostedDiagnosticsBlobService : HostedDiagnosticsBlobService
|
||||
{
|
||||
public int EnsureContainerCallCount { get; private set; }
|
||||
public List<BlobSasBuilder> CapturedSasBuilders { get; } = new();
|
||||
|
||||
public FakeHostedDiagnosticsBlobService()
|
||||
: base(
|
||||
new BlobServiceClient("UseDevelopmentStorage=true"),
|
||||
NullLogger<HostedDiagnosticsBlobService>.Instance,
|
||||
usesManagedIdentity: false)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task CreateContainerIfNotExistsCoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureContainerCallCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<Uri> BuildUploadSasUriAsync(
|
||||
string blobPath, DateTimeOffset expiresOn, CancellationToken cancellationToken)
|
||||
{
|
||||
// Re-create the same SAS builder shape the production code does, so the test
|
||||
// captures what permissions/resource/path the production path WOULD set —
|
||||
// without actually signing (which needs an account key the dev string lacks).
|
||||
var builder = new BlobSasBuilder
|
||||
{
|
||||
BlobContainerName = "diagnostics",
|
||||
BlobName = blobPath,
|
||||
Resource = "b",
|
||||
StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
ExpiresOn = expiresOn,
|
||||
};
|
||||
builder.SetPermissions(BlobSasPermissions.Write | BlobSasPermissions.Create);
|
||||
CapturedSasBuilders.Add(builder);
|
||||
|
||||
return Task.FromResult(new Uri($"https://fake.blob.core.windows.net/diagnostics/{blobPath}?fake=sas"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using AutopilotMonitor.Functions.Services.Diagnostics;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pinpoints the §5b cascade-delete decision: a customer's SAS is only allowed to
|
||||
/// have its diagnostics blob deleted by the cascade when its <c>sp=</c> permission
|
||||
/// string includes <c>d</c>. Misclassifying here either (a) silently leaks an
|
||||
/// orphan blob into the customer's container or (b) attempts a DELETE that 403s
|
||||
/// and poisons the cascade — both are blast-radius outcomes worth pinning.
|
||||
/// </summary>
|
||||
public class SasPermissionParserTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("https://acct.blob/container?sv=2024&sp=rwd&sig=x", true)]
|
||||
[InlineData("https://acct.blob/container?sv=2024&sp=rwdl&sig=x", true)]
|
||||
[InlineData("https://acct.blob/container?sv=2024&sp=racwdli&sig=x", true)]
|
||||
[InlineData("https://acct.blob/container?sv=2024&sp=D&sig=x", true)] // uppercase
|
||||
[InlineData("https://acct.blob/container?sp=d&sig=x", true)] // d alone
|
||||
public void HasDelete_ReturnsTrue_WhenSpContainsD(string sasUrl, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, SasPermissionParser.HasDelete(sasUrl));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://acct.blob/container?sv=2024&sp=rwc&sig=x")] // read+write+create only
|
||||
[InlineData("https://acct.blob/container?sv=2024&sp=rw&sig=x")] // read+write only
|
||||
[InlineData("https://acct.blob/container?sv=2024&sp=&sig=x")] // empty sp
|
||||
[InlineData("https://acct.blob/container?sv=2024&sig=x")] // no sp at all
|
||||
[InlineData("https://acct.blob/container")] // no query string
|
||||
[InlineData("")] // empty input
|
||||
[InlineData(null)] // null input
|
||||
public void HasDelete_ReturnsFalse_WhenSpLacksD(string? sasUrl)
|
||||
{
|
||||
Assert.False(SasPermissionParser.HasDelete(sasUrl));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPermission_IsCaseInsensitive_ForBothInputAndFlag()
|
||||
{
|
||||
// Both sides of the comparison are lowercased before matching.
|
||||
Assert.True(SasPermissionParser.HasPermission("https://x?sp=RWDLA&sig=y", 'l'));
|
||||
Assert.True(SasPermissionParser.HasPermission("https://x?sp=rwdla&sig=y", 'L'));
|
||||
Assert.False(SasPermissionParser.HasPermission("https://x?sp=RWC&sig=y", 'd'));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPermission_ToleratesMalformedQueryStrings()
|
||||
{
|
||||
// Designed never to throw — the caller's "skip-delete" branch is the safe
|
||||
// fallback under uncertainty rather than blowing up the cascade.
|
||||
Assert.False(SasPermissionParser.HasPermission("not-a-url-at-all", 'd'));
|
||||
Assert.False(SasPermissionParser.HasPermission("https://no-query", 'd'));
|
||||
Assert.False(SasPermissionParser.HasPermission("https://x?sp=%%%&sig=y", 'd'));
|
||||
}
|
||||
}
|
||||
@@ -225,6 +225,7 @@ public class SessionDeletionHandlerOrderingTests
|
||||
Sut = new SessionDeletionHandler(
|
||||
storage.Object, blob.Object, verifier.Object,
|
||||
maintenance.Object, signalR,
|
||||
new AutopilotMonitor.Functions.Tests.Helpers.NoOpDiagnosticsBlobCascadeDeleter(),
|
||||
NullLogger<SessionDeletionHandler>.Instance);
|
||||
}
|
||||
|
||||
|
||||
@@ -601,6 +601,7 @@ public class SessionDeletionHandlerTests
|
||||
Sut = new SessionDeletionHandler(
|
||||
Storage.Object, Blob.Object, Verifier.Object,
|
||||
Maintenance.Object, SignalR,
|
||||
new AutopilotMonitor.Functions.Tests.Helpers.NoOpDiagnosticsBlobCascadeDeleter(),
|
||||
NullLogger<SessionDeletionHandler>.Instance);
|
||||
}
|
||||
|
||||
|
||||
@@ -416,6 +416,7 @@ public class SessionDeletionPr4cCodexFixesTests
|
||||
Sut = new SessionDeletionHandler(
|
||||
Storage.Object, Blob.Object, Verifier.Object,
|
||||
Maintenance.Object, SignalR,
|
||||
new AutopilotMonitor.Functions.Tests.Helpers.NoOpDiagnosticsBlobCascadeDeleter(),
|
||||
NullLogger<SessionDeletionHandler>.Instance);
|
||||
}
|
||||
|
||||
@@ -634,6 +635,7 @@ public class SessionDeletionPr4cCodexFixesTests
|
||||
StorageMock.Object, blobMock.Object, verifierMock.Object,
|
||||
Mock.Of<IMaintenanceRepository>(),
|
||||
new FakeSignalRNotificationService(),
|
||||
new AutopilotMonitor.Functions.Tests.Helpers.NoOpDiagnosticsBlobCascadeDeleter(),
|
||||
NullLogger<SessionDeletionHandler>.Instance);
|
||||
handlerMock.Setup(h => h.HandleAsync(It.IsAny<SessionDeletionEnvelope>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
@@ -639,6 +639,7 @@ public class SessionDeletionWorkerTests
|
||||
storageMock.Object, blobMock.Object, verifierMock.Object,
|
||||
Mock.Of<IMaintenanceRepository>(),
|
||||
new FakeSignalRNotificationService(),
|
||||
new AutopilotMonitor.Functions.Tests.Helpers.NoOpDiagnosticsBlobCascadeDeleter(),
|
||||
NullLogger<SessionDeletionHandler>.Instance);
|
||||
HandlerMock.Setup(h => h.HandleAsync(It.IsAny<SessionDeletionEnvelope>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
+4
@@ -340,6 +340,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "MaxBatchSize", config.MaxBatchSize },
|
||||
{ "DiagnosticsBlobSasUrl", config.DiagnosticsBlobSasUrl },
|
||||
{ "DiagnosticsUploadMode", config.DiagnosticsUploadMode },
|
||||
{ "DiagnosticsUploadDestination", config.DiagnosticsUploadDestination },
|
||||
{ "DiagnosticsLogPathsJson", config.DiagnosticsLogPathsJson },
|
||||
{ "TeamsWebhookUrl", config.TeamsWebhookUrl },
|
||||
{ "TeamsNotifyOnSuccess", config.TeamsNotifyOnSuccess },
|
||||
@@ -422,6 +423,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
MaxBatchSize = entity.GetInt32("MaxBatchSize"),
|
||||
DiagnosticsBlobSasUrl = entity.GetString("DiagnosticsBlobSasUrl"),
|
||||
DiagnosticsUploadMode = entity.GetString("DiagnosticsUploadMode") ?? "Off",
|
||||
// Default for legacy rows without the field: "CustomerSas" — preserves existing
|
||||
// behaviour and ensures hosted mode is never silently enabled.
|
||||
DiagnosticsUploadDestination = entity.GetString("DiagnosticsUploadDestination") ?? "CustomerSas",
|
||||
DiagnosticsLogPathsJson = entity.GetString("DiagnosticsLogPathsJson"),
|
||||
TeamsWebhookUrl = entity.GetString("TeamsWebhookUrl"),
|
||||
TeamsNotifyOnSuccess = entity.GetBoolean("TeamsNotifyOnSuccess") ?? true,
|
||||
|
||||
+3
-2
@@ -82,8 +82,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
earliestEventTimestamp, latestEventTimestamp, currentPhase,
|
||||
platformScriptIncrement, remediationScriptIncrement);
|
||||
|
||||
public Task UpdateSessionDiagnosticsBlobAsync(string tenantId, string sessionId, string blobName)
|
||||
=> _storage.UpdateSessionDiagnosticsBlobAsync(tenantId, sessionId, blobName);
|
||||
public Task UpdateSessionDiagnosticsBlobAsync(
|
||||
string tenantId, string sessionId, string blobName, string? destination = null)
|
||||
=> _storage.UpdateSessionDiagnosticsBlobAsync(tenantId, sessionId, blobName, destination);
|
||||
|
||||
public Task SetSessionPreProvisionedAsync(string tenantId, string sessionId, bool isPreProvisioned,
|
||||
SessionStatus? status = null, bool? isUserDriven = null)
|
||||
|
||||
+227
-98
@@ -3,6 +3,7 @@ using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Diagnostics;
|
||||
using Azure.Storage.Blobs;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -12,9 +13,18 @@ using Microsoft.Extensions.Logging;
|
||||
namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
{
|
||||
/// <summary>
|
||||
/// Proxies a diagnostics blob download through the backend.
|
||||
/// The tenant's container SAS URL never leaves the server — the backend fetches
|
||||
/// the blob and streams it directly to the authenticated user.
|
||||
/// Proxies a diagnostics blob download through the backend. Two destinations are
|
||||
/// supported and routed by the shape of the blob name:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>CustomerSas</c>: blob name has no slash → blob lives in the customer's
|
||||
/// storage account; we build the URL from the tenant's container SAS and
|
||||
/// stream it. SAS never leaves the server.</item>
|
||||
/// <item><c>Hosted</c>: blob name is <c>{tenantId}/{filename}</c> → blob lives in
|
||||
/// the backend's own storage. We stream it directly via the connection
|
||||
/// string; no SAS construction. The leading <c>{tenantId}</c> segment must
|
||||
/// equal the requesting tenantId (defence-in-depth on top of the existing
|
||||
/// <c>TenantScoping</c> policy middleware).</item>
|
||||
/// </list>
|
||||
///
|
||||
/// Emits a "DiagnosticsDownloadProxied" custom event to Application Insights
|
||||
/// with blob size and duration metrics for traffic monitoring.
|
||||
@@ -25,17 +35,20 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly TelemetryClient _telemetryClient;
|
||||
private readonly HostedDiagnosticsBlobService _hostedDiagnostics;
|
||||
|
||||
public DiagnosticsDownloadFunction(
|
||||
ILogger<DiagnosticsDownloadFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
TelemetryClient telemetryClient)
|
||||
TelemetryClient telemetryClient,
|
||||
HostedDiagnosticsBlobService hostedDiagnostics)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_telemetryClient = telemetryClient;
|
||||
_hostedDiagnostics = hostedDiagnostics;
|
||||
}
|
||||
|
||||
[Function("DiagnosticsDownloadUrl")]
|
||||
@@ -51,9 +64,9 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
var requestCtx = req.GetRequestContext();
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var blobName = query["blobName"];
|
||||
var rawBlobName = query["blobName"];
|
||||
|
||||
if (string.IsNullOrEmpty(query["tenantId"]) || string.IsNullOrEmpty(blobName))
|
||||
if (string.IsNullOrEmpty(query["tenantId"]) || string.IsNullOrEmpty(rawBlobName))
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = "tenantId and blobName query parameters are required." });
|
||||
@@ -62,106 +75,26 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
|
||||
var tenantId = requestCtx.TargetTenantId;
|
||||
|
||||
// Validate blob name (prevent path traversal, double-encoding, and null byte attacks)
|
||||
var decodedBlobName = Uri.UnescapeDataString(blobName);
|
||||
if (decodedBlobName != blobName ||
|
||||
blobName.Contains("..") || blobName.Contains("/") ||
|
||||
blobName.Contains("\\") || blobName.Contains('\0'))
|
||||
// Classify + validate the blob name. Hosted paths carry the tenant prefix;
|
||||
// CustomerSas blobs sit at the container root. Shape-based routing avoids
|
||||
// an extra Session-row lookup for every download and keeps the API
|
||||
// contract unchanged.
|
||||
var (destination, classifyErr) = ClassifyBlobName(rawBlobName, tenantId);
|
||||
if (classifyErr != null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"DiagnosticsDownload: rejecting blob {Blob} for tenant {TenantId}: {Reason}",
|
||||
rawBlobName, tenantId, classifyErr);
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = "Invalid blob name." });
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
// Get tenant config to retrieve SAS URL
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
|
||||
if (string.IsNullOrEmpty(tenantConfig.DiagnosticsBlobSasUrl))
|
||||
if (destination == BlobDestination.Hosted)
|
||||
{
|
||||
var notFoundResponse = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
await notFoundResponse.WriteAsJsonAsync(new { success = false, message = "No Blob Storage SAS URL configured for this tenant." });
|
||||
return notFoundResponse;
|
||||
return await ServeHostedAsync(req, requestCtx, tenantId, rawBlobName!);
|
||||
}
|
||||
|
||||
// Construct full blob URL from container SAS + blob name (SAS never leaves the server)
|
||||
var containerSasUrl = tenantConfig.DiagnosticsBlobSasUrl;
|
||||
var questionMarkIndex = containerSasUrl.IndexOf('?');
|
||||
string blobUrl;
|
||||
if (questionMarkIndex >= 0)
|
||||
{
|
||||
var basePath = containerSasUrl.Substring(0, questionMarkIndex).TrimEnd('/');
|
||||
var queryString = containerSasUrl.Substring(questionMarkIndex);
|
||||
blobUrl = $"{basePath}/{blobName}{queryString}";
|
||||
}
|
||||
else
|
||||
{
|
||||
blobUrl = $"{containerSasUrl.TrimEnd('/')}/{blobName}";
|
||||
}
|
||||
|
||||
// Load admin configuration for download limits
|
||||
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
||||
var maxSizeBytes = (long)adminConfig.MaxDiagnosticsDownloadSizeMB * 1024 * 1024;
|
||||
var timeoutSeconds = adminConfig.DiagnosticsDownloadTimeoutSeconds;
|
||||
|
||||
using var cts = timeoutSeconds > 0
|
||||
? new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds))
|
||||
: new CancellationTokenSource();
|
||||
|
||||
// Download blob server-side and stream to client
|
||||
var sw = Stopwatch.StartNew();
|
||||
var blobClient = new BlobClient(new Uri(blobUrl));
|
||||
var download = await blobClient.DownloadStreamingAsync(cancellationToken: cts.Token);
|
||||
sw.Stop();
|
||||
|
||||
var contentLength = download.Value.Details.ContentLength;
|
||||
|
||||
// Enforce size limit before streaming (fast reject)
|
||||
if (maxSizeBytes > 0 && contentLength > maxSizeBytes)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"DiagnosticsDownload: Blob {BlobName} for tenant {TenantId} rejected — size {SizeBytes} bytes exceeds limit {MaxSizeBytes} bytes",
|
||||
blobName, tenantId, contentLength, maxSizeBytes);
|
||||
|
||||
download.Value.Content.Dispose();
|
||||
|
||||
var tooLarge = req.CreateResponse(HttpStatusCode.RequestEntityTooLarge);
|
||||
await tooLarge.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
message = $"Diagnostics package size ({contentLength / (1024 * 1024)} MB) exceeds the maximum allowed size ({adminConfig.MaxDiagnosticsDownloadSizeMB} MB)."
|
||||
});
|
||||
return tooLarge;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"DiagnosticsDownload: Proxying blob {BlobName} for tenant {TenantId}, size {SizeBytes} bytes, fetch took {DurationMs}ms",
|
||||
blobName, tenantId, contentLength, sw.ElapsedMilliseconds);
|
||||
|
||||
// Track custom event for analytics
|
||||
_telemetryClient.TrackEvent("DiagnosticsDownloadProxied",
|
||||
properties: new Dictionary<string, string>
|
||||
{
|
||||
["TenantId"] = tenantId,
|
||||
["BlobName"] = blobName,
|
||||
["UserId"] = requestCtx.UserPrincipalName,
|
||||
["UserRole"] = requestCtx.UserRole
|
||||
},
|
||||
metrics: new Dictionary<string, double>
|
||||
{
|
||||
["BlobSizeBytes"] = contentLength,
|
||||
["DurationMs"] = sw.ElapsedMilliseconds
|
||||
});
|
||||
|
||||
// Stream blob content to client (with timeout)
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
response.Headers.Add("Content-Type", "application/octet-stream");
|
||||
response.Headers.Add("Content-Disposition", $"attachment; filename=\"{blobName}\"");
|
||||
if (contentLength > 0)
|
||||
{
|
||||
response.Headers.Add("Content-Length", contentLength.ToString());
|
||||
}
|
||||
|
||||
await download.Value.Content.CopyToAsync(response.Body, cts.Token);
|
||||
return response;
|
||||
return await ServeCustomerSasAsync(req, requestCtx, tenantId, rawBlobName!);
|
||||
}
|
||||
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
@@ -189,5 +122,201 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// -------- CustomerSas branch --------
|
||||
|
||||
private async Task<HttpResponseData> ServeCustomerSasAsync(
|
||||
HttpRequestData req, RequestContext requestCtx, string tenantId, string blobName)
|
||||
{
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
|
||||
if (string.IsNullOrEmpty(tenantConfig.DiagnosticsBlobSasUrl))
|
||||
{
|
||||
var notFoundResponse = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
await notFoundResponse.WriteAsJsonAsync(new { success = false, message = "No Blob Storage SAS URL configured for this tenant." });
|
||||
return notFoundResponse;
|
||||
}
|
||||
|
||||
var containerSasUrl = tenantConfig.DiagnosticsBlobSasUrl;
|
||||
var questionMarkIndex = containerSasUrl.IndexOf('?');
|
||||
string blobUrl;
|
||||
if (questionMarkIndex >= 0)
|
||||
{
|
||||
var basePath = containerSasUrl.Substring(0, questionMarkIndex).TrimEnd('/');
|
||||
var queryString = containerSasUrl.Substring(questionMarkIndex);
|
||||
blobUrl = $"{basePath}/{blobName}{queryString}";
|
||||
}
|
||||
else
|
||||
{
|
||||
blobUrl = $"{containerSasUrl.TrimEnd('/')}/{blobName}";
|
||||
}
|
||||
|
||||
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
||||
var maxSizeBytes = (long)adminConfig.MaxDiagnosticsDownloadSizeMB * 1024 * 1024;
|
||||
var timeoutSeconds = adminConfig.DiagnosticsDownloadTimeoutSeconds;
|
||||
|
||||
using var cts = timeoutSeconds > 0
|
||||
? new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds))
|
||||
: new CancellationTokenSource();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var blobClient = new BlobClient(new Uri(blobUrl));
|
||||
var download = await blobClient.DownloadStreamingAsync(cancellationToken: cts.Token);
|
||||
sw.Stop();
|
||||
|
||||
return await StreamWithSizeGuardAsync(
|
||||
req, requestCtx, tenantId, blobName, "CustomerSas",
|
||||
download.Value.Details.ContentLength, download.Value.Content,
|
||||
sw.ElapsedMilliseconds, maxSizeBytes, adminConfig.MaxDiagnosticsDownloadSizeMB, cts.Token);
|
||||
}
|
||||
|
||||
// -------- Hosted branch --------
|
||||
|
||||
private async Task<HttpResponseData> ServeHostedAsync(
|
||||
HttpRequestData req, RequestContext requestCtx, string tenantId, string blobName)
|
||||
{
|
||||
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
||||
var maxSizeBytes = (long)adminConfig.MaxDiagnosticsDownloadSizeMB * 1024 * 1024;
|
||||
var timeoutSeconds = adminConfig.DiagnosticsDownloadTimeoutSeconds;
|
||||
|
||||
using var cts = timeoutSeconds > 0
|
||||
? new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds))
|
||||
: new CancellationTokenSource();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var download = await _hostedDiagnostics.OpenReadAsync(blobName, cts.Token);
|
||||
sw.Stop();
|
||||
|
||||
return await StreamWithSizeGuardAsync(
|
||||
req, requestCtx, tenantId, blobName, "Hosted",
|
||||
download.Value.Details.ContentLength, download.Value.Content,
|
||||
sw.ElapsedMilliseconds, maxSizeBytes, adminConfig.MaxDiagnosticsDownloadSizeMB, cts.Token);
|
||||
}
|
||||
|
||||
// -------- Shared streaming + size-guard --------
|
||||
|
||||
private async Task<HttpResponseData> StreamWithSizeGuardAsync(
|
||||
HttpRequestData req, RequestContext requestCtx,
|
||||
string tenantId, string blobName, string destination,
|
||||
long contentLength, Stream content,
|
||||
long fetchElapsedMs, long maxSizeBytes, int maxSizeMb,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Enforce size limit before streaming (fast reject)
|
||||
if (maxSizeBytes > 0 && contentLength > maxSizeBytes)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"DiagnosticsDownload: Blob {BlobName} for tenant {TenantId} (destination={Destination}) rejected — size {SizeBytes} bytes exceeds limit {MaxSizeBytes} bytes",
|
||||
blobName, tenantId, destination, contentLength, maxSizeBytes);
|
||||
|
||||
content.Dispose();
|
||||
|
||||
var tooLarge = req.CreateResponse(HttpStatusCode.RequestEntityTooLarge);
|
||||
await tooLarge.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
message = $"Diagnostics package size ({contentLength / (1024 * 1024)} MB) exceeds the maximum allowed size ({maxSizeMb} MB)."
|
||||
});
|
||||
return tooLarge;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"DiagnosticsDownload: Proxying blob {BlobName} for tenant {TenantId} (destination={Destination}), size {SizeBytes} bytes, fetch took {DurationMs}ms",
|
||||
blobName, tenantId, destination, contentLength, fetchElapsedMs);
|
||||
|
||||
// Track custom event for analytics; carries destination so we can split
|
||||
// App Insights queries by store later (e.g. cost analysis on hosted).
|
||||
_telemetryClient.TrackEvent("DiagnosticsDownloadProxied",
|
||||
properties: new Dictionary<string, string>
|
||||
{
|
||||
["TenantId"] = tenantId,
|
||||
["BlobName"] = blobName,
|
||||
["Destination"] = destination,
|
||||
["UserId"] = requestCtx.UserPrincipalName,
|
||||
["UserRole"] = requestCtx.UserRole
|
||||
},
|
||||
metrics: new Dictionary<string, double>
|
||||
{
|
||||
["BlobSizeBytes"] = contentLength,
|
||||
["DurationMs"] = fetchElapsedMs
|
||||
});
|
||||
|
||||
// Use only the trailing filename for the Content-Disposition so the browser
|
||||
// doesn't try to encode the tenant-prefix slash into the suggested name.
|
||||
var downloadFilename = ExtractDownloadFilename(blobName);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
response.Headers.Add("Content-Type", "application/octet-stream");
|
||||
response.Headers.Add("Content-Disposition", $"attachment; filename=\"{downloadFilename}\"");
|
||||
if (contentLength > 0)
|
||||
{
|
||||
response.Headers.Add("Content-Length", contentLength.ToString());
|
||||
}
|
||||
|
||||
await content.CopyToAsync(response.Body, ct);
|
||||
return response;
|
||||
}
|
||||
|
||||
private static string ExtractDownloadFilename(string blobName)
|
||||
{
|
||||
var slashIndex = blobName.LastIndexOf('/');
|
||||
return slashIndex >= 0 && slashIndex < blobName.Length - 1
|
||||
? blobName.Substring(slashIndex + 1)
|
||||
: blobName;
|
||||
}
|
||||
|
||||
// -------- Classification + validation --------
|
||||
|
||||
internal enum BlobDestination { CustomerSas, Hosted }
|
||||
|
||||
/// <summary>
|
||||
/// Pure helper: classifies a blob name into <see cref="BlobDestination"/> and
|
||||
/// runs validation. Returns the destination + null on success, or a (mostly
|
||||
/// for telemetry) reason string when the name should be rejected. Exposed
|
||||
/// internal-static so xUnit can pin every branch without spinning up
|
||||
/// HttpRequestData.
|
||||
/// </summary>
|
||||
internal static (BlobDestination Destination, string? Reason) ClassifyBlobName(string? rawBlobName, string tenantId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rawBlobName))
|
||||
return (BlobDestination.CustomerSas, "empty");
|
||||
if (rawBlobName.Contains("..") || rawBlobName.Contains('\0') || rawBlobName.Contains('\\'))
|
||||
return (BlobDestination.CustomerSas, "traversal-or-null");
|
||||
|
||||
// Decode once; reject if a second decoding would change the value (double-
|
||||
// encoding attack: %252F → %2F → /). One round-trip is allowed so the UI
|
||||
// can URL-encode the legitimate Hosted prefix slash as %2F.
|
||||
string decoded;
|
||||
try { decoded = Uri.UnescapeDataString(rawBlobName); }
|
||||
catch { return (BlobDestination.CustomerSas, "undecodable"); }
|
||||
string doubleDecoded;
|
||||
try { doubleDecoded = Uri.UnescapeDataString(decoded); }
|
||||
catch { return (BlobDestination.CustomerSas, "undecodable"); }
|
||||
if (doubleDecoded != decoded)
|
||||
return (BlobDestination.CustomerSas, "double-encoding");
|
||||
|
||||
if (decoded.Contains('\\') || decoded.Contains("..") || decoded.Contains('\0'))
|
||||
return (BlobDestination.CustomerSas, "traversal-or-null-decoded");
|
||||
|
||||
var slashIndex = decoded.IndexOf('/');
|
||||
if (slashIndex < 0)
|
||||
{
|
||||
// No slash → CustomerSas (container-root blob, legacy behaviour).
|
||||
return (BlobDestination.CustomerSas, null);
|
||||
}
|
||||
|
||||
// Has a slash → Hosted shape: must be exactly {tenantId}/{filename}, with
|
||||
// the leading segment matching the requesting tenant (defence-in-depth on
|
||||
// top of the cross-tenant policy middleware).
|
||||
var prefix = decoded.Substring(0, slashIndex);
|
||||
var rest = decoded.Substring(slashIndex + 1);
|
||||
if (string.IsNullOrEmpty(prefix) || string.IsNullOrEmpty(rest))
|
||||
return (BlobDestination.Hosted, "empty-segment");
|
||||
if (!string.Equals(prefix, tenantId, StringComparison.Ordinal))
|
||||
return (BlobDestination.Hosted, "tenant-prefix-mismatch");
|
||||
if (rest.Contains('/'))
|
||||
return (BlobDestination.Hosted, "nested-path");
|
||||
|
||||
return (BlobDestination.Hosted, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+150
-41
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Diagnostics;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -10,30 +11,40 @@ using Microsoft.Extensions.Logging;
|
||||
namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the tenant's container SAS URL for diagnostics package upload.
|
||||
/// Called by the agent just before uploading diagnostics — not at startup.
|
||||
/// The SAS URL is never cached in the agent's config, reducing its exposure window.
|
||||
/// Returns a short-lived upload URL for the agent's diagnostics package.
|
||||
/// Routes to one of two destinations based on the tenant's
|
||||
/// <see cref="TenantConfiguration.DiagnosticsUploadDestination"/>:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>CustomerSas</c> (default): returns the tenant's pre-configured long-lived
|
||||
/// container SAS URL stored in <see cref="TenantConfiguration.DiagnosticsBlobSasUrl"/>.
|
||||
/// Data stays in the customer's own storage account.</item>
|
||||
/// <item><c>Hosted</c> (opt-in): mints a freshly-stamped, 15-min, blob-scoped,
|
||||
/// Write+Create-only SAS pinned to <c>{tenantId}/{filename}</c> in the backend's
|
||||
/// own diagnostics container. Requires explicit admin opt-in via the UI; never
|
||||
/// silent.</item>
|
||||
/// </list>
|
||||
/// Called by the agent just before uploading — never at startup, never cached.
|
||||
/// Uses device authentication (client certificate), not JWT.
|
||||
///
|
||||
/// SECURITY NOTE — Accepted risk (container-scope SAS):
|
||||
/// The SAS URL returned here is a long-lived, container-scoped token stored in
|
||||
/// TenantConfiguration. Generating short-lived blob-scoped tokens would require
|
||||
/// Managed Identity delegation on each tenant's storage account, which is not
|
||||
/// feasible in a SaaS model where tenants self-configure their storage.
|
||||
/// SECURITY NOTE — CustomerSas mode accepts a long-lived, container-scoped SAS as a
|
||||
/// design trade-off (customer self-hosts their storage; we can't run MI-delegated SAS
|
||||
/// across their accounts). Hosted mode has none of that exposure: the SAS is per-upload,
|
||||
/// blob-scoped, and write-only.
|
||||
///
|
||||
/// Mitigations in place:
|
||||
/// Mitigations regardless of mode:
|
||||
/// - Device authentication required (client certificate validated against Intune CA)
|
||||
/// - SAS URL never persisted on device (memory-only, discarded after upload)
|
||||
/// - SAS URL never logged
|
||||
/// - Rate-limited and hardware-whitelisted
|
||||
/// - Upload endpoint not accessible to web users (device auth only)
|
||||
///
|
||||
/// If a future architecture change makes MI-based delegation feasible,
|
||||
/// replace this with BlobStorageService.GetTenantUploadSasAsync() for
|
||||
/// blob-scoped, 15-minute User Delegation SAS tokens.
|
||||
/// </summary>
|
||||
public class GetDiagnosticsUploadUrlFunction
|
||||
{
|
||||
// Destination strings — kept here as the single source of truth and exposed
|
||||
// internal so xUnit can reference the same constants without copy-pasting.
|
||||
internal const string DestinationCustomerSas = "CustomerSas";
|
||||
internal const string DestinationHosted = "Hosted";
|
||||
|
||||
private readonly ILogger<GetDiagnosticsUploadUrlFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
@@ -41,6 +52,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
|
||||
private readonly DeviceAssociationValidator _deviceAssociationValidator;
|
||||
private readonly BootstrapSessionService _bootstrapSessionService;
|
||||
private readonly HostedDiagnosticsBlobService _hostedDiagnostics;
|
||||
|
||||
public GetDiagnosticsUploadUrlFunction(
|
||||
ILogger<GetDiagnosticsUploadUrlFunction> logger,
|
||||
@@ -49,7 +61,8 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||||
DeviceAssociationValidator deviceAssociationValidator,
|
||||
BootstrapSessionService bootstrapSessionService)
|
||||
BootstrapSessionService bootstrapSessionService,
|
||||
HostedDiagnosticsBlobService hostedDiagnostics)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
@@ -58,6 +71,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||||
_deviceAssociationValidator = deviceAssociationValidator;
|
||||
_bootstrapSessionService = bootstrapSessionService;
|
||||
_hostedDiagnostics = hostedDiagnostics;
|
||||
}
|
||||
|
||||
[Function("GetDiagnosticsUploadUrl")]
|
||||
@@ -115,39 +129,31 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
|
||||
// Get tenant configuration
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(requestBody.TenantId);
|
||||
var destination = NormalizeDestination(tenantConfig.DiagnosticsUploadDestination);
|
||||
|
||||
if (string.IsNullOrEmpty(tenantConfig.DiagnosticsBlobSasUrl))
|
||||
if (destination == DestinationHosted)
|
||||
{
|
||||
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
await notFound.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = "Diagnostics storage not configured for this tenant"
|
||||
});
|
||||
return notFound;
|
||||
return await IssueHostedSasAsync(req, requestBody);
|
||||
}
|
||||
|
||||
// Parse expiry from the SAS URL's se= parameter
|
||||
var sasExpiry = ParseSasExpiry(tenantConfig.DiagnosticsBlobSasUrl);
|
||||
|
||||
// Log the request — but never log the SAS URL itself
|
||||
_logger.LogInformation(
|
||||
"GetDiagnosticsUploadUrl: Issuing upload URL for tenant {TenantId}, session {SessionId}, file {FileName}, SAS expires {ExpiresAt}",
|
||||
requestBody.TenantId,
|
||||
requestBody.SessionId,
|
||||
requestBody.FileName,
|
||||
sasExpiry?.ToString("O") ?? "unknown");
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
|
||||
if (destination == DestinationCustomerSas)
|
||||
{
|
||||
Success = true,
|
||||
// Container-scoped SAS — accepted risk, see class docstring for rationale
|
||||
UploadUrl = tenantConfig.DiagnosticsBlobSasUrl,
|
||||
ExpiresAt = sasExpiry ?? DateTime.UtcNow.AddHours(1),
|
||||
Message = null
|
||||
return await IssueCustomerSasAsync(req, requestBody, tenantConfig);
|
||||
}
|
||||
|
||||
// Defensive: an unknown destination string in the row (typo or manual edit)
|
||||
// is treated as an error rather than silently falling back — fail loud so
|
||||
// a misconfiguration surfaces immediately.
|
||||
_logger.LogWarning(
|
||||
"GetDiagnosticsUploadUrl: tenant {TenantId} has unknown DiagnosticsUploadDestination={Destination}; rejecting",
|
||||
requestBody.TenantId, tenantConfig.DiagnosticsUploadDestination);
|
||||
var unknownResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await unknownResponse.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = "Diagnostics upload destination is misconfigured for this tenant"
|
||||
});
|
||||
return response;
|
||||
return unknownResponse;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -162,6 +168,109 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseData> IssueCustomerSasAsync(
|
||||
HttpRequestData req, GetDiagnosticsUploadUrlRequest requestBody, TenantConfiguration tenantConfig)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tenantConfig.DiagnosticsBlobSasUrl))
|
||||
{
|
||||
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
await notFound.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = "Diagnostics storage not configured for this tenant"
|
||||
});
|
||||
return notFound;
|
||||
}
|
||||
|
||||
var sasExpiry = ParseSasExpiry(tenantConfig.DiagnosticsBlobSasUrl);
|
||||
|
||||
// Log the request — but never log the SAS URL itself
|
||||
_logger.LogInformation(
|
||||
"GetDiagnosticsUploadUrl: Issuing CustomerSas upload URL for tenant {TenantId}, session {SessionId}, file {FileName}, SAS expires {ExpiresAt}",
|
||||
requestBody.TenantId, requestBody.SessionId, requestBody.FileName,
|
||||
sasExpiry?.ToString("O") ?? "unknown");
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
|
||||
{
|
||||
Success = true,
|
||||
// Container-scoped SAS — accepted risk, see class docstring for rationale
|
||||
UploadUrl = tenantConfig.DiagnosticsBlobSasUrl,
|
||||
// CustomerSas blobs land at the container root; BlobName == request filename
|
||||
BlobName = requestBody.FileName,
|
||||
Destination = DestinationCustomerSas,
|
||||
ExpiresAt = sasExpiry ?? DateTime.UtcNow.AddHours(1),
|
||||
Message = null
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<HttpResponseData> IssueHostedSasAsync(
|
||||
HttpRequestData req, GetDiagnosticsUploadUrlRequest requestBody)
|
||||
{
|
||||
if (string.IsNullOrEmpty(requestBody.FileName))
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = "fileName is required for hosted destination"
|
||||
});
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
HostedUploadSasResult sasResult;
|
||||
try
|
||||
{
|
||||
sasResult = await _hostedDiagnostics.GenerateUploadSasAsync(
|
||||
requestBody.TenantId, requestBody.FileName);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
// Filename validation failed (path separator, traversal, oversize, etc.)
|
||||
_logger.LogWarning(
|
||||
"GetDiagnosticsUploadUrl: rejecting hosted SAS for tenant {TenantId}, file {FileName}: {Reason}",
|
||||
requestBody.TenantId, requestBody.FileName, ex.Message);
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid file name"
|
||||
});
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"GetDiagnosticsUploadUrl: Issuing Hosted upload URL for tenant {TenantId}, session {SessionId}, blob {BlobPath}, SAS expires {ExpiresAt}",
|
||||
requestBody.TenantId, requestBody.SessionId, sasResult.BlobPath, sasResult.ExpiresAt.ToString("O"));
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new GetDiagnosticsUploadUrlResponse
|
||||
{
|
||||
Success = true,
|
||||
UploadUrl = sasResult.UploadUrl,
|
||||
BlobName = sasResult.BlobPath, // {tenantId}/{filename} — agent persists this verbatim
|
||||
Destination = DestinationHosted,
|
||||
ExpiresAt = sasResult.ExpiresAt,
|
||||
Message = null
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes the destination string from TenantConfiguration. Null/empty becomes
|
||||
/// CustomerSas (preserves existing behaviour for tenants that predate this field).
|
||||
/// Case-insensitive match against the two known values; unknown values pass through
|
||||
/// so the caller can reject them with a 500.
|
||||
/// </summary>
|
||||
internal static string NormalizeDestination(string? raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw)) return DestinationCustomerSas;
|
||||
if (string.Equals(raw, DestinationHosted, StringComparison.OrdinalIgnoreCase)) return DestinationHosted;
|
||||
if (string.Equals(raw, DestinationCustomerSas, StringComparison.OrdinalIgnoreCase)) return DestinationCustomerSas;
|
||||
return raw; // unknown — let the caller decide what to do
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the expiry datetime from the se= query parameter of a SAS URL.
|
||||
/// Returns null if the parameter is missing or cannot be parsed.
|
||||
|
||||
+11
-4
@@ -431,16 +431,23 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
|
||||
"Fire-and-forget RecordGatherRuleStatsAsync failed"), TaskContinuationOptions.OnlyOnFaulted);
|
||||
|
||||
// Store diagnostics blob name on session (if agent uploaded a diagnostics package)
|
||||
// Store diagnostics blob name + destination on session (if agent uploaded a diagnostics package)
|
||||
if (classification.DiagnosticsUploadedEvent != null)
|
||||
{
|
||||
var blobName = classification.DiagnosticsUploadedEvent.Data?.ContainsKey("blobName") == true
|
||||
? classification.DiagnosticsUploadedEvent.Data["blobName"]?.ToString()
|
||||
var data = classification.DiagnosticsUploadedEvent.Data;
|
||||
var blobName = data?.ContainsKey("blobName") == true
|
||||
? data["blobName"]?.ToString()
|
||||
: null;
|
||||
// Older agents don't send `destination` — pass null, repo leaves the
|
||||
// column unchanged (legacy-row default at read-time is CustomerSas).
|
||||
var destination = data?.ContainsKey("destination") == true
|
||||
? data["destination"]?.ToString()
|
||||
: null;
|
||||
if (!string.IsNullOrEmpty(blobName))
|
||||
{
|
||||
await _sessionRepo.UpdateSessionDiagnosticsBlobAsync(
|
||||
request.TenantId, request.SessionId, blobName);
|
||||
request.TenantId, request.SessionId, blobName,
|
||||
string.IsNullOrEmpty(destination) ? null : destination);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,8 @@ builder.Services.AddSingleton<BlockedDeviceService>();
|
||||
builder.Services.AddSingleton<HardwareRejectionThrottleService>();
|
||||
builder.Services.AddSingleton<BlockedVersionService>();
|
||||
builder.Services.AddSingleton<BlobStorageService>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Diagnostics.HostedDiagnosticsBlobService>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Diagnostics.DiagnosticsBlobCascadeDeleter>();
|
||||
builder.Services.AddSingleton<SessionReportService>();
|
||||
builder.Services.AddSingleton<BootstrapSessionService>();
|
||||
|
||||
|
||||
@@ -86,12 +86,15 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
};
|
||||
|
||||
// Step A — load the Sessions + SessionsIndex rows up front. Both are needed for the
|
||||
// FINAL tombstone step; the Sessions row also gives us IndexRowKey + DiagnosticsBlobName.
|
||||
// FINAL tombstone step; the Sessions row also gives us IndexRowKey + DiagnosticsBlobName
|
||||
// + DiagnosticsBlobDestination (the destination travels with the manifest so the
|
||||
// cascade can route deletion to the right store even if the tenant later switches modes).
|
||||
var sessionRow = await _reader.GetSessionRowAsync(tenantId, sessionId, cancellationToken);
|
||||
TableEntity? sessionsIndexRow = null;
|
||||
if (sessionRow != null)
|
||||
{
|
||||
manifest.DiagnosticsBlobName = sessionRow.GetString("DiagnosticsBlobName");
|
||||
manifest.DiagnosticsBlobDestination = sessionRow.GetString("DiagnosticsBlobDestination");
|
||||
sessionsIndexRow = await ResolveSessionsIndexRowAsync(tenantId, sessionId, sessionRow, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using AutopilotMonitor.Functions.Services.Diagnostics;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models.Deletion;
|
||||
@@ -51,6 +52,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
private readonly CascadeVerificationService _verifier;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly ISignalRNotificationService _signalR;
|
||||
private readonly Diagnostics.DiagnosticsBlobCascadeDeleter _diagnosticsBlobDeleter;
|
||||
private readonly ILogger<SessionDeletionHandler> _logger;
|
||||
|
||||
public SessionDeletionHandler(
|
||||
@@ -59,6 +61,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
CascadeVerificationService verifier,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
ISignalRNotificationService signalR,
|
||||
Diagnostics.DiagnosticsBlobCascadeDeleter diagnosticsBlobDeleter,
|
||||
ILogger<SessionDeletionHandler> logger)
|
||||
{
|
||||
_storage = storage;
|
||||
@@ -66,6 +69,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
_verifier = verifier;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_signalR = signalR;
|
||||
_diagnosticsBlobDeleter = diagnosticsBlobDeleter;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -236,6 +240,45 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// (5b) Diagnostics-blob delete (plan §5b). Idempotent + 404-safe. The deleter
|
||||
// itself routes by manifest.DiagnosticsBlobDestination (Hosted=always,
|
||||
// CustomerSas=only if SAS has 'd'); a real storage error propagates so the
|
||||
// cascade poisons. The DiagnosticsBlobDeleteDone flag in progress is the
|
||||
// idempotency marker — set unconditionally after the step completes so
|
||||
// retries skip the step regardless of which branch executed.
|
||||
if (!progress.DiagnosticsBlobDeleteDone)
|
||||
{
|
||||
DiagnosticsBlobDeleteOutcome outcome;
|
||||
try
|
||||
{
|
||||
outcome = await _diagnosticsBlobDeleter.DeleteAsync(manifest, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (!(ex is OperationCanceledException))
|
||||
{
|
||||
LogDiagBlobStepFailed(tenantId, sessionId, manifestId, ex);
|
||||
(progress, etag) = await TryPersistFailureContextAsync(
|
||||
tenantId, sessionId, manifestId, progress, etag,
|
||||
failureType: "step_exception",
|
||||
failureMessage: $"diagnostics blob delete: {ex.GetType().Name}: {ex.Message}",
|
||||
observedResidualCount: null,
|
||||
residualSampleJson: null,
|
||||
cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"SessionDeletionHandler diagnostics blob step: tenant={TenantId} session={SessionId} manifestId={ManifestId} outcome={Outcome} blob={Blob} destination={Destination}",
|
||||
tenantId, sessionId, manifestId, outcome,
|
||||
manifest.DiagnosticsBlobName ?? string.Empty,
|
||||
manifest.DiagnosticsBlobDestination ?? string.Empty);
|
||||
|
||||
(progress, etag) = await UpdateProgressWithRetryAsync(
|
||||
tenantId, sessionId, manifestId, progress, etag,
|
||||
mutate: p => p.DiagnosticsBlobDeleteDone = true,
|
||||
isAlreadyApplied: p => p.DiagnosticsBlobDeleteDone,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// (6) FINAL tombstone. Plan §5 PR4 + §16 R2: SessionsIndex first, then Sessions.
|
||||
var tombstone = manifest.Steps.FirstOrDefault(s => s.Class == DeletionStepClass.Final)
|
||||
?? throw new InvalidOperationException($"Manifest has no FINAL step (tenant={tenantId} session={sessionId} manifestId={manifestId}).");
|
||||
@@ -689,5 +732,13 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
tenantId, sessionId, manifestId, step.Order, step.Class, step.Table ?? string.Empty,
|
||||
ex.GetType().FullName ?? "Unknown");
|
||||
}
|
||||
|
||||
private void LogDiagBlobStepFailed(
|
||||
string tenantId, string sessionId, string manifestId, Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"SessionDeletionHandler diagnostics-blob step failed: tenant={TenantId} session={SessionId} manifestId={ManifestId} exceptionType={ExceptionType}",
|
||||
tenantId, sessionId, manifestId, ex.GetType().FullName ?? "Unknown");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models.Deletion;
|
||||
using Azure.Storage.Blobs;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Diagnostics
|
||||
{
|
||||
/// <summary>
|
||||
/// Cascade-delete step for the per-session diagnostics blob (plan §5b).
|
||||
/// Closes the historical gap where the cascade captured
|
||||
/// <see cref="DeletionManifest.DiagnosticsBlobName"/> into the manifest but never
|
||||
/// actually deleted the blob.
|
||||
/// <para>Routing:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Hosted</c>: always delete via
|
||||
/// <see cref="HostedDiagnosticsBlobService.DeleteIfExistsAsync"/>. We own
|
||||
/// the storage; idempotent.</item>
|
||||
/// <item><c>CustomerSas</c> (or null=legacy): only delete when the customer's
|
||||
/// SAS includes the Delete (<c>d</c>) permission. Otherwise log + skip so
|
||||
/// the customer's lifecycle rules remain the source of truth.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// Extracted into its own class (rather than a method on SessionDeletionHandler) so
|
||||
/// the handler keeps its existing constructor surface stable for the three existing
|
||||
/// test harnesses — they only need to pass a no-op fake/mock of THIS deleter.
|
||||
/// </summary>
|
||||
public class DiagnosticsBlobCascadeDeleter
|
||||
{
|
||||
private readonly HostedDiagnosticsBlobService _hostedDiagnostics;
|
||||
private readonly TenantConfigurationService _tenantConfig;
|
||||
private readonly ILogger<DiagnosticsBlobCascadeDeleter> _logger;
|
||||
|
||||
public DiagnosticsBlobCascadeDeleter(
|
||||
HostedDiagnosticsBlobService hostedDiagnostics,
|
||||
TenantConfigurationService tenantConfig,
|
||||
ILogger<DiagnosticsBlobCascadeDeleter> logger)
|
||||
{
|
||||
_hostedDiagnostics = hostedDiagnostics;
|
||||
_tenantConfig = tenantConfig;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: parameterless ctor for subclasses that override <see cref="DeleteAsync"/>
|
||||
/// entirely. The dependency fields stay null; the base <see cref="DeleteAsync"/> must
|
||||
/// not be invoked by such subclasses (and the existing
|
||||
/// <c>SessionDeletionHandler</c> test harnesses go through the override, never the
|
||||
/// base). Marked <c>protected</c> so production-tree code can't pick the wrong ctor.
|
||||
/// </summary>
|
||||
protected DiagnosticsBlobCascadeDeleter()
|
||||
{
|
||||
_hostedDiagnostics = null!;
|
||||
_tenantConfig = null!;
|
||||
_logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<DiagnosticsBlobCascadeDeleter>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the diagnostics-blob delete step. Returns a small outcome value the
|
||||
/// caller can log; never throws on the "skip" paths. Storage errors from a real
|
||||
/// delete attempt do propagate so the cascade poison path catches them.
|
||||
/// </summary>
|
||||
public virtual async Task<DiagnosticsBlobDeleteOutcome> DeleteAsync(
|
||||
DeletionManifest manifest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (manifest == null) throw new ArgumentNullException(nameof(manifest));
|
||||
if (string.IsNullOrEmpty(manifest.DiagnosticsBlobName))
|
||||
{
|
||||
return DiagnosticsBlobDeleteOutcome.SkippedNoBlob;
|
||||
}
|
||||
|
||||
var destination = NormalizeDestination(manifest.DiagnosticsBlobDestination);
|
||||
|
||||
if (destination == DestinationHosted)
|
||||
{
|
||||
await _hostedDiagnostics.DeleteIfExistsAsync(manifest.DiagnosticsBlobName, cancellationToken);
|
||||
_logger.LogInformation(
|
||||
"Cascade deleted hosted diagnostics blob tenant={TenantId} blob={Blob}",
|
||||
manifest.TenantId, manifest.DiagnosticsBlobName);
|
||||
return DiagnosticsBlobDeleteOutcome.HostedDeleted;
|
||||
}
|
||||
|
||||
// CustomerSas (or null=legacy treated as CustomerSas)
|
||||
var config = await _tenantConfig.GetConfigurationAsync(manifest.TenantId);
|
||||
var sasUrl = config?.DiagnosticsBlobSasUrl;
|
||||
if (string.IsNullOrEmpty(sasUrl))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Cascade skipping customer diag blob delete: no SAS configured tenant={TenantId} blob={Blob}",
|
||||
manifest.TenantId, manifest.DiagnosticsBlobName);
|
||||
return DiagnosticsBlobDeleteOutcome.SkippedCustomerNoSas;
|
||||
}
|
||||
if (!SasPermissionParser.HasDelete(sasUrl))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Cascade skipping customer diag blob delete: SAS lacks Delete permission tenant={TenantId} blob={Blob}",
|
||||
manifest.TenantId, manifest.DiagnosticsBlobName);
|
||||
return DiagnosticsBlobDeleteOutcome.SkippedCustomerSasLacksDelete;
|
||||
}
|
||||
|
||||
var blobUrl = BuildBlobUrl(sasUrl, manifest.DiagnosticsBlobName);
|
||||
var blobClient = new BlobClient(new Uri(blobUrl));
|
||||
await blobClient.DeleteIfExistsAsync(cancellationToken: cancellationToken);
|
||||
_logger.LogInformation(
|
||||
"Cascade deleted customer diag blob tenant={TenantId} blob={Blob}",
|
||||
manifest.TenantId, manifest.DiagnosticsBlobName);
|
||||
return DiagnosticsBlobDeleteOutcome.CustomerSasDeleted;
|
||||
}
|
||||
|
||||
// Constants kept here as the single source of truth so callers don't drift —
|
||||
// mirror the values used by GetDiagnosticsUploadUrlFunction.
|
||||
internal const string DestinationCustomerSas = "CustomerSas";
|
||||
internal const string DestinationHosted = "Hosted";
|
||||
|
||||
internal static string NormalizeDestination(string? raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw)) return DestinationCustomerSas;
|
||||
if (string.Equals(raw, DestinationHosted, StringComparison.OrdinalIgnoreCase)) return DestinationHosted;
|
||||
if (string.Equals(raw, DestinationCustomerSas, StringComparison.OrdinalIgnoreCase)) return DestinationCustomerSas;
|
||||
// Unknown values are treated as CustomerSas — safer than treating them as
|
||||
// Hosted (which would issue a connection-string-backed delete against the
|
||||
// wrong storage account). Mirrors the agent-side fallback behaviour.
|
||||
return DestinationCustomerSas;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the customer blob URL: <c>{base}/{blobName}{?query}</c>. Mirrors the
|
||||
/// same shape as <c>DiagnosticsDownloadFunction</c>'s CustomerSas branch and
|
||||
/// the agent's <c>BuildBlobUploadUrl</c> CustomerSas branch. Pure helper for
|
||||
/// xUnit pinning.
|
||||
/// </summary>
|
||||
internal static string BuildBlobUrl(string sasUrl, string blobName)
|
||||
{
|
||||
var questionMarkIndex = sasUrl.IndexOf('?');
|
||||
if (questionMarkIndex >= 0)
|
||||
{
|
||||
var basePath = sasUrl.Substring(0, questionMarkIndex).TrimEnd('/');
|
||||
var queryString = sasUrl.Substring(questionMarkIndex);
|
||||
return $"{basePath}/{blobName}{queryString}";
|
||||
}
|
||||
return $"{sasUrl.TrimEnd('/')}/{blobName}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result classification for the diagnostics-blob cascade-delete step. Surfaced
|
||||
/// in structured logs so operators can grep for skip reasons.
|
||||
/// </summary>
|
||||
public enum DiagnosticsBlobDeleteOutcome
|
||||
{
|
||||
/// <summary>Manifest had no DiagnosticsBlobName — nothing to do.</summary>
|
||||
SkippedNoBlob,
|
||||
|
||||
/// <summary>Destination=CustomerSas, but no SAS URL configured on the tenant.</summary>
|
||||
SkippedCustomerNoSas,
|
||||
|
||||
/// <summary>Destination=CustomerSas, SAS exists but lacks the 'd' permission.</summary>
|
||||
SkippedCustomerSasLacksDelete,
|
||||
|
||||
/// <summary>Destination=Hosted; DeleteIfExists issued (404 is success).</summary>
|
||||
HostedDeleted,
|
||||
|
||||
/// <summary>Destination=CustomerSas with 'd' permission; DeleteIfExists issued.</summary>
|
||||
CustomerSasDeleted,
|
||||
}
|
||||
}
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Azure;
|
||||
using Azure.Identity;
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using Azure.Storage.Sas;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Diagnostics
|
||||
{
|
||||
/// <summary>
|
||||
/// Storage helper for the opt-in <c>Hosted</c> diagnostics destination.
|
||||
/// Encapsulates the single container <see cref="Constants.BlobContainers.HostedDiagnostics"/>
|
||||
/// and enforces the tenant-prefixed blob layout
|
||||
/// (<c>{tenantId}/AgentDiagnostics-...zip</c>) so per-tenant enumeration + retention
|
||||
/// deletion can iterate exactly one tenant without cross-tenant exposure.
|
||||
/// <para>
|
||||
/// Authentication path mirrors <see cref="BlobStorageService"/>: Managed Identity
|
||||
/// (<c>AzureStorageAccountName</c>) or connection string (<c>AzureBlobStorageConnectionString</c>).
|
||||
/// SAS issuance for the agent upload-url endpoint differs by auth path — User
|
||||
/// Delegation SAS under MI, account-key SAS under connection-string — both produce
|
||||
/// a blob-scoped, Write+Create-only, 15-min token that cannot reach other tenants'
|
||||
/// prefixes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class HostedDiagnosticsBlobService
|
||||
{
|
||||
private const string ContainerName = Constants.BlobContainers.HostedDiagnostics;
|
||||
|
||||
// Cap on SAS lifetime; 15 min is plenty for a one-shot diagnostics upload while
|
||||
// keeping the exposure window short. Hard cap defends against a misconfigured caller
|
||||
// requesting hour-long tokens.
|
||||
public static readonly TimeSpan MaxUploadSasTtl = TimeSpan.FromMinutes(60);
|
||||
|
||||
private readonly BlobServiceClient _blobServiceClient;
|
||||
private readonly ILogger<HostedDiagnosticsBlobService> _logger;
|
||||
private readonly bool _usesManagedIdentity;
|
||||
private int _containerEnsured;
|
||||
|
||||
public HostedDiagnosticsBlobService(IConfiguration configuration, ILogger<HostedDiagnosticsBlobService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
var storageAccountName = configuration["AzureStorageAccountName"];
|
||||
var connectionString = configuration["AzureBlobStorageConnectionString"];
|
||||
|
||||
if (!string.IsNullOrEmpty(storageAccountName))
|
||||
{
|
||||
var blobUri = new Uri($"https://{storageAccountName}.blob.core.windows.net");
|
||||
_blobServiceClient = new BlobServiceClient(blobUri, new DefaultAzureCredential());
|
||||
_usesManagedIdentity = true;
|
||||
_logger.LogInformation(
|
||||
"HostedDiagnosticsBlobService initialized with Managed Identity (account: {Account}, container: {Container})",
|
||||
storageAccountName, ContainerName);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(connectionString))
|
||||
{
|
||||
_blobServiceClient = new BlobServiceClient(connectionString);
|
||||
_usesManagedIdentity = false;
|
||||
_logger.LogInformation(
|
||||
"HostedDiagnosticsBlobService initialized with connection string (container: {Container})",
|
||||
ContainerName);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"HostedDiagnosticsBlobService not configured. Set either 'AzureStorageAccountName' (Managed Identity) or 'AzureBlobStorageConnectionString'.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: construct from a (possibly Moq'd) <see cref="BlobServiceClient"/>.
|
||||
/// Public so the Moq dynamic proxy assembly can call it without InternalsVisibleTo
|
||||
/// gymnastics.
|
||||
/// </summary>
|
||||
public HostedDiagnosticsBlobService(
|
||||
BlobServiceClient blobServiceClient,
|
||||
ILogger<HostedDiagnosticsBlobService> logger,
|
||||
bool usesManagedIdentity = false)
|
||||
{
|
||||
_blobServiceClient = blobServiceClient ?? throw new ArgumentNullException(nameof(blobServiceClient));
|
||||
_logger = logger;
|
||||
_usesManagedIdentity = usesManagedIdentity;
|
||||
}
|
||||
|
||||
// -------- Public API --------
|
||||
|
||||
/// <summary>
|
||||
/// Mints a blob-scoped, Write+Create-only SAS URI pinned to the EXACT path
|
||||
/// <c>{tenantId}/{filename}</c> for an agent's diagnostics-package upload. Cannot
|
||||
/// be redirected to other tenants' prefixes. TTL is capped at
|
||||
/// <see cref="MaxUploadSasTtl"/> to keep the exposure window tight.
|
||||
/// </summary>
|
||||
public virtual async Task<HostedUploadSasResult> GenerateUploadSasAsync(
|
||||
string tenantId, string filename, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
ValidateFilename(filename);
|
||||
|
||||
var effectiveTtl = ttl ?? TimeSpan.FromMinutes(15);
|
||||
if (effectiveTtl > MaxUploadSasTtl)
|
||||
effectiveTtl = MaxUploadSasTtl;
|
||||
if (effectiveTtl < TimeSpan.FromMinutes(1))
|
||||
effectiveTtl = TimeSpan.FromMinutes(1);
|
||||
|
||||
var blobPath = BuildBlobPath(tenantId, filename);
|
||||
var expiresOn = DateTimeOffset.UtcNow.Add(effectiveTtl);
|
||||
|
||||
await EnsureContainerAsync(cancellationToken);
|
||||
|
||||
var sasUri = await BuildUploadSasUriAsync(blobPath, expiresOn, cancellationToken);
|
||||
|
||||
return new HostedUploadSasResult
|
||||
{
|
||||
UploadUrl = sasUri.ToString(),
|
||||
BlobPath = blobPath,
|
||||
ExpiresAt = expiresOn.UtcDateTime,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a read stream over a hosted-diagnostics blob. Caller is responsible for
|
||||
/// disposing the returned response (it owns the underlying network stream).
|
||||
/// 404 propagates as <see cref="RequestFailedException"/>.
|
||||
/// </summary>
|
||||
public virtual async Task<Response<BlobDownloadStreamingResult>> OpenReadAsync(
|
||||
string blobPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateBlobPath(blobPath);
|
||||
var blobClient = GetContainerClient().GetBlobClient(blobPath);
|
||||
return await blobClient.DownloadStreamingAsync(cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the size + content-type of a hosted-diagnostics blob without streaming the
|
||||
/// payload. Used by the download path's pre-size check.
|
||||
/// </summary>
|
||||
public virtual async Task<BlobProperties> GetPropertiesAsync(
|
||||
string blobPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateBlobPath(blobPath);
|
||||
var blobClient = GetContainerClient().GetBlobClient(blobPath);
|
||||
var response = await blobClient.GetPropertiesAsync(cancellationToken: cancellationToken);
|
||||
return response.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent delete used by the cascade-delete worker. A 404 is a successful no-op
|
||||
/// (replay-safe). Other storage errors propagate so the cascade poison path catches them.
|
||||
/// </summary>
|
||||
public virtual async Task<bool> DeleteIfExistsAsync(
|
||||
string blobPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateBlobPath(blobPath);
|
||||
var blobClient = GetContainerClient().GetBlobClient(blobPath);
|
||||
var response = await blobClient.DeleteIfExistsAsync(cancellationToken: cancellationToken);
|
||||
return response.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates blobs under a single tenant's prefix (<c>{tenantId}/</c>).
|
||||
/// Used by ad-hoc operator scripts and (later) any orphan-scan job.
|
||||
/// </summary>
|
||||
public virtual async IAsyncEnumerable<BlobItem> EnumerateByTenantAsync(
|
||||
string tenantId,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
var containerClient = GetContainerClient();
|
||||
if (!await containerClient.ExistsAsync(cancellationToken))
|
||||
yield break;
|
||||
|
||||
// Trailing slash bounds the match so tenant "abc" doesn't also pull "abcde/...".
|
||||
var prefix = tenantId + "/";
|
||||
await foreach (var item in containerClient.GetBlobsAsync(
|
||||
BlobTraits.None, BlobStates.None, prefix: prefix, cancellationToken: cancellationToken))
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Path helpers --------
|
||||
|
||||
/// <summary>
|
||||
/// Builds the canonical blob path for a tenant + agent-supplied filename.
|
||||
/// Always <c>{tenantId}/{filename}</c>; tenants cannot escape their prefix because
|
||||
/// the filename is validated to reject path separators.
|
||||
/// </summary>
|
||||
internal static string BuildBlobPath(string tenantId, string filename)
|
||||
=> $"{tenantId}/{filename}";
|
||||
|
||||
// -------- Internals --------
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: returns the cached/built container client. Override in xUnit to inject
|
||||
/// a Moq'd <see cref="BlobContainerClient"/> + verify interactions.
|
||||
/// </summary>
|
||||
protected virtual BlobContainerClient GetContainerClient()
|
||||
=> _blobServiceClient.GetBlobContainerClient(ContainerName);
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: returns the configured authentication mode. Production reads from
|
||||
/// the ctor flag; tests subclass to force a specific path through the SAS builder.
|
||||
/// </summary>
|
||||
protected virtual bool UsesManagedIdentity => _usesManagedIdentity;
|
||||
|
||||
/// <summary>
|
||||
/// Lazy create-if-not-exists, guarded by an interlocked flag so we don't pay for
|
||||
/// the HEAD on every upload-url request after the first. The Interlocked gate
|
||||
/// lives here (NOT in the override-able seam) so subclasses can't accidentally
|
||||
/// defeat the single-shot semantic. The storage call itself goes through
|
||||
/// <see cref="CreateContainerIfNotExistsCoreAsync"/> for test isolation.
|
||||
/// </summary>
|
||||
protected async Task EnsureContainerAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _containerEnsured, 0, 0) == 1)
|
||||
return;
|
||||
await CreateContainerIfNotExistsCoreAsync(cancellationToken);
|
||||
Interlocked.Exchange(ref _containerEnsured, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam for the actual <c>CreateIfNotExistsAsync</c> call. Production hits
|
||||
/// the Azure SDK; tests subclass to count invocations + assert the single-shot
|
||||
/// guard works without spinning up Azurite.
|
||||
/// </summary>
|
||||
protected virtual async Task CreateContainerIfNotExistsCoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await GetContainerClient().CreateIfNotExistsAsync(cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the blob-scoped Write+Create-only SAS URI for upload. Branch on the
|
||||
/// auth mode: MI uses User Delegation SAS (no account key needed); connection
|
||||
/// string uses the underlying StorageSharedKeyCredential via
|
||||
/// <see cref="BlobClient.GenerateSasUri(BlobSasBuilder)"/>.
|
||||
/// </summary>
|
||||
protected virtual async Task<Uri> BuildUploadSasUriAsync(
|
||||
string blobPath, DateTimeOffset expiresOn, CancellationToken cancellationToken)
|
||||
{
|
||||
var blobClient = GetContainerClient().GetBlobClient(blobPath);
|
||||
|
||||
var sasBuilder = new BlobSasBuilder
|
||||
{
|
||||
BlobContainerName = ContainerName,
|
||||
BlobName = blobPath,
|
||||
Resource = "b", // blob-scoped, NOT container-scoped
|
||||
StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5), // clock-skew tolerance
|
||||
ExpiresOn = expiresOn,
|
||||
};
|
||||
// Write + Create only — agent must not be able to Read other tenants' blobs,
|
||||
// Delete anything, or List the container.
|
||||
sasBuilder.SetPermissions(BlobSasPermissions.Write | BlobSasPermissions.Create);
|
||||
|
||||
if (UsesManagedIdentity)
|
||||
{
|
||||
var delegationKey = await _blobServiceClient.GetUserDelegationKeyAsync(
|
||||
DateTimeOffset.UtcNow.AddMinutes(-5), expiresOn, cancellationToken);
|
||||
|
||||
var sasUri = new BlobUriBuilder(blobClient.Uri)
|
||||
{
|
||||
Sas = sasBuilder.ToSasQueryParameters(delegationKey, _blobServiceClient.AccountName),
|
||||
};
|
||||
return sasUri.ToUri();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!blobClient.CanGenerateSasUri)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"BlobClient cannot generate a SAS URI — connection string is missing the account key.");
|
||||
}
|
||||
return blobClient.GenerateSasUri(sasBuilder);
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Validation --------
|
||||
|
||||
/// <summary>
|
||||
/// Validates an agent-supplied filename: no path separators (cannot escape the
|
||||
/// <c>{tenantId}/</c> prefix), no <c>..</c> traversal, no null bytes, reasonable
|
||||
/// length. Throws <see cref="ArgumentException"/> on rejection.
|
||||
/// </summary>
|
||||
internal static void ValidateFilename(string filename)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filename))
|
||||
throw new ArgumentException("filename is required", nameof(filename));
|
||||
if (filename.Length > 256)
|
||||
throw new ArgumentException("filename exceeds 256 chars", nameof(filename));
|
||||
if (filename.Contains('/') || filename.Contains('\\'))
|
||||
throw new ArgumentException("filename must not contain path separators", nameof(filename));
|
||||
if (filename.Contains(".."))
|
||||
throw new ArgumentException("filename must not contain '..'", nameof(filename));
|
||||
if (filename.Contains('\0'))
|
||||
throw new ArgumentException("filename must not contain null bytes", nameof(filename));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a stored blob path (<c>{tenantId}/{filename}</c>) on the read paths.
|
||||
/// Defends the download proxy against path-traversal even if a row was tampered with.
|
||||
/// </summary>
|
||||
internal static void ValidateBlobPath(string blobPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(blobPath))
|
||||
throw new ArgumentException("blobPath is required", nameof(blobPath));
|
||||
if (blobPath.Contains("..") || blobPath.Contains('\0'))
|
||||
throw new ArgumentException("blobPath must not contain '..' or null bytes", nameof(blobPath));
|
||||
// Expected shape: exactly one '/' separator between tenantId and filename.
|
||||
var slashIndex = blobPath.IndexOf('/');
|
||||
if (slashIndex <= 0 || slashIndex == blobPath.Length - 1)
|
||||
throw new ArgumentException("blobPath must be of the form '{tenantId}/{filename}'", nameof(blobPath));
|
||||
// Defensive: reject backslash so callers can't smuggle Windows-style paths past
|
||||
// platforms that normalize them differently.
|
||||
if (blobPath.Contains('\\'))
|
||||
throw new ArgumentException("blobPath must not contain backslashes", nameof(blobPath));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return value of <see cref="HostedDiagnosticsBlobService.GenerateUploadSasAsync"/>.
|
||||
/// </summary>
|
||||
public sealed class HostedUploadSasResult
|
||||
{
|
||||
/// <summary>Full https URL with SAS query string. Agent PUTs the ZIP payload here.</summary>
|
||||
public string UploadUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical blob path inside the hosted container (<c>{tenantId}/{filename}</c>).
|
||||
/// Caller (the upload-url Function) returns this to the agent so the agent can
|
||||
/// persist it as <c>DiagnosticsBlobName</c> via the ingest path.
|
||||
/// </summary>
|
||||
public string BlobPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>UTC expiry of the SAS — surfaced back to the agent for telemetry only.</summary>
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Web;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Diagnostics
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses Azure Storage SAS permission strings (the <c>sp=</c> query parameter)
|
||||
/// and answers permission-presence questions. Used by the cascade-delete worker to
|
||||
/// decide whether it can clean up a customer's diagnostics blob — if the customer's
|
||||
/// SAS lacks the Delete (<c>d</c>) permission we log + skip; the customer remains
|
||||
/// responsible for their own lifecycle rules.
|
||||
/// <para>
|
||||
/// Pure static helper, no dependencies. Tolerant of malformed input — every
|
||||
/// parse-or-extract failure returns <c>false</c> so the caller's "skip-delete"
|
||||
/// branch is taken rather than throwing in the middle of a cascade.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class SasPermissionParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true when the SAS URL's <c>sp=</c> permission string contains the
|
||||
/// Delete flag (<c>d</c>). Returns false on null/empty input, missing query
|
||||
/// string, missing <c>sp</c> parameter, or any parse error.
|
||||
/// </summary>
|
||||
public static bool HasDelete(string? sasUrl)
|
||||
=> HasPermission(sasUrl, 'd');
|
||||
|
||||
/// <summary>
|
||||
/// Generic permission probe. Accepts a single character (one of
|
||||
/// <c>r w c d l a u</c> etc.) and answers case-insensitively. Exposed so
|
||||
/// future callers can ask about other permissions without re-implementing
|
||||
/// the parse.
|
||||
/// </summary>
|
||||
public static bool HasPermission(string? sasUrl, char permissionFlag)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sasUrl)) return false;
|
||||
try
|
||||
{
|
||||
var queryIndex = sasUrl!.IndexOf('?');
|
||||
if (queryIndex < 0) return false;
|
||||
|
||||
var query = HttpUtility.ParseQueryString(sasUrl.Substring(queryIndex));
|
||||
var sp = query["sp"];
|
||||
if (string.IsNullOrEmpty(sp)) return false;
|
||||
|
||||
// SAS permissions are a flat character set: e.g. "rwdlac" for full,
|
||||
// "rwc" for write-only-with-create. Case-insensitive per Azure spec.
|
||||
var needle = char.ToLowerInvariant(permissionFlag);
|
||||
foreach (var c in sp)
|
||||
{
|
||||
if (char.ToLowerInvariant(c) == needle) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,13 +278,20 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
if (classification.DiagnosticsUploadedEvent != null)
|
||||
{
|
||||
var blobName = classification.DiagnosticsUploadedEvent.Data?.ContainsKey("blobName") == true
|
||||
? classification.DiagnosticsUploadedEvent.Data["blobName"]?.ToString()
|
||||
var data = classification.DiagnosticsUploadedEvent.Data;
|
||||
var blobName = data?.ContainsKey("blobName") == true
|
||||
? data["blobName"]?.ToString()
|
||||
: null;
|
||||
// Older agents don't send `destination` — pass null, repo leaves the
|
||||
// column unchanged (legacy-row default at read-time is CustomerSas).
|
||||
var destination = data?.ContainsKey("destination") == true
|
||||
? data["destination"]?.ToString()
|
||||
: null;
|
||||
if (!string.IsNullOrEmpty(blobName))
|
||||
{
|
||||
await _sessionRepo.UpdateSessionDiagnosticsBlobAsync(
|
||||
request.TenantId, request.SessionId, blobName);
|
||||
request.TenantId, request.SessionId, blobName,
|
||||
string.IsNullOrEmpty(destination) ? null : destination);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (!string.IsNullOrEmpty(diagnosticsBlobName))
|
||||
indexEntity["DiagnosticsBlobName"] = diagnosticsBlobName;
|
||||
|
||||
var diagnosticsBlobDestination = sessionEntity.GetString("DiagnosticsBlobDestination");
|
||||
if (!string.IsNullOrEmpty(diagnosticsBlobDestination))
|
||||
indexEntity["DiagnosticsBlobDestination"] = diagnosticsBlobDestination;
|
||||
|
||||
var lastEventAt = sessionEntity.GetDateTimeOffset("LastEventAt")?.UtcDateTime;
|
||||
if (lastEventAt.HasValue)
|
||||
indexEntity["LastEventAt"] = EnsureUtc(lastEventAt.Value);
|
||||
@@ -210,6 +214,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
DurationSeconds = ComputeEffectiveDuration(entity, status, startedAt, completedAt),
|
||||
EnrollmentType = entity.GetString("EnrollmentType") ?? "v1",
|
||||
DiagnosticsBlobName = entity.GetString("DiagnosticsBlobName"),
|
||||
DiagnosticsBlobDestination = entity.GetString("DiagnosticsBlobDestination"),
|
||||
LastEventAt = SafeGetDateTime(entity, "LastEventAt"),
|
||||
IsPreProvisioned = entity.GetBoolean("IsPreProvisioned") ?? false,
|
||||
IsHybridJoin = entity.GetBoolean("IsHybridJoin") ?? false,
|
||||
@@ -294,6 +299,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
DateTime? lastEventAt = null;
|
||||
int? durationSeconds = null;
|
||||
string? diagnosticsBlobName = null;
|
||||
string? diagnosticsBlobDestination = null;
|
||||
DateTime? resumedAt = null;
|
||||
DateTime? stalledAt = null;
|
||||
string geoCountry = string.Empty;
|
||||
@@ -330,6 +336,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
lastEventAt = existingEntity.GetDateTimeOffset("LastEventAt")?.UtcDateTime;
|
||||
durationSeconds = existingEntity.GetInt32("DurationSeconds");
|
||||
diagnosticsBlobName = existingEntity.GetString("DiagnosticsBlobName");
|
||||
diagnosticsBlobDestination = existingEntity.GetString("DiagnosticsBlobDestination");
|
||||
resumedAt = existingEntity.GetDateTimeOffset("ResumedAt")?.UtcDateTime;
|
||||
stalledAt = existingEntity.GetDateTimeOffset("StalledAt")?.UtcDateTime;
|
||||
geoCountry = existingEntity.GetString("GeoCountry") ?? string.Empty;
|
||||
@@ -411,6 +418,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (!string.IsNullOrWhiteSpace(diagnosticsBlobName))
|
||||
entity["DiagnosticsBlobName"] = diagnosticsBlobName;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(diagnosticsBlobDestination))
|
||||
entity["DiagnosticsBlobDestination"] = diagnosticsBlobDestination;
|
||||
|
||||
if (resumedAt.HasValue)
|
||||
entity["ResumedAt"] = EnsureUtc(resumedAt.Value);
|
||||
|
||||
@@ -1843,9 +1853,15 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores the diagnostics blob name on an existing session (Merge-mode, single field update).
|
||||
/// Stores the diagnostics blob name + destination on an existing session
|
||||
/// (Merge-mode, two-field update written together so the download path always
|
||||
/// sees a consistent (name, destination) pair). Destination encodes whether the
|
||||
/// blob lives in the customer's SAS-backed container or in the backend's hosted
|
||||
/// container — required so that a later tenant switch from one destination to
|
||||
/// the other doesn't break downloads for already-uploaded sessions.
|
||||
/// </summary>
|
||||
public async Task UpdateSessionDiagnosticsBlobAsync(string tenantId, string sessionId, string blobName)
|
||||
public async Task UpdateSessionDiagnosticsBlobAsync(
|
||||
string tenantId, string sessionId, string blobName, string? destination = null)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
|
||||
@@ -1860,13 +1876,20 @@ namespace AutopilotMonitor.Functions.Services
|
||||
["DiagnosticsBlobName"] = blobName
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(destination))
|
||||
{
|
||||
update["DiagnosticsBlobDestination"] = destination;
|
||||
}
|
||||
|
||||
await tableClient.UpdateEntityAsync(update, entity.Value.ETag, Azure.Data.Tables.TableUpdateMode.Merge);
|
||||
|
||||
// Dual-write: merge into SessionsIndex
|
||||
var indexRowKey = entity.Value.GetString("IndexRowKey");
|
||||
await MergeSessionIndexAsync(tenantId, indexRowKey, update);
|
||||
|
||||
_logger.LogInformation($"Stored diagnostics blob name for session {sessionId}: {blobName}");
|
||||
_logger.LogInformation(
|
||||
"Stored diagnostics blob name for session {SessionId}: {BlobName} (destination={Destination})",
|
||||
sessionId, blobName, destination ?? "(unchanged)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -2094,6 +2117,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
DurationSeconds = ComputeEffectiveDuration(entity, status, startedAt, completedAt),
|
||||
EnrollmentType = entity.GetString("EnrollmentType") ?? "v1",
|
||||
DiagnosticsBlobName = entity.GetString("DiagnosticsBlobName"),
|
||||
// Legacy rows that predate this field surface as null → download path treats
|
||||
// null as "CustomerSas" for back-compat.
|
||||
DiagnosticsBlobDestination = entity.GetString("DiagnosticsBlobDestination"),
|
||||
LastEventAt = SafeGetDateTime(entity, "LastEventAt"),
|
||||
IsPreProvisioned = entity.GetBoolean("IsPreProvisioned") ?? false,
|
||||
IsHybridJoin = entity.GetBoolean("IsHybridJoin") ?? false,
|
||||
|
||||
@@ -626,6 +626,18 @@ namespace AutopilotMonitor.Shared
|
||||
/// defense-in-depth second-pass.
|
||||
/// </summary>
|
||||
public const string OffboardingState = "offboarding-state";
|
||||
|
||||
/// <summary>
|
||||
/// Hosted diagnostics destination: tenant agents may upload their diagnostics
|
||||
/// package directly into the backend's own storage account when the tenant admin
|
||||
/// opts in via <see cref="Models.TenantConfiguration.DiagnosticsUploadDestination"/>
|
||||
/// = <c>"Hosted"</c>. Blobs are tenant-scoped via a <c>{tenantId}/</c> prefix
|
||||
/// (<c>{tenantId}/AgentDiagnostics-{sessionId}-{ts}.zip</c>) so per-tenant
|
||||
/// enumeration + retention deletion can iterate exactly one tenant without
|
||||
/// cross-tenant exposure. The opposite destination, <c>"CustomerSas"</c>, leaves
|
||||
/// the blob in the customer's own storage and never touches this container.
|
||||
/// </summary>
|
||||
public const string HostedDiagnostics = "diagnostics";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -76,7 +76,8 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null,
|
||||
EnrollmentPhase? currentPhase = null,
|
||||
int platformScriptIncrement = 0, int remediationScriptIncrement = 0);
|
||||
Task UpdateSessionDiagnosticsBlobAsync(string tenantId, string sessionId, string blobName);
|
||||
Task UpdateSessionDiagnosticsBlobAsync(
|
||||
string tenantId, string sessionId, string blobName, string? destination = null);
|
||||
Task SetSessionPreProvisionedAsync(string tenantId, string sessionId, bool isPreProvisioned,
|
||||
SessionStatus? status = null, bool? isUserDriven = null);
|
||||
Task UpdateSessionGeoAsync(string tenantId, string sessionId,
|
||||
|
||||
@@ -401,17 +401,35 @@ namespace AutopilotMonitor.Shared.Models
|
||||
|
||||
/// <summary>
|
||||
/// Azure Blob Storage Container SAS URL for diagnostics package upload.
|
||||
/// Used only when <see cref="DiagnosticsUploadDestination"/> is <c>"CustomerSas"</c>.
|
||||
/// Each tenant provides their own container — data stays in the customer's storage.
|
||||
/// If null or empty, diagnostics upload is disabled.
|
||||
/// If null or empty (and destination is CustomerSas), diagnostics upload is disabled.
|
||||
/// </summary>
|
||||
public string DiagnosticsBlobSasUrl { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// When to upload diagnostics packages: "Off", "Always", "OnFailure".
|
||||
/// Default: "Off"
|
||||
/// Applies to both destinations. Default: "Off"
|
||||
/// </summary>
|
||||
public string DiagnosticsUploadMode { get; set; } = "Off";
|
||||
|
||||
/// <summary>
|
||||
/// Where diagnostics packages should be uploaded:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>"CustomerSas"</c> (default) — customer's own storage account via the SAS URL
|
||||
/// in <see cref="DiagnosticsBlobSasUrl"/>. Preserves existing behaviour; no data
|
||||
/// leaves the customer's Azure tenant boundary.</item>
|
||||
/// <item><c>"Hosted"</c> — opt-in only. Blobs land in the backend's storage account
|
||||
/// under <c>{tenantId}/AgentDiagnostics-...zip</c> in the
|
||||
/// <see cref="Constants.BlobContainers.HostedDiagnostics"/> container. Requires
|
||||
/// an explicit admin click in the tenant settings UI with a clearly-marked
|
||||
/// "data leaves your tenant" disclosure — never set silently.</item>
|
||||
/// </list>
|
||||
/// Default <c>"CustomerSas"</c> so existing tenants without the field set behave
|
||||
/// identically to today and customer data is never silently routed to hosted storage.
|
||||
/// </summary>
|
||||
public string DiagnosticsUploadDestination { get; set; } = "CustomerSas";
|
||||
|
||||
// ===== TRACE EVENTS =====
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -38,11 +38,24 @@ namespace AutopilotMonitor.Shared.Models.Deletion
|
||||
public Dictionary<string, int> PreflightCounts { get; set; } = new Dictionary<string, int>();
|
||||
|
||||
/// <summary>
|
||||
/// Customer-storage diagnostics blob name (informational only — cascade does NOT delete
|
||||
/// the blob; customer owns the storage account). See plan §3 + §11.
|
||||
/// Diagnostics blob name (e.g. <c>AgentDiagnostics-...zip</c> for CustomerSas, or
|
||||
/// <c>{tenantId}/AgentDiagnostics-...zip</c> for Hosted). The cascade DOES delete
|
||||
/// the blob when it's hosted in the backend's own storage; for CustomerSas it
|
||||
/// only attempts deletion when the customer's SAS includes the <c>d</c>
|
||||
/// permission (otherwise the customer's lifecycle rules remain the source of
|
||||
/// truth). See plan §5b.
|
||||
/// </summary>
|
||||
public string? DiagnosticsBlobName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Where the diagnostics blob lives: <c>"CustomerSas"</c>, <c>"Hosted"</c>, or
|
||||
/// null on legacy sessions (treated as <c>"CustomerSas"</c> by the cascade for
|
||||
/// back-compat). Captured at preflight from the Sessions row so the cascade
|
||||
/// can route deletion to the right store even after a tenant has switched
|
||||
/// destinations for new uploads.
|
||||
/// </summary>
|
||||
public string? DiagnosticsBlobDestination { get; set; }
|
||||
|
||||
/// <summary>Ordered cascade steps. The worker executes them in <see cref="DeletionStep.Order"/> order.</summary>
|
||||
public List<DeletionStep> Steps { get; set; } = new List<DeletionStep>();
|
||||
|
||||
@@ -183,6 +196,15 @@ namespace AutopilotMonitor.Shared.Models.Deletion
|
||||
{
|
||||
public const string SoftwareInventoryDecrement = "SoftwareInventoryDecrement";
|
||||
public const string Tombstone = "Tombstone";
|
||||
|
||||
/// <summary>
|
||||
/// Cascade-delete of the per-session diagnostics blob. Skips when
|
||||
/// <see cref="DeletionManifest.DiagnosticsBlobName"/> is null/empty. For
|
||||
/// <c>Hosted</c> destination the cascade always deletes (we own the storage);
|
||||
/// for <c>CustomerSas</c> it deletes only when the SAS includes the Delete
|
||||
/// permission. 404 from the storage layer is an idempotent no-op.
|
||||
/// </summary>
|
||||
public const string DiagnosticsBlob = "DiagnosticsBlob";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -23,6 +23,18 @@ namespace AutopilotMonitor.Shared.Models.Deletion
|
||||
/// <summary>UTC timestamp once the FINAL tombstone step has completed; null while in flight.</summary>
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan §5b: set once the cascade has executed (or intentionally skipped) the
|
||||
/// diagnostics-blob delete step. Idempotency marker so a retry after a partial
|
||||
/// success doesn't issue a second DELETE. The step runs after verification +
|
||||
/// before tombstone; on completion this flag flips true regardless of whether
|
||||
/// the cascade actually issued a delete (Hosted/CustomerSas-with-permission) or
|
||||
/// skipped (CustomerSas-without-permission/no-blob/legacy). Default false on
|
||||
/// pre-§5b progress blobs — they re-enter the step on resume and do the right
|
||||
/// thing (delete-if-exists is 404-safe; skip is a no-op).
|
||||
/// </summary>
|
||||
public bool DiagnosticsBlobDeleteDone { get; set; }
|
||||
|
||||
// ============================================================ PR4c additions ====
|
||||
// Three additive fields close the per-key idempotency + tombstone-gap correctness holes
|
||||
// discovered by Codex review of PR4 + PR4b. All three default to safe values
|
||||
|
||||
@@ -163,6 +163,31 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string? UploadUrl { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical blob path the agent MUST persist as
|
||||
/// <c>SessionSummary.DiagnosticsBlobName</c> after a successful upload. Encodes
|
||||
/// the destination-specific path layout so the download path can find the blob
|
||||
/// later without having to reconstruct it:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>CustomerSas</c>: equal to the request filename (e.g.
|
||||
/// <c>AgentDiagnostics-...zip</c>).</item>
|
||||
/// <item><c>Hosted</c>: tenant-prefixed (e.g.
|
||||
/// <c>{tenantId}/AgentDiagnostics-...zip</c>).</item>
|
||||
/// </list>
|
||||
/// Null for older backends that predate this field — the agent then falls back
|
||||
/// to the request filename for back-compat.
|
||||
/// </summary>
|
||||
public string? BlobName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Which destination minted the SAS in <see cref="UploadUrl"/>:
|
||||
/// <c>"CustomerSas"</c> or <c>"Hosted"</c>. Surfaced so the agent's ingest path
|
||||
/// can attach it to the next telemetry batch (the download path needs the
|
||||
/// per-session destination, NOT the current tenant setting — those can diverge
|
||||
/// if the admin switches modes after some sessions have already uploaded).
|
||||
/// </summary>
|
||||
public string? Destination { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -219,10 +244,35 @@ namespace AutopilotMonitor.Shared.Models
|
||||
|
||||
/// <summary>
|
||||
/// Blob name of the uploaded diagnostics archive (null if not uploaded).
|
||||
/// Used to construct a download URL via the tenant's Blob Storage SAS URL.
|
||||
/// Used to construct a download URL.
|
||||
/// <para>
|
||||
/// Path semantics depend on <see cref="DiagnosticsBlobDestination"/>:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>CustomerSas</c>: blob name only (e.g. <c>AgentDiagnostics-...zip</c>);
|
||||
/// download builds the URL from the tenant's container SAS.</item>
|
||||
/// <item><c>Hosted</c>: full path including the <c>{tenantId}/</c> prefix
|
||||
/// (e.g. <c>{tenantId}/AgentDiagnostics-...zip</c>); download streams
|
||||
/// directly via the backend connection string.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string DiagnosticsBlobName { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Where the diagnostics archive for THIS session was uploaded.
|
||||
/// Frozen at upload time so the download path can route correctly even if the
|
||||
/// tenant later switches <see cref="TenantConfiguration.DiagnosticsUploadDestination"/>.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>"CustomerSas"</c> — blob lives in the customer's storage; download uses
|
||||
/// the tenant's SAS URL (current behaviour).</item>
|
||||
/// <item><c>"Hosted"</c> — blob lives in the backend's storage under
|
||||
/// <c>{tenantId}/</c>; download streams via the Functions connection string.</item>
|
||||
/// <item><c>null</c> (legacy rows that predate this field) — treated as
|
||||
/// <c>"CustomerSas"</c> by the download path for back-compat.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public string? DiagnosticsBlobDestination { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of the most recently received event for this session.
|
||||
/// Updated on every event batch ingestion. Used by maintenance to detect
|
||||
|
||||
@@ -270,8 +270,13 @@ export function SectionSettings() {
|
||||
|
||||
{/* Diagnostics Package */}
|
||||
<SettingsGroup title="Diagnostics Package" color="bg-amber-50 text-amber-900" borderColor="border-amber-200">
|
||||
<SettingsRow name="Upload destination" defaultVal="Your own Azure Blob Storage">
|
||||
Where diagnostics packages are uploaded:{" "}
|
||||
<strong>Your own Azure Blob Storage</strong> (default) — agents upload to a container in your tenant via a Container SAS URL you provide; data never leaves your Azure tenant.{" "}
|
||||
<strong>Hosted storage</strong> (opt-in) — agents upload to the AutopilotMonitor backend's Azure Storage under a per-tenant prefix, using short-lived (15 min), blob-scoped, write-only tokens minted per upload. No long-lived credentials are stored. Switching destinations requires an explicit admin click and a Save — never silent.
|
||||
</SettingsRow>
|
||||
<SettingsRow name="Blob Storage Container SAS URL">
|
||||
An Azure Blob Storage Container SAS URL used for diagnostics package uploads. The SAS URL must grant
|
||||
Used only when the destination is <strong>Your own Azure Blob Storage</strong>. An Azure Blob Storage Container SAS URL used for diagnostics package uploads. The SAS URL must grant
|
||||
at minimum <strong>Read</strong>, <strong>Write</strong>, and <strong>Create</strong> permissions at the
|
||||
container level. The SAS URL is stored securely in the backend and never sent to devices — the agent
|
||||
requests a short-lived upload URL from the backend just before uploading. The portal shows an expiry
|
||||
@@ -282,7 +287,7 @@ export function SectionSettings() {
|
||||
<strong>Off</strong> — never upload,{" "}
|
||||
<strong>Always</strong> — upload after every session,{" "}
|
||||
<strong>On Failure Only</strong> — upload only when the session ends in failure (recommended if storage costs
|
||||
are a concern). Only available when a Blob Storage URL is configured.
|
||||
are a concern). For the customer-SAS destination, a SAS URL must be configured before Upload Mode can be enabled.
|
||||
</SettingsRow>
|
||||
<SettingsRow name="Additional Log Paths">
|
||||
<span>
|
||||
|
||||
@@ -158,6 +158,8 @@ interface TenantConfigContextValue {
|
||||
setDiagnosticsBlobSasUrl: (v: string) => void;
|
||||
diagnosticsUploadMode: string;
|
||||
setDiagnosticsUploadMode: (v: string) => void;
|
||||
diagnosticsUploadDestination: string;
|
||||
setDiagnosticsUploadDestination: (v: string) => void;
|
||||
tenantDiagPaths: DiagnosticsLogPath[];
|
||||
setTenantDiagPaths: (v: DiagnosticsLogPath[]) => void;
|
||||
globalDiagPaths: DiagnosticsLogPath[];
|
||||
@@ -321,6 +323,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
// Diagnostics
|
||||
const [diagnosticsBlobSasUrl, setDiagnosticsBlobSasUrl] = useState("");
|
||||
const [diagnosticsUploadMode, setDiagnosticsUploadMode] = useState("Off");
|
||||
// Destination defaults to "CustomerSas" so legacy rows behave exactly as before;
|
||||
// Hosted requires an explicit admin click (no silent flip).
|
||||
const [diagnosticsUploadDestination, setDiagnosticsUploadDestination] = useState("CustomerSas");
|
||||
const [tenantDiagPaths, setTenantDiagPaths] = useState<DiagnosticsLogPath[]>([]);
|
||||
const [globalDiagPaths, setGlobalDiagPaths] = useState<DiagnosticsLogPath[]>([]);
|
||||
const [newDiagPath, setNewDiagPath] = useState("");
|
||||
@@ -429,6 +434,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
const sasUrl = data.diagnosticsBlobSasUrl ?? "";
|
||||
setDiagnosticsBlobSasUrl(sasUrl);
|
||||
setDiagnosticsUploadMode(data.diagnosticsUploadMode ?? "Off");
|
||||
setDiagnosticsUploadDestination(data.diagnosticsUploadDestination ?? "CustomerSas");
|
||||
try {
|
||||
setTenantDiagPaths(data.diagnosticsLogPathsJson ? JSON.parse(data.diagnosticsLogPathsJson) : []);
|
||||
} catch {
|
||||
@@ -626,6 +632,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
slaConsecutiveFailureThreshold,
|
||||
diagnosticsBlobSasUrl: diagnosticsBlobSasUrl || undefined,
|
||||
diagnosticsUploadMode,
|
||||
diagnosticsUploadDestination,
|
||||
diagnosticsLogPathsJson: tenantDiagPaths.length > 0 ? JSON.stringify(tenantDiagPaths) : "",
|
||||
enableLocalAdminAnalyzer,
|
||||
localAdminAllowedAccountsJson: localAdminAllowedAccounts.length > 0 ? JSON.stringify(localAdminAllowedAccounts) : "",
|
||||
@@ -677,7 +684,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
slaTargetSuccessRate, slaTargetMaxDurationMinutes, slaTargetAppInstallSuccessRate,
|
||||
slaNotifyOnSuccessRateBreach, slaSuccessRateNotifyThreshold, slaNotifyOnDurationBreach,
|
||||
slaNotifyOnAppInstallBreach, slaNotifyOnConsecutiveFailures, slaConsecutiveFailureThreshold,
|
||||
diagnosticsBlobSasUrl, diagnosticsUploadMode, tenantDiagPaths,
|
||||
diagnosticsBlobSasUrl, diagnosticsUploadMode, diagnosticsUploadDestination, tenantDiagPaths,
|
||||
enableLocalAdminAnalyzer, localAdminAllowedAccounts, enableSoftwareInventoryAnalyzer,
|
||||
enableIntegrityBypassAnalyzer, unrestrictedMode,
|
||||
]);
|
||||
@@ -928,6 +935,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
if (!config) return;
|
||||
setDiagnosticsBlobSasUrl(config.diagnosticsBlobSasUrl ?? "");
|
||||
setDiagnosticsUploadMode(config.diagnosticsUploadMode ?? "Off");
|
||||
setDiagnosticsUploadDestination(config.diagnosticsUploadDestination ?? "CustomerSas");
|
||||
try {
|
||||
setTenantDiagPaths(config.diagnosticsLogPathsJson ? JSON.parse(config.diagnosticsLogPathsJson) : []);
|
||||
} catch { setTenantDiagPaths([]); }
|
||||
@@ -1293,6 +1301,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
// Diagnostics
|
||||
diagnosticsBlobSasUrl, setDiagnosticsBlobSasUrl,
|
||||
diagnosticsUploadMode, setDiagnosticsUploadMode,
|
||||
diagnosticsUploadDestination, setDiagnosticsUploadDestination,
|
||||
tenantDiagPaths, setTenantDiagPaths,
|
||||
globalDiagPaths,
|
||||
newDiagPath, setNewDiagPath,
|
||||
|
||||
@@ -8,6 +8,7 @@ export function SectionDiagnostics() {
|
||||
const {
|
||||
diagnosticsBlobSasUrl, setDiagnosticsBlobSasUrl,
|
||||
diagnosticsUploadMode, setDiagnosticsUploadMode,
|
||||
diagnosticsUploadDestination, setDiagnosticsUploadDestination,
|
||||
tenantDiagPaths, setTenantDiagPaths,
|
||||
globalDiagPaths,
|
||||
newDiagPath, setNewDiagPath,
|
||||
@@ -25,6 +26,8 @@ export function SectionDiagnostics() {
|
||||
setDiagnosticsBlobSasUrl={setDiagnosticsBlobSasUrl}
|
||||
diagnosticsUploadMode={diagnosticsUploadMode}
|
||||
setDiagnosticsUploadMode={setDiagnosticsUploadMode}
|
||||
diagnosticsUploadDestination={diagnosticsUploadDestination}
|
||||
setDiagnosticsUploadDestination={setDiagnosticsUploadDestination}
|
||||
tenantDiagPaths={tenantDiagPaths}
|
||||
setTenantDiagPaths={setTenantDiagPaths}
|
||||
globalDiagPaths={globalDiagPaths}
|
||||
|
||||
@@ -5,27 +5,20 @@ import { DiagnosticsLogPath } from "../types";
|
||||
import { validateDiagnosticsPath } from "@/utils/guardValidation";
|
||||
import { ValidationIndicator } from "@/components/ValidationIndicator";
|
||||
import SaveResetBar from "./SaveResetBar";
|
||||
import { parseSasExpiry } from "./diagnosticsSasExpiry";
|
||||
|
||||
/** Parses the expiry date from the `se=` parameter of a SAS URL. Returns null if not found. */
|
||||
export function parseSasExpiry(sasUrl: string): Date | null {
|
||||
try {
|
||||
const qIndex = sasUrl.indexOf('?');
|
||||
if (qIndex < 0) return null;
|
||||
const params = new URLSearchParams(sasUrl.substring(qIndex + 1));
|
||||
const se = params.get('se');
|
||||
if (!se) return null;
|
||||
const d = new Date(se);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Re-export for callers that still import from the section. Implementation lives
|
||||
// in `diagnosticsSasExpiry.ts` so vitest can test it without pulling in JSX.
|
||||
export { parseSasExpiry };
|
||||
|
||||
interface DiagnosticsSectionProps {
|
||||
diagnosticsBlobSasUrl: string;
|
||||
setDiagnosticsBlobSasUrl: (value: string) => void;
|
||||
diagnosticsUploadMode: string;
|
||||
setDiagnosticsUploadMode: (value: string) => void;
|
||||
/** "CustomerSas" (default — tenant's own SAS) or "Hosted" (opt-in — backend storage). */
|
||||
diagnosticsUploadDestination: string;
|
||||
setDiagnosticsUploadDestination: (value: string) => void;
|
||||
tenantDiagPaths: DiagnosticsLogPath[];
|
||||
setTenantDiagPaths: (value: DiagnosticsLogPath[]) => void;
|
||||
globalDiagPaths: DiagnosticsLogPath[];
|
||||
@@ -44,6 +37,8 @@ export default function DiagnosticsSection({
|
||||
setDiagnosticsBlobSasUrl,
|
||||
diagnosticsUploadMode,
|
||||
setDiagnosticsUploadMode,
|
||||
diagnosticsUploadDestination,
|
||||
setDiagnosticsUploadDestination,
|
||||
tenantDiagPaths,
|
||||
setTenantDiagPaths,
|
||||
globalDiagPaths,
|
||||
@@ -56,6 +51,7 @@ export default function DiagnosticsSection({
|
||||
onReset,
|
||||
saving,
|
||||
}: DiagnosticsSectionProps) {
|
||||
const isHosted = diagnosticsUploadDestination === "Hosted";
|
||||
const [newDiagSubfolders, setNewDiagSubfolders] = useState(false);
|
||||
|
||||
// Compute SAS expiry directly from the current URL value so feedback is instant
|
||||
@@ -82,69 +78,150 @@ export default function DiagnosticsSection({
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
|
||||
{/* Info */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p className="text-sm text-amber-900">
|
||||
The agent requests an upload URL from the backend <strong>just before uploading</strong>. Your SAS URL is stored securely in the backend and never sent to devices in the agent configuration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Blob Storage SAS URL */}
|
||||
<div>
|
||||
<label className="block">
|
||||
<span className="text-gray-700 font-medium">Blob Storage Container SAS URL</span>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
Create an Azure Blob Storage container and generate a Container-level SAS URL with <strong className="text-amber-700">Read</strong>, <strong className="text-amber-700">Write</strong> and <strong className="text-amber-700">Create</strong> permissions.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Destination selector — explicit admin choice between customer-controlled SAS
|
||||
(default, data stays in customer's storage) and hosted (opt-in, data leaves
|
||||
tenant boundary). Never silently flipped. */}
|
||||
<div data-testid="diagnostics-destination" className="border border-gray-200 rounded-lg p-3">
|
||||
<p className="text-gray-700 font-medium text-sm mb-2">Upload destination</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<label
|
||||
className={`flex-1 flex items-start gap-2 cursor-pointer rounded-lg border p-3 transition-colors ${
|
||||
!isHosted ? "border-amber-400 bg-amber-50" : "border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
value={diagnosticsBlobSasUrl}
|
||||
onChange={(e) => setDiagnosticsBlobSasUrl(e.target.value)}
|
||||
placeholder="https://storageaccount.blob.core.windows.net/diagnostics?sv=...&sig=..."
|
||||
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500 transition-colors font-mono text-sm"
|
||||
type="radio"
|
||||
name="diagDestination"
|
||||
value="CustomerSas"
|
||||
checked={!isHosted}
|
||||
onChange={() => setDiagnosticsUploadDestination("CustomerSas")}
|
||||
className="mt-1 text-amber-600 focus:ring-amber-500"
|
||||
/>
|
||||
{diagnosticsBlobSasUrl && diagnosticsUploadMode !== "Off" && (
|
||||
<span className="mt-1 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 whitespace-nowrap">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* SAS URL expiry indicator */}
|
||||
{diagnosticsBlobSasUrl && diagnosticsSasExpiry && (() => {
|
||||
const now = new Date();
|
||||
const daysRemaining = Math.ceil((diagnosticsSasExpiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const isExpired = daysRemaining <= 0;
|
||||
const isWarning = daysRemaining > 0 && daysRemaining <= 7;
|
||||
return (
|
||||
<div className={`mt-2 flex items-center gap-1.5 text-xs ${isExpired ? 'text-red-600' : isWarning ? 'text-amber-600' : 'text-green-600'}`}>
|
||||
{isExpired ? (
|
||||
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : isWarning ? (
|
||||
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
<span>
|
||||
{isExpired
|
||||
? `Expired on ${diagnosticsSasExpiry.toLocaleDateString()}`
|
||||
: `Expires on ${diagnosticsSasExpiry.toLocaleDateString()}${isWarning ? ` (${daysRemaining} day${daysRemaining === 1 ? '' : 's'} remaining)` : ''}`}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-gray-900 text-sm">Your own Azure Blob Storage</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Data stays in your Azure tenant. You configure a Container SAS URL below.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</label>
|
||||
<label
|
||||
data-testid="diagnostics-destination-hosted"
|
||||
className={`flex-1 flex items-start gap-2 cursor-pointer rounded-lg border p-3 transition-colors ${
|
||||
isHosted ? "border-red-400 bg-red-50" : "border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="diagDestination"
|
||||
value="Hosted"
|
||||
checked={isHosted}
|
||||
onChange={() => setDiagnosticsUploadDestination("Hosted")}
|
||||
className="mt-1 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-gray-900 text-sm flex items-center gap-1.5">
|
||||
Hosted storage
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-red-100 text-red-800">
|
||||
data leaves your tenant
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Uploads go to the AutopilotMonitor backend's Azure Storage. Per-upload, blob-scoped tokens; 15-min TTL.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Mode */}
|
||||
<div className={`p-4 rounded-lg border transition-colors ${diagnosticsBlobSasUrl ? 'border-gray-200 hover:border-amber-200' : 'border-gray-100 opacity-50'}`}>
|
||||
{/* CustomerSas branch: existing info + SAS URL input + expiry indicator */}
|
||||
{!isHosted && (
|
||||
<>
|
||||
{/* Info */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p className="text-sm text-amber-900">
|
||||
The agent requests an upload URL from the backend <strong>just before uploading</strong>. Your SAS URL is stored securely in the backend and never sent to devices in the agent configuration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Blob Storage SAS URL */}
|
||||
<div data-testid="diagnostics-customersas-block">
|
||||
<label className="block">
|
||||
<span className="text-gray-700 font-medium">Blob Storage Container SAS URL</span>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
Create an Azure Blob Storage container and generate a Container-level SAS URL with <strong className="text-amber-700">Read</strong>, <strong className="text-amber-700">Write</strong> and <strong className="text-amber-700">Create</strong> permissions.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={diagnosticsBlobSasUrl}
|
||||
onChange={(e) => setDiagnosticsBlobSasUrl(e.target.value)}
|
||||
placeholder="https://storageaccount.blob.core.windows.net/diagnostics?sv=...&sig=..."
|
||||
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500 transition-colors font-mono text-sm"
|
||||
/>
|
||||
{diagnosticsBlobSasUrl && diagnosticsUploadMode !== "Off" && (
|
||||
<span className="mt-1 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 whitespace-nowrap">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* SAS URL expiry indicator */}
|
||||
{diagnosticsBlobSasUrl && diagnosticsSasExpiry && (() => {
|
||||
const now = new Date();
|
||||
const daysRemaining = Math.ceil((diagnosticsSasExpiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const isExpired = daysRemaining <= 0;
|
||||
const isWarning = daysRemaining > 0 && daysRemaining <= 7;
|
||||
return (
|
||||
<div className={`mt-2 flex items-center gap-1.5 text-xs ${isExpired ? 'text-red-600' : isWarning ? 'text-amber-600' : 'text-green-600'}`}>
|
||||
{isExpired ? (
|
||||
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : isWarning ? (
|
||||
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
<span>
|
||||
{isExpired
|
||||
? `Expired on ${diagnosticsSasExpiry.toLocaleDateString()}`
|
||||
: `Expires on ${diagnosticsSasExpiry.toLocaleDateString()}${isWarning ? ` (${daysRemaining} day${daysRemaining === 1 ? '' : 's'} remaining)` : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Hosted branch: prominent disclosure block in place of the SAS input. */}
|
||||
{isHosted && (
|
||||
<div data-testid="diagnostics-hosted-block" className="bg-red-50 border border-red-300 rounded-lg p-4">
|
||||
<p className="font-semibold text-red-900 text-sm mb-2 flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
</svg>
|
||||
Hosted storage — data leaves your tenant
|
||||
</p>
|
||||
<p className="text-sm text-red-900 mb-2">
|
||||
By switching to Hosted, your diagnostics packages will be uploaded to the AutopilotMonitor backend's Azure Storage (operated by the Autopilot Monitor Team), not your own tenant. Blobs are isolated per tenant via a <code className="font-mono text-xs bg-red-100 px-1 rounded">{tenantId}/</code> prefix and short-lived, blob-scoped upload tokens (15-min, write-only).
|
||||
</p>
|
||||
<p className="text-xs text-red-800">
|
||||
Retention follows your tenant's <strong>Data Retention Days</strong> setting and is enforced by the cascade-delete pipeline. Review the diagnostics paths section below to see what is collected before enabling.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Mode — applies to both destinations. For CustomerSas it's gated on
|
||||
a SAS URL being present; for Hosted it's always enabled. */}
|
||||
<div className={`p-4 rounded-lg border transition-colors ${
|
||||
isHosted || diagnosticsBlobSasUrl ? 'border-gray-200 hover:border-amber-200' : 'border-gray-100 opacity-50'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Upload Mode</p>
|
||||
@@ -153,7 +230,7 @@ export default function DiagnosticsSection({
|
||||
<select
|
||||
value={diagnosticsUploadMode}
|
||||
onChange={(e) => setDiagnosticsUploadMode(e.target.value)}
|
||||
disabled={!diagnosticsBlobSasUrl}
|
||||
disabled={!isHosted && !diagnosticsBlobSasUrl}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
<option value="Off">Off</option>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Parses the expiry date from the `se=` parameter of a SAS URL.
|
||||
* Returns null if the URL has no query string, no `se=` parameter, or the value
|
||||
* cannot be parsed as a date.
|
||||
*
|
||||
* Extracted into a plain .ts module so it can be unit-tested by vitest without
|
||||
* pulling in the parent .tsx React component. See
|
||||
* `lib/__tests__/diagnosticsDestination.test.ts`.
|
||||
*/
|
||||
export function parseSasExpiry(sasUrl: string): Date | null {
|
||||
try {
|
||||
const qIndex = sasUrl.indexOf("?");
|
||||
if (qIndex < 0) return null;
|
||||
const params = new URLSearchParams(sasUrl.substring(qIndex + 1));
|
||||
const se = params.get("se");
|
||||
if (!se) return null;
|
||||
const d = new Date(se);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,9 @@ export interface TenantConfiguration {
|
||||
// Diagnostics package
|
||||
diagnosticsBlobSasUrl?: string;
|
||||
diagnosticsUploadMode?: string;
|
||||
// "CustomerSas" (default — agents upload to the tenant's own SAS-backed container)
|
||||
// or "Hosted" (opt-in — agents upload to the AutopilotMonitor backend's storage).
|
||||
diagnosticsUploadDestination?: string;
|
||||
diagnosticsLogPathsJson?: string;
|
||||
// Enrollment summary dialog
|
||||
showEnrollmentSummary?: boolean;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseSasExpiry } from "../../app/settings/components/diagnosticsSasExpiry";
|
||||
|
||||
// Phase 4 of the hosted-diagnostics work. Covers:
|
||||
// 1. `parseSasExpiry` — backs the green/amber/red SAS-expiry indicator on the
|
||||
// CustomerSas branch of the UI. Pure helper; no DOM needed.
|
||||
// 2. The destination string contract. The values "CustomerSas" / "Hosted" must
|
||||
// match what the backend's `GetDiagnosticsUploadUrlFunction.NormalizeDestination`
|
||||
// accepts; a typo here would silently route an admin's click to the unknown-
|
||||
// destination 500 branch instead of switching destinations.
|
||||
|
||||
describe("parseSasExpiry", () => {
|
||||
it("extracts the se= UTC datetime from a real-shaped SAS URL", () => {
|
||||
const sas =
|
||||
"https://acct.blob.core.windows.net/diag?sv=2024-10-04&ss=b&srt=co&se=2026-12-31T23%3A59%3A00Z&sig=abc";
|
||||
const parsed = parseSasExpiry(sas);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.toISOString()).toBe("2026-12-31T23:59:00.000Z");
|
||||
});
|
||||
|
||||
it("returns null when the SAS URL has no query string", () => {
|
||||
expect(parseSasExpiry("https://acct.blob.core.windows.net/diag")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the se= parameter is missing", () => {
|
||||
expect(
|
||||
parseSasExpiry("https://acct.blob.core.windows.net/diag?sv=2024&sig=abc"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on a totally malformed se= value", () => {
|
||||
expect(
|
||||
parseSasExpiry("https://acct.blob.core.windows.net/diag?se=not-a-date&sig=abc"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an empty input rather than throwing", () => {
|
||||
expect(parseSasExpiry("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("diagnosticsUploadDestination contract", () => {
|
||||
// These two values are the source of truth — UI radio buttons set them
|
||||
// verbatim, and the backend's NormalizeDestination matches them
|
||||
// case-insensitively. If the strings ever drift, an admin click would land
|
||||
// in the unknown-destination 500 branch instead of switching destinations.
|
||||
const KnownDestinations = ["CustomerSas", "Hosted"] as const;
|
||||
|
||||
it("includes both expected values", () => {
|
||||
expect(KnownDestinations).toContain("CustomerSas");
|
||||
expect(KnownDestinations).toContain("Hosted");
|
||||
});
|
||||
|
||||
it("treats CustomerSas as the safe default for legacy configs", () => {
|
||||
// The UI's <DiagnosticsSection> uses `=== "Hosted"` as the only positive
|
||||
// check; everything else (null, undefined, empty, unknown) renders the
|
||||
// CustomerSas branch. Encodes the "Hosted requires explicit admin click"
|
||||
// guarantee — no silent flip.
|
||||
const isHosted = (value: string | undefined | null) => value === "Hosted";
|
||||
expect(isHosted(null)).toBe(false);
|
||||
expect(isHosted(undefined)).toBe(false);
|
||||
expect(isHosted("")).toBe(false);
|
||||
expect(isHosted("CustomerSas")).toBe(false);
|
||||
expect(isHosted("anything-else")).toBe(false);
|
||||
expect(isHosted("Hosted")).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user