mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
+144
@@ -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 && 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;
|
||||
|
||||
Reference in New Issue
Block a user