diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CriticalTableBackupServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CriticalTableBackupServiceTests.cs
index a55d8260..b5535c1d 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/CriticalTableBackupServiceTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/CriticalTableBackupServiceTests.cs
@@ -47,7 +47,7 @@ public class CriticalTableBackupServiceTests
Assert.Equal(BackupOutcome.Success, result.Outcome);
Assert.True(store.ManifestWritten, "manifest must be written LAST");
- Assert.Equal(15, result.Manifest.Tables.Count); // critical-tables catalog count
+ Assert.Equal(16, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins)
Assert.All(result.Manifest.Tables, t =>
Assert.True(t.Status == TableBackupStatus.Ok || t.Status == TableBackupStatus.Empty));
}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs
new file mode 100644
index 00000000..ec6cb1d4
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs
@@ -0,0 +1,213 @@
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.DataAccess;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Tests for scope resolution — the "scoped global" (MSP) tier.
+/// Locks in the fail-closed rules: only Active + enabled + recognized-role rows confer scope; empty role
+/// defaults to the least-privileged DelegatedReader; unknown roles are dropped. Also covers the 5-minute
+/// cache and its invalidation on mutation.
+///
+public class DelegatedAdminServiceTests
+{
+ private const string Upn = "msp-admin@partner.example";
+ private const string TenantA = "11111111-1111-1111-1111-111111111111";
+ private const string TenantB = "22222222-2222-2222-2222-222222222222";
+
+ private static (DelegatedAdminService Svc, Mock Repo) Build()
+ {
+ var repo = new Mock();
+ repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny()))
+ .ReturnsAsync(new List());
+ var cache = new MemoryCache(new MemoryCacheOptions());
+ var svc = new DelegatedAdminService(repo.Object, cache, NullLogger.Instance);
+ return (svc, repo);
+ }
+
+ private static DelegatedAdminEntry Row(
+ string tenantId,
+ string role = Constants.DelegatedRoles.DelegatedReader,
+ bool enabled = true,
+ string status = Constants.DelegatedStatus.Active) => new()
+ {
+ Upn = Upn,
+ TenantId = tenantId,
+ Role = role,
+ IsEnabled = enabled,
+ Status = status,
+ Source = Constants.DelegatedSource.OperatorGranted,
+ };
+
+ private static void Returns(Mock repo, params DelegatedAdminEntry[] rows) =>
+ repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny())).ReturnsAsync(rows.ToList());
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task GetScope_BlankUpn_ReturnsEmpty(string? upn)
+ {
+ var (svc, repo) = Build();
+ var scope = await svc.GetScopeAsync(upn);
+ Assert.True(scope.IsEmpty);
+ repo.Verify(r => r.GetDelegatedTenantsAsync(It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task GetScope_NoRows_ReturnsEmpty()
+ {
+ var (svc, _) = Build();
+ var scope = await svc.GetScopeAsync(Upn);
+ Assert.True(scope.IsEmpty);
+ Assert.False(scope.Covers(TenantA));
+ }
+
+ [Fact]
+ public async Task GetScope_ActiveEnabledRow_IsCovered()
+ {
+ var (svc, repo) = Build();
+ Returns(repo, Row(TenantA, Constants.DelegatedRoles.DelegatedReader));
+
+ var scope = await svc.GetScopeAsync(Upn);
+
+ Assert.True(scope.Covers(TenantA));
+ Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantA));
+ Assert.False(scope.CanWrite(TenantA));
+ Assert.Single(scope.TenantIds);
+ }
+
+ [Fact]
+ public async Task GetScope_DelegatedAdminRole_CanWrite()
+ {
+ var (svc, repo) = Build();
+ Returns(repo, Row(TenantA, Constants.DelegatedRoles.DelegatedAdmin));
+
+ var scope = await svc.GetScopeAsync(Upn);
+
+ Assert.True(scope.CanWrite(TenantA));
+ }
+
+ [Theory]
+ [InlineData(Constants.DelegatedStatus.PendingApproval)]
+ [InlineData(Constants.DelegatedStatus.Revoked)]
+ public async Task GetScope_NonActiveStatus_NotCovered(string status)
+ {
+ var (svc, repo) = Build();
+ Returns(repo, Row(TenantA, status: status));
+
+ var scope = await svc.GetScopeAsync(Upn);
+
+ Assert.False(scope.Covers(TenantA));
+ Assert.True(scope.IsEmpty);
+ }
+
+ [Fact]
+ public async Task GetScope_DisabledRow_NotCovered()
+ {
+ var (svc, repo) = Build();
+ Returns(repo, Row(TenantA, enabled: false));
+
+ var scope = await svc.GetScopeAsync(Upn);
+
+ Assert.False(scope.Covers(TenantA));
+ }
+
+ [Fact]
+ public async Task GetScope_EmptyRole_DefaultsToReader()
+ {
+ var (svc, repo) = Build();
+ Returns(repo, Row(TenantA, role: ""));
+
+ var scope = await svc.GetScopeAsync(Upn);
+
+ Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantA));
+ }
+
+ [Fact]
+ public async Task GetScope_UnknownRole_IsDropped()
+ {
+ var (svc, repo) = Build();
+ Returns(repo, Row(TenantA, role: "SuperRoot"));
+
+ var scope = await svc.GetScopeAsync(Upn);
+
+ Assert.False(scope.Covers(TenantA));
+ Assert.True(scope.IsEmpty);
+ }
+
+ [Fact]
+ public async Task GetScope_MultipleTenants_AllResolved()
+ {
+ var (svc, repo) = Build();
+ Returns(repo,
+ Row(TenantA, Constants.DelegatedRoles.DelegatedReader),
+ Row(TenantB, Constants.DelegatedRoles.DelegatedAdmin));
+
+ var scope = await svc.GetScopeAsync(Upn);
+
+ Assert.Equal(2, scope.TenantIds.Count);
+ Assert.False(scope.CanWrite(TenantA));
+ Assert.True(scope.CanWrite(TenantB));
+ }
+
+ [Fact]
+ public async Task GetScope_TenantIdLookup_IsCaseInsensitive()
+ {
+ var (svc, repo) = Build();
+ Returns(repo, Row(TenantA.ToLowerInvariant()));
+
+ var scope = await svc.GetScopeAsync(Upn);
+
+ Assert.True(scope.Covers(TenantA.ToUpperInvariant()));
+ }
+
+ [Fact]
+ public async Task GetScope_SecondCall_IsCached()
+ {
+ var (svc, repo) = Build();
+ Returns(repo, Row(TenantA));
+
+ await svc.GetScopeAsync(Upn);
+ await svc.GetScopeAsync(Upn);
+
+ repo.Verify(r => r.GetDelegatedTenantsAsync(It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Upsert_InvalidatesCache()
+ {
+ var (svc, repo) = Build();
+ Returns(repo, Row(TenantA));
+ repo.Setup(r => r.UpsertDelegatedAdminAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(true);
+
+ await svc.GetScopeAsync(Upn); // primes cache
+ await svc.UpsertAsync(Upn, TenantB, Constants.DelegatedRoles.DelegatedReader,
+ Constants.DelegatedStatus.Active, Constants.DelegatedSource.OperatorGranted, "ga@vendor.example");
+ await svc.GetScopeAsync(Upn); // must re-query after invalidation
+
+ repo.Verify(r => r.GetDelegatedTenantsAsync(It.IsAny()), Times.Exactly(2));
+ }
+
+ [Fact]
+ public async Task Revoke_InvalidatesCache()
+ {
+ var (svc, repo) = Build();
+ Returns(repo, Row(TenantA));
+ repo.Setup(r => r.SetDelegatedAdminStatusAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(true);
+
+ await svc.GetScopeAsync(Upn);
+ await svc.SetStatusAsync(Upn, TenantA, Constants.DelegatedStatus.Revoked);
+ await svc.GetScopeAsync(Upn);
+
+ repo.Verify(r => r.GetDelegatedTenantsAsync(It.IsAny()), Times.Exactly(2));
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs
index a65a496a..58da381b 100644
--- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs
+++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs
@@ -16,6 +16,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
private readonly TableClient _globalAdminsTableClient;
private readonly TableClient _tenantAdminsTableClient;
private readonly TableClient _mcpUsersTableClient;
+ private readonly TableClient _delegatedAdminsTableClient;
private readonly ILogger _logger;
public TableAdminRepository(
@@ -26,6 +27,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
_globalAdminsTableClient = storage.GetTableClient(Constants.TableNames.GlobalAdmins);
_tenantAdminsTableClient = storage.GetTableClient(Constants.TableNames.TenantAdmins);
_mcpUsersTableClient = storage.GetTableClient(Constants.TableNames.McpUsers);
+ _delegatedAdminsTableClient = storage.GetTableClient(Constants.TableNames.DelegatedAdmins);
}
// --- Global Admins ---
@@ -282,6 +284,133 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
+ // --- Delegated Admins (scoped-global / "MSP mode") ---
+
+ public async Task> GetDelegatedTenantsAsync(string upn)
+ {
+ var entries = new List();
+ if (string.IsNullOrWhiteSpace(upn))
+ return entries;
+
+ var normalizedUpn = upn.ToLowerInvariant();
+ // Typed predicate overload builds the OData filter safely (escapes quotes) — no string interpolation.
+ await foreach (var entity in _delegatedAdminsTableClient.QueryAsync(
+ e => e.PartitionKey == normalizedUpn))
+ {
+ entries.Add(MapToDelegatedEntry(entity));
+ }
+ return entries;
+ }
+
+ public async Task> GetDelegatedAssigneesAsync(string tenantId)
+ {
+ var entries = new List();
+ if (string.IsNullOrWhiteSpace(tenantId))
+ return entries;
+
+ // Cross-partition scan on RowKey — admin-UI path, not the hot auth path.
+ // Typed predicate overload builds the OData filter safely (escapes quotes) — no string interpolation.
+ var normalizedTenantId = tenantId.ToLowerInvariant();
+ await foreach (var entity in _delegatedAdminsTableClient.QueryAsync(
+ e => e.RowKey == normalizedTenantId))
+ {
+ entries.Add(MapToDelegatedEntry(entity));
+ }
+ return entries;
+ }
+
+ public async Task GetDelegatedAdminAsync(string upn, string tenantId)
+ {
+ if (string.IsNullOrWhiteSpace(upn) || string.IsNullOrWhiteSpace(tenantId))
+ return null;
+
+ try
+ {
+ var result = await _delegatedAdminsTableClient.GetEntityAsync(
+ upn.ToLowerInvariant(), tenantId.ToLowerInvariant());
+ return result.Value == null ? null : MapToDelegatedEntry(result.Value);
+ }
+ catch (RequestFailedException ex) when (ex.Status == 404)
+ {
+ return null;
+ }
+ }
+
+ public async Task UpsertDelegatedAdminAsync(
+ string upn, string tenantId, string role, string status, string source, string grantedBy)
+ {
+ upn = upn.ToLowerInvariant();
+ tenantId = tenantId.ToLowerInvariant();
+ grantedBy = grantedBy.ToLowerInvariant();
+
+ var entity = new DelegatedAdminEntity
+ {
+ PartitionKey = upn,
+ RowKey = tenantId,
+ Upn = upn,
+ TenantId = tenantId,
+ Role = role,
+ IsEnabled = true,
+ Status = status,
+ Source = source,
+ GrantedDate = DateTime.UtcNow,
+ GrantedBy = grantedBy
+ };
+
+ await _delegatedAdminsTableClient.UpsertEntityAsync(entity);
+ return true;
+ }
+
+ public async Task SetDelegatedAdminStatusAsync(string upn, string tenantId, string status)
+ {
+ upn = upn.ToLowerInvariant();
+ tenantId = tenantId.ToLowerInvariant();
+
+ try
+ {
+ var result = await _delegatedAdminsTableClient.GetEntityAsync(upn, tenantId);
+ var entity = result.Value;
+ if (entity == null) return false;
+
+ entity.Status = status;
+ await _delegatedAdminsTableClient.UpdateEntityAsync(entity, ETag.All);
+ return true;
+ }
+ catch (RequestFailedException ex) when (ex.Status == 404)
+ {
+ return false;
+ }
+ }
+
+ public async Task SetDelegatedAdminEnabledAsync(string upn, string tenantId, bool isEnabled)
+ {
+ upn = upn.ToLowerInvariant();
+ tenantId = tenantId.ToLowerInvariant();
+
+ try
+ {
+ var result = await _delegatedAdminsTableClient.GetEntityAsync(upn, tenantId);
+ var entity = result.Value;
+ if (entity == null) return false;
+
+ entity.IsEnabled = isEnabled;
+ await _delegatedAdminsTableClient.UpdateEntityAsync(entity, ETag.All);
+ return true;
+ }
+ catch (RequestFailedException ex) when (ex.Status == 404)
+ {
+ return false;
+ }
+ }
+
+ public async Task RemoveDelegatedAdminAsync(string upn, string tenantId)
+ {
+ upn = upn.ToLowerInvariant();
+ tenantId = tenantId.ToLowerInvariant();
+ await _delegatedAdminsTableClient.DeleteEntityAsync(upn, tenantId);
+ return true;
+ }
+
// --- Tenant Members ---
public async Task> GetTenantMembersAsync(string tenantId)
@@ -412,6 +541,21 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
// --- Helpers ---
+ private static DelegatedAdminEntry MapToDelegatedEntry(DelegatedAdminEntity entity)
+ {
+ return new DelegatedAdminEntry
+ {
+ Upn = entity.Upn,
+ TenantId = entity.TenantId,
+ Role = entity.Role,
+ IsEnabled = entity.IsEnabled,
+ Status = entity.Status,
+ Source = entity.Source,
+ GrantedAt = entity.GrantedDate,
+ GrantedBy = entity.GrantedBy
+ };
+ }
+
private static TenantMember MapToTenantMember(TenantAdminEntity entity)
{
return new TenantMember
diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs
index ca558e6c..e2433dfa 100644
--- a/src/Backend/AutopilotMonitor.Functions/Program.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Program.cs
@@ -222,6 +222,7 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs b/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs
new file mode 100644
index 00000000..64ec15c2
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs
@@ -0,0 +1,222 @@
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.DataAccess;
+using Azure.Data.Tables;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+
+namespace AutopilotMonitor.Functions.Services;
+
+///
+/// Resolves and manages delegated admin assignments — the "scoped global" tier between a
+/// single-tenant member and a platform GlobalAdmin. A delegated admin may read (and later write) a
+/// SUBSET of tenants: exactly the tenants for which it holds an Active, enabled DelegatedAdmins row.
+/// Externally this is surfaced as "MSP mode".
+///
+/// The scope is keyed on UPN and is tid-agnostic — identical to :
+/// the caller signs into their own home tenant, and their cross-tenant reach is resolved from this table
+/// regardless of the JWT's tid. Resolution is cached for 5 minutes; every mutation invalidates the cache.
+///
+public class DelegatedAdminService
+{
+ private readonly IAdminRepository _adminRepo;
+ private readonly IMemoryCache _cache;
+ private readonly ILogger _logger;
+ private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5);
+
+ public DelegatedAdminService(
+ IAdminRepository adminRepo,
+ IMemoryCache cache,
+ ILogger logger)
+ {
+ _adminRepo = adminRepo;
+ _cache = cache;
+ _logger = logger;
+ }
+
+ ///
+ /// Resolves the caller's effective delegated scope: the set of tenants it may access and the role per
+ /// tenant. Only + enabled rows with a recognized role
+ /// contribute; pending/revoked/disabled/unknown-role rows are ignored (fail-closed). Cached 5 minutes.
+ /// Returns an empty (never null) scope for a UPN with no effective assignments.
+ ///
+ public virtual async Task GetScopeAsync(string? upn)
+ {
+ if (string.IsNullOrWhiteSpace(upn))
+ return DelegatedScope.Empty;
+
+ upn = upn.ToLowerInvariant();
+ var cacheKey = $"delegated-scope:{upn}";
+ if (_cache.TryGetValue(cacheKey, out var cached) && cached != null)
+ return cached;
+
+ var rows = await _adminRepo.GetDelegatedTenantsAsync(upn);
+ var tenantRoles = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var row in rows)
+ {
+ if (!row.IsEnabled || row.Status != Constants.DelegatedStatus.Active)
+ continue;
+
+ var role = NormalizeRole(row.Role, upn, row.TenantId);
+ if (role == null)
+ continue;
+
+ // A duplicate (admin, tenant) RowKey is impossible in Table Storage, but if two rows ever
+ // collide on tenantId casing, the stronger role wins (DelegatedAdmin > DelegatedReader).
+ if (tenantRoles.TryGetValue(row.TenantId, out var existing) && IsStronger(existing, role))
+ continue;
+ tenantRoles[row.TenantId] = role;
+ }
+
+ var scope = new DelegatedScope(tenantRoles);
+ _cache.Set(cacheKey, scope, _cacheDuration);
+ return scope;
+ }
+
+ /// Creates or replaces an assignment, then invalidates the UPN's cached scope.
+ public async Task UpsertAsync(
+ string upn, string tenantId, string role, string status, string source, string grantedBy)
+ {
+ upn = upn.ToLowerInvariant();
+ tenantId = tenantId.ToLowerInvariant();
+ grantedBy = grantedBy.ToLowerInvariant();
+
+ await _adminRepo.UpsertDelegatedAdminAsync(upn, tenantId, role, status, source, grantedBy);
+ Invalidate(upn);
+
+ return new DelegatedAdminEntry
+ {
+ Upn = upn,
+ TenantId = tenantId,
+ Role = role,
+ IsEnabled = true,
+ Status = status,
+ Source = source,
+ GrantedAt = DateTime.UtcNow,
+ GrantedBy = grantedBy
+ };
+ }
+
+ /// Transitions an assignment's status (e.g. PendingApproval → Active on approval, → Revoked).
+ public async Task SetStatusAsync(string upn, string tenantId, string status)
+ {
+ upn = upn.ToLowerInvariant();
+ tenantId = tenantId.ToLowerInvariant();
+ var ok = await _adminRepo.SetDelegatedAdminStatusAsync(upn, tenantId, status);
+ Invalidate(upn);
+ return ok;
+ }
+
+ public async Task SetEnabledAsync(string upn, string tenantId, bool isEnabled)
+ {
+ upn = upn.ToLowerInvariant();
+ tenantId = tenantId.ToLowerInvariant();
+ var ok = await _adminRepo.SetDelegatedAdminEnabledAsync(upn, tenantId, isEnabled);
+ Invalidate(upn);
+ return ok;
+ }
+
+ public async Task RemoveAsync(string upn, string tenantId)
+ {
+ upn = upn.ToLowerInvariant();
+ tenantId = tenantId.ToLowerInvariant();
+ var ok = await _adminRepo.RemoveDelegatedAdminAsync(upn, tenantId);
+ Invalidate(upn);
+ return ok;
+ }
+
+ /// All assignment rows for a UPN (any status) — for the operator/admin management UI.
+ public Task> GetAssignmentsForUpnAsync(string upn)
+ => _adminRepo.GetDelegatedTenantsAsync(upn.ToLowerInvariant());
+
+ /// All assignment rows targeting a tenant (any status) — for the customer "who manages me?" UI.
+ public Task> GetAssigneesForTenantAsync(string tenantId)
+ => _adminRepo.GetDelegatedAssigneesAsync(tenantId.ToLowerInvariant());
+
+ private void Invalidate(string upn) => _cache.Remove($"delegated-scope:{upn.ToLowerInvariant()}");
+
+ /// Empty/missing role defaults to the least-privileged DelegatedReader; an unrecognized
+ /// role is dropped (fail-closed) rather than silently granting access.
+ private string? NormalizeRole(string role, string upn, string tenantId)
+ {
+ if (string.IsNullOrWhiteSpace(role))
+ return Constants.DelegatedRoles.DelegatedReader;
+ if (role == Constants.DelegatedRoles.DelegatedReader || role == Constants.DelegatedRoles.DelegatedAdmin)
+ return role;
+
+ _logger.LogWarning("Unrecognized delegated Role '{Role}' for {Upn} on tenant {TenantId} — ignoring row",
+ role, upn, tenantId);
+ return null;
+ }
+
+ private static bool IsStronger(string existing, string candidate)
+ => existing == Constants.DelegatedRoles.DelegatedAdmin
+ && candidate == Constants.DelegatedRoles.DelegatedReader;
+}
+
+///
+/// Immutable resolved delegated scope: which tenants a UPN may access and at what role. Empty when the
+/// caller is not a delegated admin. Consumed by the auth middleware to gate cross-tenant access against
+/// a subset (vs. the all-or-nothing GlobalAdmin scope).
+///
+public sealed class DelegatedScope
+{
+ public static readonly DelegatedScope Empty = new(new Dictionary());
+
+ private readonly IReadOnlyDictionary _tenantRoles;
+
+ public DelegatedScope(IReadOnlyDictionary tenantRoles)
+ => _tenantRoles = tenantRoles;
+
+ /// Tenant IDs (lowercase) this scope grants access to.
+ public IReadOnlyCollection TenantIds => (IReadOnlyCollection)_tenantRoles.Keys;
+
+ public bool IsEmpty => _tenantRoles.Count == 0;
+
+ /// True if the scope grants access to the given tenant (any delegated role).
+ public bool Covers(string? tenantId)
+ => !string.IsNullOrEmpty(tenantId) && _tenantRoles.ContainsKey(tenantId);
+
+ /// The delegated role for a tenant, or null if not covered.
+ public string? RoleFor(string? tenantId)
+ => tenantId != null && _tenantRoles.TryGetValue(tenantId, out var r) ? r : null;
+
+ /// True if the scope grants write (DelegatedAdmin) on the given tenant.
+ public bool CanWrite(string? tenantId)
+ => RoleFor(tenantId) == Constants.DelegatedRoles.DelegatedAdmin;
+}
+
+///
+/// Entity representing a delegated admin assignment in Table Storage.
+/// PartitionKey = delegated-admin UPN (lowercase); RowKey = TenantId (lowercase).
+///
+public class DelegatedAdminEntity : ITableEntity
+{
+ public string PartitionKey { get; set; } = string.Empty; // UPN (lowercase)
+ public string RowKey { get; set; } = string.Empty; // TenantId (lowercase)
+ public DateTimeOffset? Timestamp { get; set; }
+ public Azure.ETag ETag { get; set; }
+
+ /// The delegated admin's UPN (lowercase) — denormalized copy of PartitionKey.
+ public string Upn { get; set; } = string.Empty;
+
+ /// The managed tenant ID (lowercase) — denormalized copy of RowKey.
+ public string TenantId { get; set; } = string.Empty;
+
+ /// : DelegatedReader (default) or DelegatedAdmin.
+ public string Role { get; set; } = Constants.DelegatedRoles.DelegatedReader;
+
+ /// Whether this assignment is currently enabled (soft toggle, independent of Status).
+ public bool IsEnabled { get; set; } = true;
+
+ /// : Active / PendingApproval / Revoked.
+ public string Status { get; set; } = Constants.DelegatedStatus.Active;
+
+ /// : OperatorGranted / CustomerDelegated.
+ public string Source { get; set; } = Constants.DelegatedSource.OperatorGranted;
+
+ /// When this assignment was created.
+ public DateTime GrantedDate { get; set; }
+
+ /// UPN of the principal who created this assignment (operator GA, or customer tenant admin).
+ public string GrantedBy { get; set; } = string.Empty;
+}
diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs
index 56200f83..20e5e99e 100644
--- a/src/Shared/AutopilotMonitor.Shared/Constants.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs
@@ -672,6 +672,43 @@ namespace AutopilotMonitor.Shared
public const string GlobalReader = "GlobalReader";
}
+ ///
+ /// Per-tenant roles stored in the DelegatedAdmins table (Role column). A delegated admin is scoped
+ /// to a SUBSET of tenants (its assignment rows) rather than the whole platform — the "scoped global"
+ /// tier between a single-tenant member and a GlobalAdmin. Mirrors :
+ /// DelegatedReader = cross-tenant READ over the assigned tenants only (no mutation, secrets redacted);
+ /// DelegatedAdmin = read + (later) scoped write over the assigned tenants. Externally surfaced as
+ /// "MSP mode". An unrecognized Role string is treated as no role (fail-closed).
+ ///
+ public static class DelegatedRoles
+ {
+ public const string DelegatedReader = "DelegatedReader";
+ public const string DelegatedAdmin = "DelegatedAdmin";
+ }
+
+ ///
+ /// Lifecycle status of a DelegatedAdmins assignment row. Only rows confer scope;
+ /// (customer-delegation awaiting tenant-admin approval) and
+ /// rows are retained for audit but grant nothing.
+ ///
+ public static class DelegatedStatus
+ {
+ public const string Active = "Active";
+ public const string PendingApproval = "PendingApproval";
+ public const string Revoked = "Revoked";
+ }
+
+ ///
+ /// How a DelegatedAdmins assignment was created: (a platform
+ /// GlobalAdmin assigned it centrally) or (the customer's own
+ /// tenant admin delegated access to an external party — self-service).
+ ///
+ public static class DelegatedSource
+ {
+ public const string OperatorGranted = "OperatorGranted";
+ public const string CustomerDelegated = "CustomerDelegated";
+ }
+
///
/// Placeholder substituted for secret-bearing string fields (SAS URLs, webhook URLs, API keys,
/// custom webhook headers) when a config object is served to a read-only GlobalReader. Non-empty
@@ -735,6 +772,10 @@ namespace AutopilotMonitor.Shared
public const string TenantAdmins = "TenantAdmins";
public const string McpUsers = "McpUsers";
+ // Delegated (scoped-global) admin assignments — "MSP mode". PK=delegated-admin UPN (lowercase),
+ // RK=TenantId (lowercase). One row per (admin, managed-tenant) pair.
+ public const string DelegatedAdmins = "DelegatedAdmins";
+
// Preview gating (temporary — remove after GA)
public const string PreviewWhitelist = "PreviewWhitelist";
public const string PreviewConfig = "PreviewConfig";
@@ -888,6 +929,7 @@ namespace AutopilotMonitor.Shared
GlobalAdmins,
TenantAdmins,
McpUsers,
+ DelegatedAdmins,
PreviewWhitelist,
PreviewConfig,
Feedback,
@@ -1069,9 +1111,9 @@ namespace AutopilotMonitor.Shared
/// Critical tables that the daily backup feature snapshots. Order matters only for
/// deterministic test output; the per-table loop is independent so a failure in
/// one table does NOT block the others (Outcome=Partial covers that case).
- /// Three of these (GlobalAdmins, TenantAdmins, McpUsers)
- /// are gated to single-row restore in the API layer — full-table restore would
- /// reactivate disabled or re-create removed identities.
+ /// Four of these (GlobalAdmins, TenantAdmins, McpUsers,
+ /// DelegatedAdmins) are gated to single-row restore in the API layer — full-table
+ /// restore would reactivate disabled or re-create removed identities.
/// OffboardingAudit additionally blocks replace-all (append-only audit).
///
public static class CriticalBackupTables
@@ -1080,6 +1122,7 @@ namespace AutopilotMonitor.Shared
{
TableNames.AdminConfiguration,
TableNames.AnalyzeRules,
+ TableNames.DelegatedAdmins,
TableNames.Feedback,
TableNames.GatherRules,
TableNames.GlobalAdmins,
@@ -1105,6 +1148,7 @@ namespace AutopilotMonitor.Shared
TableNames.GlobalAdmins,
TableNames.TenantAdmins,
TableNames.McpUsers,
+ TableNames.DelegatedAdmins,
};
///
diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs
index d7490e8f..e787a308 100644
--- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs
+++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs
@@ -6,7 +6,7 @@ namespace AutopilotMonitor.Shared.DataAccess
{
///
/// Repository for admin and tenant member management.
- /// Covers: GlobalAdmins, TenantAdmins tables.
+ /// Covers: GlobalAdmins, TenantAdmins, McpUsers, DelegatedAdmins tables.
///
public interface IAdminRepository
{
@@ -31,6 +31,20 @@ namespace AutopilotMonitor.Shared.DataAccess
Task SetMcpUserEnabledAsync(string upn, bool isEnabled);
Task SetMcpUserUsagePlanAsync(string upn, string? usagePlan);
+ // --- Delegated Admins (scoped-global / "MSP mode") ---
+ /// All assignment rows for one delegated-admin UPN (PK scan), regardless of status/enabled.
+ /// Callers that need only effective scope filter on Status==Active && IsEnabled.
+ Task> GetDelegatedTenantsAsync(string upn);
+ /// All assignment rows targeting one tenant (cross-partition RowKey scan — admin UI, not hot path).
+ /// Powers the customer's "who manages my tenant?" view including pending requests.
+ Task> GetDelegatedAssigneesAsync(string tenantId);
+ Task GetDelegatedAdminAsync(string upn, string tenantId);
+ /// Creates or replaces an assignment row (upsert on PK=upn, RK=tenantId).
+ Task UpsertDelegatedAdminAsync(string upn, string tenantId, string role, string status, string source, string grantedBy);
+ Task SetDelegatedAdminStatusAsync(string upn, string tenantId, string status);
+ Task SetDelegatedAdminEnabledAsync(string upn, string tenantId, bool isEnabled);
+ Task RemoveDelegatedAdminAsync(string upn, string tenantId);
+
// --- Tenant Members ---
Task> GetTenantMembersAsync(string tenantId);
Task AddTenantMemberAsync(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens = false);
@@ -52,6 +66,25 @@ namespace AutopilotMonitor.Shared.DataAccess
public string Role { get; set; } = string.Empty;
}
+ ///
+ /// One delegated-admin assignment: UPN X may access tenant Y at role Role. The "scoped global" tier
+ /// (subset of tenants) between a single-tenant member and a platform GlobalAdmin. Surfaced as "MSP mode".
+ ///
+ public class DelegatedAdminEntry
+ {
+ public string Upn { get; set; } = string.Empty;
+ public string TenantId { get; set; } = string.Empty;
+ /// Constants.DelegatedRoles: "DelegatedReader" (default) or "DelegatedAdmin".
+ public string Role { get; set; } = string.Empty;
+ public bool IsEnabled { get; set; } = true;
+ /// Constants.DelegatedStatus: "Active" / "PendingApproval" / "Revoked". Only Active confers scope.
+ public string Status { get; set; } = string.Empty;
+ /// Constants.DelegatedSource: "OperatorGranted" / "CustomerDelegated".
+ public string Source { get; set; } = string.Empty;
+ public DateTime GrantedAt { get; set; }
+ public string GrantedBy { get; set; } = string.Empty;
+ }
+
public class McpUserEntry
{
public string Upn { get; set; } = string.Empty;