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();
}
}