diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GetAllTenantConfigurationsFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GetAllTenantConfigurationsFunctionTests.cs new file mode 100644 index 00000000..82d8f426 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GetAllTenantConfigurationsFunctionTests.cs @@ -0,0 +1,91 @@ +using AutopilotMonitor.Functions.Functions.Config; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Covers the delegated ("MSP") config/all transformation seams in +/// . The HTTP entry point is intentionally not exercised +/// (mocking HttpRequestData + the middleware chain is more setup than the test is worth — same rationale as +/// ); the security-relevant logic is the pure subset/redaction +/// transform, which is tested directly. +/// +/// Guards two invariants for a delegated caller: (1) only managed tenants that actually have a config row +/// are returned (no phantom/empty entries), and (2) per-tenant secrets are ALWAYS redacted (a delegated +/// admin is never a Global Admin, so it must never receive SAS/webhook/header secrets for a managed tenant). +/// +public class GetAllTenantConfigurationsFunctionTests +{ + private const string TenantB = "22222222-2222-2222-2222-222222222222"; + private const string TenantC = "33333333-3333-3333-3333-333333333333"; + + [Fact] + public void ExistingManagedConfigs_DropsTenantsWithoutConfigRow() + { + var reads = new (TenantConfiguration config, bool exists)[] + { + (new TenantConfiguration { TenantId = TenantB }, true), + (new TenantConfiguration { TenantId = TenantC }, false), // no config row → must be dropped + }; + + var result = GetAllTenantConfigurationsFunction.ExistingManagedConfigs(reads); + + Assert.Single(result); + Assert.Equal(TenantB, result[0].TenantId); + } + + [Fact] + public void ExistingManagedConfigs_AllExist_KeepsAll() + { + var reads = new (TenantConfiguration config, bool exists)[] + { + (new TenantConfiguration { TenantId = TenantB }, true), + (new TenantConfiguration { TenantId = TenantC }, true), + }; + + var result = GetAllTenantConfigurationsFunction.ExistingManagedConfigs(reads); + + Assert.Equal(2, result.Count); + } + + [Fact] + public void ExistingManagedConfigs_NoneExist_Empty() + { + var reads = new (TenantConfiguration config, bool exists)[] + { + (new TenantConfiguration { TenantId = TenantB }, false), + }; + + Assert.Empty(GetAllTenantConfigurationsFunction.ExistingManagedConfigs(reads)); + } + + [Fact] + public void DelegatedBareArrayView_RedactsSecretsButKeepsNonSecrets() + { + var managed = new[] + { + new TenantConfiguration + { + TenantId = TenantB, + DiagnosticsBlobSasUrl = "https://acct.blob.core.windows.net/c?sig=secret", + WebhookUrl = "https://hooks.example/abc", + DomainName = "contoso.com", + }, + }; + + var view = GetAllTenantConfigurationsFunction.DelegatedBareArrayView(managed); + + var v = Assert.Single(view); + Assert.Equal(Constants.RedactedSecretPlaceholder, v.DiagnosticsBlobSasUrl); + Assert.Equal(Constants.RedactedSecretPlaceholder, v.WebhookUrl); + Assert.Equal("contoso.com", v.DomainName); // non-secret preserved + // original instance is never mutated (redaction returns a copy) + Assert.Equal("https://hooks.example/abc", managed[0].WebhookUrl); + } + + [Fact] + public void DelegatedBareArrayView_Empty_Empty() + => Assert.Empty(GetAllTenantConfigurationsFunction.DelegatedBareArrayView(new TenantConfiguration[0])); +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAllTenantConfigurationsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAllTenantConfigurationsFunction.cs index 4558a043..0ae1cf66 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAllTenantConfigurationsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAllTenantConfigurationsFunction.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -6,6 +7,7 @@ using System.Web; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Pagination; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -58,15 +60,14 @@ namespace AutopilotMonitor.Functions.Functions.Config // never writes a default row). exists==false drops any id with no config row. var reads = await Task.WhenAll(requestCtx.AllowedTenantIds.Select(async tid => await _configService.TryGetConfigurationAsync(tid))); - var subset = reads.Where(r => r.exists).Select(r => r.config).ToList(); + var subset = ExistingManagedConfigs(reads); _logger.LogInformation("GetAllTenantConfigurations (delegated subset, {Count} tenants) by {User}", subset.Count, userIdentifier); if (parsed.PageSize == null) { - var redacted = subset.Select(c => c.RedactedCopyForReader()).ToList(); var resp = req.CreateResponse(HttpStatusCode.OK); - await resp.WriteAsJsonAsync(redacted); + await resp.WriteAsJsonAsync(DelegatedBareArrayView(subset)); return resp; } @@ -149,5 +150,24 @@ namespace AutopilotMonitor.Functions.Functions.Config return response; } } + + /// + /// From the per-tenant point-read results, keep only the managed tenants that actually HAVE a config + /// row (exists). A delegated caller's AllowedTenantIds is the authoritative bound; an id with no row + /// (offboarded / never onboarded) is silently dropped rather than surfaced as an empty default. + /// Pure + testable seam (handler HTTP entry is not unit-tested — see GetAllBlockedDevicesFunctionTests). + /// + internal static List ExistingManagedConfigs( + IEnumerable<(TenantConfiguration config, bool exists)> reads) + => reads.Where(r => r.exists).Select(r => r.config).ToList(); + + /// + /// The delegated bare-array view of config/all: every managed-tenant config with its secrets + /// (SAS / webhook URLs / custom headers) redacted. A delegated admin is never a Global Admin, so it + /// must NEVER receive unredacted secrets for a tenant it merely manages. Pure + testable. + /// + internal static List DelegatedBareArrayView( + IEnumerable existingManaged) + => existingManaged.Select(c => c.RedactedCopyForReader()).ToList(); } }