diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs index ec6cb1d4..ad9c2c07 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs @@ -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 Repo) Build() { var repo = new Mock(); repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny())) .ReturnsAsync(new List()); + // Default: no template assignments. Template-specific tests override per-case. + repo.Setup(r => r.GetTemplateAssignmentsForUpnAsync(It.IsAny())) + .ReturnsAsync(new List()); var cache = new MemoryCache(new MemoryCacheOptions()); var svc = new DelegatedAdminService(repo.Object, cache, NullLogger.Instance); return (svc, repo); } + private static 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 repo, params TenantTemplateAssignment[] assignments) => + repo.Setup(r => r.GetTemplateAssignmentsForUpnAsync(It.IsAny())).ReturnsAsync(assignments.ToList()); + + private static void TemplateTenants(Mock 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()), 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()), 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(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .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()), 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(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .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(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), 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(), It.IsAny())).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(), It.IsAny())).ReturnsAsync(true); + + var unassigned = await svc.UnassignTemplateAsync(Upn, TemplateId1); + + Assert.False(unassigned); + repo.Verify(r => r.UnassignTemplateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + private static void TemplateExists(Mock 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(), It.IsAny())).ReturnsAsync(true); + repo.Setup(r => r.GetTemplateAssigneesAsync(TemplateId1)).ReturnsAsync(new List + { + 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(), It.IsAny())).ReturnsAsync(true); + + var added = await svc.AddTenantToTemplateAsync(TemplateId1, TenantA); + + Assert.False(added); + repo.Verify(r => r.AddTenantToTemplateAsync(It.IsAny(), It.IsAny()), 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()); + + 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(), It.IsAny())).ReturnsAsync(true); + + var removed = await svc.RemoveTenantFromTemplateAsync(TemplateId1, TenantA); + + Assert.False(removed); + repo.Verify(r => r.RemoveTenantFromTemplateAsync(It.IsAny(), It.IsAny()), 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(), It.IsAny())).ReturnsAsync(true); + + var removed = await svc.RemoveTenantFromTemplateAsync(TemplateId1, TenantA); + + Assert.False(removed); + repo.Verify(r => r.RemoveTenantFromTemplateAsync(It.IsAny(), It.IsAny()), 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())).ReturnsAsync(true); + // Assignees are captured BEFORE the cascade delete removes them. + repo.Setup(r => r.GetTemplateAssigneesAsync(TemplateId1)).ReturnsAsync(new List + { + 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(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .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(), It.IsAny())).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()), Times.Once); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs index 2b6a3fc6..bb68f7bf 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs @@ -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; } + /// Table names passed to the property-only (Variant C) wipe — lets tests assert coverage. + public List PropertyOnlyWipes { get; } = new(); public CountingSafeWipeService() : base( new TableStorageService(Mock.Of(), NullLogger.Instance), new BlobStorageService(new BlobServiceClient("UseDevelopmentStorage=true"), @@ -414,7 +433,7 @@ public class TenantOffboardingHandlerOrderingTests public override Task WipeByExactPartitionAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); } public override Task WipeByCompositePartitionRangeAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); } public override Task WipeByDiscriminatorAndTenantPropertyAsync(string t, string d, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); } - public override Task WipeByTenantIdPropertyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); } + public override Task WipeByTenantIdPropertyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; PropertyOnlyWipes.Add(t); return Task.FromResult(0); } public override Task WipeBlobsByTenantPrefixAsync(string c, string i, CancellationToken ct = default) { WipeCallCount++; return Task.FromResult(0); } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs index 99353914..92abc176 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs @@ -72,6 +72,9 @@ public class PolicyEnforcementMiddlewareTests .ReturnsAsync((TenantMember?)null); repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny())) .ReturnsAsync(new List()); + // No template assignments by default (delegated scope resolves direct grants + template tenants). + repo.Setup(r => r.GetTemplateAssignmentsForUpnAsync(It.IsAny())) + .ReturnsAsync(new List()); // 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() { diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs index 338d42f2..c5075bc2 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs @@ -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 _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 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 RenameTenantTemplateAsync(string templateId, string name) + { + if (string.IsNullOrWhiteSpace(templateId)) + return false; + + try + { + var result = await _tenantTemplatesTableClient.GetEntityAsync( + 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 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( + 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( + e => e.RowKey == templateId)) + { + await DeleteIfPresentAsync(_tenantTemplateAssignmentsTableClient, assignment.PartitionKey, assignment.RowKey); + } + + return true; + } + + public async Task> 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(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(StringComparer.Ordinal); + await foreach (var entity in _tenantTemplatesTableClient.QueryAsync()) + { + if (ApplyRowToTemplate(byId, entity)) + metaBacked.Add(entity.PartitionKey); + } + + // Assignees (separate table; management path only). + await foreach (var assignment in _tenantTemplateAssignmentsTableClient.QueryAsync()) + { + if (byId.TryGetValue(assignment.RowKey, out var template)) + { + template.Assignees.Add(MapToTemplateAssignment(assignment)); + template.AssigneeCount++; + } + } + + var result = new List(); + foreach (var template in byId.Values) + { + if (metaBacked.Contains(template.TemplateId)) + result.Add(template); + } + return result; + } + + public async Task GetTenantTemplateAsync(string templateId) + { + if (string.IsNullOrWhiteSpace(templateId)) + return null; + + var byId = new Dictionary(StringComparer.Ordinal); + var metaBacked = false; + await foreach (var entity in _tenantTemplatesTableClient.QueryAsync( + 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( + e => e.RowKey == templateId)) + { + template.Assignees.Add(MapToTemplateAssignment(assignment)); + template.AssigneeCount++; + } + + return template; + } + + public async Task 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 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> GetTemplateTenantsAsync(string templateId) + { + var tenants = new List(); + 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( + e => e.PartitionKey == templateId)) + { + if (entity.RowKey == TenantTemplateEntity.MetaRowKey) + metaBacked = true; + else + tenants.Add(entity.RowKey); + } + return metaBacked ? tenants : new List(); + } + + public async Task 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 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> GetTemplateAssignmentsForUpnAsync(string upn) + { + var entries = new List(); + if (string.IsNullOrWhiteSpace(upn)) + return entries; + + var normalizedUpn = upn.ToLowerInvariant(); + // HOT PATH (scope resolution): PartitionKey point-scan. + await foreach (var entity in _tenantTemplateAssignmentsTableClient.QueryAsync( + e => e.PartitionKey == normalizedUpn)) + { + entries.Add(MapToTemplateAssignment(entity)); + } + return entries; + } + + public async Task> GetTemplateAssigneesAsync(string templateId) + { + var entries = new List(); + 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( + e => e.RowKey == templateId)) + { + entries.Add(MapToTemplateAssignment(entity)); + } + return entries; + } + // --- Tenant Members --- public async Task> GetTenantMembersAsync(string tenantId) @@ -568,6 +812,56 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage }; } + /// + /// 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). + /// + private static bool ApplyRowToTemplate(Dictionary 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 + }; + } + + /// Deletes a row, tolerating a concurrent delete (404) so cascade/cleanup stays idempotent. + 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 diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantTemplateManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantTemplateManagementFunction.cs new file mode 100644 index 00000000..ec06903a --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantTemplateManagementFunction.cs @@ -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; + +/// +/// GlobalAdmin-only management of Tenant Templates — 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 ). +/// 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 PolicyEnforcementMiddleware via the route catalog), so a delegated +/// caller can never manage templates. ALL mutations go through (never the +/// repository directly) so the delegated-scope cache is invalidated in lockstep. +/// +/// Audit: access-affecting mutations are logged under the affected managed tenant(s)' 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). +/// +public class TenantTemplateManagementFunction +{ + private readonly ILogger _logger; + private readonly DelegatedAdminService _delegatedAdminService; + private readonly IMaintenanceRepository _maintenanceRepo; + + private const string AuditEntity = "DelegatedTemplateAccess"; + + public TenantTemplateManagementFunction( + ILogger logger, + DelegatedAdminService delegatedAdminService, + IMaintenanceRepository maintenanceRepo) + { + _logger = logger; + _delegatedAdminService = delegatedAdminService; + _maintenanceRepo = maintenanceRepo; + } + + /// GET /api/global/tenant-templates — list every template with tenants + assignee count. GlobalReadOrAdmin. + [Function("GetTenantTemplates")] + [Authorize] + public async Task 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; + } + + /// POST /api/global/tenant-templates — create a template. GlobalAdminOnly. Body: { "name": "..." }. + [Function("CreateTenantTemplate")] + [Authorize] + public async Task CreateTenantTemplate( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/tenant-templates")] HttpRequestData req, + FunctionContext context) + { + var currentUpn = context.GetRequestContext().UserPrincipalName; + + var body = await req.ReadFromJsonAsync(); + 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; + } + + /// PATCH /api/global/tenant-templates/{templateId} — rename. GlobalAdminOnly. Body: { "name": "..." }. + [Function("RenameTenantTemplate")] + [Authorize] + public async Task 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(); + 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; + } + + /// DELETE /api/global/tenant-templates/{templateId} — delete template + all its assignments. GlobalAdminOnly. + [Function("DeleteTenantTemplate")] + [Authorize] + public async Task 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 + { + { "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; + } + + /// POST /api/global/tenant-templates/{templateId}/tenants — add a tenant. GlobalAdminOnly. Body: { "tenantId": "<guid>" }. + [Function("AddTenantToTemplate")] + [Authorize] + public async Task 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(); + 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 { { "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; + } + + /// DELETE /api/global/tenant-templates/{templateId}/tenants/{tenantId} — remove a tenant. GlobalAdminOnly. + [Function("RemoveTenantFromTemplate")] + [Authorize] + public async Task 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 { { "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; + } + + /// + /// POST /api/global/tenant-templates/{templateId}/assignees — assign a UPN to the template. GlobalAdminOnly. + /// Body: { "upn": "user@domain.com", "role": "DelegatedReader" | "DelegatedAdmin" }. + /// + [Function("AssignTenantTemplate")] + [Authorize] + public async Task 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(); + 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 + { + { "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; + } + + /// DELETE /api/global/tenant-templates/{templateId}/assignees/{upn} — unassign a UPN. GlobalAdminOnly. + [Function("UnassignTenantTemplate")] + [Authorize] + public async Task 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 + { + { "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; + } + + /// Logs one audit entry per tenant (the customer-visible "who can read my tenant" trail). + private async Task AuditPerTenantAsync( + IEnumerable tenantIds, string action, string entityId, string? performedBy, Dictionary details) + { + foreach (var tenantId in tenantIds) + { + await _maintenanceRepo.LogAuditEntryAsync( + tenantId.ToLowerInvariant(), action, AuditEntity, entityId, performedBy ?? "", details); + } + } + + private static async Task Bad(HttpRequestData req, string error) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { error }); + return bad; + } + + private static async Task 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; } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index b2ed3afd..704286b4 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -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), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs b/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs index daa7d57c..bf170ec9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs @@ -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> 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. + + /// Read-through: all templates with tenant members + assignee counts (management UI). + public Task> GetAllTemplatesAsync() => _adminRepo.GetAllTenantTemplatesAsync(); + + /// Read-through: one template with its tenant members + assignee count. + public Task GetTemplateAsync(string templateId) => _adminRepo.GetTenantTemplateAsync(NormalizeTemplateId(templateId)); + + /// Read-through: all UPNs assigned to a template (management UI). + public Task> GetTemplateAssigneesAsync(string templateId) + => _adminRepo.GetTemplateAssigneesAsync(NormalizeTemplateId(templateId)); + + /// Creates a template (no scope effect — a fresh template has no assignees). + public Task CreateTemplateAsync(string name, string createdBy) + => _adminRepo.CreateTenantTemplateAsync(name, createdBy); + + /// Renames a template (name only — no scope effect, no invalidation needed). + public Task RenameTemplateAsync(string templateId, string name) + => _adminRepo.RenameTenantTemplateAsync(NormalizeTemplateId(templateId), name); + + /// Deletes a template and all its assignments; invalidates every (former) assignee's scope. + public async Task 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; + } + + /// + /// 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. + /// + public async Task 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; + } + + /// + /// 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. + /// + public async Task 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; + } + + /// + /// 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. + /// + public async Task 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; + } + + /// + /// 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. + /// + public async Task 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; + } + + /// Invalidates the cached scope of every current assignee of a template. + private async Task InvalidateTemplateAssigneesAsync(string templateId) + => InvalidateAll(await _adminRepo.GetTemplateAssigneesAsync(templateId)); + + private void InvalidateAll(IEnumerable assignees) + { + foreach (var assignee in assignees) + Invalidate(assignee.Upn); + } + private void Invalidate(string upn) => _cache.Remove($"delegated-scope:{upn.ToLowerInvariant()}"); + /// Normalizes the opaque templateId to lowercase so the storage key is case-insensitive to callers. + private static string NormalizeTemplateId(string templateId) => (templateId ?? string.Empty).ToLowerInvariant(); + /// Empty/missing role defaults to the least-privileged DelegatedReader; an unrecognized /// role is dropped (fail-closed) rather than silently granting access. private string? NormalizeRole(string role, string upn, string tenantId) @@ -229,3 +387,68 @@ public class DelegatedAdminEntity : ITableEntity /// UPN of the principal who created this assignment (operator GA, or customer tenant admin). public string GrantedBy { get; set; } = string.Empty; } + +/// +/// A row in the table. Two row layouts share the +/// class, discriminated by : +/// +/// PK = templateId, RK = — template metadata (, creator). +/// PK = templateId, RK = tenantId (lowercase) — one membership row per tenant in the template. +/// +/// A template is an app-internal named bundle of tenants; a delegated admin assigned to it (see +/// ) gains read scope to every tenant member. +/// +public class TenantTemplateEntity : ITableEntity +{ + /// The reserved RowKey of the per-template metadata row. Tenant IDs are GUIDs and never collide with it. + 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; } + + /// Display name (meta row only; empty on membership rows). + public string Name { get; set; } = string.Empty; + + /// UPN of the GlobalAdmin who created the template (meta row only). + public string CreatedBy { get; set; } = string.Empty; + + /// When the template was created (meta row only). + public DateTime CreatedDate { get; set; } + + /// The managed tenant ID (lowercase) — denormalized copy of RowKey on membership rows; empty on meta. + public string TenantId { get; set; } = string.Empty; +} + +/// +/// A row in the table: delegated-admin UPN X +/// is assigned to template T at 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. +/// +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; } + + /// The delegated admin's UPN (lowercase) — denormalized copy of PartitionKey. + public string Upn { get; set; } = string.Empty; + + /// The template ID — denormalized copy of RowKey. + public string TemplateId { get; set; } = string.Empty; + + /// : DelegatedReader (default) or DelegatedAdmin. + public string Role { get; set; } = Constants.DelegatedRoles.DelegatedReader; + + /// Whether this assignment is currently enabled (soft toggle). + public bool IsEnabled { get; set; } = true; + + /// UPN of the GlobalAdmin who created this assignment. + public string AssignedBy { get; set; } = string.Empty; + + /// When this assignment was created. + public DateTime AssignedDate { get; set; } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs index 3cfdc3c1..22e57084 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs @@ -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 diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 20e5e99e..7ca8d1da 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -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, diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs index b2e9ea7d..48d7a88a 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs @@ -48,6 +48,29 @@ namespace AutopilotMonitor.Shared.DataAccess Task SetDelegatedAdminEnabledAsync(string upn, string tenantId, bool isEnabled); Task RemoveDelegatedAdminAsync(string upn, string tenantId); + // --- Tenant Templates (app-internal tenant bundles for delegated admins / "MSP mode") --- + /// Creates a template (meta row) and returns the generated templateId. + Task CreateTenantTemplateAsync(string name, string createdBy); + /// Renames a template (meta row). Returns false if the template does not exist. + Task RenameTenantTemplateAsync(string templateId, string name); + /// Deletes a template: all rows in its partition (meta + membership) AND every UPN + /// assignment referencing it (cross-partition RowKey scan of the assignments table). + Task DeleteTenantTemplateAsync(string templateId); + /// All templates with their tenant members + assignee counts — for the management UI (not hot path). + Task> GetAllTenantTemplatesAsync(); + Task GetTenantTemplateAsync(string templateId); + Task AddTenantToTemplateAsync(string templateId, string tenantId); + Task RemoveTenantFromTemplateAsync(string templateId, string tenantId); + /// Tenant IDs (lowercase) in a template, excluding the meta row (HOT PATH — PartitionKey scan). + Task> GetTemplateTenantsAsync(string templateId); + /// Creates or replaces a UPN→template assignment. + Task AssignTemplateAsync(string upn, string templateId, string role, bool isEnabled, string assignedBy); + Task UnassignTemplateAsync(string upn, string templateId); + /// All template assignments for one UPN (HOT PATH — PartitionKey point-scan; scope resolution). + Task> GetTemplateAssignmentsForUpnAsync(string upn); + /// All UPNs assigned to one template (cross-partition RowKey scan — management UI, not hot path). + Task> GetTemplateAssigneesAsync(string templateId); + // --- Tenant Members --- Task> GetTenantMembersAsync(string tenantId); Task 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; } + /// + /// A Tenant Template: an app-internal named bundle of tenants. A delegated admin assigned to the + /// template (see ) gains read scope to every tenant in + /// . Adding a tenant to the template grants it to all assignees at once. + /// + 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; } + /// Tenant IDs (lowercase) in this template. + public List TenantIds { get; set; } = new(); + /// Number of UPNs assigned to this template (== .Count). + public int AssigneeCount { get; set; } + /// The UPNs assigned to this template (for the management UI). + public List Assignees { get; set; } = new(); + } + + /// One UPN→template assignment. PK=UPN, RK=templateId in storage. + public class TenantTemplateAssignment + { + public string Upn { get; set; } = string.Empty; + public string TemplateId { get; set; } = string.Empty; + /// Constants.DelegatedRoles: "DelegatedReader" (default) or "DelegatedAdmin". + 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; diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/[section]/page.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/[section]/page.tsx index 464da9a6..46ddc907 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/[section]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/settings/[section]/page.tsx @@ -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 = { "diagnostics-log-paths": SectionDiagnosticsLogPaths, "mcp-users": SectionMcpUsers, "delegated-admins": SectionDelegatedAdmins, + "tenant-templates": SectionTenantTemplates, "config-reseed": SectionConfigReseed, "usage-plans": SectionUsagePlans, "alerts": SectionAlerts, diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionTenantTemplates.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionTenantTemplates.tsx new file mode 100644 index 00000000..c4cab82a --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionTenantTemplates.tsx @@ -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 = { + 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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + const [newName, setNewName] = useState(""); + const [creating, setCreating] = useState(false); + const [busyKey, setBusyKey] = useState(null); + + // Per-template input state (keyed by templateId). + const [tenantToAdd, setTenantToAdd] = useState>({}); + const [assignUpn, setAssignUpn] = useState>({}); + const [assignRole, setAssignRole] = useState>({}); + + 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 ( +
+ {/* Header */} +
+
+ + + +

Tenant Templates (MSP Mode)

+
+

+ 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. +

+
+ +
+ {/* Notifications */} + {successMessage && ( +
+ + + + {successMessage} +
+ )} + {error && ( +
+ + + + {error} +
+ )} + + {/* Create form */} +
+

New template

+
+ 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" + /> + +
+
+ + {/* Loading */} + {loading && ( +
+
+ Loading templates… +
+ )} + + {/* Empty */} + {!loading && sorted.length === 0 && ( +

+ No templates yet. Create one above, add tenants to it, then assign your team. +

+ )} + + {/* Templates */} + {!loading && sorted.length > 0 && ( +
+ {sorted.map((t) => { + const availableTenants = tenants.filter( + (x) => !t.tenantIds.some((id) => id.toLowerCase() === x.tenantId.toLowerCase()), + ); + return ( +
+ {/* Template header */} +
+
+ {t.name} + + {t.tenantIds.length} tenant{t.tenantIds.length === 1 ? "" : "s"} · {t.assigneeCount} assignee + {t.assigneeCount === 1 ? "" : "s"} + +
+
+ + +
+
+ +
+ {/* Tenants column */} +
+

Tenants

+ {t.tenantIds.length === 0 ? ( +

No tenants yet.

+ ) : ( +
    + {t.tenantIds.map((id) => ( +
  • + {domainOf(id) || id} + +
  • + ))} +
+ )} +
+ + +
+
+ + {/* Assignees column */} +
+

Assignees

+ {t.assignees.length === 0 ? ( +

No one assigned yet.

+ ) : ( +
    + {t.assignees.map((a) => ( +
  • + + {a.upn} + + {ROLE_LABELS[a.role] ?? a.role} + + + +
  • + ))} +
+ )} +
+ 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" + /> + + +
+
+
+
+ ); + })} +
+ )} + +

+ Reader is read-only (secrets redacted). Only onboarded tenants appear in the dropdown. Changes take + effect on the assignee’s next request. +

+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/settingsNavSections.ts b/src/Web/autopilot-monitor-web/app/admin/settings/settingsNavSections.ts index 4d04a39b..ba73e59c 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/settingsNavSections.ts +++ b/src/Web/autopilot-monitor-web/app/admin/settings/settingsNavSections.ts @@ -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" }, diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 16bddc3c..88ffecad 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -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`, diff --git a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx index 41f6e893..47405d25 100644 --- a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx +++ b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx @@ -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" }, ],