feat(backend): delegated-admin (MSP mode) data model + scope service [Phase 0]

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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-25 18:50:56 +02:00
parent f37ee3e6b1
commit 05a591e1d4
7 changed files with 662 additions and 5 deletions
@@ -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));
}
@@ -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;
/// <summary>
/// Tests for <see cref="DelegatedAdminService"/> 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.
/// </summary>
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<IAdminRepository> Repo) Build()
{
var repo = new Mock<IAdminRepository>();
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()))
.ReturnsAsync(new List<DelegatedAdminEntry>());
var cache = new MemoryCache(new MemoryCacheOptions());
var svc = new DelegatedAdminService(repo.Object, cache, NullLogger<DelegatedAdminService>.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<IAdminRepository> repo, params DelegatedAdminEntry[] rows) =>
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>())).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<string>()), 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<string>()), Times.Once);
}
[Fact]
public async Task Upsert_InvalidatesCache()
{
var (svc, repo) = Build();
Returns(repo, Row(TenantA));
repo.Setup(r => r.UpsertDelegatedAdminAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.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<string>()), Times.Exactly(2));
}
[Fact]
public async Task Revoke_InvalidatesCache()
{
var (svc, repo) = Build();
Returns(repo, Row(TenantA));
repo.Setup(r => r.SetDelegatedAdminStatusAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.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<string>()), Times.Exactly(2));
}
}
@@ -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<TableAdminRepository> _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<List<DelegatedAdminEntry>> GetDelegatedTenantsAsync(string upn)
{
var entries = new List<DelegatedAdminEntry>();
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<DelegatedAdminEntity>(
e => e.PartitionKey == normalizedUpn))
{
entries.Add(MapToDelegatedEntry(entity));
}
return entries;
}
public async Task<List<DelegatedAdminEntry>> GetDelegatedAssigneesAsync(string tenantId)
{
var entries = new List<DelegatedAdminEntry>();
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<DelegatedAdminEntity>(
e => e.RowKey == normalizedTenantId))
{
entries.Add(MapToDelegatedEntry(entity));
}
return entries;
}
public async Task<DelegatedAdminEntry?> GetDelegatedAdminAsync(string upn, string tenantId)
{
if (string.IsNullOrWhiteSpace(upn) || string.IsNullOrWhiteSpace(tenantId))
return null;
try
{
var result = await _delegatedAdminsTableClient.GetEntityAsync<DelegatedAdminEntity>(
upn.ToLowerInvariant(), tenantId.ToLowerInvariant());
return result.Value == null ? null : MapToDelegatedEntry(result.Value);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return null;
}
}
public async Task<bool> 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<bool> SetDelegatedAdminStatusAsync(string upn, string tenantId, string status)
{
upn = upn.ToLowerInvariant();
tenantId = tenantId.ToLowerInvariant();
try
{
var result = await _delegatedAdminsTableClient.GetEntityAsync<DelegatedAdminEntity>(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<bool> SetDelegatedAdminEnabledAsync(string upn, string tenantId, bool isEnabled)
{
upn = upn.ToLowerInvariant();
tenantId = tenantId.ToLowerInvariant();
try
{
var result = await _delegatedAdminsTableClient.GetEntityAsync<DelegatedAdminEntity>(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<bool> RemoveDelegatedAdminAsync(string upn, string tenantId)
{
upn = upn.ToLowerInvariant();
tenantId = tenantId.ToLowerInvariant();
await _delegatedAdminsTableClient.DeleteEntityAsync(upn, tenantId);
return true;
}
// --- Tenant Members ---
public async Task<List<TenantMember>> 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
@@ -222,6 +222,7 @@ builder.Services.AddSingleton<PlatformMetricsService>();
builder.Services.AddSingleton<SlaMetricsService>();
builder.Services.AddSingleton<SlaBreachEvaluationService>();
builder.Services.AddSingleton<GlobalAdminService>();
builder.Services.AddSingleton<DelegatedAdminService>();
builder.Services.AddSingleton<McpUserService>();
builder.Services.AddSingleton<PreviewWhitelistService>();
builder.Services.AddSingleton<TenantAdminsService>();
@@ -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;
/// <summary>
/// Resolves and manages <b>delegated admin</b> 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 <b>tid-agnostic</b> — identical to <see cref="GlobalAdminService"/>:
/// 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.
/// </summary>
public class DelegatedAdminService
{
private readonly IAdminRepository _adminRepo;
private readonly IMemoryCache _cache;
private readonly ILogger<DelegatedAdminService> _logger;
private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5);
public DelegatedAdminService(
IAdminRepository adminRepo,
IMemoryCache cache,
ILogger<DelegatedAdminService> logger)
{
_adminRepo = adminRepo;
_cache = cache;
_logger = logger;
}
/// <summary>
/// Resolves the caller's effective delegated scope: the set of tenants it may access and the role per
/// tenant. Only <see cref="Constants.DelegatedStatus.Active"/> + 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.
/// </summary>
public virtual async Task<DelegatedScope> GetScopeAsync(string? upn)
{
if (string.IsNullOrWhiteSpace(upn))
return DelegatedScope.Empty;
upn = upn.ToLowerInvariant();
var cacheKey = $"delegated-scope:{upn}";
if (_cache.TryGetValue<DelegatedScope>(cacheKey, out var cached) && cached != null)
return cached;
var rows = await _adminRepo.GetDelegatedTenantsAsync(upn);
var tenantRoles = new Dictionary<string, string>(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;
}
/// <summary>Creates or replaces an assignment, then invalidates the UPN's cached scope.</summary>
public async Task<DelegatedAdminEntry> 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
};
}
/// <summary>Transitions an assignment's status (e.g. PendingApproval → Active on approval, → Revoked).</summary>
public async Task<bool> 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<bool> 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<bool> RemoveAsync(string upn, string tenantId)
{
upn = upn.ToLowerInvariant();
tenantId = tenantId.ToLowerInvariant();
var ok = await _adminRepo.RemoveDelegatedAdminAsync(upn, tenantId);
Invalidate(upn);
return ok;
}
/// <summary>All assignment rows for a UPN (any status) — for the operator/admin management UI.</summary>
public Task<List<DelegatedAdminEntry>> GetAssignmentsForUpnAsync(string upn)
=> _adminRepo.GetDelegatedTenantsAsync(upn.ToLowerInvariant());
/// <summary>All assignment rows targeting a tenant (any status) — for the customer "who manages me?" UI.</summary>
public Task<List<DelegatedAdminEntry>> GetAssigneesForTenantAsync(string tenantId)
=> _adminRepo.GetDelegatedAssigneesAsync(tenantId.ToLowerInvariant());
private void Invalidate(string upn) => _cache.Remove($"delegated-scope:{upn.ToLowerInvariant()}");
/// <summary>Empty/missing role defaults to the least-privileged DelegatedReader; an unrecognized
/// role is dropped (fail-closed) rather than silently granting access.</summary>
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;
}
/// <summary>
/// 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).
/// </summary>
public sealed class DelegatedScope
{
public static readonly DelegatedScope Empty = new(new Dictionary<string, string>());
private readonly IReadOnlyDictionary<string, string> _tenantRoles;
public DelegatedScope(IReadOnlyDictionary<string, string> tenantRoles)
=> _tenantRoles = tenantRoles;
/// <summary>Tenant IDs (lowercase) this scope grants access to.</summary>
public IReadOnlyCollection<string> TenantIds => (IReadOnlyCollection<string>)_tenantRoles.Keys;
public bool IsEmpty => _tenantRoles.Count == 0;
/// <summary>True if the scope grants access to the given tenant (any delegated role).</summary>
public bool Covers(string? tenantId)
=> !string.IsNullOrEmpty(tenantId) && _tenantRoles.ContainsKey(tenantId);
/// <summary>The delegated role for a tenant, or null if not covered.</summary>
public string? RoleFor(string? tenantId)
=> tenantId != null && _tenantRoles.TryGetValue(tenantId, out var r) ? r : null;
/// <summary>True if the scope grants write (DelegatedAdmin) on the given tenant.</summary>
public bool CanWrite(string? tenantId)
=> RoleFor(tenantId) == Constants.DelegatedRoles.DelegatedAdmin;
}
/// <summary>
/// Entity representing a delegated admin assignment in Table Storage.
/// PartitionKey = delegated-admin UPN (lowercase); RowKey = TenantId (lowercase).
/// </summary>
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; }
/// <summary>The delegated admin's UPN (lowercase) — denormalized copy of PartitionKey.</summary>
public string Upn { get; set; } = string.Empty;
/// <summary>The managed tenant ID (lowercase) — denormalized copy of RowKey.</summary>
public string TenantId { get; set; } = string.Empty;
/// <summary><see cref="Constants.DelegatedRoles"/>: DelegatedReader (default) or DelegatedAdmin.</summary>
public string Role { get; set; } = Constants.DelegatedRoles.DelegatedReader;
/// <summary>Whether this assignment is currently enabled (soft toggle, independent of Status).</summary>
public bool IsEnabled { get; set; } = true;
/// <summary><see cref="Constants.DelegatedStatus"/>: Active / PendingApproval / Revoked.</summary>
public string Status { get; set; } = Constants.DelegatedStatus.Active;
/// <summary><see cref="Constants.DelegatedSource"/>: OperatorGranted / CustomerDelegated.</summary>
public string Source { get; set; } = Constants.DelegatedSource.OperatorGranted;
/// <summary>When this assignment was created.</summary>
public DateTime GrantedDate { get; set; }
/// <summary>UPN of the principal who created this assignment (operator GA, or customer tenant admin).</summary>
public string GrantedBy { get; set; } = string.Empty;
}
@@ -672,6 +672,43 @@ namespace AutopilotMonitor.Shared
public const string GlobalReader = "GlobalReader";
}
/// <summary>
/// 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 <see cref="GlobalRoles"/>:
/// 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).
/// </summary>
public static class DelegatedRoles
{
public const string DelegatedReader = "DelegatedReader";
public const string DelegatedAdmin = "DelegatedAdmin";
}
/// <summary>
/// Lifecycle status of a DelegatedAdmins assignment row. Only <see cref="Active"/> rows confer scope;
/// <see cref="PendingApproval"/> (customer-delegation awaiting tenant-admin approval) and
/// <see cref="Revoked"/> rows are retained for audit but grant nothing.
/// </summary>
public static class DelegatedStatus
{
public const string Active = "Active";
public const string PendingApproval = "PendingApproval";
public const string Revoked = "Revoked";
}
/// <summary>
/// How a DelegatedAdmins assignment was created: <see cref="OperatorGranted"/> (a platform
/// GlobalAdmin assigned it centrally) or <see cref="CustomerDelegated"/> (the customer's own
/// tenant admin delegated access to an external party — self-service).
/// </summary>
public static class DelegatedSource
{
public const string OperatorGranted = "OperatorGranted";
public const string CustomerDelegated = "CustomerDelegated";
}
/// <summary>
/// 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 (<c>GlobalAdmins</c>, <c>TenantAdmins</c>, <c>McpUsers</c>)
/// are gated to single-row restore in the API layer — full-table restore would
/// reactivate disabled or re-create removed identities.
/// Four of these (<c>GlobalAdmins</c>, <c>TenantAdmins</c>, <c>McpUsers</c>,
/// <c>DelegatedAdmins</c>) are gated to single-row restore in the API layer — full-table
/// restore would reactivate disabled or re-create removed identities.
/// <c>OffboardingAudit</c> additionally blocks replace-all (append-only audit).
/// </summary>
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,
};
/// <summary>
@@ -6,7 +6,7 @@ namespace AutopilotMonitor.Shared.DataAccess
{
/// <summary>
/// Repository for admin and tenant member management.
/// Covers: GlobalAdmins, TenantAdmins tables.
/// Covers: GlobalAdmins, TenantAdmins, McpUsers, DelegatedAdmins tables.
/// </summary>
public interface IAdminRepository
{
@@ -31,6 +31,20 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<bool> SetMcpUserEnabledAsync(string upn, bool isEnabled);
Task<bool> SetMcpUserUsagePlanAsync(string upn, string? usagePlan);
// --- Delegated Admins (scoped-global / "MSP mode") ---
/// <summary>All assignment rows for one delegated-admin UPN (PK scan), regardless of status/enabled.
/// Callers that need only effective scope filter on Status==Active &amp;&amp; IsEnabled.</summary>
Task<List<DelegatedAdminEntry>> GetDelegatedTenantsAsync(string upn);
/// <summary>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.</summary>
Task<List<DelegatedAdminEntry>> GetDelegatedAssigneesAsync(string tenantId);
Task<DelegatedAdminEntry?> GetDelegatedAdminAsync(string upn, string tenantId);
/// <summary>Creates or replaces an assignment row (upsert on PK=upn, RK=tenantId).</summary>
Task<bool> UpsertDelegatedAdminAsync(string upn, string tenantId, string role, string status, string source, string grantedBy);
Task<bool> SetDelegatedAdminStatusAsync(string upn, string tenantId, string status);
Task<bool> SetDelegatedAdminEnabledAsync(string upn, string tenantId, bool isEnabled);
Task<bool> RemoveDelegatedAdminAsync(string upn, string tenantId);
// --- Tenant Members ---
Task<List<TenantMember>> GetTenantMembersAsync(string tenantId);
Task<bool> 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;
}
/// <summary>
/// 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".
/// </summary>
public class DelegatedAdminEntry
{
public string Upn { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
/// <summary>Constants.DelegatedRoles: "DelegatedReader" (default) or "DelegatedAdmin".</summary>
public string Role { get; set; } = string.Empty;
public bool IsEnabled { get; set; } = true;
/// <summary>Constants.DelegatedStatus: "Active" / "PendingApproval" / "Revoked". Only Active confers scope.</summary>
public string Status { get; set; } = string.Empty;
/// <summary>Constants.DelegatedSource: "OperatorGranted" / "CustomerDelegated".</summary>
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;