feat(delegated): tenant templates — app-internal tenant bundles for MSP delegated admins

Let a GlobalAdmin define a named Tenant Template (a bundle of tenants) and
assign a delegated-admin UPN to the template instead of to each tenant. A UPN's
effective scope = union of direct grants + every tenant in every assigned
template, resolved in DelegatedAdminService.GetScopeAsync — so middleware
enforcement, /auth/me delegatedTenantIds and MCP all inherit it unchanged.
Adding a tenant to a template grants it to all assignees at once (membership-
driven MSP management). Fully app-internal: no Entra group claim / Graph.

- Storage: TenantTemplates (PK=templateId; meta + membership rows) and
  TenantTemplateAssignments (PK=upn) tables; meta-backed existence invariant in
  both management reads AND the auth hot path (no ghost templates, no scope from
  stray rows).
- Offboarding hygiene: property-only wipe now purges TenantTemplates membership
  AND DelegatedAdmins rows for the offboarded tenant (closes a pre-existing
  orphaned-grant gap).
- Management API: GlobalAdminOnly /api/global/tenant-templates/* (8 routes);
  per-affected-tenant audit; all mutations route through the service so the
  delegated-scope cache is invalidated in lockstep; existence/membership
  invariant owned by the service (no ghost create, no false revoke audits);
  templateId normalized case-insensitively at the service boundary.
- Web: SectionTenantTemplates management UI (create/rename/delete, add/remove
  tenants, assign/unassign), triple-registered + api.ts block.

Backend 2538 tests green; web tsc clean + vitest 488 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-29 16:17:04 +02:00
parent ecb1e14cf5
commit 86f8fd5ace
15 changed files with 1867 additions and 1 deletions
@@ -19,16 +19,41 @@ public class DelegatedAdminServiceTests
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private const string TemplateId1 = "tpl-aaaaaaaa";
private const string TemplateId2 = "tpl-bbbbbbbb";
private const string TenantC = "33333333-3333-3333-3333-333333333333";
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>());
// Default: no template assignments. Template-specific tests override per-case.
repo.Setup(r => r.GetTemplateAssignmentsForUpnAsync(It.IsAny<string>()))
.ReturnsAsync(new List<TenantTemplateAssignment>());
var cache = new MemoryCache(new MemoryCacheOptions());
var svc = new DelegatedAdminService(repo.Object, cache, NullLogger<DelegatedAdminService>.Instance);
return (svc, repo);
}
private static TenantTemplateAssignment Assignment(
string templateId,
string role = Constants.DelegatedRoles.DelegatedReader,
bool enabled = true) => new()
{
Upn = Upn,
TemplateId = templateId,
Role = role,
IsEnabled = enabled,
AssignedBy = "ga@vendor.example",
};
private static void ReturnsTemplates(Mock<IAdminRepository> repo, params TenantTemplateAssignment[] assignments) =>
repo.Setup(r => r.GetTemplateAssignmentsForUpnAsync(It.IsAny<string>())).ReturnsAsync(assignments.ToList());
private static void TemplateTenants(Mock<IAdminRepository> repo, string templateId, params string[] tenantIds) =>
repo.Setup(r => r.GetTemplateTenantsAsync(templateId)).ReturnsAsync(tenantIds.ToList());
private static DelegatedAdminEntry Row(
string tenantId,
string role = Constants.DelegatedRoles.DelegatedReader,
@@ -210,4 +235,341 @@ public class DelegatedAdminServiceTests
repo.Verify(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()), Times.Exactly(2));
}
// --- Tenant Templates: scope is the union of direct grants + every tenant in every assigned template ---
[Fact]
public async Task GetScope_TemplateAssignment_ExpandsToTemplateTenants()
{
var (svc, repo) = Build();
ReturnsTemplates(repo, Assignment(TemplateId1, Constants.DelegatedRoles.DelegatedReader));
TemplateTenants(repo, TemplateId1, TenantA, TenantB);
var scope = await svc.GetScopeAsync(Upn);
Assert.Equal(2, scope.TenantIds.Count);
Assert.True(scope.Covers(TenantA));
Assert.True(scope.Covers(TenantB));
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantA));
}
[Fact]
public async Task GetScope_DirectAndTemplate_AreUnioned()
{
var (svc, repo) = Build();
Returns(repo, Row(TenantA));
ReturnsTemplates(repo, Assignment(TemplateId1));
TemplateTenants(repo, TemplateId1, TenantB, TenantC);
var scope = await svc.GetScopeAsync(Upn);
Assert.Equal(3, scope.TenantIds.Count);
Assert.True(scope.Covers(TenantA));
Assert.True(scope.Covers(TenantB));
Assert.True(scope.Covers(TenantC));
}
[Fact]
public async Task GetScope_DisabledTemplateAssignment_Ignored()
{
var (svc, repo) = Build();
ReturnsTemplates(repo, Assignment(TemplateId1, enabled: false));
TemplateTenants(repo, TemplateId1, TenantA);
var scope = await svc.GetScopeAsync(Upn);
Assert.True(scope.IsEmpty);
Assert.False(scope.Covers(TenantA));
}
[Fact]
public async Task GetScope_UnknownTemplateRole_IsDropped()
{
var (svc, repo) = Build();
ReturnsTemplates(repo, Assignment(TemplateId1, role: "SuperRoot"));
TemplateTenants(repo, TemplateId1, TenantA);
var scope = await svc.GetScopeAsync(Upn);
Assert.True(scope.IsEmpty);
}
[Fact]
public async Task GetScope_EmptyTemplate_ContributesNothing()
{
var (svc, repo) = Build();
ReturnsTemplates(repo, Assignment(TemplateId1));
TemplateTenants(repo, TemplateId1); // no tenants
var scope = await svc.GetScopeAsync(Upn);
Assert.True(scope.IsEmpty);
}
[Fact]
public async Task GetScope_TenantInTwoTemplates_ResolvedOnce()
{
var (svc, repo) = Build();
ReturnsTemplates(repo, Assignment(TemplateId1), Assignment(TemplateId2));
TemplateTenants(repo, TemplateId1, TenantA);
TemplateTenants(repo, TemplateId2, TenantA, TenantB);
var scope = await svc.GetScopeAsync(Upn);
Assert.Equal(2, scope.TenantIds.Count);
Assert.True(scope.Covers(TenantA));
Assert.True(scope.Covers(TenantB));
}
[Fact]
public async Task GetScope_TemplateAdminRole_BeatsDirectReader()
{
var (svc, repo) = Build();
Returns(repo, Row(TenantA, Constants.DelegatedRoles.DelegatedReader));
ReturnsTemplates(repo, Assignment(TemplateId1, Constants.DelegatedRoles.DelegatedAdmin));
TemplateTenants(repo, TemplateId1, TenantA);
var scope = await svc.GetScopeAsync(Upn);
Assert.True(scope.CanWrite(TenantA)); // stronger DelegatedAdmin wins across sources
}
[Fact]
public async Task GetScope_DirectAdmin_BeatsTemplateReader()
{
var (svc, repo) = Build();
Returns(repo, Row(TenantA, Constants.DelegatedRoles.DelegatedAdmin));
ReturnsTemplates(repo, Assignment(TemplateId1, Constants.DelegatedRoles.DelegatedReader));
TemplateTenants(repo, TemplateId1, TenantA);
var scope = await svc.GetScopeAsync(Upn);
Assert.True(scope.CanWrite(TenantA)); // direct DelegatedAdmin not downgraded by template reader
}
[Fact]
public async Task GetScope_WithTemplates_IsCached()
{
var (svc, repo) = Build();
ReturnsTemplates(repo, Assignment(TemplateId1));
TemplateTenants(repo, TemplateId1, TenantA);
await svc.GetScopeAsync(Upn);
await svc.GetScopeAsync(Upn);
repo.Verify(r => r.GetTemplateAssignmentsForUpnAsync(It.IsAny<string>()), Times.Once);
repo.Verify(r => r.GetTemplateTenantsAsync(TemplateId1), Times.Once);
}
// --- Template mutations go through the service and invalidate cached scope (no stale auth) ---
[Fact]
public async Task AssignTemplate_InvalidatesUpnCache()
{
var (svc, repo) = Build();
ReturnsTemplates(repo, Assignment(TemplateId1));
TemplateTenants(repo, TemplateId1, TenantA);
TemplateExists(repo, TemplateId2); // target template must exist for the assign to take effect
repo.Setup(r => r.AssignTemplateAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<string>()))
.ReturnsAsync(true);
await svc.GetScopeAsync(Upn); // primes cache
await svc.AssignTemplateAsync(Upn, TemplateId2, Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example");
await svc.GetScopeAsync(Upn); // must re-resolve after invalidation
repo.Verify(r => r.GetTemplateAssignmentsForUpnAsync(It.IsAny<string>()), Times.Exactly(2));
}
[Fact]
public async Task AssignTemplate_NonexistentTemplate_IsNoOp()
{
var (svc, repo) = Build();
// GetTenantTemplateAsync null by default → no assignment row, no cache invalidation.
repo.Setup(r => r.AssignTemplateAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<string>()))
.ReturnsAsync(true);
var assigned = await svc.AssignTemplateAsync(
Upn, TemplateId1, Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example");
Assert.False(assigned);
repo.Verify(r => r.AssignTemplateAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task UnassignTemplate_InvalidatesUpnCache()
{
var (svc, repo) = Build();
ReturnsTemplates(repo, Assignment(TemplateId1)); // UPN IS assigned to TemplateId1
TemplateTenants(repo, TemplateId1, TenantA);
repo.Setup(r => r.UnassignTemplateAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
await svc.GetScopeAsync(Upn);
var unassigned = await svc.UnassignTemplateAsync(Upn, TemplateId1);
await svc.GetScopeAsync(Upn);
Assert.True(unassigned);
// Verify re-resolution via the tenant expansion (the unassign now also reads assignments itself,
// so asserting on GetTemplateTenantsAsync isolates the two scope resolutions cleanly).
repo.Verify(r => r.GetTemplateTenantsAsync(TemplateId1), Times.Exactly(2));
}
[Fact]
public async Task UnassignTemplate_NotAssigned_IsNoOp()
{
var (svc, repo) = Build();
// GetTemplateAssignmentsForUpnAsync returns empty by default → the UPN is not assigned → no delete,
// no invalidation, so the endpoint won't write false revoke audits.
repo.Setup(r => r.UnassignTemplateAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
var unassigned = await svc.UnassignTemplateAsync(Upn, TemplateId1);
Assert.False(unassigned);
repo.Verify(r => r.UnassignTemplateAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
private static void TemplateExists(Mock<IAdminRepository> repo, string templateId, params string[] tenantIds) =>
repo.Setup(r => r.GetTenantTemplateAsync(templateId)).ReturnsAsync(new TenantTemplate
{
TemplateId = templateId,
Name = "Managed Service Tenants",
TenantIds = tenantIds.ToList(),
});
[Fact]
public async Task RemoveTenantFromTemplate_InvalidatesAllAssignees()
{
const string upnB = "msp-admin-2@partner.example";
var (svc, repo) = Build();
ReturnsTemplates(repo, Assignment(TemplateId1));
TemplateTenants(repo, TemplateId1, TenantA);
TemplateExists(repo, TemplateId1, TenantA); // tenant IS a member → real removal
repo.Setup(r => r.RemoveTenantFromTemplateAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
repo.Setup(r => r.GetTemplateAssigneesAsync(TemplateId1)).ReturnsAsync(new List<TenantTemplateAssignment>
{
new() { Upn = Upn, TemplateId = TemplateId1, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true },
new() { Upn = upnB, TemplateId = TemplateId1, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true },
});
await svc.GetScopeAsync(Upn); // primes A
await svc.GetScopeAsync(upnB); // primes B
var removed = await svc.RemoveTenantFromTemplateAsync(TemplateId1, TenantA);
await svc.GetScopeAsync(Upn); // re-resolves A
await svc.GetScopeAsync(upnB); // re-resolves B
Assert.True(removed);
repo.Verify(r => r.GetTemplateAssignmentsForUpnAsync(Upn), Times.Exactly(2));
repo.Verify(r => r.GetTemplateAssignmentsForUpnAsync(upnB), Times.Exactly(2));
}
[Fact]
public async Task AddTenantToTemplate_NonexistentTemplate_IsNoOp()
{
var (svc, repo) = Build();
// GetTenantTemplateAsync defaults to null (template does not exist) → must NOT upsert a ghost row.
repo.Setup(r => r.AddTenantToTemplateAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
var added = await svc.AddTenantToTemplateAsync(TemplateId1, TenantA);
Assert.False(added);
repo.Verify(r => r.AddTenantToTemplateAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task AddTenantToTemplate_ExistingTemplate_Adds()
{
var (svc, repo) = Build();
TemplateExists(repo, TemplateId1); // meta-backed, no tenants yet
repo.Setup(r => r.AddTenantToTemplateAsync(TemplateId1, TenantA)).ReturnsAsync(true);
repo.Setup(r => r.GetTemplateAssigneesAsync(TemplateId1)).ReturnsAsync(new List<TenantTemplateAssignment>());
var added = await svc.AddTenantToTemplateAsync(TemplateId1, TenantA);
Assert.True(added);
repo.Verify(r => r.AddTenantToTemplateAsync(TemplateId1, TenantA), Times.Once);
}
[Fact]
public async Task RemoveTenantFromTemplate_NonexistentTemplate_IsNoOp()
{
var (svc, repo) = Build();
// GetTenantTemplateAsync null by default → no removal, no false audit signal.
repo.Setup(r => r.RemoveTenantFromTemplateAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
var removed = await svc.RemoveTenantFromTemplateAsync(TemplateId1, TenantA);
Assert.False(removed);
repo.Verify(r => r.RemoveTenantFromTemplateAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task RemoveTenantFromTemplate_TenantNotMember_IsNoOp()
{
var (svc, repo) = Build();
TemplateExists(repo, TemplateId1, TenantB); // template exists but does NOT contain TenantA
repo.Setup(r => r.RemoveTenantFromTemplateAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
var removed = await svc.RemoveTenantFromTemplateAsync(TemplateId1, TenantA);
Assert.False(removed);
repo.Verify(r => r.RemoveTenantFromTemplateAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task DeleteTemplate_InvalidatesCapturedAssignees()
{
var (svc, repo) = Build();
ReturnsTemplates(repo, Assignment(TemplateId1));
TemplateTenants(repo, TemplateId1, TenantA);
repo.Setup(r => r.DeleteTenantTemplateAsync(It.IsAny<string>())).ReturnsAsync(true);
// Assignees are captured BEFORE the cascade delete removes them.
repo.Setup(r => r.GetTemplateAssigneesAsync(TemplateId1)).ReturnsAsync(new List<TenantTemplateAssignment>
{
new() { Upn = Upn, TemplateId = TemplateId1, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true },
});
await svc.GetScopeAsync(Upn);
await svc.DeleteTemplateAsync(TemplateId1);
await svc.GetScopeAsync(Upn);
repo.Verify(r => r.GetTemplateAssignmentsForUpnAsync(Upn), Times.Exactly(2));
}
[Fact]
public async Task AssignTemplate_NormalizesTemplateIdCase()
{
// The opaque templateId is case-insensitive at the service boundary: an upper-cased id resolves the
// same lowercase-stored template, so external/hand-crafted callers don't silently miss.
var (svc, repo) = Build();
TemplateExists(repo, "tpl-lower"); // stored/generated id is lowercase
repo.Setup(r => r.AssignTemplateAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<string>()))
.ReturnsAsync(true);
var assigned = await svc.AssignTemplateAsync(
Upn, "TPL-LOWER", Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example");
Assert.True(assigned);
// Repo was looked up + written with the normalized (lowercase) id.
repo.Verify(r => r.GetTenantTemplateAsync("tpl-lower"), Times.Once);
repo.Verify(r => r.AssignTemplateAsync(Upn, "tpl-lower",
Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example"), Times.Once);
}
[Fact]
public async Task RenameTemplate_DoesNotInvalidateCache()
{
var (svc, repo) = Build();
ReturnsTemplates(repo, Assignment(TemplateId1));
TemplateTenants(repo, TemplateId1, TenantA);
repo.Setup(r => r.RenameTenantTemplateAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
await svc.GetScopeAsync(Upn);
await svc.RenameTemplateAsync(TemplateId1, "Renamed");
await svc.GetScopeAsync(Upn); // name-only change → scope stays cached
repo.Verify(r => r.GetTemplateAssignmentsForUpnAsync(It.IsAny<string>()), Times.Once);
}
}
@@ -262,6 +262,23 @@ public class TenantOffboardingHandlerOrderingTests
Assert.Single(harness.FarewellEmail.Calls);
}
// ── Offboarding wipe coverage (hygiene: no orphaned cross-tenant grants) ────
[Fact]
public async Task PostDrain_PropertyOnlyWipe_PurgesDelegatedAdminsAndTenantTemplates()
{
// A delegated admin's grant rows (DelegatedAdmins, RK=tenantId) and a tenant's Tenant Template
// membership rows (TenantTemplates, RK=tenantId) both carry a TenantId property, so they must be
// purged via the property-only wipe. Otherwise they orphan and can silently re-grant access on
// re-onboarding.
var harness = Harness.New();
await harness.Sut.HandleAsync(harness.Envelope());
Assert.Contains(Constants.TableNames.DelegatedAdmins, harness.SafeWipeProbe.PropertyOnlyWipes);
Assert.Contains(Constants.TableNames.TenantTemplates, harness.SafeWipeProbe.PropertyOnlyWipes);
}
// ── Harness (copied minimal — only what these tests need) ───────────────────
private sealed class Harness
@@ -406,6 +423,8 @@ public class TenantOffboardingHandlerOrderingTests
private sealed class CountingSafeWipeService : SafeWipeService
{
public int WipeCallCount { get; private set; }
/// <summary>Table names passed to the property-only (Variant C) wipe — lets tests assert coverage.</summary>
public List<string> PropertyOnlyWipes { get; } = new();
public CountingSafeWipeService() : base(
new TableStorageService(Mock.Of<TableServiceClient>(), NullLogger<TableStorageService>.Instance),
new BlobStorageService(new BlobServiceClient("UseDevelopmentStorage=true"),
@@ -414,7 +433,7 @@ public class TenantOffboardingHandlerOrderingTests
public override Task<int> WipeByExactPartitionAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
public override Task<int> WipeByCompositePartitionRangeAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
public override Task<int> WipeByDiscriminatorAndTenantPropertyAsync(string t, string d, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
public override Task<int> WipeByTenantIdPropertyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
public override Task<int> WipeByTenantIdPropertyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; PropertyOnlyWipes.Add(t); return Task.FromResult(0); }
public override Task<int> WipeBlobsByTenantPrefixAsync(string c, string i, CancellationToken ct = default) { WipeCallCount++; return Task.FromResult(0); }
}
@@ -72,6 +72,9 @@ public class PolicyEnforcementMiddlewareTests
.ReturnsAsync((TenantMember?)null);
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()))
.ReturnsAsync(new List<DelegatedAdminEntry>());
// No template assignments by default (delegated scope resolves direct grants + template tenants).
repo.Setup(r => r.GetTemplateAssignmentsForUpnAsync(It.IsAny<string>()))
.ReturnsAsync(new List<TenantTemplateAssignment>());
// Captured config repo: GetTenantConfigurationAsync returns null (tenant has no config row) so we can
// assert that authorization role resolution never PERSISTS a default row as a side effect.
@@ -410,6 +413,66 @@ public class PolicyEnforcementMiddlewareTests
Assert.Equal(403, result.StatusCode);
}
// ── Tenant Templates management — GlobalAdminOnly mutations, GlobalReadOrAdmin list ──
[Fact]
public async Task TenantTemplates_Mutation_PureDelegated_IsForbidden()
{
// A delegated ("MSP") admin must never manage templates — every mutation is GlobalAdminOnly,
// which is not a read tier, so the delegated rescue cannot admit it.
const string upn = "msp@partner.example";
var h = BuildHarness();
h.AsDelegated(TenantB, Constants.DelegatedRoles.DelegatedAdmin);
var result = await h.Middleware.DecideAsync(
"POST", "/api/global/tenant-templates/tpl-1/assignees", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
}
[Fact]
public async Task TenantTemplates_RemoveTenant_PureGlobalReader_IsForbidden()
{
// The one {tenantId} route (RouteParam) is still GlobalAdminOnly — a read-only Global Reader is denied.
const string upn = "reader@vendor.example";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
var result = await h.Middleware.DecideAsync(
"DELETE", $"/api/global/tenant-templates/tpl-1/tenants/{TenantB}", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
}
[Fact]
public async Task TenantTemplates_List_GlobalReader_IsAllowed()
{
// Listing templates is GlobalReadOrAdmin — a read-only Global Reader may audit them.
const string upn = "reader@vendor.example";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
var result = await h.Middleware.DecideAsync(
"GET", "/api/global/tenant-templates", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
}
[Fact]
public async Task TenantTemplates_Create_GlobalAdmin_IsAllowed()
{
const string upn = "ga@vendor.example";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
var result = await h.Middleware.DecideAsync(
"POST", "/api/global/tenant-templates", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
}
[Fact]
public async Task Delegated_OwnHomeTenant_NonMember_IsStillForbidden()
{
@@ -17,6 +17,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
private readonly TableClient _tenantAdminsTableClient;
private readonly TableClient _mcpUsersTableClient;
private readonly TableClient _delegatedAdminsTableClient;
private readonly TableClient _tenantTemplatesTableClient;
private readonly TableClient _tenantTemplateAssignmentsTableClient;
private readonly ILogger<TableAdminRepository> _logger;
public TableAdminRepository(
@@ -28,6 +30,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
_tenantAdminsTableClient = storage.GetTableClient(Constants.TableNames.TenantAdmins);
_mcpUsersTableClient = storage.GetTableClient(Constants.TableNames.McpUsers);
_delegatedAdminsTableClient = storage.GetTableClient(Constants.TableNames.DelegatedAdmins);
_tenantTemplatesTableClient = storage.GetTableClient(Constants.TableNames.TenantTemplates);
_tenantTemplateAssignmentsTableClient = storage.GetTableClient(Constants.TableNames.TenantTemplateAssignments);
}
// --- Global Admins ---
@@ -423,6 +427,246 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
return true;
}
// --- Tenant Templates (app-internal tenant bundles for delegated admins / "MSP mode") ---
public async Task<string> CreateTenantTemplateAsync(string name, string createdBy)
{
var templateId = Guid.NewGuid().ToString("N");
var entity = new TenantTemplateEntity
{
PartitionKey = templateId,
RowKey = TenantTemplateEntity.MetaRowKey,
Name = name?.Trim() ?? string.Empty,
CreatedBy = createdBy?.ToLowerInvariant() ?? string.Empty,
CreatedDate = DateTime.UtcNow
};
// Fresh GUID partition — AddEntity (not Upsert) so a (vanishingly unlikely) collision fails loud.
await _tenantTemplatesTableClient.AddEntityAsync(entity);
return templateId;
}
public async Task<bool> RenameTenantTemplateAsync(string templateId, string name)
{
if (string.IsNullOrWhiteSpace(templateId))
return false;
try
{
var result = await _tenantTemplatesTableClient.GetEntityAsync<TenantTemplateEntity>(
templateId, TenantTemplateEntity.MetaRowKey);
var entity = result.Value;
if (entity == null) return false;
entity.Name = name?.Trim() ?? string.Empty;
await _tenantTemplatesTableClient.UpdateEntityAsync(entity, ETag.All);
return true;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return false;
}
}
public async Task<bool> DeleteTenantTemplateAsync(string templateId)
{
if (string.IsNullOrWhiteSpace(templateId))
return false;
// Delete every row in the template's partition (meta + membership rows).
// Typed predicate overload builds the OData filter safely (escapes quotes) — no string interpolation.
await foreach (var entity in _tenantTemplatesTableClient.QueryAsync<TenantTemplateEntity>(
e => e.PartitionKey == templateId))
{
await DeleteIfPresentAsync(_tenantTemplatesTableClient, entity.PartitionKey, entity.RowKey);
}
// Cascade: remove every UPN assignment to this template (cross-partition RowKey scan).
await foreach (var assignment in _tenantTemplateAssignmentsTableClient.QueryAsync<TenantTemplateAssignmentEntity>(
e => e.RowKey == templateId))
{
await DeleteIfPresentAsync(_tenantTemplateAssignmentsTableClient, assignment.PartitionKey, assignment.RowKey);
}
return true;
}
public async Task<List<TenantTemplate>> GetAllTenantTemplatesAsync()
{
// Full-table scan: the management UI lists every template. Both tables hold only admin-scale
// rows (a handful of templates × their tenants/assignees), so this is small and off the hot path.
var byId = new Dictionary<string, TenantTemplate>(StringComparer.Ordinal);
// A template EXISTS only if its meta row is present. A partition with only membership rows is an
// anomaly (e.g. a partial write), NOT a template — never surface it as a blank/name-less template.
var metaBacked = new HashSet<string>(StringComparer.Ordinal);
await foreach (var entity in _tenantTemplatesTableClient.QueryAsync<TenantTemplateEntity>())
{
if (ApplyRowToTemplate(byId, entity))
metaBacked.Add(entity.PartitionKey);
}
// Assignees (separate table; management path only).
await foreach (var assignment in _tenantTemplateAssignmentsTableClient.QueryAsync<TenantTemplateAssignmentEntity>())
{
if (byId.TryGetValue(assignment.RowKey, out var template))
{
template.Assignees.Add(MapToTemplateAssignment(assignment));
template.AssigneeCount++;
}
}
var result = new List<TenantTemplate>();
foreach (var template in byId.Values)
{
if (metaBacked.Contains(template.TemplateId))
result.Add(template);
}
return result;
}
public async Task<TenantTemplate?> GetTenantTemplateAsync(string templateId)
{
if (string.IsNullOrWhiteSpace(templateId))
return null;
var byId = new Dictionary<string, TenantTemplate>(StringComparer.Ordinal);
var metaBacked = false;
await foreach (var entity in _tenantTemplatesTableClient.QueryAsync<TenantTemplateEntity>(
e => e.PartitionKey == templateId))
{
if (ApplyRowToTemplate(byId, entity))
metaBacked = true;
}
// No meta row ⇒ the template does not exist (a stray membership row is not a template).
if (!metaBacked || !byId.TryGetValue(templateId, out var template))
return null;
await foreach (var assignment in _tenantTemplateAssignmentsTableClient.QueryAsync<TenantTemplateAssignmentEntity>(
e => e.RowKey == templateId))
{
template.Assignees.Add(MapToTemplateAssignment(assignment));
template.AssigneeCount++;
}
return template;
}
public async Task<bool> AddTenantToTemplateAsync(string templateId, string tenantId)
{
if (string.IsNullOrWhiteSpace(templateId) || string.IsNullOrWhiteSpace(tenantId))
return false;
tenantId = tenantId.ToLowerInvariant();
// Defensive: a tenantId must never collide with the reserved meta RowKey (tenant IDs are GUIDs).
if (tenantId == TenantTemplateEntity.MetaRowKey)
return false;
var entity = new TenantTemplateEntity
{
PartitionKey = templateId,
RowKey = tenantId,
TenantId = tenantId
};
await _tenantTemplatesTableClient.UpsertEntityAsync(entity);
return true;
}
public async Task<bool> RemoveTenantFromTemplateAsync(string templateId, string tenantId)
{
if (string.IsNullOrWhiteSpace(templateId) || string.IsNullOrWhiteSpace(tenantId))
return false;
tenantId = tenantId.ToLowerInvariant();
await DeleteIfPresentAsync(_tenantTemplatesTableClient, templateId, tenantId);
return true;
}
public async Task<List<string>> GetTemplateTenantsAsync(string templateId)
{
var tenants = new List<string>();
if (string.IsNullOrWhiteSpace(templateId))
return tenants;
// HOT PATH (scope resolution): PartitionKey scan, skip the meta row. A template only "exists" if
// its meta row is present — a partition with only membership rows (partial delete / bad data) must
// NOT grant scope. Mirrors the meta-backed invariant in GetTenantTemplateAsync so auth and the
// management reads agree on what a template is.
var metaBacked = false;
await foreach (var entity in _tenantTemplatesTableClient.QueryAsync<TenantTemplateEntity>(
e => e.PartitionKey == templateId))
{
if (entity.RowKey == TenantTemplateEntity.MetaRowKey)
metaBacked = true;
else
tenants.Add(entity.RowKey);
}
return metaBacked ? tenants : new List<string>();
}
public async Task<bool> AssignTemplateAsync(string upn, string templateId, string role, bool isEnabled, string assignedBy)
{
if (string.IsNullOrWhiteSpace(upn) || string.IsNullOrWhiteSpace(templateId))
return false;
upn = upn.ToLowerInvariant();
assignedBy = assignedBy?.ToLowerInvariant() ?? string.Empty;
var entity = new TenantTemplateAssignmentEntity
{
PartitionKey = upn,
RowKey = templateId,
Upn = upn,
TemplateId = templateId,
Role = role,
IsEnabled = isEnabled,
AssignedBy = assignedBy,
AssignedDate = DateTime.UtcNow
};
await _tenantTemplateAssignmentsTableClient.UpsertEntityAsync(entity);
return true;
}
public async Task<bool> UnassignTemplateAsync(string upn, string templateId)
{
if (string.IsNullOrWhiteSpace(upn) || string.IsNullOrWhiteSpace(templateId))
return false;
upn = upn.ToLowerInvariant();
await DeleteIfPresentAsync(_tenantTemplateAssignmentsTableClient, upn, templateId);
return true;
}
public async Task<List<TenantTemplateAssignment>> GetTemplateAssignmentsForUpnAsync(string upn)
{
var entries = new List<TenantTemplateAssignment>();
if (string.IsNullOrWhiteSpace(upn))
return entries;
var normalizedUpn = upn.ToLowerInvariant();
// HOT PATH (scope resolution): PartitionKey point-scan.
await foreach (var entity in _tenantTemplateAssignmentsTableClient.QueryAsync<TenantTemplateAssignmentEntity>(
e => e.PartitionKey == normalizedUpn))
{
entries.Add(MapToTemplateAssignment(entity));
}
return entries;
}
public async Task<List<TenantTemplateAssignment>> GetTemplateAssigneesAsync(string templateId)
{
var entries = new List<TenantTemplateAssignment>();
if (string.IsNullOrWhiteSpace(templateId))
return entries;
// Cross-partition scan on RowKey — admin-UI path, not the hot auth path.
await foreach (var entity in _tenantTemplateAssignmentsTableClient.QueryAsync<TenantTemplateAssignmentEntity>(
e => e.RowKey == templateId))
{
entries.Add(MapToTemplateAssignment(entity));
}
return entries;
}
// --- Tenant Members ---
public async Task<List<TenantMember>> GetTenantMembersAsync(string tenantId)
@@ -568,6 +812,56 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
};
}
/// <summary>
/// Folds a TenantTemplates row (meta or membership) into the accumulating DTO for its partition.
/// Returns true if the row was the meta row (proof the template exists).
/// </summary>
private static bool ApplyRowToTemplate(Dictionary<string, TenantTemplate> byId, TenantTemplateEntity entity)
{
if (!byId.TryGetValue(entity.PartitionKey, out var template))
{
template = new TenantTemplate { TemplateId = entity.PartitionKey };
byId[entity.PartitionKey] = template;
}
if (entity.RowKey == TenantTemplateEntity.MetaRowKey)
{
template.Name = entity.Name;
template.CreatedBy = entity.CreatedBy;
template.CreatedAt = entity.CreatedDate;
return true;
}
template.TenantIds.Add(entity.RowKey);
return false;
}
private static TenantTemplateAssignment MapToTemplateAssignment(TenantTemplateAssignmentEntity entity)
{
return new TenantTemplateAssignment
{
Upn = entity.Upn,
TemplateId = entity.TemplateId,
Role = entity.Role,
IsEnabled = entity.IsEnabled,
AssignedBy = entity.AssignedBy,
AssignedAt = entity.AssignedDate
};
}
/// <summary>Deletes a row, tolerating a concurrent delete (404) so cascade/cleanup stays idempotent.</summary>
private static async Task DeleteIfPresentAsync(TableClient client, string partitionKey, string rowKey)
{
try
{
await client.DeleteEntityAsync(partitionKey, rowKey, ETag.All);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// Already gone — idempotent.
}
}
private static TenantMember MapToTenantMember(TenantAdminEntity entity)
{
return new TenantMember
@@ -0,0 +1,334 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Admin;
/// <summary>
/// GlobalAdmin-only management of <b>Tenant Templates</b> — app-internal named bundles of tenants for the
/// delegated-admin ("MSP mode") tier. An operator assigns a delegated UPN to a template instead of to each
/// tenant; the UPN then reads every tenant in the template (resolved by <see cref="DelegatedAdminService"/>).
/// Adding a tenant to the template grants it to all assignees at once.
///
/// Reads are GlobalReadOrAdmin (a read-only Global Reader may audit templates); all mutations are
/// GlobalAdminOnly (enforced by <c>PolicyEnforcementMiddleware</c> via the route catalog), so a delegated
/// caller can never manage templates. ALL mutations go through <see cref="DelegatedAdminService"/> (never the
/// repository directly) so the delegated-scope cache is invalidated in lockstep.
///
/// Audit: access-affecting mutations are logged under the <b>affected managed tenant(s)</b>' trail (so the
/// customer sees "who can read my tenant" / "access removed"). Template create/rename carry no tenant context
/// and are not customer-visible access changes — they are logged operationally only (no AuditLogs partition).
/// </summary>
public class TenantTemplateManagementFunction
{
private readonly ILogger<TenantTemplateManagementFunction> _logger;
private readonly DelegatedAdminService _delegatedAdminService;
private readonly IMaintenanceRepository _maintenanceRepo;
private const string AuditEntity = "DelegatedTemplateAccess";
public TenantTemplateManagementFunction(
ILogger<TenantTemplateManagementFunction> logger,
DelegatedAdminService delegatedAdminService,
IMaintenanceRepository maintenanceRepo)
{
_logger = logger;
_delegatedAdminService = delegatedAdminService;
_maintenanceRepo = maintenanceRepo;
}
/// <summary>GET /api/global/tenant-templates — list every template with tenants + assignee count. GlobalReadOrAdmin.</summary>
[Function("GetTenantTemplates")]
[Authorize]
public async Task<HttpResponseData> GetTenantTemplates(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/tenant-templates")] HttpRequestData req)
{
var templates = await _delegatedAdminService.GetAllTemplatesAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { templates });
return response;
}
/// <summary>POST /api/global/tenant-templates — create a template. GlobalAdminOnly. Body: { "name": "..." }.</summary>
[Function("CreateTenantTemplate")]
[Authorize]
public async Task<HttpResponseData> CreateTenantTemplate(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/tenant-templates")] HttpRequestData req,
FunctionContext context)
{
var currentUpn = context.GetRequestContext().UserPrincipalName;
var body = await req.ReadFromJsonAsync<CreateTenantTemplateRequest>();
if (body == null || string.IsNullOrWhiteSpace(body.Name))
return await Bad(req, "name is required");
var templateId = await _delegatedAdminService.CreateTemplateAsync(body.Name, currentUpn ?? "");
_logger.LogInformation("Tenant template created: {TemplateId} '{Name}' by {By}", templateId, body.Name, currentUpn);
var response = req.CreateResponse(HttpStatusCode.Created);
await response.WriteAsJsonAsync(new { templateId, name = body.Name.Trim() });
return response;
}
/// <summary>PATCH /api/global/tenant-templates/{templateId} — rename. GlobalAdminOnly. Body: { "name": "..." }.</summary>
[Function("RenameTenantTemplate")]
[Authorize]
public async Task<HttpResponseData> RenameTenantTemplate(
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "global/tenant-templates/{templateId}")] HttpRequestData req,
string templateId, FunctionContext context)
{
var currentUpn = context.GetRequestContext().UserPrincipalName;
var body = await req.ReadFromJsonAsync<RenameTenantTemplateRequest>();
if (body == null || string.IsNullOrWhiteSpace(body.Name))
return await Bad(req, "name is required");
var ok = await _delegatedAdminService.RenameTemplateAsync(templateId, body.Name);
if (!ok)
return await NotFound(req);
_logger.LogInformation("Tenant template renamed: {TemplateId} -> '{Name}' by {By}", templateId, body.Name, currentUpn);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { message = "Template renamed" });
return response;
}
/// <summary>DELETE /api/global/tenant-templates/{templateId} — delete template + all its assignments. GlobalAdminOnly.</summary>
[Function("DeleteTenantTemplate")]
[Authorize]
public async Task<HttpResponseData> DeleteTenantTemplate(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/tenant-templates/{templateId}")] HttpRequestData req,
string templateId, FunctionContext context)
{
var currentUpn = context.GetRequestContext().UserPrincipalName;
// Capture tenants + assignee count BEFORE the cascade delete removes them, so we can audit the
// bulk access removal under each affected tenant.
var template = await _delegatedAdminService.GetTemplateAsync(templateId);
await _delegatedAdminService.DeleteTemplateAsync(templateId);
if (template != null && template.AssigneeCount > 0)
{
await AuditPerTenantAsync(template.TenantIds, "DELETE", "*", currentUpn,
new Dictionary<string, string>
{
{ "Template", template.Name },
{ "TemplateId", templateId },
{ "Reason", "template-deleted" },
{ "AssigneeCount", template.AssigneeCount.ToString() },
});
}
_logger.LogInformation("Tenant template deleted: {TemplateId} by {By}", templateId, currentUpn);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { message = "Template deleted" });
return response;
}
/// <summary>POST /api/global/tenant-templates/{templateId}/tenants — add a tenant. GlobalAdminOnly. Body: { "tenantId": "&lt;guid&gt;" }.</summary>
[Function("AddTenantToTemplate")]
[Authorize]
public async Task<HttpResponseData> AddTenantToTemplate(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/tenant-templates/{templateId}/tenants")] HttpRequestData req,
string templateId, FunctionContext context)
{
var currentUpn = context.GetRequestContext().UserPrincipalName;
var body = await req.ReadFromJsonAsync<AddTemplateTenantRequest>();
if (body == null || string.IsNullOrWhiteSpace(body.TenantId) || !Guid.TryParse(body.TenantId, out _))
return await Bad(req, "a valid tenantId (GUID) is required");
var tenantId = body.TenantId.ToLowerInvariant();
// The service refuses to add to a non-existent template (no ghost from a lone membership row).
var added = await _delegatedAdminService.AddTenantToTemplateAsync(templateId, tenantId);
if (!added)
return await NotFound(req);
// Every current assignee just gained access to this tenant — audit under that tenant per assignee.
var assignees = await _delegatedAdminService.GetTemplateAssigneesAsync(templateId);
foreach (var assignee in assignees)
{
await _maintenanceRepo.LogAuditEntryAsync(
tenantId, "CREATE", AuditEntity, assignee.Upn, currentUpn ?? "",
new Dictionary<string, string> { { "TemplateId", templateId }, { "Reason", "tenant-added-to-template" } });
}
_logger.LogInformation("Tenant {TenantId} added to template {TemplateId} by {By}", tenantId, templateId, currentUpn);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { message = "Tenant added to template" });
return response;
}
/// <summary>DELETE /api/global/tenant-templates/{templateId}/tenants/{tenantId} — remove a tenant. GlobalAdminOnly.</summary>
[Function("RemoveTenantFromTemplate")]
[Authorize]
public async Task<HttpResponseData> RemoveTenantFromTemplate(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/tenant-templates/{templateId}/tenants/{tenantId}")] HttpRequestData req,
string templateId, string tenantId, FunctionContext context)
{
var currentUpn = context.GetRequestContext().UserPrincipalName;
var normalizedTenantId = tenantId.ToLowerInvariant();
// Snapshot assignees BEFORE the removal so we can audit each one's lost access under this tenant.
var assignees = await _delegatedAdminService.GetTemplateAssigneesAsync(templateId);
// The service returns false when the template doesn't exist or the tenant isn't a member — so a typo
// can't 200 and write false "access removed" audit rows. Only audit after a real removal.
var removed = await _delegatedAdminService.RemoveTenantFromTemplateAsync(templateId, normalizedTenantId);
if (!removed)
return await NotFound(req);
foreach (var assignee in assignees)
{
await _maintenanceRepo.LogAuditEntryAsync(
normalizedTenantId, "DELETE", AuditEntity, assignee.Upn, currentUpn ?? "",
new Dictionary<string, string> { { "TemplateId", templateId }, { "Reason", "tenant-removed-from-template" } });
}
_logger.LogInformation("Tenant {TenantId} removed from template {TemplateId} by {By}", normalizedTenantId, templateId, currentUpn);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { message = "Tenant removed from template" });
return response;
}
/// <summary>
/// POST /api/global/tenant-templates/{templateId}/assignees — assign a UPN to the template. GlobalAdminOnly.
/// Body: { "upn": "user@domain.com", "role": "DelegatedReader" | "DelegatedAdmin" }.
/// </summary>
[Function("AssignTenantTemplate")]
[Authorize]
public async Task<HttpResponseData> AssignTenantTemplate(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/tenant-templates/{templateId}/assignees")] HttpRequestData req,
string templateId, FunctionContext context)
{
var currentUpn = context.GetRequestContext().UserPrincipalName;
var body = await req.ReadFromJsonAsync<AssignTemplateRequest>();
if (body == null || string.IsNullOrWhiteSpace(body.Upn))
return await Bad(req, "upn is required");
// Fail-closed role handling — mirror the delegated grant: default to least privilege, reject unknowns.
var role = string.IsNullOrWhiteSpace(body.Role) ? Constants.DelegatedRoles.DelegatedReader : body.Role;
if (role != Constants.DelegatedRoles.DelegatedReader && role != Constants.DelegatedRoles.DelegatedAdmin)
return await Bad(req, $"role must be '{Constants.DelegatedRoles.DelegatedReader}' or '{Constants.DelegatedRoles.DelegatedAdmin}'");
// Guard against assigning to a non-existent template (would create an orphan assignment row).
var template = await _delegatedAdminService.GetTemplateAsync(templateId);
if (template == null)
return await NotFound(req);
var upn = body.Upn.ToLowerInvariant();
// The service re-checks existence (covers a delete racing this assign) — skip the audit if it no-ops.
var assigned = await _delegatedAdminService.AssignTemplateAsync(upn, templateId, role, true, currentUpn ?? "");
if (!assigned)
return await NotFound(req);
// The UPN just gained read access to every tenant in the template — audit under each.
await AuditPerTenantAsync(template.TenantIds, "CREATE", upn, currentUpn,
new Dictionary<string, string>
{
{ "Template", template.Name },
{ "TemplateId", templateId },
{ "Role", role },
{ "Reason", "template-assigned" },
});
_logger.LogInformation("Template {TemplateId} assigned to {Upn} ({Role}) by {By}", templateId, upn, role, currentUpn);
var response = req.CreateResponse(HttpStatusCode.Created);
await response.WriteAsJsonAsync(new { message = "Assigned to template" });
return response;
}
/// <summary>DELETE /api/global/tenant-templates/{templateId}/assignees/{upn} — unassign a UPN. GlobalAdminOnly.</summary>
[Function("UnassignTenantTemplate")]
[Authorize]
public async Task<HttpResponseData> UnassignTenantTemplate(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/tenant-templates/{templateId}/assignees/{upn}")] HttpRequestData req,
string templateId, string upn, FunctionContext context)
{
var currentUpn = context.GetRequestContext().UserPrincipalName;
var normalizedUpn = upn.ToLowerInvariant();
// Read the template's tenants (unchanged by unassign) so we can audit the UPN's lost access per tenant.
var template = await _delegatedAdminService.GetTemplateAsync(templateId);
// The service returns false when the UPN was not actually assigned — so a mistyped UPN can't 200 and
// write false "access removed" audit rows. Only audit after a real unassign.
var unassigned = await _delegatedAdminService.UnassignTemplateAsync(normalizedUpn, templateId);
if (!unassigned)
return await NotFound(req);
if (template != null)
{
await AuditPerTenantAsync(template.TenantIds, "DELETE", normalizedUpn, currentUpn,
new Dictionary<string, string>
{
{ "Template", template.Name },
{ "TemplateId", templateId },
{ "Reason", "template-unassigned" },
});
}
_logger.LogInformation("Template {TemplateId} unassigned from {Upn} by {By}", templateId, normalizedUpn, currentUpn);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { message = "Unassigned from template" });
return response;
}
/// <summary>Logs one audit entry per tenant (the customer-visible "who can read my tenant" trail).</summary>
private async Task AuditPerTenantAsync(
IEnumerable<string> tenantIds, string action, string entityId, string? performedBy, Dictionary<string, string> details)
{
foreach (var tenantId in tenantIds)
{
await _maintenanceRepo.LogAuditEntryAsync(
tenantId.ToLowerInvariant(), action, AuditEntity, entityId, performedBy ?? "", details);
}
}
private static async Task<HttpResponseData> Bad(HttpRequestData req, string error)
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { error });
return bad;
}
private static async Task<HttpResponseData> NotFound(HttpRequestData req)
{
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new { error = "Template not found" });
return notFound;
}
}
public class CreateTenantTemplateRequest
{
public string Name { get; set; } = string.Empty;
}
public class RenameTenantTemplateRequest
{
public string Name { get; set; } = string.Empty;
}
public class AddTemplateTenantRequest
{
public string TenantId { get; set; } = string.Empty;
}
public class AssignTemplateRequest
{
public string Upn { get; set; } = string.Empty;
public string? Role { get; set; }
}
@@ -450,6 +450,18 @@ public static class EndpointAccessPolicyCatalog
new("DELETE", "global/delegated-admins/{upn}/{tenantId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
new("PATCH", "global/delegated-admins/{upn}/{tenantId}/enable", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
new("PATCH", "global/delegated-admins/{upn}/{tenantId}/disable", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
// Tenant Templates ("MSP mode" tenant bundles) management — list is GlobalReadOrAdmin; all mutations
// are GlobalAdminOnly so a delegated caller can never manage templates. Global routes are
// TenantScoping.None (the {templateId}/{upn} segments are not tenants; tenants come in the body or are
// resolved server-side). The ONE route with a {tenantId} segment uses RouteParam per the fail-closed convention.
new("GET", "global/tenant-templates", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "global/tenant-templates", EndpointPolicy.GlobalAdminOnly),
new("PATCH", "global/tenant-templates/{templateId}", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "global/tenant-templates/{templateId}", EndpointPolicy.GlobalAdminOnly),
new("POST", "global/tenant-templates/{templateId}/tenants", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "global/tenant-templates/{templateId}/tenants/{tenantId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
new("POST", "global/tenant-templates/{templateId}/assignees", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "global/tenant-templates/{templateId}/assignees/{upn}", EndpointPolicy.GlobalAdminOnly),
new("PATCH", "config/{tenantId}/plan", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
new("GET", "global/config/plan-tiers", EndpointPolicy.GlobalReadOrAdmin),
new("PUT", "global/config/plan-tiers", EndpointPolicy.GlobalAdminOnly),
@@ -72,6 +72,32 @@ public class DelegatedAdminService
tenantRoles[row.TenantId] = role;
}
// Template-derived tenants: a UPN assigned to a Tenant Template inherits read scope to every
// tenant in that template. This is the MSP convenience — assign the UPN once, manage the tenant
// set on the template. Same fail-closed + stronger-role-wins merge as direct grants; a tenant
// present both directly and via a template keeps the stronger role. Membership changes converge
// within the cache TTL (no separate per-template cache by design).
var templateAssignments = await _adminRepo.GetTemplateAssignmentsForUpnAsync(upn);
foreach (var assignment in templateAssignments)
{
if (!assignment.IsEnabled)
continue;
var role = NormalizeRole(assignment.Role, upn, $"template:{assignment.TemplateId}");
if (role == null)
continue;
var templateTenants = await _adminRepo.GetTemplateTenantsAsync(assignment.TemplateId);
foreach (var tenantId in templateTenants)
{
if (string.IsNullOrWhiteSpace(tenantId))
continue;
if (tenantRoles.TryGetValue(tenantId, out var existing) && IsStronger(existing, role))
continue;
tenantRoles[tenantId] = role;
}
}
var scope = new DelegatedScope(tenantRoles);
_cache.Set(cacheKey, scope, _cacheDuration);
return scope;
@@ -141,8 +167,140 @@ public class DelegatedAdminService
public Task<List<DelegatedAdminEntry>> GetAssigneesForTenantAsync(string tenantId)
=> _adminRepo.GetDelegatedAssigneesAsync(tenantId.ToLowerInvariant());
// --- Tenant Templates (app-internal tenant bundles) ---
//
// ALL template mutations go through the service (never the repo directly) so the delegated-scope
// cache is invalidated in lockstep — otherwise a removed tenant / unassigned UPN keeps stale auth
// scope until TTL expiry. As with direct grants, Invalidate clears only THIS instance's cache; other
// scaled-out instances self-heal within the short TTL (see _cacheDuration). Mutations that change the
// tenant SET of a template (add/remove tenant, delete) invalidate EVERY current assignee.
//
// templateId is normalized to lowercase at this boundary so the opaque key is case-insensitive for any
// (e.g. hand-crafted external) caller. Generated IDs are already lowercase hex GUIDs, so this is lossless.
/// <summary>Read-through: all templates with tenant members + assignee counts (management UI).</summary>
public Task<List<TenantTemplate>> GetAllTemplatesAsync() => _adminRepo.GetAllTenantTemplatesAsync();
/// <summary>Read-through: one template with its tenant members + assignee count.</summary>
public Task<TenantTemplate?> GetTemplateAsync(string templateId) => _adminRepo.GetTenantTemplateAsync(NormalizeTemplateId(templateId));
/// <summary>Read-through: all UPNs assigned to a template (management UI).</summary>
public Task<List<TenantTemplateAssignment>> GetTemplateAssigneesAsync(string templateId)
=> _adminRepo.GetTemplateAssigneesAsync(NormalizeTemplateId(templateId));
/// <summary>Creates a template (no scope effect — a fresh template has no assignees).</summary>
public Task<string> CreateTemplateAsync(string name, string createdBy)
=> _adminRepo.CreateTenantTemplateAsync(name, createdBy);
/// <summary>Renames a template (name only — no scope effect, no invalidation needed).</summary>
public Task<bool> RenameTemplateAsync(string templateId, string name)
=> _adminRepo.RenameTenantTemplateAsync(NormalizeTemplateId(templateId), name);
/// <summary>Deletes a template and all its assignments; invalidates every (former) assignee's scope.</summary>
public async Task<bool> DeleteTemplateAsync(string templateId)
{
templateId = NormalizeTemplateId(templateId);
// Capture assignees BEFORE the cascade delete removes their assignment rows.
var assignees = await _adminRepo.GetTemplateAssigneesAsync(templateId);
var ok = await _adminRepo.DeleteTenantTemplateAsync(templateId);
InvalidateAll(assignees);
return ok;
}
/// <summary>
/// Adds a tenant to a template; every assignee gains it — invalidate them so it takes effect now.
/// Returns false (no-op) if the template does not exist (meta-backed) — prevents a "ghost" template
/// being conjured from a lone membership row.
/// </summary>
public async Task<bool> AddTenantToTemplateAsync(string templateId, string tenantId)
{
templateId = NormalizeTemplateId(templateId);
if (await _adminRepo.GetTenantTemplateAsync(templateId) == null)
return false;
var ok = await _adminRepo.AddTenantToTemplateAsync(templateId, tenantId);
await InvalidateTemplateAssigneesAsync(templateId);
return ok;
}
/// <summary>
/// Removes a tenant from a template (REVOKE flow); invalidate every assignee immediately. Returns false
/// (no-op, no invalidation) if the template does not exist or the tenant is not currently a member — so a
/// typo cannot return success and write false "access removed" audit rows.
/// </summary>
public async Task<bool> RemoveTenantFromTemplateAsync(string templateId, string tenantId)
{
templateId = NormalizeTemplateId(templateId);
tenantId = tenantId.ToLowerInvariant();
var template = await _adminRepo.GetTenantTemplateAsync(templateId);
if (template == null || !template.TenantIds.Contains(tenantId))
return false;
var ok = await _adminRepo.RemoveTenantFromTemplateAsync(templateId, tenantId);
await InvalidateTemplateAssigneesAsync(templateId);
return ok;
}
/// <summary>
/// Assigns a UPN to a template; invalidates that UPN's scope. Returns false (no-op) if the template does
/// not exist (meta-backed) — so the service fully owns the existence invariant, independent of any caller
/// pre-check, and a delete racing an assign can't leave a dangling assignment row.
/// </summary>
public async Task<bool> AssignTemplateAsync(string upn, string templateId, string role, bool isEnabled, string assignedBy)
{
templateId = NormalizeTemplateId(templateId);
if (await _adminRepo.GetTenantTemplateAsync(templateId) == null)
return false;
upn = upn.ToLowerInvariant();
var ok = await _adminRepo.AssignTemplateAsync(upn, templateId, role, isEnabled, assignedBy);
Invalidate(upn);
return ok;
}
/// <summary>
/// Unassigns a UPN from a template (REVOKE flow); invalidates that UPN's scope immediately. Returns false
/// (no-op, no invalidation) if the UPN is not currently assigned to the template — so a mistyped UPN can't
/// return success and write false "access removed" audit rows.
/// </summary>
public async Task<bool> UnassignTemplateAsync(string upn, string templateId)
{
upn = upn.ToLowerInvariant();
templateId = NormalizeTemplateId(templateId);
var assignments = await _adminRepo.GetTemplateAssignmentsForUpnAsync(upn);
var isAssigned = false;
foreach (var assignment in assignments)
{
if (string.Equals(assignment.TemplateId, templateId, StringComparison.Ordinal))
{
isAssigned = true;
break;
}
}
if (!isAssigned)
return false;
var ok = await _adminRepo.UnassignTemplateAsync(upn, templateId);
Invalidate(upn);
return ok;
}
/// <summary>Invalidates the cached scope of every current assignee of a template.</summary>
private async Task InvalidateTemplateAssigneesAsync(string templateId)
=> InvalidateAll(await _adminRepo.GetTemplateAssigneesAsync(templateId));
private void InvalidateAll(IEnumerable<TenantTemplateAssignment> assignees)
{
foreach (var assignee in assignees)
Invalidate(assignee.Upn);
}
private void Invalidate(string upn) => _cache.Remove($"delegated-scope:{upn.ToLowerInvariant()}");
/// <summary>Normalizes the opaque templateId to lowercase so the storage key is case-insensitive to callers.</summary>
private static string NormalizeTemplateId(string templateId) => (templateId ?? string.Empty).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)
@@ -229,3 +387,68 @@ public class DelegatedAdminEntity : ITableEntity
/// <summary>UPN of the principal who created this assignment (operator GA, or customer tenant admin).</summary>
public string GrantedBy { get; set; } = string.Empty;
}
/// <summary>
/// A row in the <see cref="Constants.TableNames.TenantTemplates"/> table. Two row layouts share the
/// class, discriminated by <see cref="RowKey"/>:
/// <list type="bullet">
/// <item>PK = templateId, RK = <see cref="MetaRowKey"/> — template metadata (<see cref="Name"/>, creator).</item>
/// <item>PK = templateId, RK = tenantId (lowercase) — one membership row per tenant in the template.</item>
/// </list>
/// A template is an app-internal named bundle of tenants; a delegated admin assigned to it (see
/// <see cref="TenantTemplateAssignmentEntity"/>) gains read scope to every tenant member.
/// </summary>
public class TenantTemplateEntity : ITableEntity
{
/// <summary>The reserved RowKey of the per-template metadata row. Tenant IDs are GUIDs and never collide with it.</summary>
public const string MetaRowKey = "meta";
public string PartitionKey { get; set; } = string.Empty; // templateId
public string RowKey { get; set; } = string.Empty; // "meta" or tenantId (lowercase)
public DateTimeOffset? Timestamp { get; set; }
public Azure.ETag ETag { get; set; }
/// <summary>Display name (meta row only; empty on membership rows).</summary>
public string Name { get; set; } = string.Empty;
/// <summary>UPN of the GlobalAdmin who created the template (meta row only).</summary>
public string CreatedBy { get; set; } = string.Empty;
/// <summary>When the template was created (meta row only).</summary>
public DateTime CreatedDate { get; set; }
/// <summary>The managed tenant ID (lowercase) — denormalized copy of RowKey on membership rows; empty on meta.</summary>
public string TenantId { get; set; } = string.Empty;
}
/// <summary>
/// A row in the <see cref="Constants.TableNames.TenantTemplateAssignments"/> table: delegated-admin UPN X
/// is assigned to template T at role <see cref="Role"/>. PK = UPN (lowercase), RK = templateId. Resolving
/// a UPN's scope point-scans this by PK, then expands each template into its tenant members. Operator-managed
/// only (no PendingApproval flow); fail-closed — only enabled + recognized-role assignments confer scope.
/// </summary>
public class TenantTemplateAssignmentEntity : ITableEntity
{
public string PartitionKey { get; set; } = string.Empty; // UPN (lowercase)
public string RowKey { get; set; } = string.Empty; // templateId
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 template ID — denormalized copy of RowKey.</summary>
public string TemplateId { 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).</summary>
public bool IsEnabled { get; set; } = true;
/// <summary>UPN of the GlobalAdmin who created this assignment.</summary>
public string AssignedBy { get; set; } = string.Empty;
/// <summary>When this assignment was created.</summary>
public DateTime AssignedDate { get; set; }
}
@@ -109,6 +109,16 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
private static readonly string[] PropertyOnlyTables =
{
Constants.TableNames.UserUsageLog,
// Delegated-admin ("MSP mode") grants targeting this tenant. PK=admin-UPN, RK=tenantId, but
// the rows carry a denormalized TenantId property, so the property-only wipe purges every
// admin's grant to the offboarded tenant. Previously NOT wiped → orphaned cross-tenant grants
// survived offboarding and could silently re-grant access on re-onboarding.
Constants.TableNames.DelegatedAdmins,
// Tenant Template membership rows for this tenant (PK=templateId, RK=tenantId, TenantId property
// set). Meta rows (RowKey="meta", empty TenantId) never match the tenant GUID, so the template
// itself and its other tenants survive. The assignments table is per-UPN (no TenantId) and is
// intentionally NOT wiped here.
Constants.TableNames.TenantTemplates,
};
// PR3.B plan §3 — Customs rules tables: archive each row to
@@ -776,6 +776,19 @@ namespace AutopilotMonitor.Shared
// RK=TenantId (lowercase). One row per (admin, managed-tenant) pair.
public const string DelegatedAdmins = "DelegatedAdmins";
// Tenant Templates — app-internal named bundles of tenants for delegated admins ("MSP mode").
// Two row layouts share this table (RowKey discriminates):
// PK=templateId, RK="meta" → template metadata (display name, creator).
// PK=templateId, RK=tenantId → one membership row per tenant in the template (lowercase).
// A delegated UPN assigned to a template (see TenantTemplateAssignments) gains read scope to
// every tenant in it. Tenant offboarding purges the membership rows (RowKey == tenantId).
public const string TenantTemplates = "TenantTemplates";
// Tenant Template assignments — which delegated-admin UPN is assigned to which template.
// PK=UPN (lowercase), RK=templateId. Hot path: GetScopeAsync point-scans by PK to expand the
// UPN's templates into the effective tenant set. NOT tenant-id-keyed (offboarding never touches it).
public const string TenantTemplateAssignments = "TenantTemplateAssignments";
// Preview gating (temporary — remove after GA)
public const string PreviewWhitelist = "PreviewWhitelist";
public const string PreviewConfig = "PreviewConfig";
@@ -930,6 +943,8 @@ namespace AutopilotMonitor.Shared
TenantAdmins,
McpUsers,
DelegatedAdmins,
TenantTemplates,
TenantTemplateAssignments,
PreviewWhitelist,
PreviewConfig,
Feedback,
@@ -48,6 +48,29 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<bool> SetDelegatedAdminEnabledAsync(string upn, string tenantId, bool isEnabled);
Task<bool> RemoveDelegatedAdminAsync(string upn, string tenantId);
// --- Tenant Templates (app-internal tenant bundles for delegated admins / "MSP mode") ---
/// <summary>Creates a template (meta row) and returns the generated templateId.</summary>
Task<string> CreateTenantTemplateAsync(string name, string createdBy);
/// <summary>Renames a template (meta row). Returns false if the template does not exist.</summary>
Task<bool> RenameTenantTemplateAsync(string templateId, string name);
/// <summary>Deletes a template: all rows in its partition (meta + membership) AND every UPN
/// assignment referencing it (cross-partition RowKey scan of the assignments table).</summary>
Task<bool> DeleteTenantTemplateAsync(string templateId);
/// <summary>All templates with their tenant members + assignee counts — for the management UI (not hot path).</summary>
Task<List<TenantTemplate>> GetAllTenantTemplatesAsync();
Task<TenantTemplate?> GetTenantTemplateAsync(string templateId);
Task<bool> AddTenantToTemplateAsync(string templateId, string tenantId);
Task<bool> RemoveTenantFromTemplateAsync(string templateId, string tenantId);
/// <summary>Tenant IDs (lowercase) in a template, excluding the meta row (HOT PATH — PartitionKey scan).</summary>
Task<List<string>> GetTemplateTenantsAsync(string templateId);
/// <summary>Creates or replaces a UPN→template assignment.</summary>
Task<bool> AssignTemplateAsync(string upn, string templateId, string role, bool isEnabled, string assignedBy);
Task<bool> UnassignTemplateAsync(string upn, string templateId);
/// <summary>All template assignments for one UPN (HOT PATH — PartitionKey point-scan; scope resolution).</summary>
Task<List<TenantTemplateAssignment>> GetTemplateAssignmentsForUpnAsync(string upn);
/// <summary>All UPNs assigned to one template (cross-partition RowKey scan — management UI, not hot path).</summary>
Task<List<TenantTemplateAssignment>> GetTemplateAssigneesAsync(string templateId);
// --- Tenant Members ---
Task<List<TenantMember>> GetTenantMembersAsync(string tenantId);
Task<bool> AddTenantMemberAsync(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens = false);
@@ -88,6 +111,37 @@ namespace AutopilotMonitor.Shared.DataAccess
public string GrantedBy { get; set; } = string.Empty;
}
/// <summary>
/// A Tenant Template: an app-internal named bundle of tenants. A delegated admin assigned to the
/// template (see <see cref="TenantTemplateAssignment"/>) gains read scope to every tenant in
/// <see cref="TenantIds"/>. Adding a tenant to the template grants it to all assignees at once.
/// </summary>
public class TenantTemplate
{
public string TemplateId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string CreatedBy { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
/// <summary>Tenant IDs (lowercase) in this template.</summary>
public List<string> TenantIds { get; set; } = new();
/// <summary>Number of UPNs assigned to this template (== <see cref="Assignees"/>.Count).</summary>
public int AssigneeCount { get; set; }
/// <summary>The UPNs assigned to this template (for the management UI).</summary>
public List<TenantTemplateAssignment> Assignees { get; set; } = new();
}
/// <summary>One UPN→template assignment. PK=UPN, RK=templateId in storage.</summary>
public class TenantTemplateAssignment
{
public string Upn { get; set; } = string.Empty;
public string TemplateId { 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;
public string AssignedBy { get; set; } = string.Empty;
public DateTime AssignedAt { get; set; }
}
public class McpUserEntry
{
public string Upn { get; set; } = string.Empty;
@@ -7,6 +7,7 @@ import { SectionGlobalSettings } from "../sections/SectionGlobalSettings";
import { SectionDiagnosticsLogPaths } from "../sections/SectionDiagnosticsLogPaths";
import { SectionMcpUsers } from "../sections/SectionMcpUsers";
import { SectionDelegatedAdmins } from "../sections/SectionDelegatedAdmins";
import { SectionTenantTemplates } from "../sections/SectionTenantTemplates";
import { SectionConfigReseed } from "../sections/SectionConfigReseed";
import { SectionUsagePlans } from "../sections/SectionUsagePlans";
import { SectionAlerts } from "../sections/SectionAlerts";
@@ -16,6 +17,7 @@ const SECTION_COMPONENTS: Record<SettingsSectionId, React.ComponentType> = {
"diagnostics-log-paths": SectionDiagnosticsLogPaths,
"mcp-users": SectionMcpUsers,
"delegated-admins": SectionDelegatedAdmins,
"tenant-templates": SectionTenantTemplates,
"config-reseed": SectionConfigReseed,
"usage-plans": SectionUsagePlans,
"alerts": SectionAlerts,
@@ -0,0 +1,458 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useNotifications } from "@/contexts/NotificationContext";
import { useTenantList } from "@/hooks/useTenantList";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { api } from "@/lib/api";
/** One UPN assigned to a template (camelCase JSON from the backend). */
interface TemplateAssignee {
upn: string;
role: string; // "DelegatedReader" | "DelegatedAdmin"
isEnabled: boolean;
}
/** A tenant template as returned by /api/global/tenant-templates. */
interface TenantTemplate {
templateId: string;
name: string;
createdBy: string;
createdAt: string;
tenantIds: string[];
assigneeCount: number;
assignees: TemplateAssignee[];
}
const ROLE_LABELS: Record<string, string> = {
DelegatedReader: "Reader (read-only)",
DelegatedAdmin: "Admin (read + write)",
};
/**
* GlobalAdmin-only management of Tenant Templates — app-internal named bundles of tenants for the
* delegated-admin ("MSP mode") tier. Assign a person to a template instead of to each tenant; adding a tenant
* to the template grants it to every assignee at once. Writes go through /api/global/tenant-templates
* (GlobalAdminOnly); the backend invalidates affected scopes so changes take effect on the next request.
*/
export function SectionTenantTemplates() {
const { getAccessToken } = useAuth();
const { addNotification } = useNotifications();
const tenants = useTenantList(true);
const [templates, setTemplates] = useState<TenantTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
const [busyKey, setBusyKey] = useState<string | null>(null);
// Per-template input state (keyed by templateId).
const [tenantToAdd, setTenantToAdd] = useState<Record<string, string>>({});
const [assignUpn, setAssignUpn] = useState<Record<string, string>>({});
const [assignRole, setAssignRole] = useState<Record<string, string>>({});
const domainOf = useMemo(() => {
const map = new Map(tenants.map((t) => [t.tenantId.toLowerCase(), t.domainName]));
return (tenantId: string) => map.get(tenantId.toLowerCase()) || "";
}, [tenants]);
const flash = (msg: string) => {
setSuccessMessage(msg);
setTimeout(() => setSuccessMessage(null), 3000);
};
const handleError = (err: unknown, fallback: string) => {
if (err instanceof TokenExpiredError) {
addNotification("error", "Session Expired", err.message, "session-expired-error");
} else {
setError(err instanceof Error ? err.message : fallback);
}
};
const fetchTemplates = useCallback(async () => {
try {
setLoading(true);
const response = await authenticatedFetch(api.tenantTemplates.list(), getAccessToken);
if (!response.ok) throw new Error(`Failed to load templates: ${response.statusText}`);
const data = await response.json();
setTemplates(data.templates ?? []);
} catch (err) {
handleError(err, "Failed to load templates");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getAccessToken]);
useEffect(() => {
fetchTemplates();
}, [fetchTemplates]);
/** Runs a mutation with shared busy/error/refetch handling. `body` undefined ⇒ no JSON body. */
const mutate = useCallback(
async (key: string, url: string, method: string, body: unknown, ok: string) => {
try {
setBusyKey(key);
setError(null);
const init: RequestInit = { method };
if (body !== undefined) {
init.headers = { "Content-Type": "application/json" };
init.body = JSON.stringify(body);
}
const response = await authenticatedFetch(url, getAccessToken, init);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || `Request failed: ${response.statusText}`);
}
flash(ok);
await fetchTemplates();
return true;
} catch (err) {
handleError(err, "Request failed");
return false;
} finally {
setBusyKey(null);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[getAccessToken, fetchTemplates],
);
const handleCreate = useCallback(async () => {
const name = newName.trim();
if (!name) return;
setCreating(true);
const ok = await mutate("create", api.tenantTemplates.create(), "POST", { name }, `Created template "${name}".`);
setCreating(false);
if (ok) setNewName("");
}, [newName, mutate]);
const handleRename = useCallback(
async (t: TenantTemplate) => {
const name = prompt("Rename template:", t.name)?.trim();
if (!name || name === t.name) return;
await mutate(`rename:${t.templateId}`, api.tenantTemplates.rename(t.templateId), "PATCH", { name }, `Renamed to "${name}".`);
},
[mutate],
);
const handleDeleteTemplate = useCallback(
async (t: TenantTemplate) => {
const extra = t.assigneeCount > 0 ? ` ${t.assigneeCount} assignee(s) will lose this access.` : "";
if (!confirm(`Delete template "${t.name}"?${extra}`)) return;
await mutate(`delete:${t.templateId}`, api.tenantTemplates.remove(t.templateId), "DELETE", undefined, `Deleted "${t.name}".`);
},
[mutate],
);
const handleAddTenant = useCallback(
async (t: TenantTemplate) => {
const tenantId = tenantToAdd[t.templateId];
if (!tenantId) return;
const ok = await mutate(
`addtenant:${t.templateId}`,
api.tenantTemplates.addTenant(t.templateId),
"POST",
{ tenantId },
`Added ${domainOf(tenantId) || tenantId} to "${t.name}".`,
);
if (ok) setTenantToAdd((prev) => ({ ...prev, [t.templateId]: "" }));
},
[tenantToAdd, mutate, domainOf],
);
const handleRemoveTenant = useCallback(
async (t: TenantTemplate, tenantId: string) => {
const dom = domainOf(tenantId) || tenantId;
if (!confirm(`Remove ${dom} from "${t.name}"? Assignees lose access to this tenant.`)) return;
await mutate(
`rmtenant:${t.templateId}:${tenantId}`,
api.tenantTemplates.removeTenant(t.templateId, tenantId),
"DELETE",
undefined,
`Removed ${dom} from "${t.name}".`,
);
},
[mutate, domainOf],
);
const handleAssign = useCallback(
async (t: TenantTemplate) => {
const upn = (assignUpn[t.templateId] || "").trim();
if (!upn) return;
const role = assignRole[t.templateId] || "DelegatedReader";
const ok = await mutate(
`assign:${t.templateId}`,
api.tenantTemplates.assign(t.templateId),
"POST",
{ upn, role },
`Assigned ${upn} to "${t.name}".`,
);
if (ok) setAssignUpn((prev) => ({ ...prev, [t.templateId]: "" }));
},
[assignUpn, assignRole, mutate],
);
const handleUnassign = useCallback(
async (t: TenantTemplate, upn: string) => {
if (!confirm(`Remove ${upn} from "${t.name}"? They lose access to all tenants in this template.`)) return;
await mutate(
`unassign:${t.templateId}:${upn}`,
api.tenantTemplates.unassign(t.templateId, upn),
"DELETE",
undefined,
`Removed ${upn} from "${t.name}".`,
);
},
[mutate],
);
const sorted = useMemo(
() => [...templates].sort((a, b) => a.name.localeCompare(b.name)),
[templates],
);
return (
<div className="bg-white rounded-lg shadow">
{/* Header */}
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-sky-50 to-indigo-50">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-sky-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<h2 className="text-lg font-semibold text-gray-900">Tenant Templates (MSP Mode)</h2>
</div>
<p className="mt-1 text-sm text-gray-600">
Bundle tenants into a named template, then assign people to the template instead of to each tenant.
Add a tenant once and every assignee gets it; manage your managed-service team by who is assigned here.
</p>
</div>
<div className="p-6 space-y-4">
{/* Notifications */}
{successMessage && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-center space-x-3">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-green-800 font-medium">{successMessage}</span>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center space-x-3">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="text-red-800">{error}</span>
</div>
)}
{/* Create form */}
<div className="bg-sky-50 border border-sky-200 rounded-lg p-4 space-y-3">
<p className="text-sm font-medium text-sky-800">New template</p>
<div className="flex flex-col gap-2 sm:flex-row">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleCreate();
}
}}
placeholder="e.g. Managed Service Tenants"
autoComplete="off"
className="flex-1 min-w-0 px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors"
/>
<button
onClick={handleCreate}
disabled={creating || !newName.trim()}
className="px-6 py-2 bg-sky-600 text-white rounded-lg hover:bg-sky-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center space-x-2"
>
{creating ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
<span>Creating…</span>
</>
) : (
<span>Create</span>
)}
</button>
</div>
</div>
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-sky-600" />
<span className="ml-3 text-sm text-gray-500">Loading templates…</span>
</div>
)}
{/* Empty */}
{!loading && sorted.length === 0 && (
<p className="text-sm text-gray-500 py-4 text-center">
No templates yet. Create one above, add tenants to it, then assign your team.
</p>
)}
{/* Templates */}
{!loading && sorted.length > 0 && (
<div className="space-y-4">
{sorted.map((t) => {
const availableTenants = tenants.filter(
(x) => !t.tenantIds.some((id) => id.toLowerCase() === x.tenantId.toLowerCase()),
);
return (
<div key={t.templateId} className="border border-gray-200 rounded-lg">
{/* Template header */}
<div className="px-4 py-2 border-b border-gray-100 bg-gray-50 rounded-t-lg flex items-center justify-between gap-2">
<div className="min-w-0">
<span className="font-medium text-gray-900 truncate">{t.name}</span>
<span className="ml-2 text-xs text-gray-500">
{t.tenantIds.length} tenant{t.tenantIds.length === 1 ? "" : "s"} · {t.assigneeCount} assignee
{t.assigneeCount === 1 ? "" : "s"}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => handleRename(t)}
className="px-3 py-1 text-sm border border-gray-300 rounded-lg hover:bg-gray-100 transition-colors"
>
Rename
</button>
<button
onClick={() => handleDeleteTemplate(t)}
className="px-3 py-1 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50 transition-colors"
>
Delete
</button>
</div>
</div>
<div className="p-4 grid gap-4 md:grid-cols-2">
{/* Tenants column */}
<div className="space-y-2">
<p className="text-xs font-semibold text-gray-700 uppercase tracking-wide">Tenants</p>
{t.tenantIds.length === 0 ? (
<p className="text-sm text-gray-400">No tenants yet.</p>
) : (
<ul className="space-y-1">
{t.tenantIds.map((id) => (
<li key={id} className="flex items-center justify-between gap-2 text-sm">
<span className="truncate text-gray-900">{domainOf(id) || id}</span>
<button
onClick={() => handleRemoveTenant(t, id)}
disabled={busyKey === `rmtenant:${t.templateId}:${id}`}
className="text-red-600 hover:text-red-800 disabled:opacity-50 text-xs"
aria-label={`Remove ${domainOf(id) || id}`}
>
Remove
</button>
</li>
))}
</ul>
)}
<div className="flex gap-2 pt-1">
<select
value={tenantToAdd[t.templateId] || ""}
onChange={(e) => setTenantToAdd((p) => ({ ...p, [t.templateId]: e.target.value }))}
className="flex-1 min-w-0 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
>
<option value="">Add tenant…</option>
{availableTenants.map((x) => (
<option key={x.tenantId} value={x.tenantId}>
{x.domainName || x.tenantId}
</option>
))}
</select>
<button
onClick={() => handleAddTenant(t)}
disabled={!tenantToAdd[t.templateId] || busyKey === `addtenant:${t.templateId}`}
className="px-3 py-1.5 text-sm bg-sky-600 text-white rounded-lg hover:bg-sky-700 disabled:opacity-50 transition-colors"
>
Add
</button>
</div>
</div>
{/* Assignees column */}
<div className="space-y-2">
<p className="text-xs font-semibold text-gray-700 uppercase tracking-wide">Assignees</p>
{t.assignees.length === 0 ? (
<p className="text-sm text-gray-400">No one assigned yet.</p>
) : (
<ul className="space-y-1">
{t.assignees.map((a) => (
<li key={a.upn} className="flex items-center justify-between gap-2 text-sm">
<span className="min-w-0 truncate text-gray-900">
{a.upn}
<span className="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-sky-100 text-sky-800">
{ROLE_LABELS[a.role] ?? a.role}
</span>
</span>
<button
onClick={() => handleUnassign(t, a.upn)}
disabled={busyKey === `unassign:${t.templateId}:${a.upn}`}
className="text-red-600 hover:text-red-800 disabled:opacity-50 text-xs shrink-0"
aria-label={`Remove ${a.upn}`}
>
Remove
</button>
</li>
))}
</ul>
)}
<div className="flex gap-2 pt-1">
<input
type="email"
value={assignUpn[t.templateId] || ""}
onChange={(e) => setAssignUpn((p) => ({ ...p, [t.templateId]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAssign(t);
}
}}
placeholder="user@domain.com"
autoComplete="off"
className="flex-1 min-w-0 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
/>
<select
value={assignRole[t.templateId] || "DelegatedReader"}
onChange={(e) => setAssignRole((p) => ({ ...p, [t.templateId]: e.target.value }))}
className="px-2 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
>
<option value="DelegatedReader">Reader</option>
<option value="DelegatedAdmin">Admin</option>
</select>
<button
onClick={() => handleAssign(t)}
disabled={!(assignUpn[t.templateId] || "").trim() || busyKey === `assign:${t.templateId}`}
className="px-3 py-1.5 text-sm bg-sky-600 text-white rounded-lg hover:bg-sky-700 disabled:opacity-50 transition-colors"
>
Assign
</button>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
<p className="text-xs text-gray-500">
Reader is read-only (secrets redacted). Only onboarded tenants appear in the dropdown. Changes take
effect on the assignee&rsquo;s next request.
</p>
</div>
</div>
);
}
@@ -3,6 +3,7 @@ export const SETTINGS_NAV_SECTIONS = [
{ id: "diagnostics-log-paths", label: "Diagnostics Log Paths", description: "Global diagnostics log path configuration" },
{ id: "mcp-users", label: "MCP Users", description: "Manage AI agent access via MCP" },
{ id: "delegated-admins", label: "Delegated Admins", description: "Grant cross-tenant read access (MSP mode)" },
{ id: "tenant-templates", label: "Tenant Templates", description: "Bundle tenants and assign delegated admins (MSP mode)" },
{ id: "config-reseed", label: "Config Reseed", description: "Fetch and reseed rules from GitHub" },
{ id: "usage-plans", label: "Usage Plans", description: "Define MCP usage plan tiers and limits" },
{ id: "alerts", label: "Alerts", description: "Configure ops event alert rules and notification providers" },
+18
View File
@@ -511,6 +511,24 @@ export const api = {
`${API_BASE_URL}/api/global/delegated-admins/${encodeURIComponent(upn)}/${encodeURIComponent(tenantId)}/disable`,
},
// ── Tenant Templates (MSP mode — named bundles of tenants for delegated admins) ──
tenantTemplates: {
list: () => `${API_BASE_URL}/api/global/tenant-templates`,
create: () => `${API_BASE_URL}/api/global/tenant-templates`,
rename: (templateId: string) =>
`${API_BASE_URL}/api/global/tenant-templates/${encodeURIComponent(templateId)}`,
remove: (templateId: string) =>
`${API_BASE_URL}/api/global/tenant-templates/${encodeURIComponent(templateId)}`,
addTenant: (templateId: string) =>
`${API_BASE_URL}/api/global/tenant-templates/${encodeURIComponent(templateId)}/tenants`,
removeTenant: (templateId: string, tenantId: string) =>
`${API_BASE_URL}/api/global/tenant-templates/${encodeURIComponent(templateId)}/tenants/${encodeURIComponent(tenantId)}`,
assign: (templateId: string) =>
`${API_BASE_URL}/api/global/tenant-templates/${encodeURIComponent(templateId)}/assignees`,
unassign: (templateId: string, upn: string) =>
`${API_BASE_URL}/api/global/tenant-templates/${encodeURIComponent(templateId)}/assignees/${encodeURIComponent(upn)}`,
},
// ── MCP Users ─────────────────────────────────────────────────────────────
mcpUsers: {
list: () => `${API_BASE_URL}/api/global/mcp-users`,
@@ -283,6 +283,7 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [
{ id: "ga-diag-paths", label: "Diagnostics Log Paths", href: "/admin/settings/diagnostics-log-paths" },
{ id: "ga-mcp-users", label: "MCP Users", href: "/admin/settings/mcp-users" },
{ id: "ga-delegated-admins", label: "Delegated Admins", href: "/admin/settings/delegated-admins" },
{ id: "ga-tenant-templates", label: "Tenant Templates", href: "/admin/settings/tenant-templates" },
{ id: "ga-config-reseed", label: "Config Reseed", href: "/admin/settings/config-reseed" },
{ id: "ga-usage-plans", label: "Usage Plans", href: "/admin/settings/usage-plans" },
],