diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DiagnosticsBlobCascadeDeleterTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DiagnosticsBlobCascadeDeleterTests.cs index 61568f4f..fdd3d0e6 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/DiagnosticsBlobCascadeDeleterTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DiagnosticsBlobCascadeDeleterTests.cs @@ -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.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.Instance, + usesManagedIdentity: false) + { + } + + public override Task DeleteBySessionPrefixAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default) + { + SweptSession = (tenantId, sessionId); + return Task.FromResult(1); + } + + public override Task DeleteIfExistsAsync( + string blobPath, CancellationToken cancellationToken = default) + { + DeletedBlobPath = blobPath; + return Task.FromResult(true); + } + } + // ── BuildBlobUrl helper (CustomerSas URL construction) ───────────────────────── [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs index ff371c77..d3d728e4 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs @@ -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] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/HostedDiagnosticsBlobServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/HostedDiagnosticsBlobServiceTests.cs index b946710a..10214c38 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/HostedDiagnosticsBlobServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/HostedDiagnosticsBlobServiceTests.cs @@ -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( + () => 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()); + var deleted = await svc.DeleteBySessionPrefixAsync(TenantA, SessionA); + Assert.Equal(0, deleted); + Assert.Equal($"{TenantA}/AgentDiagnostics-{SessionA}-", svc.CapturedPrefix); + } + + /// + /// Overrides GetContainerClient with a Moq'd 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. + /// + private sealed class PrefixDeleteFake : HostedDiagnosticsBlobService + { + private readonly Mock _container; + + public string? CapturedPrefix { get; private set; } + public List DeletedBlobNames { get; } = new(); + + public PrefixDeleteFake(bool containerExists, string[]? blobNames = null) + : base( + new BlobServiceClient("UseDevelopmentStorage=true"), + NullLogger.Instance, + usesManagedIdentity: false) + { + _container = new Mock(); + _container + .Setup(c => c.ExistsAsync(It.IsAny())) + .ReturnsAsync(Response.FromValue(containerExists, Mock.Of())); + + _container + .Setup(c => c.GetBlobsAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((BlobTraits _, BlobStates _, string? prefix, CancellationToken _) => + { + CapturedPrefix = prefix; + var items = (blobNames ?? Array.Empty()) + .Select(n => BlobsModelFactory.BlobItem(name: n)) + .ToList(); + return AsyncPageable.FromPages(new[] + { + Page.FromValues(items, continuationToken: null, Mock.Of()), + }); + }); + + _container + .Setup(c => c.GetBlobClient(It.IsAny())) + .Returns((string name) => + { + var blob = new Mock(); + blob + .Setup(b => b.DeleteIfExistsAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(() => + { + DeletedBlobNames.Add(name); + return Response.FromValue(true, Mock.Of()); + }); + return blob.Object; + }); + } + + protected override BlobContainerClient GetContainerClient() => _container.Object; + } + // ── Constants ─────────────────────────────────────────────────────────────────── [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs index c5b95b3d..c7c22c78 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs @@ -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(), It.IsAny())) + .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. diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ServerActionQueueTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ServerActionQueueTests.cs index 9b355374..c99b18f9 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/ServerActionQueueTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/ServerActionQueueTests.cs @@ -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; /// 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() { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs index 1d69c93b..5867b8c4 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs @@ -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, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QueueSessionActionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QueueSessionActionFunction.cs index a1f954a7..f0702482 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QueueSessionActionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/QueueSessionActionFunction.cs @@ -32,6 +32,28 @@ namespace AutopilotMonitor.Functions.Functions.Sessions ServerActionTypes.RequestDiagnostics }; + /// + /// 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 . + /// + private static readonly HashSet OperatorAllowedTypes = new(StringComparer.OrdinalIgnoreCase) + { + ServerActionTypes.RequestDiagnostics + }; + + /// + /// Type-level authorization on top of the route policy: Admins and Global Admins may queue + /// every allowed type, Operators only the types in . + /// Internal-static so tests can pin the matrix without an HTTP host. + /// + internal static bool IsTypeAllowedForCaller(string actionType, bool isTenantAdmin, bool isGlobalAdmin) + { + if (isTenantAdmin || isGlobalAdmin) + return true; + return OperatorAllowedTypes.Contains(actionType); + } + public QueueSessionActionFunction( ILogger 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; diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs index cd50aafb..cc80a5f3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs @@ -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"); } + /// + /// 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. + /// + private async Task 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 EvaluateBootstrapManagerOrGAAsync( string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier) { diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 8c2b541b..415d3aec 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -55,6 +55,15 @@ public enum EndpointPolicy /// MemberRead, + /// + /// 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 + /// RequestContext.IsTenantAdmin || IsGlobalAdmin (see QueueSessionActionFunction). + /// + TenantAdminOrOperator, + /// /// Own-tenant Admin, OR a platform role (Global Admin / Global Reader). Tenant-scoped admin-tier /// READ access (e.g. GET config/{tenantId}). Like 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), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/DiagnosticsBlobCascadeDeleter.cs b/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/DiagnosticsBlobCascadeDeleter.cs index dd88a569..4700abc0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/DiagnosticsBlobCascadeDeleter.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/DiagnosticsBlobCascadeDeleter.cs @@ -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; } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/HostedDiagnosticsBlobService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/HostedDiagnosticsBlobService.cs index 27c96604..3a7ca898 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/HostedDiagnosticsBlobService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Diagnostics/HostedDiagnosticsBlobService.cs @@ -165,6 +165,40 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics return response.Value; } + /// + /// Deletes every hosted diagnostics blob belonging to one session + /// (prefix {tenantId}/AgentDiagnostics-{sessionId}-). 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. + /// + public virtual async Task 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; + } + /// /// Enumerates blobs under a single tenant's prefix ({tenantId}/). /// Used by ad-hoc operator scripts and (later) any orphan-scan job.