diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs index ad9c2c07..0d319fd8 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs @@ -19,8 +19,8 @@ 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 GroupId1 = "tpl-aaaaaaaa"; + private const string GroupId2 = "tpl-bbbbbbbb"; private const string TenantC = "33333333-3333-3333-3333-333333333333"; private static (DelegatedAdminService Svc, Mock Repo) Build() @@ -28,31 +28,31 @@ public class DelegatedAdminServiceTests 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()); + // Default: no group assignments. Group-specific tests override per-case. + repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(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, + private static TenantGroupAssignment Assignment( + string groupId, string role = Constants.DelegatedRoles.DelegatedReader, bool enabled = true) => new() { Upn = Upn, - TemplateId = templateId, + GroupId = groupId, 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 ReturnsGroups(Mock repo, params TenantGroupAssignment[] assignments) => + repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(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 void GroupTenants(Mock repo, string groupId, params string[] tenantIds) => + repo.Setup(r => r.GetGroupTenantsAsync(groupId)).ReturnsAsync(tenantIds.ToList()); private static DelegatedAdminEntry Row( string tenantId, @@ -236,14 +236,14 @@ 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 --- + // --- Tenant Groups: scope is the union of direct grants + every tenant in every assigned group --- [Fact] - public async Task GetScope_TemplateAssignment_ExpandsToTemplateTenants() + public async Task GetScope_GroupAssignment_ExpandsToGroupTenants() { var (svc, repo) = Build(); - ReturnsTemplates(repo, Assignment(TemplateId1, Constants.DelegatedRoles.DelegatedReader)); - TemplateTenants(repo, TemplateId1, TenantA, TenantB); + ReturnsGroups(repo, Assignment(GroupId1, Constants.DelegatedRoles.DelegatedReader)); + GroupTenants(repo, GroupId1, TenantA, TenantB); var scope = await svc.GetScopeAsync(Upn); @@ -254,12 +254,12 @@ public class DelegatedAdminServiceTests } [Fact] - public async Task GetScope_DirectAndTemplate_AreUnioned() + public async Task GetScope_DirectAndGroup_AreUnioned() { var (svc, repo) = Build(); Returns(repo, Row(TenantA)); - ReturnsTemplates(repo, Assignment(TemplateId1)); - TemplateTenants(repo, TemplateId1, TenantB, TenantC); + ReturnsGroups(repo, Assignment(GroupId1)); + GroupTenants(repo, GroupId1, TenantB, TenantC); var scope = await svc.GetScopeAsync(Upn); @@ -270,11 +270,11 @@ public class DelegatedAdminServiceTests } [Fact] - public async Task GetScope_DisabledTemplateAssignment_Ignored() + public async Task GetScope_DisabledGroupAssignment_Ignored() { var (svc, repo) = Build(); - ReturnsTemplates(repo, Assignment(TemplateId1, enabled: false)); - TemplateTenants(repo, TemplateId1, TenantA); + ReturnsGroups(repo, Assignment(GroupId1, enabled: false)); + GroupTenants(repo, GroupId1, TenantA); var scope = await svc.GetScopeAsync(Upn); @@ -283,11 +283,11 @@ public class DelegatedAdminServiceTests } [Fact] - public async Task GetScope_UnknownTemplateRole_IsDropped() + public async Task GetScope_UnknownGroupRole_IsDropped() { var (svc, repo) = Build(); - ReturnsTemplates(repo, Assignment(TemplateId1, role: "SuperRoot")); - TemplateTenants(repo, TemplateId1, TenantA); + ReturnsGroups(repo, Assignment(GroupId1, role: "SuperRoot")); + GroupTenants(repo, GroupId1, TenantA); var scope = await svc.GetScopeAsync(Upn); @@ -295,11 +295,11 @@ public class DelegatedAdminServiceTests } [Fact] - public async Task GetScope_EmptyTemplate_ContributesNothing() + public async Task GetScope_EmptyGroup_ContributesNothing() { var (svc, repo) = Build(); - ReturnsTemplates(repo, Assignment(TemplateId1)); - TemplateTenants(repo, TemplateId1); // no tenants + ReturnsGroups(repo, Assignment(GroupId1)); + GroupTenants(repo, GroupId1); // no tenants var scope = await svc.GetScopeAsync(Upn); @@ -307,12 +307,12 @@ public class DelegatedAdminServiceTests } [Fact] - public async Task GetScope_TenantInTwoTemplates_ResolvedOnce() + public async Task GetScope_TenantInTwoGroups_ResolvedOnce() { var (svc, repo) = Build(); - ReturnsTemplates(repo, Assignment(TemplateId1), Assignment(TemplateId2)); - TemplateTenants(repo, TemplateId1, TenantA); - TemplateTenants(repo, TemplateId2, TenantA, TenantB); + ReturnsGroups(repo, Assignment(GroupId1), Assignment(GroupId2)); + GroupTenants(repo, GroupId1, TenantA); + GroupTenants(repo, GroupId2, TenantA, TenantB); var scope = await svc.GetScopeAsync(Upn); @@ -322,12 +322,12 @@ public class DelegatedAdminServiceTests } [Fact] - public async Task GetScope_TemplateAdminRole_BeatsDirectReader() + public async Task GetScope_GroupAdminRole_BeatsDirectReader() { var (svc, repo) = Build(); Returns(repo, Row(TenantA, Constants.DelegatedRoles.DelegatedReader)); - ReturnsTemplates(repo, Assignment(TemplateId1, Constants.DelegatedRoles.DelegatedAdmin)); - TemplateTenants(repo, TemplateId1, TenantA); + ReturnsGroups(repo, Assignment(GroupId1, Constants.DelegatedRoles.DelegatedAdmin)); + GroupTenants(repo, GroupId1, TenantA); var scope = await svc.GetScopeAsync(Upn); @@ -335,241 +335,241 @@ public class DelegatedAdminServiceTests } [Fact] - public async Task GetScope_DirectAdmin_BeatsTemplateReader() + public async Task GetScope_DirectAdmin_BeatsGroupReader() { var (svc, repo) = Build(); Returns(repo, Row(TenantA, Constants.DelegatedRoles.DelegatedAdmin)); - ReturnsTemplates(repo, Assignment(TemplateId1, Constants.DelegatedRoles.DelegatedReader)); - TemplateTenants(repo, TemplateId1, TenantA); + ReturnsGroups(repo, Assignment(GroupId1, Constants.DelegatedRoles.DelegatedReader)); + GroupTenants(repo, GroupId1, TenantA); var scope = await svc.GetScopeAsync(Upn); - Assert.True(scope.CanWrite(TenantA)); // direct DelegatedAdmin not downgraded by template reader + Assert.True(scope.CanWrite(TenantA)); // direct DelegatedAdmin not downgraded by group reader } [Fact] - public async Task GetScope_WithTemplates_IsCached() + public async Task GetScope_WithGroups_IsCached() { var (svc, repo) = Build(); - ReturnsTemplates(repo, Assignment(TemplateId1)); - TemplateTenants(repo, TemplateId1, TenantA); + ReturnsGroups(repo, Assignment(GroupId1)); + GroupTenants(repo, GroupId1, 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); + repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny()), Times.Once); + repo.Verify(r => r.GetGroupTenantsAsync(GroupId1), Times.Once); } - // --- Template mutations go through the service and invalidate cached scope (no stale auth) --- + // --- Group mutations go through the service and invalidate cached scope (no stale auth) --- [Fact] - public async Task AssignTemplate_InvalidatesUpnCache() + public async Task AssignGroup_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( + ReturnsGroups(repo, Assignment(GroupId1)); + GroupTenants(repo, GroupId1, TenantA); + GroupExists(repo, GroupId2); // target group must exist for the assign to take effect + repo.Setup(r => r.AssignGroupAsync( 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.AssignGroupAsync(Upn, GroupId2, 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)); + repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny()), Times.Exactly(2)); } [Fact] - public async Task AssignTemplate_NonexistentTemplate_IsNoOp() + public async Task AssignGroup_NonexistentGroup_IsNoOp() { var (svc, repo) = Build(); - // GetTenantTemplateAsync null by default → no assignment row, no cache invalidation. - repo.Setup(r => r.AssignTemplateAsync( + // GetTenantGroupAsync null by default → no assignment row, no cache invalidation. + repo.Setup(r => r.AssignGroupAsync( 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"); + var assigned = await svc.AssignGroupAsync( + Upn, GroupId1, Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example"); Assert.False(assigned); - repo.Verify(r => r.AssignTemplateAsync( + repo.Verify(r => r.AssignGroupAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] - public async Task UnassignTemplate_InvalidatesUpnCache() + public async Task UnassignGroup_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); + ReturnsGroups(repo, Assignment(GroupId1)); // UPN IS assigned to GroupId1 + GroupTenants(repo, GroupId1, TenantA); + repo.Setup(r => r.UnassignGroupAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); await svc.GetScopeAsync(Upn); - var unassigned = await svc.UnassignTemplateAsync(Upn, TemplateId1); + var unassigned = await svc.UnassignGroupAsync(Upn, GroupId1); 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)); + // so asserting on GetGroupTenantsAsync isolates the two scope resolutions cleanly). + repo.Verify(r => r.GetGroupTenantsAsync(GroupId1), Times.Exactly(2)); } [Fact] - public async Task UnassignTemplate_NotAssigned_IsNoOp() + public async Task UnassignGroup_NotAssigned_IsNoOp() { var (svc, repo) = Build(); - // GetTemplateAssignmentsForUpnAsync returns empty by default → the UPN is not assigned → no delete, + // GetGroupAssignmentsForUpnAsync 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); + repo.Setup(r => r.UnassignGroupAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); - var unassigned = await svc.UnassignTemplateAsync(Upn, TemplateId1); + var unassigned = await svc.UnassignGroupAsync(Upn, GroupId1); Assert.False(unassigned); - repo.Verify(r => r.UnassignTemplateAsync(It.IsAny(), It.IsAny()), Times.Never); + repo.Verify(r => r.UnassignGroupAsync(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 + private static void GroupExists(Mock repo, string groupId, params string[] tenantIds) => + repo.Setup(r => r.GetTenantGroupAsync(groupId)).ReturnsAsync(new TenantGroup { - TemplateId = templateId, + GroupId = groupId, Name = "Managed Service Tenants", TenantIds = tenantIds.ToList(), }); [Fact] - public async Task RemoveTenantFromTemplate_InvalidatesAllAssignees() + public async Task RemoveTenantFromGroup_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 + ReturnsGroups(repo, Assignment(GroupId1)); + GroupTenants(repo, GroupId1, TenantA); + GroupExists(repo, GroupId1, TenantA); // tenant IS a member → real removal + repo.Setup(r => r.RemoveTenantFromGroupAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + repo.Setup(r => r.GetGroupAssigneesAsync(GroupId1)).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 }, + new() { Upn = Upn, GroupId = GroupId1, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true }, + new() { Upn = upnB, GroupId = GroupId1, 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); + var removed = await svc.RemoveTenantFromGroupAsync(GroupId1, 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)); + repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(Upn), Times.Exactly(2)); + repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(upnB), Times.Exactly(2)); } [Fact] - public async Task AddTenantToTemplate_NonexistentTemplate_IsNoOp() + public async Task AddTenantToGroup_NonexistentGroup_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); + // GetTenantGroupAsync defaults to null (group does not exist) → must NOT upsert a ghost row. + repo.Setup(r => r.AddTenantToGroupAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); - var added = await svc.AddTenantToTemplateAsync(TemplateId1, TenantA); + var added = await svc.AddTenantToGroupAsync(GroupId1, TenantA); Assert.False(added); - repo.Verify(r => r.AddTenantToTemplateAsync(It.IsAny(), It.IsAny()), Times.Never); + repo.Verify(r => r.AddTenantToGroupAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] - public async Task AddTenantToTemplate_ExistingTemplate_Adds() + public async Task AddTenantToGroup_ExistingGroup_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()); + GroupExists(repo, GroupId1); // meta-backed, no tenants yet + repo.Setup(r => r.AddTenantToGroupAsync(GroupId1, TenantA)).ReturnsAsync(true); + repo.Setup(r => r.GetGroupAssigneesAsync(GroupId1)).ReturnsAsync(new List()); - var added = await svc.AddTenantToTemplateAsync(TemplateId1, TenantA); + var added = await svc.AddTenantToGroupAsync(GroupId1, TenantA); Assert.True(added); - repo.Verify(r => r.AddTenantToTemplateAsync(TemplateId1, TenantA), Times.Once); + repo.Verify(r => r.AddTenantToGroupAsync(GroupId1, TenantA), Times.Once); } [Fact] - public async Task RemoveTenantFromTemplate_NonexistentTemplate_IsNoOp() + public async Task RemoveTenantFromGroup_NonexistentGroup_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); + // GetTenantGroupAsync null by default → no removal, no false audit signal. + repo.Setup(r => r.RemoveTenantFromGroupAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); - var removed = await svc.RemoveTenantFromTemplateAsync(TemplateId1, TenantA); + var removed = await svc.RemoveTenantFromGroupAsync(GroupId1, TenantA); Assert.False(removed); - repo.Verify(r => r.RemoveTenantFromTemplateAsync(It.IsAny(), It.IsAny()), Times.Never); + repo.Verify(r => r.RemoveTenantFromGroupAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] - public async Task RemoveTenantFromTemplate_TenantNotMember_IsNoOp() + public async Task RemoveTenantFromGroup_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); + GroupExists(repo, GroupId1, TenantB); // group exists but does NOT contain TenantA + repo.Setup(r => r.RemoveTenantFromGroupAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); - var removed = await svc.RemoveTenantFromTemplateAsync(TemplateId1, TenantA); + var removed = await svc.RemoveTenantFromGroupAsync(GroupId1, TenantA); Assert.False(removed); - repo.Verify(r => r.RemoveTenantFromTemplateAsync(It.IsAny(), It.IsAny()), Times.Never); + repo.Verify(r => r.RemoveTenantFromGroupAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] - public async Task DeleteTemplate_InvalidatesCapturedAssignees() + public async Task DeleteGroup_InvalidatesCapturedAssignees() { var (svc, repo) = Build(); - ReturnsTemplates(repo, Assignment(TemplateId1)); - TemplateTenants(repo, TemplateId1, TenantA); - repo.Setup(r => r.DeleteTenantTemplateAsync(It.IsAny())).ReturnsAsync(true); + ReturnsGroups(repo, Assignment(GroupId1)); + GroupTenants(repo, GroupId1, TenantA); + repo.Setup(r => r.DeleteTenantGroupAsync(It.IsAny())).ReturnsAsync(true); // Assignees are captured BEFORE the cascade delete removes them. - repo.Setup(r => r.GetTemplateAssigneesAsync(TemplateId1)).ReturnsAsync(new List + repo.Setup(r => r.GetGroupAssigneesAsync(GroupId1)).ReturnsAsync(new List { - new() { Upn = Upn, TemplateId = TemplateId1, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true }, + new() { Upn = Upn, GroupId = GroupId1, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true }, }); await svc.GetScopeAsync(Upn); - await svc.DeleteTemplateAsync(TemplateId1); + await svc.DeleteGroupAsync(GroupId1); await svc.GetScopeAsync(Upn); - repo.Verify(r => r.GetTemplateAssignmentsForUpnAsync(Upn), Times.Exactly(2)); + repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(Upn), Times.Exactly(2)); } [Fact] - public async Task AssignTemplate_NormalizesTemplateIdCase() + public async Task AssignGroup_NormalizesGroupIdCase() { - // 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. + // The opaque groupId is case-insensitive at the service boundary: an upper-cased id resolves the + // same lowercase-stored group, 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( + GroupExists(repo, "tpl-lower"); // stored/generated id is lowercase + repo.Setup(r => r.AssignGroupAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(true); - var assigned = await svc.AssignTemplateAsync( + var assigned = await svc.AssignGroupAsync( 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", + repo.Verify(r => r.GetTenantGroupAsync("tpl-lower"), Times.Once); + repo.Verify(r => r.AssignGroupAsync(Upn, "tpl-lower", Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example"), Times.Once); } [Fact] - public async Task RenameTemplate_DoesNotInvalidateCache() + public async Task RenameGroup_DoesNotInvalidateCache() { var (svc, repo) = Build(); - ReturnsTemplates(repo, Assignment(TemplateId1)); - TemplateTenants(repo, TemplateId1, TenantA); - repo.Setup(r => r.RenameTenantTemplateAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + ReturnsGroups(repo, Assignment(GroupId1)); + GroupTenants(repo, GroupId1, TenantA); + repo.Setup(r => r.RenameTenantGroupAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); await svc.GetScopeAsync(Upn); - await svc.RenameTemplateAsync(TemplateId1, "Renamed"); + await svc.RenameGroupAsync(GroupId1, "Renamed"); await svc.GetScopeAsync(Upn); // name-only change → scope stays cached - repo.Verify(r => r.GetTemplateAssignmentsForUpnAsync(It.IsAny()), Times.Once); + repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(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 bb68f7bf..18448e75 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs @@ -265,10 +265,10 @@ public class TenantOffboardingHandlerOrderingTests // ── Offboarding wipe coverage (hygiene: no orphaned cross-tenant grants) ──── [Fact] - public async Task PostDrain_PropertyOnlyWipe_PurgesDelegatedAdminsAndTenantTemplates() + public async Task PostDrain_PropertyOnlyWipe_PurgesDelegatedAdminsAndTenantGroups() { - // 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 + // A delegated admin's grant rows (DelegatedAdmins, RK=tenantId) and a tenant's Tenant Group + // membership rows (TenantGroups, 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(); @@ -276,7 +276,7 @@ public class TenantOffboardingHandlerOrderingTests await harness.Sut.HandleAsync(harness.Envelope()); Assert.Contains(Constants.TableNames.DelegatedAdmins, harness.SafeWipeProbe.PropertyOnlyWipes); - Assert.Contains(Constants.TableNames.TenantTemplates, harness.SafeWipeProbe.PropertyOnlyWipes); + Assert.Contains(Constants.TableNames.TenantGroups, harness.SafeWipeProbe.PropertyOnlyWipes); } // ── Harness (copied minimal — only what these tests need) ─────────────────── diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs index 92abc176..a36ca985 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs @@ -72,9 +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()); + // No group assignments by default (delegated scope resolves direct grants + group tenants). + repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(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. @@ -413,26 +413,26 @@ public class PolicyEnforcementMiddlewareTests Assert.Equal(403, result.StatusCode); } - // ── Tenant Templates management — GlobalAdminOnly mutations, GlobalReadOrAdmin list ── + // ── Tenant Groups management — GlobalAdminOnly mutations, GlobalReadOrAdmin list ── [Fact] - public async Task TenantTemplates_Mutation_PureDelegated_IsForbidden() + public async Task TenantGroups_Mutation_PureDelegated_IsForbidden() { - // A delegated ("MSP") admin must never manage templates — every mutation is GlobalAdminOnly, + // A delegated ("MSP") admin must never manage groups — 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)); + "POST", "/api/global/tenant-groups/tpl-1/assignees", null, AuthedPrincipal(TenantA, upn)); Assert.False(result.Allowed); Assert.Equal(403, result.StatusCode); } [Fact] - public async Task TenantTemplates_RemoveTenant_PureGlobalReader_IsForbidden() + public async Task TenantGroups_RemoveTenant_PureGlobalReader_IsForbidden() { // The one {tenantId} route (RouteParam) is still GlobalAdminOnly — a read-only Global Reader is denied. const string upn = "reader@vendor.example"; @@ -440,35 +440,35 @@ public class PolicyEnforcementMiddlewareTests h.AsGlobalRole(Constants.GlobalRoles.GlobalReader); var result = await h.Middleware.DecideAsync( - "DELETE", $"/api/global/tenant-templates/tpl-1/tenants/{TenantB}", null, AuthedPrincipal(TenantA, upn)); + "DELETE", $"/api/global/tenant-groups/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() + public async Task TenantGroups_List_GlobalReader_IsAllowed() { - // Listing templates is GlobalReadOrAdmin — a read-only Global Reader may audit them. + // Listing groups 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)); + "GET", "/api/global/tenant-groups", null, AuthedPrincipal(TenantA, upn)); Assert.True(result.Allowed); } [Fact] - public async Task TenantTemplates_Create_GlobalAdmin_IsAllowed() + public async Task TenantGroups_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)); + "POST", "/api/global/tenant-groups", null, AuthedPrincipal(TenantA, upn)); Assert.True(result.Allowed); } diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs index 8c9c2527..ab28aa27 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs @@ -17,8 +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 TableClient _tenantGroupsTableClient; + private readonly TableClient _tenantGroupAssignmentsTableClient; private readonly ILogger _logger; public TableAdminRepository( @@ -30,8 +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); + _tenantGroupsTableClient = storage.GetTableClient(Constants.TableNames.TenantGroups); + _tenantGroupAssignmentsTableClient = storage.GetTableClient(Constants.TableNames.TenantGroupAssignments); } // --- Global Admins --- @@ -427,38 +427,38 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage return true; } - // --- Tenant Templates (app-internal tenant bundles for delegated admins / "MSP mode") --- + // --- Tenant Groups (app-internal tenant bundles for delegated admins / "MSP mode") --- - public async Task CreateTenantTemplateAsync(string name, string createdBy) + public async Task CreateTenantGroupAsync(string name, string createdBy) { - var templateId = Guid.NewGuid().ToString("N"); - var entity = new TenantTemplateEntity + var groupId = Guid.NewGuid().ToString("N"); + var entity = new TenantGroupEntity { - PartitionKey = templateId, - RowKey = TenantTemplateEntity.MetaRowKey, + PartitionKey = groupId, + RowKey = TenantGroupEntity.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; + await _tenantGroupsTableClient.AddEntityAsync(entity); + return groupId; } - public async Task RenameTenantTemplateAsync(string templateId, string name) + public async Task RenameTenantGroupAsync(string groupId, string name) { - if (string.IsNullOrWhiteSpace(templateId)) + if (string.IsNullOrWhiteSpace(groupId)) return false; try { - var result = await _tenantTemplatesTableClient.GetEntityAsync( - templateId, TenantTemplateEntity.MetaRowKey); + var result = await _tenantGroupsTableClient.GetEntityAsync( + groupId, TenantGroupEntity.MetaRowKey); var entity = result.Value; if (entity == null) return false; entity.Name = name?.Trim() ?? string.Empty; - await _tenantTemplatesTableClient.UpdateEntityAsync(entity, ETag.All); + await _tenantGroupsTableClient.UpdateEntityAsync(entity, ETag.All); return true; } catch (RequestFailedException ex) when (ex.Status == 404) @@ -467,135 +467,135 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage } } - public async Task DeleteTenantTemplateAsync(string templateId) + public async Task DeleteTenantGroupAsync(string groupId) { - if (string.IsNullOrWhiteSpace(templateId)) + if (string.IsNullOrWhiteSpace(groupId)) return false; - // Delete every row in the template's partition (meta + membership rows). + // Delete every row in the group'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 foreach (var entity in _tenantGroupsTableClient.QueryAsync( + e => e.PartitionKey == groupId)) { - await DeleteIfPresentAsync(_tenantTemplatesTableClient, entity.PartitionKey, entity.RowKey); + await DeleteIfPresentAsync(_tenantGroupsTableClient, 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)) + // Cascade: remove every UPN assignment to this group (cross-partition RowKey scan). + await foreach (var assignment in _tenantGroupAssignmentsTableClient.QueryAsync( + e => e.RowKey == groupId)) { - await DeleteIfPresentAsync(_tenantTemplateAssignmentsTableClient, assignment.PartitionKey, assignment.RowKey); + await DeleteIfPresentAsync(_tenantGroupAssignmentsTableClient, assignment.PartitionKey, assignment.RowKey); } return true; } - public async Task> GetAllTenantTemplatesAsync() + public async Task> GetAllTenantGroupsAsync() { - // 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. + // Full-table scan: the management UI lists every group. Both tables hold only admin-scale + // rows (a handful of groups × their tenants/assignees), so this is small and off the hot path. + var byId = new Dictionary(StringComparer.Ordinal); + // A group EXISTS only if its meta row is present. A partition with only membership rows is an + // anomaly (e.g. a partial write), NOT a group — never surface it as a blank/name-less group. var metaBacked = new HashSet(StringComparer.Ordinal); - await foreach (var entity in _tenantTemplatesTableClient.QueryAsync()) + await foreach (var entity in _tenantGroupsTableClient.QueryAsync()) { - if (ApplyRowToTemplate(byId, entity)) + if (ApplyRowToGroup(byId, entity)) metaBacked.Add(entity.PartitionKey); } // Assignees (separate table; management path only). - await foreach (var assignment in _tenantTemplateAssignmentsTableClient.QueryAsync()) + await foreach (var assignment in _tenantGroupAssignmentsTableClient.QueryAsync()) { - if (byId.TryGetValue(assignment.RowKey, out var template)) + if (byId.TryGetValue(assignment.RowKey, out var group)) { - template.Assignees.Add(MapToTemplateAssignment(assignment)); - template.AssigneeCount++; + group.Assignees.Add(MapToGroupAssignment(assignment)); + group.AssigneeCount++; } } - var result = new List(); - foreach (var template in byId.Values) + var result = new List(); + foreach (var group in byId.Values) { - if (metaBacked.Contains(template.TemplateId)) - result.Add(template); + if (metaBacked.Contains(group.GroupId)) + result.Add(group); } return result; } - public async Task GetTenantTemplateAsync(string templateId) + public async Task GetTenantGroupAsync(string groupId) { - if (string.IsNullOrWhiteSpace(templateId)) + if (string.IsNullOrWhiteSpace(groupId)) return null; - var byId = new Dictionary(StringComparer.Ordinal); + var byId = new Dictionary(StringComparer.Ordinal); var metaBacked = false; - await foreach (var entity in _tenantTemplatesTableClient.QueryAsync( - e => e.PartitionKey == templateId)) + await foreach (var entity in _tenantGroupsTableClient.QueryAsync( + e => e.PartitionKey == groupId)) { - if (ApplyRowToTemplate(byId, entity)) + if (ApplyRowToGroup(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)) + // No meta row ⇒ the group does not exist (a stray membership row is not a group). + if (!metaBacked || !byId.TryGetValue(groupId, out var group)) return null; - await foreach (var assignment in _tenantTemplateAssignmentsTableClient.QueryAsync( - e => e.RowKey == templateId)) + await foreach (var assignment in _tenantGroupAssignmentsTableClient.QueryAsync( + e => e.RowKey == groupId)) { - template.Assignees.Add(MapToTemplateAssignment(assignment)); - template.AssigneeCount++; + group.Assignees.Add(MapToGroupAssignment(assignment)); + group.AssigneeCount++; } - return template; + return group; } - public async Task AddTenantToTemplateAsync(string templateId, string tenantId) + public async Task AddTenantToGroupAsync(string groupId, string tenantId) { - if (string.IsNullOrWhiteSpace(templateId) || string.IsNullOrWhiteSpace(tenantId)) + if (string.IsNullOrWhiteSpace(groupId) || 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) + if (tenantId == TenantGroupEntity.MetaRowKey) return false; - var entity = new TenantTemplateEntity + var entity = new TenantGroupEntity { - PartitionKey = templateId, + PartitionKey = groupId, RowKey = tenantId, TenantId = tenantId }; - await _tenantTemplatesTableClient.UpsertEntityAsync(entity); + await _tenantGroupsTableClient.UpsertEntityAsync(entity); return true; } - public async Task RemoveTenantFromTemplateAsync(string templateId, string tenantId) + public async Task RemoveTenantFromGroupAsync(string groupId, string tenantId) { - if (string.IsNullOrWhiteSpace(templateId) || string.IsNullOrWhiteSpace(tenantId)) + if (string.IsNullOrWhiteSpace(groupId) || string.IsNullOrWhiteSpace(tenantId)) return false; tenantId = tenantId.ToLowerInvariant(); - await DeleteIfPresentAsync(_tenantTemplatesTableClient, templateId, tenantId); + await DeleteIfPresentAsync(_tenantGroupsTableClient, groupId, tenantId); return true; } - public async Task> GetTemplateTenantsAsync(string templateId) + public async Task> GetGroupTenantsAsync(string groupId) { var tenants = new List(); - if (string.IsNullOrWhiteSpace(templateId)) + if (string.IsNullOrWhiteSpace(groupId)) return tenants; - // HOT PATH (scope resolution): PartitionKey scan, skip the meta row. A template only "exists" if + // HOT PATH (scope resolution): PartitionKey scan, skip the meta row. A group 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. + // NOT grant scope. Mirrors the meta-backed invariant in GetTenantGroupAsync so auth and the + // management reads agree on what a group is. var metaBacked = false; - await foreach (var entity in _tenantTemplatesTableClient.QueryAsync( - e => e.PartitionKey == templateId)) + await foreach (var entity in _tenantGroupsTableClient.QueryAsync( + e => e.PartitionKey == groupId)) { - if (entity.RowKey == TenantTemplateEntity.MetaRowKey) + if (entity.RowKey == TenantGroupEntity.MetaRowKey) metaBacked = true; else tenants.Add(entity.RowKey); @@ -603,66 +603,66 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage return metaBacked ? tenants : new List(); } - public async Task AssignTemplateAsync(string upn, string templateId, string role, bool isEnabled, string assignedBy) + public async Task AssignGroupAsync(string upn, string groupId, string role, bool isEnabled, string assignedBy) { - if (string.IsNullOrWhiteSpace(upn) || string.IsNullOrWhiteSpace(templateId)) + if (string.IsNullOrWhiteSpace(upn) || string.IsNullOrWhiteSpace(groupId)) return false; upn = upn.ToLowerInvariant(); assignedBy = assignedBy?.ToLowerInvariant() ?? string.Empty; - var entity = new TenantTemplateAssignmentEntity + var entity = new TenantGroupAssignmentEntity { PartitionKey = upn, - RowKey = templateId, + RowKey = groupId, Upn = upn, - TemplateId = templateId, + GroupId = groupId, Role = role, IsEnabled = isEnabled, AssignedBy = assignedBy, AssignedDate = DateTime.UtcNow }; - await _tenantTemplateAssignmentsTableClient.UpsertEntityAsync(entity); + await _tenantGroupAssignmentsTableClient.UpsertEntityAsync(entity); return true; } - public async Task UnassignTemplateAsync(string upn, string templateId) + public async Task UnassignGroupAsync(string upn, string groupId) { - if (string.IsNullOrWhiteSpace(upn) || string.IsNullOrWhiteSpace(templateId)) + if (string.IsNullOrWhiteSpace(upn) || string.IsNullOrWhiteSpace(groupId)) return false; upn = upn.ToLowerInvariant(); - await DeleteIfPresentAsync(_tenantTemplateAssignmentsTableClient, upn, templateId); + await DeleteIfPresentAsync(_tenantGroupAssignmentsTableClient, upn, groupId); return true; } - public async Task> GetTemplateAssignmentsForUpnAsync(string upn) + public async Task> GetGroupAssignmentsForUpnAsync(string upn) { - var entries = new List(); + 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( + await foreach (var entity in _tenantGroupAssignmentsTableClient.QueryAsync( e => e.PartitionKey == normalizedUpn)) { - entries.Add(MapToTemplateAssignment(entity)); + entries.Add(MapToGroupAssignment(entity)); } return entries; } - public async Task> GetTemplateAssigneesAsync(string templateId) + public async Task> GetGroupAssigneesAsync(string groupId) { - var entries = new List(); - if (string.IsNullOrWhiteSpace(templateId)) + var entries = new List(); + if (string.IsNullOrWhiteSpace(groupId)) 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)) + await foreach (var entity in _tenantGroupAssignmentsTableClient.QueryAsync( + e => e.RowKey == groupId)) { - entries.Add(MapToTemplateAssignment(entity)); + entries.Add(MapToGroupAssignment(entity)); } return entries; } @@ -813,35 +813,35 @@ 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). + /// Folds a TenantGroups row (meta or membership) into the accumulating DTO for its partition. + /// Returns true if the row was the meta row (proof the group exists). /// - private static bool ApplyRowToTemplate(Dictionary byId, TenantTemplateEntity entity) + private static bool ApplyRowToGroup(Dictionary byId, TenantGroupEntity entity) { - if (!byId.TryGetValue(entity.PartitionKey, out var template)) + if (!byId.TryGetValue(entity.PartitionKey, out var group)) { - template = new TenantTemplate { TemplateId = entity.PartitionKey }; - byId[entity.PartitionKey] = template; + group = new TenantGroup { GroupId = entity.PartitionKey }; + byId[entity.PartitionKey] = group; } - if (entity.RowKey == TenantTemplateEntity.MetaRowKey) + if (entity.RowKey == TenantGroupEntity.MetaRowKey) { - template.Name = entity.Name; - template.CreatedBy = entity.CreatedBy; - template.CreatedAt = entity.CreatedDate ?? default; + group.Name = entity.Name; + group.CreatedBy = entity.CreatedBy; + group.CreatedAt = entity.CreatedDate ?? default; return true; } - template.TenantIds.Add(entity.RowKey); + group.TenantIds.Add(entity.RowKey); return false; } - private static TenantTemplateAssignment MapToTemplateAssignment(TenantTemplateAssignmentEntity entity) + private static TenantGroupAssignment MapToGroupAssignment(TenantGroupAssignmentEntity entity) { - return new TenantTemplateAssignment + return new TenantGroupAssignment { Upn = entity.Upn, - TemplateId = entity.TemplateId, + GroupId = entity.GroupId, Role = entity.Role, IsEnabled = entity.IsEnabled, AssignedBy = entity.AssignedBy, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantTemplateManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantGroupManagementFunction.cs similarity index 50% rename from src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantTemplateManagementFunction.cs rename to src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantGroupManagementFunction.cs index ec06903a..5c9d3130 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantTemplateManagementFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantGroupManagementFunction.cs @@ -11,30 +11,30 @@ 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. +/// GlobalAdmin-only management of Tenant Groups — app-internal named bundles of tenants for the +/// delegated-admin ("MSP mode") tier. An operator assigns a delegated UPN to a group instead of to each +/// tenant; the UPN then reads every tenant in the group (resolved by ). +/// Adding a tenant to the group grants it to all assignees at once. /// -/// Reads are GlobalReadOrAdmin (a read-only Global Reader may audit templates); all mutations are +/// Reads are GlobalReadOrAdmin (a read-only Global Reader may audit groups); 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 +/// caller can never manage groups. 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 +/// customer sees "who can read my tenant" / "access removed"). Group create/rename carry no tenant context /// and are not customer-visible access changes — they are logged operationally only (no AuditLogs partition). /// -public class TenantTemplateManagementFunction +public class TenantGroupManagementFunction { - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly DelegatedAdminService _delegatedAdminService; private readonly IMaintenanceRepository _maintenanceRepo; - private const string AuditEntity = "DelegatedTemplateAccess"; + private const string AuditEntity = "DelegatedGroupAccess"; - public TenantTemplateManagementFunction( - ILogger logger, + public TenantGroupManagementFunction( + ILogger logger, DelegatedAdminService delegatedAdminService, IMaintenanceRepository maintenanceRepo) { @@ -43,147 +43,147 @@ public class TenantTemplateManagementFunction _maintenanceRepo = maintenanceRepo; } - /// GET /api/global/tenant-templates — list every template with tenants + assignee count. GlobalReadOrAdmin. - [Function("GetTenantTemplates")] + /// GET /api/global/tenant-groups — list every group with tenants + assignee count. GlobalReadOrAdmin. + [Function("GetTenantGroups")] [Authorize] - public async Task GetTenantTemplates( - [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/tenant-templates")] HttpRequestData req) + public async Task GetTenantGroups( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/tenant-groups")] HttpRequestData req) { - var templates = await _delegatedAdminService.GetAllTemplatesAsync(); + var groups = await _delegatedAdminService.GetAllGroupsAsync(); var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new { templates }); + await response.WriteAsJsonAsync(new { groups }); return response; } - /// POST /api/global/tenant-templates — create a template. GlobalAdminOnly. Body: { "name": "..." }. - [Function("CreateTenantTemplate")] + /// POST /api/global/tenant-groups — create a group. GlobalAdminOnly. Body: { "name": "..." }. + [Function("CreateTenantGroup")] [Authorize] - public async Task CreateTenantTemplate( - [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/tenant-templates")] HttpRequestData req, + public async Task CreateTenantGroup( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/tenant-groups")] HttpRequestData req, FunctionContext context) { var currentUpn = context.GetRequestContext().UserPrincipalName; - var body = await req.ReadFromJsonAsync(); + 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 ?? ""); + var groupId = await _delegatedAdminService.CreateGroupAsync(body.Name, currentUpn ?? ""); - _logger.LogInformation("Tenant template created: {TemplateId} '{Name}' by {By}", templateId, body.Name, currentUpn); + _logger.LogInformation("Tenant group created: {GroupId} '{Name}' by {By}", groupId, body.Name, currentUpn); var response = req.CreateResponse(HttpStatusCode.Created); - await response.WriteAsJsonAsync(new { templateId, name = body.Name.Trim() }); + await response.WriteAsJsonAsync(new { groupId, name = body.Name.Trim() }); return response; } - /// PATCH /api/global/tenant-templates/{templateId} — rename. GlobalAdminOnly. Body: { "name": "..." }. - [Function("RenameTenantTemplate")] + /// PATCH /api/global/tenant-groups/{groupId} — rename. GlobalAdminOnly. Body: { "name": "..." }. + [Function("RenameTenantGroup")] [Authorize] - public async Task RenameTenantTemplate( - [HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "global/tenant-templates/{templateId}")] HttpRequestData req, - string templateId, FunctionContext context) + public async Task RenameTenantGroup( + [HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "global/tenant-groups/{groupId}")] HttpRequestData req, + string groupId, FunctionContext context) { var currentUpn = context.GetRequestContext().UserPrincipalName; - var body = await req.ReadFromJsonAsync(); + 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); + var ok = await _delegatedAdminService.RenameGroupAsync(groupId, body.Name); if (!ok) return await NotFound(req); - _logger.LogInformation("Tenant template renamed: {TemplateId} -> '{Name}' by {By}", templateId, body.Name, currentUpn); + _logger.LogInformation("Tenant group renamed: {GroupId} -> '{Name}' by {By}", groupId, body.Name, currentUpn); var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new { message = "Template renamed" }); + await response.WriteAsJsonAsync(new { message = "Group renamed" }); return response; } - /// DELETE /api/global/tenant-templates/{templateId} — delete template + all its assignments. GlobalAdminOnly. - [Function("DeleteTenantTemplate")] + /// DELETE /api/global/tenant-groups/{groupId} — delete group + all its assignments. GlobalAdminOnly. + [Function("DeleteTenantGroup")] [Authorize] - public async Task DeleteTenantTemplate( - [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/tenant-templates/{templateId}")] HttpRequestData req, - string templateId, FunctionContext context) + public async Task DeleteTenantGroup( + [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/tenant-groups/{groupId}")] HttpRequestData req, + string groupId, 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); + var group = await _delegatedAdminService.GetGroupAsync(groupId); + await _delegatedAdminService.DeleteGroupAsync(groupId); - if (template != null && template.AssigneeCount > 0) + if (group != null && group.AssigneeCount > 0) { - await AuditPerTenantAsync(template.TenantIds, "DELETE", "*", currentUpn, + await AuditPerTenantAsync(group.TenantIds, "DELETE", "*", currentUpn, new Dictionary { - { "Template", template.Name }, - { "TemplateId", templateId }, - { "Reason", "template-deleted" }, - { "AssigneeCount", template.AssigneeCount.ToString() }, + { "Group", group.Name }, + { "GroupId", groupId }, + { "Reason", "group-deleted" }, + { "AssigneeCount", group.AssigneeCount.ToString() }, }); } - _logger.LogInformation("Tenant template deleted: {TemplateId} by {By}", templateId, currentUpn); + _logger.LogInformation("Tenant group deleted: {GroupId} by {By}", groupId, currentUpn); var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new { message = "Template deleted" }); + await response.WriteAsJsonAsync(new { message = "Group deleted" }); return response; } - /// POST /api/global/tenant-templates/{templateId}/tenants — add a tenant. GlobalAdminOnly. Body: { "tenantId": "<guid>" }. - [Function("AddTenantToTemplate")] + /// POST /api/global/tenant-groups/{groupId}/tenants — add a tenant. GlobalAdminOnly. Body: { "tenantId": "<guid>" }. + [Function("AddTenantToGroup")] [Authorize] - public async Task AddTenantToTemplate( - [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/tenant-templates/{templateId}/tenants")] HttpRequestData req, - string templateId, FunctionContext context) + public async Task AddTenantToGroup( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/tenant-groups/{groupId}/tenants")] HttpRequestData req, + string groupId, FunctionContext context) { var currentUpn = context.GetRequestContext().UserPrincipalName; - var body = await req.ReadFromJsonAsync(); + 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); + // The service refuses to add to a non-existent group (no ghost from a lone membership row). + var added = await _delegatedAdminService.AddTenantToGroupAsync(groupId, 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); + var assignees = await _delegatedAdminService.GetGroupAssigneesAsync(groupId); foreach (var assignee in assignees) { await _maintenanceRepo.LogAuditEntryAsync( tenantId, "CREATE", AuditEntity, assignee.Upn, currentUpn ?? "", - new Dictionary { { "TemplateId", templateId }, { "Reason", "tenant-added-to-template" } }); + new Dictionary { { "GroupId", groupId }, { "Reason", "tenant-added-to-group" } }); } - _logger.LogInformation("Tenant {TenantId} added to template {TemplateId} by {By}", tenantId, templateId, currentUpn); + _logger.LogInformation("Tenant {TenantId} added to group {GroupId} by {By}", tenantId, groupId, currentUpn); var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new { message = "Tenant added to template" }); + await response.WriteAsJsonAsync(new { message = "Tenant added to group" }); return response; } - /// DELETE /api/global/tenant-templates/{templateId}/tenants/{tenantId} — remove a tenant. GlobalAdminOnly. - [Function("RemoveTenantFromTemplate")] + /// DELETE /api/global/tenant-groups/{groupId}/tenants/{tenantId} — remove a tenant. GlobalAdminOnly. + [Function("RemoveTenantFromGroup")] [Authorize] - public async Task RemoveTenantFromTemplate( - [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/tenant-templates/{templateId}/tenants/{tenantId}")] HttpRequestData req, - string templateId, string tenantId, FunctionContext context) + public async Task RemoveTenantFromGroup( + [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/tenant-groups/{groupId}/tenants/{tenantId}")] HttpRequestData req, + string groupId, 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 + var assignees = await _delegatedAdminService.GetGroupAssigneesAsync(groupId); + // The service returns false when the group 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); + var removed = await _delegatedAdminService.RemoveTenantFromGroupAsync(groupId, normalizedTenantId); if (!removed) return await NotFound(req); @@ -191,29 +191,29 @@ public class TenantTemplateManagementFunction { await _maintenanceRepo.LogAuditEntryAsync( normalizedTenantId, "DELETE", AuditEntity, assignee.Upn, currentUpn ?? "", - new Dictionary { { "TemplateId", templateId }, { "Reason", "tenant-removed-from-template" } }); + new Dictionary { { "GroupId", groupId }, { "Reason", "tenant-removed-from-group" } }); } - _logger.LogInformation("Tenant {TenantId} removed from template {TemplateId} by {By}", normalizedTenantId, templateId, currentUpn); + _logger.LogInformation("Tenant {TenantId} removed from group {GroupId} by {By}", normalizedTenantId, groupId, currentUpn); var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new { message = "Tenant removed from template" }); + await response.WriteAsJsonAsync(new { message = "Tenant removed from group" }); return response; } /// - /// POST /api/global/tenant-templates/{templateId}/assignees — assign a UPN to the template. GlobalAdminOnly. + /// POST /api/global/tenant-groups/{groupId}/assignees — assign a UPN to the group. GlobalAdminOnly. /// Body: { "upn": "user@domain.com", "role": "DelegatedReader" | "DelegatedAdmin" }. /// - [Function("AssignTenantTemplate")] + [Function("AssignTenantGroup")] [Authorize] - public async Task AssignTenantTemplate( - [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/tenant-templates/{templateId}/assignees")] HttpRequestData req, - string templateId, FunctionContext context) + public async Task AssignTenantGroup( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/tenant-groups/{groupId}/assignees")] HttpRequestData req, + string groupId, FunctionContext context) { var currentUpn = context.GetRequestContext().UserPrincipalName; - var body = await req.ReadFromJsonAsync(); + var body = await req.ReadFromJsonAsync(); if (body == null || string.IsNullOrWhiteSpace(body.Upn)) return await Bad(req, "upn is required"); @@ -222,67 +222,67 @@ public class TenantTemplateManagementFunction 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) + // Guard against assigning to a non-existent group (would create an orphan assignment row). + var group = await _delegatedAdminService.GetGroupAsync(groupId); + if (group == 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 ?? ""); + var assigned = await _delegatedAdminService.AssignGroupAsync(upn, groupId, 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, + // The UPN just gained read access to every tenant in the group — audit under each. + await AuditPerTenantAsync(group.TenantIds, "CREATE", upn, currentUpn, new Dictionary { - { "Template", template.Name }, - { "TemplateId", templateId }, + { "Group", group.Name }, + { "GroupId", groupId }, { "Role", role }, - { "Reason", "template-assigned" }, + { "Reason", "group-assigned" }, }); - _logger.LogInformation("Template {TemplateId} assigned to {Upn} ({Role}) by {By}", templateId, upn, role, currentUpn); + _logger.LogInformation("Group {GroupId} assigned to {Upn} ({Role}) by {By}", groupId, upn, role, currentUpn); var response = req.CreateResponse(HttpStatusCode.Created); - await response.WriteAsJsonAsync(new { message = "Assigned to template" }); + await response.WriteAsJsonAsync(new { message = "Assigned to group" }); return response; } - /// DELETE /api/global/tenant-templates/{templateId}/assignees/{upn} — unassign a UPN. GlobalAdminOnly. - [Function("UnassignTenantTemplate")] + /// DELETE /api/global/tenant-groups/{groupId}/assignees/{upn} — unassign a UPN. GlobalAdminOnly. + [Function("UnassignTenantGroup")] [Authorize] - public async Task UnassignTenantTemplate( - [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/tenant-templates/{templateId}/assignees/{upn}")] HttpRequestData req, - string templateId, string upn, FunctionContext context) + public async Task UnassignTenantGroup( + [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/tenant-groups/{groupId}/assignees/{upn}")] HttpRequestData req, + string groupId, 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); + // Read the group's tenants (unchanged by unassign) so we can audit the UPN's lost access per tenant. + var group = await _delegatedAdminService.GetGroupAsync(groupId); // 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); + var unassigned = await _delegatedAdminService.UnassignGroupAsync(normalizedUpn, groupId); if (!unassigned) return await NotFound(req); - if (template != null) + if (group != null) { - await AuditPerTenantAsync(template.TenantIds, "DELETE", normalizedUpn, currentUpn, + await AuditPerTenantAsync(group.TenantIds, "DELETE", normalizedUpn, currentUpn, new Dictionary { - { "Template", template.Name }, - { "TemplateId", templateId }, - { "Reason", "template-unassigned" }, + { "Group", group.Name }, + { "GroupId", groupId }, + { "Reason", "group-unassigned" }, }); } - _logger.LogInformation("Template {TemplateId} unassigned from {Upn} by {By}", templateId, normalizedUpn, currentUpn); + _logger.LogInformation("Group {GroupId} unassigned from {Upn} by {By}", groupId, normalizedUpn, currentUpn); var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new { message = "Unassigned from template" }); + await response.WriteAsJsonAsync(new { message = "Unassigned from group" }); return response; } @@ -307,27 +307,27 @@ public class TenantTemplateManagementFunction private static async Task NotFound(HttpRequestData req) { var notFound = req.CreateResponse(HttpStatusCode.NotFound); - await notFound.WriteAsJsonAsync(new { error = "Template not found" }); + await notFound.WriteAsJsonAsync(new { error = "Group not found" }); return notFound; } } -public class CreateTenantTemplateRequest +public class CreateTenantGroupRequest { public string Name { get; set; } = string.Empty; } -public class RenameTenantTemplateRequest +public class RenameTenantGroupRequest { public string Name { get; set; } = string.Empty; } -public class AddTemplateTenantRequest +public class AddGroupTenantRequest { public string TenantId { get; set; } = string.Empty; } -public class AssignTemplateRequest +public class AssignGroupRequest { 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 704286b4..272d17cd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -450,18 +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 + // Tenant Groups ("MSP mode" tenant bundles) management — list is GlobalReadOrAdmin; all mutations + // are GlobalAdminOnly so a delegated caller can never manage groups. Global routes are + // TenantScoping.None (the {groupId}/{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("GET", "global/tenant-groups", EndpointPolicy.GlobalReadOrAdmin), + new("POST", "global/tenant-groups", EndpointPolicy.GlobalAdminOnly), + new("PATCH", "global/tenant-groups/{groupId}", EndpointPolicy.GlobalAdminOnly), + new("DELETE", "global/tenant-groups/{groupId}", EndpointPolicy.GlobalAdminOnly), + new("POST", "global/tenant-groups/{groupId}/tenants", EndpointPolicy.GlobalAdminOnly), + new("DELETE", "global/tenant-groups/{groupId}/tenants/{tenantId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam), + new("POST", "global/tenant-groups/{groupId}/assignees", EndpointPolicy.GlobalAdminOnly), + new("DELETE", "global/tenant-groups/{groupId}/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 f594b8fb..0bed49cd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs @@ -72,23 +72,23 @@ 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) + // Group-derived tenants: a UPN assigned to a Tenant Group inherits read scope to every + // tenant in that group. This is the MSP convenience — assign the UPN once, manage the tenant + // set on the group. Same fail-closed + stronger-role-wins merge as direct grants; a tenant + // present both directly and via a group keeps the stronger role. Membership changes converge + // within the cache TTL (no separate per-group cache by design). + var groupAssignments = await _adminRepo.GetGroupAssignmentsForUpnAsync(upn); + foreach (var assignment in groupAssignments) { if (!assignment.IsEnabled) continue; - var role = NormalizeRole(assignment.Role, upn, $"template:{assignment.TemplateId}"); + var role = NormalizeRole(assignment.Role, upn, $"group:{assignment.GroupId}"); if (role == null) continue; - var templateTenants = await _adminRepo.GetTemplateTenantsAsync(assignment.TemplateId); - foreach (var tenantId in templateTenants) + var groupTenants = await _adminRepo.GetGroupTenantsAsync(assignment.GroupId); + foreach (var tenantId in groupTenants) { if (string.IsNullOrWhiteSpace(tenantId)) continue; @@ -167,112 +167,112 @@ public class DelegatedAdminService public Task> GetAssigneesForTenantAsync(string tenantId) => _adminRepo.GetDelegatedAssigneesAsync(tenantId.ToLowerInvariant()); - // --- Tenant Templates (app-internal tenant bundles) --- + // --- Tenant Groups (app-internal tenant bundles) --- // - // ALL template mutations go through the service (never the repo directly) so the delegated-scope + // ALL group 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. + // tenant SET of a group (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 + // groupId 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: all groups with tenant members + assignee counts (management UI). + public Task> GetAllGroupsAsync() => _adminRepo.GetAllTenantGroupsAsync(); - /// Read-through: one template with its tenant members + assignee count. - public Task GetTemplateAsync(string templateId) => _adminRepo.GetTenantTemplateAsync(NormalizeTemplateId(templateId)); + /// Read-through: one group with its tenant members + assignee count. + public Task GetGroupAsync(string groupId) => _adminRepo.GetTenantGroupAsync(NormalizeGroupId(groupId)); - /// Read-through: all UPNs assigned to a template (management UI). - public Task> GetTemplateAssigneesAsync(string templateId) - => _adminRepo.GetTemplateAssigneesAsync(NormalizeTemplateId(templateId)); + /// Read-through: all UPNs assigned to a group (management UI). + public Task> GetGroupAssigneesAsync(string groupId) + => _adminRepo.GetGroupAssigneesAsync(NormalizeGroupId(groupId)); - /// Creates a template (no scope effect — a fresh template has no assignees). - public Task CreateTemplateAsync(string name, string createdBy) - => _adminRepo.CreateTenantTemplateAsync(name, createdBy); + /// Creates a group (no scope effect — a fresh group has no assignees). + public Task CreateGroupAsync(string name, string createdBy) + => _adminRepo.CreateTenantGroupAsync(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); + /// Renames a group (name only — no scope effect, no invalidation needed). + public Task RenameGroupAsync(string groupId, string name) + => _adminRepo.RenameTenantGroupAsync(NormalizeGroupId(groupId), name); - /// Deletes a template and all its assignments; invalidates every (former) assignee's scope. - public async Task DeleteTemplateAsync(string templateId) + /// Deletes a group and all its assignments; invalidates every (former) assignee's scope. + public async Task DeleteGroupAsync(string groupId) { - templateId = NormalizeTemplateId(templateId); + groupId = NormalizeGroupId(groupId); // Capture assignees BEFORE the cascade delete removes their assignment rows. - var assignees = await _adminRepo.GetTemplateAssigneesAsync(templateId); - var ok = await _adminRepo.DeleteTenantTemplateAsync(templateId); + var assignees = await _adminRepo.GetGroupAssigneesAsync(groupId); + var ok = await _adminRepo.DeleteTenantGroupAsync(groupId); 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 + /// Adds a tenant to a group; every assignee gains it — invalidate them so it takes effect now. + /// Returns false (no-op) if the group does not exist (meta-backed) — prevents a "ghost" group /// being conjured from a lone membership row. /// - public async Task AddTenantToTemplateAsync(string templateId, string tenantId) + public async Task AddTenantToGroupAsync(string groupId, string tenantId) { - templateId = NormalizeTemplateId(templateId); - if (await _adminRepo.GetTenantTemplateAsync(templateId) == null) + groupId = NormalizeGroupId(groupId); + if (await _adminRepo.GetTenantGroupAsync(groupId) == null) return false; - var ok = await _adminRepo.AddTenantToTemplateAsync(templateId, tenantId); - await InvalidateTemplateAssigneesAsync(templateId); + var ok = await _adminRepo.AddTenantToGroupAsync(groupId, tenantId); + await InvalidateGroupAssigneesAsync(groupId); 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 + /// Removes a tenant from a group (REVOKE flow); invalidate every assignee immediately. Returns false + /// (no-op, no invalidation) if the group 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) + public async Task RemoveTenantFromGroupAsync(string groupId, string tenantId) { - templateId = NormalizeTemplateId(templateId); + groupId = NormalizeGroupId(groupId); tenantId = tenantId.ToLowerInvariant(); - var template = await _adminRepo.GetTenantTemplateAsync(templateId); - if (template == null || !template.TenantIds.Contains(tenantId)) + var group = await _adminRepo.GetTenantGroupAsync(groupId); + if (group == null || !group.TenantIds.Contains(tenantId)) return false; - var ok = await _adminRepo.RemoveTenantFromTemplateAsync(templateId, tenantId); - await InvalidateTemplateAssigneesAsync(templateId); + var ok = await _adminRepo.RemoveTenantFromGroupAsync(groupId, tenantId); + await InvalidateGroupAssigneesAsync(groupId); return ok; } /// - /// Assigns a UPN to a template; invalidates that UPN's scope. Returns false (no-op) if the template does + /// Assigns a UPN to a group; invalidates that UPN's scope. Returns false (no-op) if the group 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) + public async Task AssignGroupAsync(string upn, string groupId, string role, bool isEnabled, string assignedBy) { - templateId = NormalizeTemplateId(templateId); - if (await _adminRepo.GetTenantTemplateAsync(templateId) == null) + groupId = NormalizeGroupId(groupId); + if (await _adminRepo.GetTenantGroupAsync(groupId) == null) return false; upn = upn.ToLowerInvariant(); - var ok = await _adminRepo.AssignTemplateAsync(upn, templateId, role, isEnabled, assignedBy); + var ok = await _adminRepo.AssignGroupAsync(upn, groupId, 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 + /// Unassigns a UPN from a group (REVOKE flow); invalidates that UPN's scope immediately. Returns false + /// (no-op, no invalidation) if the UPN is not currently assigned to the group — so a mistyped UPN can't /// return success and write false "access removed" audit rows. /// - public async Task UnassignTemplateAsync(string upn, string templateId) + public async Task UnassignGroupAsync(string upn, string groupId) { upn = upn.ToLowerInvariant(); - templateId = NormalizeTemplateId(templateId); + groupId = NormalizeGroupId(groupId); - var assignments = await _adminRepo.GetTemplateAssignmentsForUpnAsync(upn); + var assignments = await _adminRepo.GetGroupAssignmentsForUpnAsync(upn); var isAssigned = false; foreach (var assignment in assignments) { - if (string.Equals(assignment.TemplateId, templateId, StringComparison.Ordinal)) + if (string.Equals(assignment.GroupId, groupId, StringComparison.Ordinal)) { isAssigned = true; break; @@ -281,16 +281,16 @@ public class DelegatedAdminService if (!isAssigned) return false; - var ok = await _adminRepo.UnassignTemplateAsync(upn, templateId); + var ok = await _adminRepo.UnassignGroupAsync(upn, groupId); 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)); + /// Invalidates the cached scope of every current assignee of a group. + private async Task InvalidateGroupAssigneesAsync(string groupId) + => InvalidateAll(await _adminRepo.GetGroupAssigneesAsync(groupId)); - private void InvalidateAll(IEnumerable assignees) + private void InvalidateAll(IEnumerable assignees) { foreach (var assignee in assignees) Invalidate(assignee.Upn); @@ -298,8 +298,8 @@ public class DelegatedAdminService 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(); + /// Normalizes the opaque groupId to lowercase so the storage key is case-insensitive to callers. + private static string NormalizeGroupId(string groupId) => (groupId ?? string.Empty).ToLowerInvariant(); /// Empty/missing role defaults to the least-privileged DelegatedReader; an unrecognized /// role is dropped (fail-closed) rather than silently granting access. @@ -389,21 +389,21 @@ public class DelegatedAdminEntity : ITableEntity } /// -/// A row in the table. Two row layouts share the +/// 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. +/// PK = groupId, RK = — group metadata (, creator). +/// PK = groupId, RK = tenantId (lowercase) — one membership row per tenant in the group. /// -/// A template is an app-internal named bundle of tenants; a delegated admin assigned to it (see -/// ) gains read scope to every tenant member. +/// A group 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 +public class TenantGroupEntity : ITableEntity { - /// The reserved RowKey of the per-template metadata row. Tenant IDs are GUIDs and never collide with it. + /// The reserved RowKey of the per-group 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 PartitionKey { get; set; } = string.Empty; // groupId public string RowKey { get; set; } = string.Empty; // "meta" or tenantId (lowercase) public DateTimeOffset? Timestamp { get; set; } public Azure.ETag ETag { get; set; } @@ -411,11 +411,11 @@ public class TenantTemplateEntity : ITableEntity /// 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). + /// UPN of the GlobalAdmin who created the group (meta row only). public string CreatedBy { get; set; } = string.Empty; /// - /// When the template was created (meta row only). NULLABLE on purpose: membership rows leave it unset, and + /// When the group was created (meta row only). NULLABLE on purpose: membership rows leave it unset, and /// Azure Table Storage rejects a default(DateTime) (0001-01-01) — below the Edm.DateTime minimum of 1601 — /// which would make every membership-row write throw. A null DateTime? is simply omitted (valid). /// @@ -426,23 +426,23 @@ public class TenantTemplateEntity : ITableEntity } /// -/// 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 +/// A row in the table: delegated-admin UPN X +/// is assigned to group T at role . PK = UPN (lowercase), RK = groupId. Resolving +/// a UPN's scope point-scans this by PK, then expands each group into its tenant members. Operator-managed /// only (no PendingApproval flow); fail-closed — only enabled + recognized-role assignments confer scope. /// -public class TenantTemplateAssignmentEntity : ITableEntity +public class TenantGroupAssignmentEntity : ITableEntity { public string PartitionKey { get; set; } = string.Empty; // UPN (lowercase) - public string RowKey { get; set; } = string.Empty; // templateId + public string RowKey { get; set; } = string.Empty; // groupId 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; + /// The group ID — denormalized copy of RowKey. + public string GroupId { get; set; } = string.Empty; /// : DelegatedReader (default) or DelegatedAdmin. public string Role { get; set; } = Constants.DelegatedRoles.DelegatedReader; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs index 22e57084..31926f48 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs @@ -114,11 +114,11 @@ namespace AutopilotMonitor.Functions.Services.Offboarding // 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 + // Tenant Group membership rows for this tenant (PK=groupId, RK=tenantId, TenantId property + // set). Meta rows (RowKey="meta", empty TenantId) never match the tenant GUID, so the group // itself and its other tenants survive. The assignments table is per-UPN (no TenantId) and is // intentionally NOT wiped here. - Constants.TableNames.TenantTemplates, + Constants.TableNames.TenantGroups, }; // 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 7ca8d1da..dd284644 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -776,18 +776,18 @@ 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"). + // Tenant Groups — 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 + // PK=groupId, RK="meta" → group metadata (display name, creator). + // PK=groupId, RK=tenantId → one membership row per tenant in the group (lowercase). + // A delegated UPN assigned to a group (see TenantGroupAssignments) gains read scope to // every tenant in it. Tenant offboarding purges the membership rows (RowKey == tenantId). - public const string TenantTemplates = "TenantTemplates"; + public const string TenantGroups = "TenantGroups"; - // 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"; + // Tenant Group assignments — which delegated-admin UPN is assigned to which group. + // PK=UPN (lowercase), RK=groupId. Hot path: GetScopeAsync point-scans by PK to expand the + // UPN's groups into the effective tenant set. NOT tenant-id-keyed (offboarding never touches it). + public const string TenantGroupAssignments = "TenantGroupAssignments"; // Preview gating (temporary — remove after GA) public const string PreviewWhitelist = "PreviewWhitelist"; @@ -943,8 +943,8 @@ namespace AutopilotMonitor.Shared TenantAdmins, McpUsers, DelegatedAdmins, - TenantTemplates, - TenantTemplateAssignments, + TenantGroups, + TenantGroupAssignments, PreviewWhitelist, PreviewConfig, Feedback, diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs index 48d7a88a..5bd41921 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs @@ -48,28 +48,28 @@ 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 + // --- Tenant Groups (app-internal tenant bundles for delegated admins / "MSP mode") --- + /// Creates a group (meta row) and returns the generated groupId. + Task CreateTenantGroupAsync(string name, string createdBy); + /// Renames a group (meta row). Returns false if the group does not exist. + Task RenameTenantGroupAsync(string groupId, string name); + /// Deletes a group: 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); + Task DeleteTenantGroupAsync(string groupId); + /// All groups with their tenant members + assignee counts — for the management UI (not hot path). + Task> GetAllTenantGroupsAsync(); + Task GetTenantGroupAsync(string groupId); + Task AddTenantToGroupAsync(string groupId, string tenantId); + Task RemoveTenantFromGroupAsync(string groupId, string tenantId); + /// Tenant IDs (lowercase) in a group, excluding the meta row (HOT PATH — PartitionKey scan). + Task> GetGroupTenantsAsync(string groupId); + /// Creates or replaces a UPN→group assignment. + Task AssignGroupAsync(string upn, string groupId, string role, bool isEnabled, string assignedBy); + Task UnassignGroupAsync(string upn, string groupId); + /// All group assignments for one UPN (HOT PATH — PartitionKey point-scan; scope resolution). + Task> GetGroupAssignmentsForUpnAsync(string upn); + /// All UPNs assigned to one group (cross-partition RowKey scan — management UI, not hot path). + Task> GetGroupAssigneesAsync(string groupId); // --- Tenant Members --- Task> GetTenantMembersAsync(string tenantId); @@ -112,29 +112,29 @@ namespace AutopilotMonitor.Shared.DataAccess } /// - /// 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. + /// A Tenant Group: an app-internal named bundle of tenants. A delegated admin assigned to the + /// group (see ) gains read scope to every tenant in + /// . Adding a tenant to the group grants it to all assignees at once. /// - public class TenantTemplate + public class TenantGroup { - public string TemplateId { get; set; } = string.Empty; + public string GroupId { 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. + /// Tenant IDs (lowercase) in this group. public List TenantIds { get; set; } = new(); - /// Number of UPNs assigned to this template (== .Count). + /// Number of UPNs assigned to this group (== .Count). public int AssigneeCount { get; set; } - /// The UPNs assigned to this template (for the management UI). - public List Assignees { get; set; } = new(); + /// The UPNs assigned to this group (for the management UI). + public List Assignees { get; set; } = new(); } - /// One UPN→template assignment. PK=UPN, RK=templateId in storage. - public class TenantTemplateAssignment + /// One UPN→group assignment. PK=UPN, RK=groupId in storage. + public class TenantGroupAssignment { public string Upn { get; set; } = string.Empty; - public string TemplateId { get; set; } = string.Empty; + public string GroupId { get; set; } = string.Empty; /// Constants.DelegatedRoles: "DelegatedReader" (default) or "DelegatedAdmin". public string Role { get; set; } = string.Empty; public bool IsEnabled { get; set; } = true; 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 6406061d..332b038f 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,7 +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 { SectionTenantGroups } from "../sections/SectionTenantGroups"; import { SectionConfigReseed } from "../sections/SectionConfigReseed"; import { SectionUsagePlans } from "../sections/SectionUsagePlans"; import { SectionAlerts } from "../sections/SectionAlerts"; @@ -17,7 +17,7 @@ const SECTION_COMPONENTS: Record = { "diagnostics-log-paths": SectionDiagnosticsLogPaths, "mcp-users": SectionMcpUsers, "delegated-admins": SectionDelegatedAdmins, - "tenant-groups": SectionTenantTemplates, + "tenant-groups": SectionTenantGroups, "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/SectionTenantGroups.tsx similarity index 83% rename from src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionTenantTemplates.tsx rename to src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionTenantGroups.tsx index 17cd83c3..269b8035 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionTenantTemplates.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionTenantGroups.tsx @@ -7,22 +7,22 @@ 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 { +/** One UPN assigned to a group (camelCase JSON from the backend). */ +interface GroupAssignee { upn: string; role: string; // "DelegatedReader" | "DelegatedAdmin" isEnabled: boolean; } -/** A tenant template as returned by /api/global/tenant-templates. */ -interface TenantTemplate { - templateId: string; +/** A tenant group as returned by /api/global/tenant-groups. */ +interface TenantGroup { + groupId: string; name: string; createdBy: string; createdAt: string; tenantIds: string[]; assigneeCount: number; - assignees: TemplateAssignee[]; + assignees: GroupAssignee[]; } const ROLE_LABELS: Record = { @@ -31,17 +31,17 @@ const ROLE_LABELS: Record = { }; /** - * 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 + * GlobalAdmin-only management of Tenant Groups — app-internal named bundles of tenants for the + * delegated-admin ("MSP mode") tier. Assign a person to a group instead of to each tenant; adding a tenant + * to the group grants it to every assignee at once. Writes go through /api/global/tenant-groups * (GlobalAdminOnly); the backend invalidates affected scopes so changes take effect on the next request. */ -export function SectionTenantTemplates() { +export function SectionTenantGroups() { const { getAccessToken } = useAuth(); const { addNotification } = useNotifications(); const tenants = useTenantList(true); - const [templates, setTemplates] = useState([]); + const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); @@ -50,7 +50,7 @@ export function SectionTenantTemplates() { const [creating, setCreating] = useState(false); const [busyKey, setBusyKey] = useState(null); - // Per-template input state (keyed by templateId). + // Per-group input state (keyed by groupId). const [tenantToAdd, setTenantToAdd] = useState>({}); const [assignUpn, setAssignUpn] = useState>({}); const [assignRole, setAssignRole] = useState>({}); @@ -73,13 +73,13 @@ export function SectionTenantTemplates() { } }; - const fetchTemplates = useCallback(async () => { + const fetchGroups = useCallback(async () => { try { setLoading(true); - const response = await authenticatedFetch(api.tenantTemplates.list(), getAccessToken); + const response = await authenticatedFetch(api.tenantGroups.list(), getAccessToken); if (!response.ok) throw new Error(`Failed to load groups: ${response.statusText}`); const data = await response.json(); - setTemplates(data.templates ?? []); + setGroups(data.groups ?? []); } catch (err) { handleError(err, "Failed to load groups"); } finally { @@ -89,8 +89,8 @@ export function SectionTenantTemplates() { }, [getAccessToken]); useEffect(() => { - fetchTemplates(); - }, [fetchTemplates]); + fetchGroups(); + }, [fetchGroups]); /** Runs a mutation with shared busy/error/refetch handling. `body` undefined ⇒ no JSON body. */ const mutate = useCallback( @@ -109,7 +109,7 @@ export function SectionTenantTemplates() { throw new Error(data.error || `Request failed: ${response.statusText}`); } flash(ok); - await fetchTemplates(); + await fetchGroups(); return true; } catch (err) { handleError(err, "Request failed"); @@ -119,59 +119,59 @@ export function SectionTenantTemplates() { } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [getAccessToken, fetchTemplates], + [getAccessToken, fetchGroups], ); const handleCreate = useCallback(async () => { const name = newName.trim(); if (!name) return; setCreating(true); - const ok = await mutate("create", api.tenantTemplates.create(), "POST", { name }, `Created group "${name}".`); + const ok = await mutate("create", api.tenantGroups.create(), "POST", { name }, `Created group "${name}".`); setCreating(false); if (ok) setNewName(""); }, [newName, mutate]); const handleRename = useCallback( - async (t: TenantTemplate) => { + async (t: TenantGroup) => { const name = prompt("Rename group:", t.name)?.trim(); if (!name || name === t.name) return; - await mutate(`rename:${t.templateId}`, api.tenantTemplates.rename(t.templateId), "PATCH", { name }, `Renamed to "${name}".`); + await mutate(`rename:${t.groupId}`, api.tenantGroups.rename(t.groupId), "PATCH", { name }, `Renamed to "${name}".`); }, [mutate], ); - const handleDeleteTemplate = useCallback( - async (t: TenantTemplate) => { + const handleDeleteGroup = useCallback( + async (t: TenantGroup) => { const extra = t.assigneeCount > 0 ? ` ${t.assigneeCount} assignee(s) will lose this access.` : ""; if (!confirm(`Delete group "${t.name}"?${extra}`)) return; - await mutate(`delete:${t.templateId}`, api.tenantTemplates.remove(t.templateId), "DELETE", undefined, `Deleted "${t.name}".`); + await mutate(`delete:${t.groupId}`, api.tenantGroups.remove(t.groupId), "DELETE", undefined, `Deleted "${t.name}".`); }, [mutate], ); const handleAddTenant = useCallback( - async (t: TenantTemplate) => { - const tenantId = tenantToAdd[t.templateId]; + async (t: TenantGroup) => { + const tenantId = tenantToAdd[t.groupId]; if (!tenantId) return; const ok = await mutate( - `addtenant:${t.templateId}`, - api.tenantTemplates.addTenant(t.templateId), + `addtenant:${t.groupId}`, + api.tenantGroups.addTenant(t.groupId), "POST", { tenantId }, `Added ${domainOf(tenantId) || tenantId} to "${t.name}".`, ); - if (ok) setTenantToAdd((prev) => ({ ...prev, [t.templateId]: "" })); + if (ok) setTenantToAdd((prev) => ({ ...prev, [t.groupId]: "" })); }, [tenantToAdd, mutate, domainOf], ); const handleRemoveTenant = useCallback( - async (t: TenantTemplate, tenantId: string) => { + async (t: TenantGroup, 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), + `rmtenant:${t.groupId}:${tenantId}`, + api.tenantGroups.removeTenant(t.groupId, tenantId), "DELETE", undefined, `Removed ${dom} from "${t.name}".`, @@ -181,28 +181,28 @@ export function SectionTenantTemplates() { ); const handleAssign = useCallback( - async (t: TenantTemplate) => { - const upn = (assignUpn[t.templateId] || "").trim(); + async (t: TenantGroup) => { + const upn = (assignUpn[t.groupId] || "").trim(); if (!upn) return; - const role = assignRole[t.templateId] || "DelegatedReader"; + const role = assignRole[t.groupId] || "DelegatedReader"; const ok = await mutate( - `assign:${t.templateId}`, - api.tenantTemplates.assign(t.templateId), + `assign:${t.groupId}`, + api.tenantGroups.assign(t.groupId), "POST", { upn, role }, `Assigned ${upn} to "${t.name}".`, ); - if (ok) setAssignUpn((prev) => ({ ...prev, [t.templateId]: "" })); + if (ok) setAssignUpn((prev) => ({ ...prev, [t.groupId]: "" })); }, [assignUpn, assignRole, mutate], ); const handleUnassign = useCallback( - async (t: TenantTemplate, upn: string) => { + async (t: TenantGroup, upn: string) => { if (!confirm(`Remove ${upn} from "${t.name}"? They lose access to all tenants in this group.`)) return; await mutate( - `unassign:${t.templateId}:${upn}`, - api.tenantTemplates.unassign(t.templateId, upn), + `unassign:${t.groupId}:${upn}`, + api.tenantGroups.unassign(t.groupId, upn), "DELETE", undefined, `Removed ${upn} from "${t.name}".`, @@ -212,8 +212,8 @@ export function SectionTenantTemplates() { ); const sorted = useMemo( - () => [...templates].sort((a, b) => a.name.localeCompare(b.name)), - [templates], + () => [...groups].sort((a, b) => a.name.localeCompare(b.name)), + [groups], ); return ( @@ -302,7 +302,7 @@ export function SectionTenantTemplates() {

)} - {/* Templates */} + {/* Groups */} {!loading && sorted.length > 0 && (
{sorted.map((t) => { @@ -310,8 +310,8 @@ export function SectionTenantTemplates() { (x) => !t.tenantIds.some((id) => id.toLowerCase() === x.tenantId.toLowerCase()), ); return ( -
- {/* Template header */} +
+ {/* Group header */}
{t.name} @@ -328,7 +328,7 @@ export function SectionTenantTemplates() { Rename