feat(backend): operator-tier session actions + diagnostics groundwork for on-demand log collection

- New EndpointPolicy.TenantAdminOrOperator (Admin/Operator/GA, no Viewer) with
  middleware evaluator; POST sessions/{sessionId}/actions moves to this tier so
  troubleshooting operators can queue request_diagnostics ("Collect Logs").
- QueueSessionActionFunction re-gates per type: terminate_session and
  rotate_config stay Admin/GA-only (403 for operators).
- GET config/{tenantId}/feature-flags exposes diagnosticsUploadConfigured so
  members below Admin can see whether an on-demand upload would work.
- Offboarding cascade: hosted diagnostics deletion now sweeps the session
  prefix {tenantId}/AgentDiagnostics-{sessionId}- (plus the manifest-named
  blob) so on-demand packages overwritten on the Sessions row by the terminal
  package no longer survive deletion. CustomerSas branch unchanged.
- Notification dismiss routes deliberately NOT migrated: dismissal is still
  tenant-shared; operators would clear admin-tier notifications they cannot see.

Tests: middleware role matrix on the actions route, per-type gate matrix,
feature-flag matrix, prefix-sweep + real hosted-branch cascade tests.
Backend suite 3253/3253.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-24 18:05:39 +02:00
parent 8bb02acc4a
commit 2fee50276e
11 changed files with 477 additions and 4 deletions
@@ -154,6 +154,80 @@ public class DiagnosticsBlobCascadeDeleterTests
Assert.Equal(0, deleter.HostedDeleteCalls);
}
// ── REAL DeleteAsync, Hosted branch — prefix sweep + belt-and-braces name delete ──
// The RecordingDeleter above replaces DeleteAsync wholesale, so these run the REAL
// base implementation against a fake storage service to pin the sweep behaviour.
private const string SessionGuid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
[Fact]
public async Task RealDeleteAsync_Hosted_SweepsSessionPrefix_AndDeletesManifestBlob()
{
var storage = new RecordingHostedStorage();
var deleter = RealDeleter(storage);
var manifest = ManifestWith(blobName: $"{TenantA}/{Filename}", destination: "Hosted");
manifest.SessionId = SessionGuid;
var outcome = await deleter.DeleteAsync(manifest);
Assert.Equal(DiagnosticsBlobDeleteOutcome.HostedDeleted, outcome);
Assert.Equal((TenantA, SessionGuid), storage.SweptSession);
Assert.Equal($"{TenantA}/{Filename}", storage.DeletedBlobPath);
}
[Fact]
public async Task RealDeleteAsync_Hosted_NonGuidSessionId_SkipsSweep_StillDeletesManifestBlob()
{
// Legacy manifests may carry a malformed/empty SessionId — the sweep must degrade
// to the single-blob delete, never throw into the cascade poison path.
var storage = new RecordingHostedStorage();
var deleter = RealDeleter(storage);
var manifest = ManifestWith(blobName: $"{TenantA}/{Filename}", destination: "Hosted");
manifest.SessionId = "not-a-guid";
var outcome = await deleter.DeleteAsync(manifest);
Assert.Equal(DiagnosticsBlobDeleteOutcome.HostedDeleted, outcome);
Assert.Null(storage.SweptSession);
Assert.Equal($"{TenantA}/{Filename}", storage.DeletedBlobPath);
}
private static DiagnosticsBlobCascadeDeleter RealDeleter(RecordingHostedStorage storage)
// TenantConfigurationService is only consulted on the CustomerSas branch; the
// Hosted-branch tests never reach it, so null keeps the harness Azure-free.
=> new(
storage,
tenantConfig: null!,
Microsoft.Extensions.Logging.Abstractions.NullLogger<DiagnosticsBlobCascadeDeleter>.Instance);
private sealed class RecordingHostedStorage : HostedDiagnosticsBlobService
{
public (string TenantId, string SessionId)? SweptSession { get; private set; }
public string? DeletedBlobPath { get; private set; }
public RecordingHostedStorage()
: base(
new Azure.Storage.Blobs.BlobServiceClient("UseDevelopmentStorage=true"),
Microsoft.Extensions.Logging.Abstractions.NullLogger<HostedDiagnosticsBlobService>.Instance,
usesManagedIdentity: false)
{
}
public override Task<int> DeleteBySessionPrefixAsync(
string tenantId, string sessionId, CancellationToken cancellationToken = default)
{
SweptSession = (tenantId, sessionId);
return Task.FromResult(1);
}
public override Task<bool> DeleteIfExistsAsync(
string blobPath, CancellationToken cancellationToken = default)
{
DeletedBlobPath = blobPath;
return Task.FromResult(true);
}
}
// ── BuildBlobUrl helper (CustomerSas URL construction) ─────────────────────────
[Fact]
@@ -35,6 +35,7 @@ public class GetTenantFeatureFlagsPayloadTests
Assert.Equal(new[]
{
"bootstrapTokenEnabled",
"diagnosticsUploadConfigured",
"edition",
"enableIntegrityBypassAnalyzer",
"enableSoftwareInventoryAnalyzer",
@@ -114,6 +115,35 @@ public class GetTenantFeatureFlagsPayloadTests
Assert.DoesNotContain("webhookUrl", json, StringComparison.OrdinalIgnoreCase);
}
// ── diagnosticsUploadConfigured (drives the session-detail "Collect Logs" button) ──
[Theory]
// mode Off (or unset) → never configured, regardless of destination
[InlineData(null, null, null, false)]
[InlineData("Off", "https://x.blob.core.windows.net/c?sv=1", "CustomerSas", false)]
[InlineData("Off", null, "Hosted", false)]
// mode active + Hosted destination → configured without any SAS
[InlineData("OnFailure", null, "Hosted", true)]
[InlineData("Always", null, "Hosted", true)]
// mode active + CustomerSas → configured only when a SAS URL is present
[InlineData("OnFailure", "https://x.blob.core.windows.net/c?sv=1", "CustomerSas", true)]
[InlineData("OnFailure", null, "CustomerSas", false)]
[InlineData("Always", "", null, false)]
public void Payload_DiagnosticsUploadConfigured_ModeAndDestinationMatrix(
string? mode, string? sasUrl, string? destination, bool expected)
{
// Null-forgiving: the model declares these non-nullable with defaults, but table
// round-trips can produce null at runtime — the payload must tolerate that shape.
var element = Serialize(new TenantConfiguration
{
DiagnosticsUploadMode = mode!,
DiagnosticsBlobSasUrl = sasUrl!,
DiagnosticsUploadDestination = destination!,
});
Assert.Equal(expected, element.GetProperty("diagnosticsUploadConfigured").GetBoolean());
}
// ── Edition / trial surface ─────────────────────────────────────────────
[Fact]
@@ -4,9 +4,12 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Diagnostics;
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Azure.Storage.Sas;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
@@ -172,6 +175,119 @@ public class HostedDiagnosticsBlobServiceTests
Assert.StartsWith(TenantB + "/", b.BlobPath);
}
// ── DeleteBySessionPrefixAsync — cascade sweep for multi-package sessions ──────
private const string SessionA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
[Theory]
[InlineData("not-a-guid", SessionA)]
[InlineData(TenantA, "not-a-guid")]
public async Task DeleteBySessionPrefix_RejectsNonGuidInputs(string tenantId, string sessionId)
{
var svc = new PrefixDeleteFake(containerExists: true);
await Assert.ThrowsAsync<ArgumentException>(
() => svc.DeleteBySessionPrefixAsync(tenantId, sessionId));
}
[Fact]
public async Task DeleteBySessionPrefix_MissingContainer_IsNoOp()
{
var svc = new PrefixDeleteFake(containerExists: false);
var deleted = await svc.DeleteBySessionPrefixAsync(TenantA, SessionA);
Assert.Equal(0, deleted);
Assert.Null(svc.CapturedPrefix);
}
[Fact]
public async Task DeleteBySessionPrefix_UsesSessionScopedPrefix_AndDeletesEveryMatch()
{
// Two packages for the session: an on-demand (server-requested) one and the terminal
// one. The sweep must issue a delete for BOTH even though the Sessions row (and thus
// the deletion manifest) only references the last.
var svc = new PrefixDeleteFake(
containerExists: true,
blobNames: new[]
{
$"{TenantA}/AgentDiagnostics-{SessionA}-20260724T100000-server-requested.zip",
$"{TenantA}/AgentDiagnostics-{SessionA}-20260724T113000.zip",
});
var deleted = await svc.DeleteBySessionPrefixAsync(TenantA, SessionA);
Assert.Equal(2, deleted);
Assert.Equal($"{TenantA}/AgentDiagnostics-{SessionA}-", svc.CapturedPrefix);
Assert.Equal(2, svc.DeletedBlobNames.Count);
Assert.All(svc.DeletedBlobNames, n => Assert.StartsWith(svc.CapturedPrefix!, n));
}
[Fact]
public async Task DeleteBySessionPrefix_NoMatches_ReturnsZero()
{
var svc = new PrefixDeleteFake(containerExists: true, blobNames: Array.Empty<string>());
var deleted = await svc.DeleteBySessionPrefixAsync(TenantA, SessionA);
Assert.Equal(0, deleted);
Assert.Equal($"{TenantA}/AgentDiagnostics-{SessionA}-", svc.CapturedPrefix);
}
/// <summary>
/// Overrides <c>GetContainerClient</c> with a Moq'd <see cref="BlobContainerClient"/> whose
/// enumeration returns a canned page for the CAPTURED prefix. Prefix filtering itself is
/// the Azure SDK's contract — these tests pin what prefix we ask for and that every
/// returned item is deleted.
/// </summary>
private sealed class PrefixDeleteFake : HostedDiagnosticsBlobService
{
private readonly Mock<BlobContainerClient> _container;
public string? CapturedPrefix { get; private set; }
public List<string> DeletedBlobNames { get; } = new();
public PrefixDeleteFake(bool containerExists, string[]? blobNames = null)
: base(
new BlobServiceClient("UseDevelopmentStorage=true"),
NullLogger<HostedDiagnosticsBlobService>.Instance,
usesManagedIdentity: false)
{
_container = new Mock<BlobContainerClient>();
_container
.Setup(c => c.ExistsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(containerExists, Mock.Of<Response>()));
_container
.Setup(c => c.GetBlobsAsync(
It.IsAny<BlobTraits>(), It.IsAny<BlobStates>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Returns((BlobTraits _, BlobStates _, string? prefix, CancellationToken _) =>
{
CapturedPrefix = prefix;
var items = (blobNames ?? Array.Empty<string>())
.Select(n => BlobsModelFactory.BlobItem(name: n))
.ToList();
return AsyncPageable<BlobItem>.FromPages(new[]
{
Page<BlobItem>.FromValues(items, continuationToken: null, Mock.Of<Response>()),
});
});
_container
.Setup(c => c.GetBlobClient(It.IsAny<string>()))
.Returns((string name) =>
{
var blob = new Mock<BlobClient>();
blob
.Setup(b => b.DeleteIfExistsAsync(
It.IsAny<DeleteSnapshotsOption>(), It.IsAny<BlobRequestConditions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
DeletedBlobNames.Add(name);
return Response.FromValue(true, Mock.Of<Response>());
});
return blob.Object;
});
}
protected override BlobContainerClient GetContainerClient() => _container.Object;
}
// ── Constants ───────────────────────────────────────────────────────────────────
[Fact]
@@ -205,6 +205,99 @@ public class PolicyEnforcementMiddlewareTests
Assert.Equal(403, result.StatusCode);
}
// ── TenantAdminOrOperator (POST sessions/{id}/actions — "Collect Logs") ────────
private const string SessionA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
private static void AsTenantMemberRole(Harness h, string tenantId, string upn, string role) =>
h.Repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new TenantMember { Upn = upn, TenantId = tenantId, Role = role, IsEnabled = true });
[Fact]
public async Task SessionActions_TenantOperator_IsAllowed_WithOperatorRoleOnContext()
{
const string upn = "operator@contoso.com";
var h = BuildHarness();
AsTenantMemberRole(h, TenantA, upn, Constants.TenantRoles.Operator);
var result = await h.Middleware.DecideAsync(
"POST", $"/api/sessions/{SessionA}/actions", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
// The function's per-type gate depends on these exact flags: an Operator must arrive
// with IsTenantAdmin=false + UserRole=Operator so terminate_session/rotate_config 403.
Assert.False(result.Context!.IsTenantAdmin);
Assert.Equal(Constants.TenantRoles.Operator, result.Context!.UserRole);
}
[Fact]
public async Task SessionActions_TenantAdmin_IsAllowed_WithAdminFlag()
{
const string upn = "admin@contoso.com";
var h = BuildHarness();
h.AsTenantAdmin(TenantA, upn);
var result = await h.Middleware.DecideAsync(
"POST", $"/api/sessions/{SessionA}/actions", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
Assert.True(result.Context!.IsTenantAdmin);
}
[Fact]
public async Task SessionActions_TenantViewer_IsForbidden()
{
const string upn = "viewer@contoso.com";
var h = BuildHarness();
AsTenantMemberRole(h, TenantA, upn, Constants.TenantRoles.Viewer);
var result = await h.Middleware.DecideAsync(
"POST", $"/api/sessions/{SessionA}/actions", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
}
[Fact]
public async Task SessionActions_NonMember_IsForbidden()
{
var h = BuildHarness(); // defaults: no membership, no global role
var result = await h.Middleware.DecideAsync(
"POST", $"/api/sessions/{SessionA}/actions", null, AuthedPrincipal(TenantA, "outsider@contoso.com"));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
}
[Fact]
public async Task SessionActions_GlobalAdmin_IsAllowed()
{
const string upn = "ga@platform.com";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
var result = await h.Middleware.DecideAsync(
"POST", $"/api/sessions/{SessionA}/actions", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
Assert.True(result.Context!.IsGlobalAdmin);
}
[Fact]
public async Task SessionActions_PureGlobalReader_IsForbidden_WriteTier()
{
const string upn = "reader@platform.com";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
var result = await h.Middleware.DecideAsync(
"POST", $"/api/sessions/{SessionA}/actions", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
}
// ── config GET (TenantAdminOrGlobalReader): own-tenant admin view vs reader view ──
// GetTenantConfigurationFunction redacts only when IsGlobalReader && !(IsTenantAdmin && Target==Tenant).
// These lock in the RequestContext that drives that decision.
@@ -1,3 +1,4 @@
using AutopilotMonitor.Functions.Functions.Sessions;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
@@ -11,6 +12,35 @@ namespace AutopilotMonitor.Functions.Tests;
/// </summary>
public class ServerActionQueueTests
{
// ── Per-type caller gate (route tier is TenantAdminOrOperator; the function must
// re-gate admin-only types for Operators) ─────────────────────────────────────
[Theory]
// Operator (neither admin flag): only request_diagnostics
[InlineData(ServerActionTypes.RequestDiagnostics, false, false, true)]
[InlineData(ServerActionTypes.TerminateSession, false, false, false)]
[InlineData(ServerActionTypes.RotateConfig, false, false, false)]
// Tenant Admin: everything
[InlineData(ServerActionTypes.TerminateSession, true, false, true)]
[InlineData(ServerActionTypes.RotateConfig, true, false, true)]
[InlineData(ServerActionTypes.RequestDiagnostics, true, false, true)]
// Global Admin: everything
[InlineData(ServerActionTypes.TerminateSession, false, true, true)]
[InlineData(ServerActionTypes.RotateConfig, false, true, true)]
public void IsTypeAllowedForCaller_Matrix(
string actionType, bool isTenantAdmin, bool isGlobalAdmin, bool expected)
{
Assert.Equal(expected,
QueueSessionActionFunction.IsTypeAllowedForCaller(actionType, isTenantAdmin, isGlobalAdmin));
}
[Fact]
public void IsTypeAllowedForCaller_IsCaseInsensitive_ForOperator()
{
Assert.True(QueueSessionActionFunction.IsTypeAllowedForCaller(
"Request_Diagnostics", isTenantAdmin: false, isGlobalAdmin: false));
}
[Fact]
public void DeserializePendingActions_NullOrEmpty_ReturnsEmpty()
{
@@ -76,6 +76,14 @@ namespace AutopilotMonitor.Functions.Functions.Config
return new
{
bootstrapTokenEnabled = config.BootstrapTokenEnabled,
// Session-detail "Collect Logs" button: whether an on-demand diagnostics upload can
// succeed right now (mode not Off + a usable destination). Members below Admin cannot
// read the full config, so this boolean is their only signal. Deliberately exposes no
// destination detail — just "would an upload work".
diagnosticsUploadConfigured =
!string.Equals(config.DiagnosticsUploadMode ?? "Off", "Off", StringComparison.OrdinalIgnoreCase)
&& GetAgentConfigFunction.ResolveDiagnosticsUploadEnabled(
config.DiagnosticsBlobSasUrl, config.DiagnosticsUploadDestination),
// Drives the "Autopilot Device Validation disabled" dashboard banner
// (useTenantSecurityConfig).
validateAutopilotDevice = config.ValidateAutopilotDevice,
@@ -32,6 +32,28 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
ServerActionTypes.RequestDiagnostics
};
/// <summary>
/// Action types an Operator may queue. The route is TenantAdminOrOperator so troubleshooting
/// staff can trigger an on-demand diagnostics collection; everything with config or lifecycle
/// impact (terminate_session, rotate_config) stays Admin/GA-only via <see cref="IsTypeAllowedForCaller"/>.
/// </summary>
private static readonly HashSet<string> OperatorAllowedTypes = new(StringComparer.OrdinalIgnoreCase)
{
ServerActionTypes.RequestDiagnostics
};
/// <summary>
/// Type-level authorization on top of the route policy: Admins and Global Admins may queue
/// every allowed type, Operators only the types in <see cref="OperatorAllowedTypes"/>.
/// Internal-static so tests can pin the matrix without an HTTP host.
/// </summary>
internal static bool IsTypeAllowedForCaller(string actionType, bool isTenantAdmin, bool isGlobalAdmin)
{
if (isTenantAdmin || isGlobalAdmin)
return true;
return OperatorAllowedTypes.Contains(actionType);
}
public QueueSessionActionFunction(
ILogger<QueueSessionActionFunction> logger,
ISessionRepository sessionRepo,
@@ -53,9 +75,11 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
{
try
{
// Authentication + TenantAdminOrGA authorization enforced by PolicyEnforcementMiddleware.
// Authentication + TenantAdminOrOperator authorization enforced by
// PolicyEnforcementMiddleware; per-type re-gating for Operators happens below.
var tenantId = TenantHelper.GetTenantId(req);
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var requestCtx = req.GetRequestContext();
string body;
using (var reader = new StreamReader(req.Body))
@@ -78,6 +102,20 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
if (!AllowedTypes.Contains(action.Type))
return await BadRequestAsync(req, $"Unknown action type '{action.Type}'. Allowed: {string.Join(", ", AllowedTypes)}");
if (!IsTypeAllowedForCaller(action.Type, requestCtx.IsTenantAdmin, requestCtx.IsGlobalAdmin))
{
_logger.LogWarning(
"Operator {User} denied queueing admin-only action '{Type}' for session {SessionId}",
userIdentifier, action.Type, sessionId);
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new
{
success = false,
message = $"Action type '{action.Type}' requires the Tenant Admin role"
});
return forbidden;
}
// Stamp server-side fields — never trust client timestamps, and never let the caller
// forge a RuleId (that's reserved for the RuleEngine).
action.QueuedAt = DateTime.UtcNow;
@@ -336,6 +336,9 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
case EndpointPolicy.TenantAdminOrGlobalReader:
return await EvaluateTenantAdminOrGlobalReaderAsync(tenantId, upn, principal, userIdentifier);
case EndpointPolicy.TenantAdminOrOperator:
return await EvaluateTenantAdminOrOperatorAsync(tenantId, upn, principal, userIdentifier);
case EndpointPolicy.TenantAdminOrGA:
return await EvaluateTenantAdminOrGAAsync(tenantId, upn, principal, userIdentifier);
@@ -469,6 +472,28 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return CatalogDecisionResult.Deny(userIdentifier, role?.Role ?? "NonMember", "NotAdminOrGA");
}
/// <summary>
/// Operational write tier: own-tenant Admin or Operator (no Viewer), or Global Admin.
/// The admitted member's role flows onto RequestContext.UserRole so a function that
/// serves multiple operation kinds can re-gate the admin-only ones on IsTenantAdmin.
/// The read-only Global Reader is deliberately NOT admitted — this is a write tier.
/// </summary>
private async Task<CatalogDecisionResult> EvaluateTenantAdminOrOperatorAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
{
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
if (await _globalAdminService.IsGlobalAdminAsync(upn))
return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "GABypass");
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
if (role?.Role == Constants.TenantRoles.Admin || role?.Role == Constants.TenantRoles.Operator)
return CatalogDecisionResult.Allow(userIdentifier, role.Role, "TenantAdminOrOperator");
return CatalogDecisionResult.Deny(userIdentifier, role?.Role ?? "NonMember", "NotAdminOperatorOrGA");
}
private async Task<CatalogDecisionResult> EvaluateBootstrapManagerOrGAAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
{
@@ -55,6 +55,15 @@ public enum EndpointPolicy
/// </summary>
MemberRead,
/// <summary>
/// Own-tenant Admin or Operator (no Viewer), OR Global Admin. Tenant-scoped OPERATIONAL write
/// access: actions a troubleshooting operator may take on a session/device without holding
/// config-level admin power. Routes on this tier whose function accepts multiple operation
/// kinds MUST re-gate the admin-only kinds in the function on
/// <c>RequestContext.IsTenantAdmin || IsGlobalAdmin</c> (see QueueSessionActionFunction).
/// </summary>
TenantAdminOrOperator,
/// <summary>
/// Own-tenant Admin, OR a platform role (Global Admin / Global Reader). Tenant-scoped admin-tier
/// READ access (e.g. GET config/{tenantId}). Like <see cref="TenantAdminOrGA"/> but additionally
@@ -254,7 +263,10 @@ public static class EndpointAccessPolicyCatalog
new("PUT", "rules/ime-log-patterns/{patternId}", EndpointPolicy.TenantAdminOrGA),
new("POST", "sessions/{sessionId}/mark-failed", EndpointPolicy.TenantAdminOrGA),
new("POST", "sessions/{sessionId}/mark-succeeded", EndpointPolicy.TenantAdminOrGA),
new("POST", "sessions/{sessionId}/actions", EndpointPolicy.TenantAdminOrGA),
// Operator tier so troubleshooting staff can queue request_diagnostics ("Collect Logs" in
// the portal); the function re-gates the admin-only action types (terminate_session,
// rotate_config) on RequestContext.IsTenantAdmin || IsGlobalAdmin.
new("POST", "sessions/{sessionId}/actions", EndpointPolicy.TenantAdminOrOperator),
new("POST", "sessions/{sessionId}/report", EndpointPolicy.TenantAdminOrGA),
new("POST", "diagnostics/files", EndpointPolicy.TenantAdminOrGA),
new("DELETE", "sessions/{sessionId}", EndpointPolicy.TenantAdminOrGA, TenantScoping.QueryParam),
@@ -75,10 +75,23 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
if (destination == DestinationHosted)
{
// Prefix sweep first: the session may have produced several packages (on-demand
// server-requested uploads plus the terminal one) while the manifest carries only
// the last row-referenced name. Guarded by TryParse because a malformed legacy
// manifest must degrade to the single-blob delete, not poison the cascade.
var swept = 0;
if (Guid.TryParse(manifest.SessionId, out _))
{
swept = await _hostedDiagnostics.DeleteBySessionPrefixAsync(
manifest.TenantId, manifest.SessionId, cancellationToken);
}
// Belt-and-braces: the manifest-named blob covers any shape outside the canonical
// {tenantId}/AgentDiagnostics-{sessionId}- layout. Idempotent (404 = no-op).
await _hostedDiagnostics.DeleteIfExistsAsync(manifest.DiagnosticsBlobName, cancellationToken);
_logger.LogInformation(
"Cascade deleted hosted diagnostics blob tenant={TenantId} blob={Blob}",
manifest.TenantId, manifest.DiagnosticsBlobName);
"Cascade deleted hosted diagnostics blobs tenant={TenantId} blob={Blob} sweptByPrefix={Swept}",
manifest.TenantId, manifest.DiagnosticsBlobName, swept);
return DiagnosticsBlobDeleteOutcome.HostedDeleted;
}
@@ -165,6 +165,40 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
return response.Value;
}
/// <summary>
/// Deletes every hosted diagnostics blob belonging to one session
/// (prefix <c>{tenantId}/AgentDiagnostics-{sessionId}-</c>). A session can leave more
/// than one blob behind — an on-demand ("Collect Logs" / server-requested) upload is
/// later overwritten on the Sessions row by the terminal package, so deleting only the
/// row-referenced name would strand the earlier blobs at offboarding.
/// Returns the number of blobs deleted. Idempotent; a missing container is a no-op.
/// </summary>
public virtual async Task<int> DeleteBySessionPrefixAsync(
string tenantId, string sessionId, CancellationToken cancellationToken = default)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
var containerClient = GetContainerClient();
if (!await containerClient.ExistsAsync(cancellationToken))
return 0;
// Trailing dash bounds the match to this exact session id (GUIDs are fixed-length,
// but the explicit separator keeps the invariant obvious and layout-proof).
var prefix = $"{tenantId}/AgentDiagnostics-{sessionId}-";
var deleted = 0;
await foreach (var item in containerClient.GetBlobsAsync(
BlobTraits.None, BlobStates.None, prefix: prefix, cancellationToken: cancellationToken))
{
var response = await containerClient.GetBlobClient(item.Name)
.DeleteIfExistsAsync(cancellationToken: cancellationToken);
if (response.Value)
deleted++;
}
return deleted;
}
/// <summary>
/// Enumerates blobs under a single tenant's prefix (<c>{tenantId}/</c>).
/// Used by ad-hoc operator scripts and (later) any orphan-scan job.