From 05a591e1d46be06e947b38bbbfe6c694fba996cf Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 25 Jun 2026 18:50:56 +0200 Subject: [PATCH] feat(backend): delegated-admin (MSP mode) data model + scope service [Phase 0] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for "MSP mode" — a scoped-global authorization tier between a single-tenant member and a platform GlobalAdmin. A delegated admin may access a SUBSET of tenants (its assignment rows), resolved UPN-based and tid-agnostic, exactly like GlobalAdmins. Phase 0 is purely additive: the service is registered but no auth path consults it yet (the cross-tenant gate change lands in Phase 1), so behavior is unchanged. - Constants: DelegatedRoles/DelegatedStatus/DelegatedSource; new DelegatedAdmins table added to TableNames.All (auto-create), CriticalBackupTables.All and AuthTablesFullRestoreForbidden (identity table — full-table restore blocked). - IAdminRepository + TableAdminRepository: DelegatedAdminEntry DTO + CRUD (PK=UPN, RK=TenantId). Hot-path GetDelegatedTenantsAsync (partition scan) and reverse GetDelegatedAssigneesAsync. Queries use the typed predicate overload (no string-interpolated OData filter — avoids filter injection). - DelegatedAdminService: resolves DelegatedScope (Covers/RoleFor/CanWrite) with a 5-min cache + write-through invalidation. Fail-closed: only Active+enabled rows with a recognized role confer scope; empty role -> DelegatedReader; unknown -> dropped. - 16 unit tests (fake repo); full Functions suite 2434/2434 green. Backup catalog-count assertion bumped 15 -> 16. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../CriticalTableBackupServiceTests.cs | 2 +- .../DelegatedAdminServiceTests.cs | 213 +++++++++++++++++ .../TableStorage/TableAdminRepository.cs | 144 ++++++++++++ .../AutopilotMonitor.Functions/Program.cs | 1 + .../Services/DelegatedAdminService.cs | 222 ++++++++++++++++++ .../AutopilotMonitor.Shared/Constants.cs | 50 +++- .../DataAccess/IAdminRepository.cs | 35 ++- 7 files changed, 662 insertions(+), 5 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs 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;