refactor(delegated): rename TenantTemplate -> TenantGroup across the full stack

Completes the Tenant Templates -> Tenant Groups rename down to identifiers,
tables, routes and DTOs (the earlier commit renamed only the UI labels). Done
now while the table is effectively empty; the new TenantGroups /
TenantGroupAssignments tables are auto-created on deploy, the operator re-enters
their group, and the old TenantTemplates table is dropped in Azure manually.

- Tables: TenantTemplates -> TenantGroups, TenantTemplateAssignments ->
  TenantGroupAssignments (Constants + All).
- Entities/DTOs: TenantTemplate(Entity)/TenantTemplateAssignment(Entity) ->
  TenantGroup*, property TemplateId -> GroupId.
- Repo/service/API: all *Template* methods -> *Group*; routes
  /api/global/tenant-templates -> /api/global/tenant-groups, {templateId} ->
  {groupId}; function class + file renamed; audit entity DelegatedTemplateAccess
  -> DelegatedGroupAccess; NormalizeTemplateId -> NormalizeGroupId.
- Offboarding wipe list + tests updated.
- Web: api.tenantGroups, SectionTenantGroups (file renamed), response key groups.

Scoped strictly to the feature: unrelated 'template' usages (analyze-rule
templates, EmailTemplates, interpolate-rule-template) left untouched. Backend
2538 tests green; web tsc clean + vitest 494 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-29 19:47:05 +02:00
parent bce5d90c2a
commit 0abccf1fe0
13 changed files with 577 additions and 577 deletions
@@ -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<IAdminRepository> Repo) Build()
@@ -28,31 +28,31 @@ public class DelegatedAdminServiceTests
var repo = new Mock<IAdminRepository>();
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()))
.ReturnsAsync(new List<DelegatedAdminEntry>());
// Default: no template assignments. Template-specific tests override per-case.
repo.Setup(r => r.GetTemplateAssignmentsForUpnAsync(It.IsAny<string>()))
.ReturnsAsync(new List<TenantTemplateAssignment>());
// Default: no group assignments. Group-specific tests override per-case.
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()))
.ReturnsAsync(new List<TenantGroupAssignment>());
var cache = new MemoryCache(new MemoryCacheOptions());
var svc = new DelegatedAdminService(repo.Object, cache, NullLogger<DelegatedAdminService>.Instance);
return (svc, repo);
}
private static TenantTemplateAssignment Assignment(
string templateId,
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<IAdminRepository> repo, params TenantTemplateAssignment[] assignments) =>
repo.Setup(r => r.GetTemplateAssignmentsForUpnAsync(It.IsAny<string>())).ReturnsAsync(assignments.ToList());
private static void ReturnsGroups(Mock<IAdminRepository> repo, params TenantGroupAssignment[] assignments) =>
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>())).ReturnsAsync(assignments.ToList());
private static void TemplateTenants(Mock<IAdminRepository> repo, string templateId, params string[] tenantIds) =>
repo.Setup(r => r.GetTemplateTenantsAsync(templateId)).ReturnsAsync(tenantIds.ToList());
private static void GroupTenants(Mock<IAdminRepository> 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<string>()), 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<string>()), Times.Once);
repo.Verify(r => r.GetTemplateTenantsAsync(TemplateId1), Times.Once);
repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()), 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<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<string>()))
.ReturnsAsync(true);
await svc.GetScopeAsync(Upn); // primes cache
await svc.AssignTemplateAsync(Upn, TemplateId2, Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example");
await svc.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<string>()), Times.Exactly(2));
repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()), 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<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<string>()))
.ReturnsAsync(true);
var assigned = await svc.AssignTemplateAsync(
Upn, TemplateId1, Constants.DelegatedRoles.DelegatedReader, true, "ga@vendor.example");
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<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<string>()), 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<string>(), It.IsAny<string>())).ReturnsAsync(true);
ReturnsGroups(repo, Assignment(GroupId1)); // UPN IS assigned to GroupId1
GroupTenants(repo, GroupId1, TenantA);
repo.Setup(r => r.UnassignGroupAsync(It.IsAny<string>(), It.IsAny<string>())).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<string>(), It.IsAny<string>())).ReturnsAsync(true);
repo.Setup(r => r.UnassignGroupAsync(It.IsAny<string>(), It.IsAny<string>())).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<string>(), It.IsAny<string>()), Times.Never);
repo.Verify(r => r.UnassignGroupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
private static void TemplateExists(Mock<IAdminRepository> repo, string templateId, params string[] tenantIds) =>
repo.Setup(r => r.GetTenantTemplateAsync(templateId)).ReturnsAsync(new TenantTemplate
private static void GroupExists(Mock<IAdminRepository> 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<string>(), It.IsAny<string>())).ReturnsAsync(true);
repo.Setup(r => r.GetTemplateAssigneesAsync(TemplateId1)).ReturnsAsync(new List<TenantTemplateAssignment>
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<string>(), It.IsAny<string>())).ReturnsAsync(true);
repo.Setup(r => r.GetGroupAssigneesAsync(GroupId1)).ReturnsAsync(new List<TenantGroupAssignment>
{
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<string>(), It.IsAny<string>())).ReturnsAsync(true);
// GetTenantGroupAsync defaults to null (group does not exist) → must NOT upsert a ghost row.
repo.Setup(r => r.AddTenantToGroupAsync(It.IsAny<string>(), It.IsAny<string>())).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<string>(), It.IsAny<string>()), Times.Never);
repo.Verify(r => r.AddTenantToGroupAsync(It.IsAny<string>(), It.IsAny<string>()), 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<TenantTemplateAssignment>());
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<TenantGroupAssignment>());
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<string>(), It.IsAny<string>())).ReturnsAsync(true);
// GetTenantGroupAsync null by default → no removal, no false audit signal.
repo.Setup(r => r.RemoveTenantFromGroupAsync(It.IsAny<string>(), It.IsAny<string>())).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<string>(), It.IsAny<string>()), Times.Never);
repo.Verify(r => r.RemoveTenantFromGroupAsync(It.IsAny<string>(), It.IsAny<string>()), 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<string>(), It.IsAny<string>())).ReturnsAsync(true);
GroupExists(repo, GroupId1, TenantB); // group exists but does NOT contain TenantA
repo.Setup(r => r.RemoveTenantFromGroupAsync(It.IsAny<string>(), It.IsAny<string>())).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<string>(), It.IsAny<string>()), Times.Never);
repo.Verify(r => r.RemoveTenantFromGroupAsync(It.IsAny<string>(), It.IsAny<string>()), 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<string>())).ReturnsAsync(true);
ReturnsGroups(repo, Assignment(GroupId1));
GroupTenants(repo, GroupId1, TenantA);
repo.Setup(r => r.DeleteTenantGroupAsync(It.IsAny<string>())).ReturnsAsync(true);
// Assignees are captured BEFORE the cascade delete removes them.
repo.Setup(r => r.GetTemplateAssigneesAsync(TemplateId1)).ReturnsAsync(new List<TenantTemplateAssignment>
repo.Setup(r => r.GetGroupAssigneesAsync(GroupId1)).ReturnsAsync(new List<TenantGroupAssignment>
{
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<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<string>()))
.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<string>(), It.IsAny<string>())).ReturnsAsync(true);
ReturnsGroups(repo, Assignment(GroupId1));
GroupTenants(repo, GroupId1, TenantA);
repo.Setup(r => r.RenameTenantGroupAsync(It.IsAny<string>(), It.IsAny<string>())).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<string>()), Times.Once);
repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()), Times.Once);
}
}
@@ -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) ───────────────────
@@ -72,9 +72,9 @@ public class PolicyEnforcementMiddlewareTests
.ReturnsAsync((TenantMember?)null);
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()))
.ReturnsAsync(new List<DelegatedAdminEntry>());
// No template assignments by default (delegated scope resolves direct grants + template tenants).
repo.Setup(r => r.GetTemplateAssignmentsForUpnAsync(It.IsAny<string>()))
.ReturnsAsync(new List<TenantTemplateAssignment>());
// No group assignments by default (delegated scope resolves direct grants + group tenants).
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()))
.ReturnsAsync(new List<TenantGroupAssignment>());
// 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);
}
@@ -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<TableAdminRepository> _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<string> CreateTenantTemplateAsync(string name, string createdBy)
public async Task<string> 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<bool> RenameTenantTemplateAsync(string templateId, string name)
public async Task<bool> RenameTenantGroupAsync(string groupId, string name)
{
if (string.IsNullOrWhiteSpace(templateId))
if (string.IsNullOrWhiteSpace(groupId))
return false;
try
{
var result = await _tenantTemplatesTableClient.GetEntityAsync<TenantTemplateEntity>(
templateId, TenantTemplateEntity.MetaRowKey);
var result = await _tenantGroupsTableClient.GetEntityAsync<TenantGroupEntity>(
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<bool> DeleteTenantTemplateAsync(string templateId)
public async Task<bool> 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<TenantTemplateEntity>(
e => e.PartitionKey == templateId))
await foreach (var entity in _tenantGroupsTableClient.QueryAsync<TenantGroupEntity>(
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<TenantTemplateAssignmentEntity>(
e => e.RowKey == templateId))
// Cascade: remove every UPN assignment to this group (cross-partition RowKey scan).
await foreach (var assignment in _tenantGroupAssignmentsTableClient.QueryAsync<TenantGroupAssignmentEntity>(
e => e.RowKey == groupId))
{
await DeleteIfPresentAsync(_tenantTemplateAssignmentsTableClient, assignment.PartitionKey, assignment.RowKey);
await DeleteIfPresentAsync(_tenantGroupAssignmentsTableClient, assignment.PartitionKey, assignment.RowKey);
}
return true;
}
public async Task<List<TenantTemplate>> GetAllTenantTemplatesAsync()
public async Task<List<TenantGroup>> 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<string, TenantTemplate>(StringComparer.Ordinal);
// A template EXISTS only if its meta row is present. A partition with only membership rows is an
// anomaly (e.g. a partial write), NOT a template — never surface it as a blank/name-less template.
// 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<string, TenantGroup>(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<string>(StringComparer.Ordinal);
await foreach (var entity in _tenantTemplatesTableClient.QueryAsync<TenantTemplateEntity>())
await foreach (var entity in _tenantGroupsTableClient.QueryAsync<TenantGroupEntity>())
{
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<TenantTemplateAssignmentEntity>())
await foreach (var assignment in _tenantGroupAssignmentsTableClient.QueryAsync<TenantGroupAssignmentEntity>())
{
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<TenantTemplate>();
foreach (var template in byId.Values)
var result = new List<TenantGroup>();
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<TenantTemplate?> GetTenantTemplateAsync(string templateId)
public async Task<TenantGroup?> GetTenantGroupAsync(string groupId)
{
if (string.IsNullOrWhiteSpace(templateId))
if (string.IsNullOrWhiteSpace(groupId))
return null;
var byId = new Dictionary<string, TenantTemplate>(StringComparer.Ordinal);
var byId = new Dictionary<string, TenantGroup>(StringComparer.Ordinal);
var metaBacked = false;
await foreach (var entity in _tenantTemplatesTableClient.QueryAsync<TenantTemplateEntity>(
e => e.PartitionKey == templateId))
await foreach (var entity in _tenantGroupsTableClient.QueryAsync<TenantGroupEntity>(
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<TenantTemplateAssignmentEntity>(
e => e.RowKey == templateId))
await foreach (var assignment in _tenantGroupAssignmentsTableClient.QueryAsync<TenantGroupAssignmentEntity>(
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<bool> AddTenantToTemplateAsync(string templateId, string tenantId)
public async Task<bool> 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<bool> RemoveTenantFromTemplateAsync(string templateId, string tenantId)
public async Task<bool> 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<List<string>> GetTemplateTenantsAsync(string templateId)
public async Task<List<string>> GetGroupTenantsAsync(string groupId)
{
var tenants = new List<string>();
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<TenantTemplateEntity>(
e => e.PartitionKey == templateId))
await foreach (var entity in _tenantGroupsTableClient.QueryAsync<TenantGroupEntity>(
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<string>();
}
public async Task<bool> AssignTemplateAsync(string upn, string templateId, string role, bool isEnabled, string assignedBy)
public async Task<bool> 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<bool> UnassignTemplateAsync(string upn, string templateId)
public async Task<bool> 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<List<TenantTemplateAssignment>> GetTemplateAssignmentsForUpnAsync(string upn)
public async Task<List<TenantGroupAssignment>> GetGroupAssignmentsForUpnAsync(string upn)
{
var entries = new List<TenantTemplateAssignment>();
var entries = new List<TenantGroupAssignment>();
if (string.IsNullOrWhiteSpace(upn))
return entries;
var normalizedUpn = upn.ToLowerInvariant();
// HOT PATH (scope resolution): PartitionKey point-scan.
await foreach (var entity in _tenantTemplateAssignmentsTableClient.QueryAsync<TenantTemplateAssignmentEntity>(
await foreach (var entity in _tenantGroupAssignmentsTableClient.QueryAsync<TenantGroupAssignmentEntity>(
e => e.PartitionKey == normalizedUpn))
{
entries.Add(MapToTemplateAssignment(entity));
entries.Add(MapToGroupAssignment(entity));
}
return entries;
}
public async Task<List<TenantTemplateAssignment>> GetTemplateAssigneesAsync(string templateId)
public async Task<List<TenantGroupAssignment>> GetGroupAssigneesAsync(string groupId)
{
var entries = new List<TenantTemplateAssignment>();
if (string.IsNullOrWhiteSpace(templateId))
var entries = new List<TenantGroupAssignment>();
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<TenantTemplateAssignmentEntity>(
e => e.RowKey == templateId))
await foreach (var entity in _tenantGroupAssignmentsTableClient.QueryAsync<TenantGroupAssignmentEntity>(
e => e.RowKey == groupId))
{
entries.Add(MapToTemplateAssignment(entity));
entries.Add(MapToGroupAssignment(entity));
}
return entries;
}
@@ -813,35 +813,35 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
/// <summary>
/// Folds a TenantTemplates row (meta or membership) into the accumulating DTO for its partition.
/// Returns true if the row was the meta row (proof the template exists).
/// 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).
/// </summary>
private static bool ApplyRowToTemplate(Dictionary<string, TenantTemplate> byId, TenantTemplateEntity entity)
private static bool ApplyRowToGroup(Dictionary<string, TenantGroup> 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,
@@ -11,30 +11,30 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Admin;
/// <summary>
/// GlobalAdmin-only management of <b>Tenant Templates</b> — app-internal named bundles of tenants for the
/// delegated-admin ("MSP mode") tier. An operator assigns a delegated UPN to a template instead of to each
/// tenant; the UPN then reads every tenant in the template (resolved by <see cref="DelegatedAdminService"/>).
/// Adding a tenant to the template grants it to all assignees at once.
/// GlobalAdmin-only management of <b>Tenant Groups</b> — 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 <see cref="DelegatedAdminService"/>).
/// 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 <c>PolicyEnforcementMiddleware</c> via the route catalog), so a delegated
/// caller can never manage templates. ALL mutations go through <see cref="DelegatedAdminService"/> (never the
/// caller can never manage groups. ALL mutations go through <see cref="DelegatedAdminService"/> (never the
/// repository directly) so the delegated-scope cache is invalidated in lockstep.
///
/// Audit: access-affecting mutations are logged under the <b>affected managed tenant(s)</b>' trail (so the
/// customer sees "who can read my tenant" / "access removed"). Template create/rename carry no tenant context
/// 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).
/// </summary>
public class TenantTemplateManagementFunction
public class TenantGroupManagementFunction
{
private readonly ILogger<TenantTemplateManagementFunction> _logger;
private readonly ILogger<TenantGroupManagementFunction> _logger;
private readonly DelegatedAdminService _delegatedAdminService;
private readonly IMaintenanceRepository _maintenanceRepo;
private const string AuditEntity = "DelegatedTemplateAccess";
private const string AuditEntity = "DelegatedGroupAccess";
public TenantTemplateManagementFunction(
ILogger<TenantTemplateManagementFunction> logger,
public TenantGroupManagementFunction(
ILogger<TenantGroupManagementFunction> logger,
DelegatedAdminService delegatedAdminService,
IMaintenanceRepository maintenanceRepo)
{
@@ -43,147 +43,147 @@ public class TenantTemplateManagementFunction
_maintenanceRepo = maintenanceRepo;
}
/// <summary>GET /api/global/tenant-templates — list every template with tenants + assignee count. GlobalReadOrAdmin.</summary>
[Function("GetTenantTemplates")]
/// <summary>GET /api/global/tenant-groups — list every group with tenants + assignee count. GlobalReadOrAdmin.</summary>
[Function("GetTenantGroups")]
[Authorize]
public async Task<HttpResponseData> GetTenantTemplates(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/tenant-templates")] HttpRequestData req)
public async Task<HttpResponseData> 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;
}
/// <summary>POST /api/global/tenant-templates — create a template. GlobalAdminOnly. Body: { "name": "..." }.</summary>
[Function("CreateTenantTemplate")]
/// <summary>POST /api/global/tenant-groups — create a group. GlobalAdminOnly. Body: { "name": "..." }.</summary>
[Function("CreateTenantGroup")]
[Authorize]
public async Task<HttpResponseData> CreateTenantTemplate(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/tenant-templates")] HttpRequestData req,
public async Task<HttpResponseData> CreateTenantGroup(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/tenant-groups")] HttpRequestData req,
FunctionContext context)
{
var currentUpn = context.GetRequestContext().UserPrincipalName;
var body = await req.ReadFromJsonAsync<CreateTenantTemplateRequest>();
var body = await req.ReadFromJsonAsync<CreateTenantGroupRequest>();
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;
}
/// <summary>PATCH /api/global/tenant-templates/{templateId} — rename. GlobalAdminOnly. Body: { "name": "..." }.</summary>
[Function("RenameTenantTemplate")]
/// <summary>PATCH /api/global/tenant-groups/{groupId} — rename. GlobalAdminOnly. Body: { "name": "..." }.</summary>
[Function("RenameTenantGroup")]
[Authorize]
public async Task<HttpResponseData> RenameTenantTemplate(
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "global/tenant-templates/{templateId}")] HttpRequestData req,
string templateId, FunctionContext context)
public async Task<HttpResponseData> 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<RenameTenantTemplateRequest>();
var body = await req.ReadFromJsonAsync<RenameTenantGroupRequest>();
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;
}
/// <summary>DELETE /api/global/tenant-templates/{templateId} — delete template + all its assignments. GlobalAdminOnly.</summary>
[Function("DeleteTenantTemplate")]
/// <summary>DELETE /api/global/tenant-groups/{groupId} — delete group + all its assignments. GlobalAdminOnly.</summary>
[Function("DeleteTenantGroup")]
[Authorize]
public async Task<HttpResponseData> DeleteTenantTemplate(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/tenant-templates/{templateId}")] HttpRequestData req,
string templateId, FunctionContext context)
public async Task<HttpResponseData> 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<string, string>
{
{ "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;
}
/// <summary>POST /api/global/tenant-templates/{templateId}/tenants — add a tenant. GlobalAdminOnly. Body: { "tenantId": "&lt;guid&gt;" }.</summary>
[Function("AddTenantToTemplate")]
/// <summary>POST /api/global/tenant-groups/{groupId}/tenants — add a tenant. GlobalAdminOnly. Body: { "tenantId": "&lt;guid&gt;" }.</summary>
[Function("AddTenantToGroup")]
[Authorize]
public async Task<HttpResponseData> AddTenantToTemplate(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/tenant-templates/{templateId}/tenants")] HttpRequestData req,
string templateId, FunctionContext context)
public async Task<HttpResponseData> 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<AddTemplateTenantRequest>();
var body = await req.ReadFromJsonAsync<AddGroupTenantRequest>();
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<string, string> { { "TemplateId", templateId }, { "Reason", "tenant-added-to-template" } });
new Dictionary<string, string> { { "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;
}
/// <summary>DELETE /api/global/tenant-templates/{templateId}/tenants/{tenantId} — remove a tenant. GlobalAdminOnly.</summary>
[Function("RemoveTenantFromTemplate")]
/// <summary>DELETE /api/global/tenant-groups/{groupId}/tenants/{tenantId} — remove a tenant. GlobalAdminOnly.</summary>
[Function("RemoveTenantFromGroup")]
[Authorize]
public async Task<HttpResponseData> RemoveTenantFromTemplate(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/tenant-templates/{templateId}/tenants/{tenantId}")] HttpRequestData req,
string templateId, string tenantId, FunctionContext context)
public async Task<HttpResponseData> 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<string, string> { { "TemplateId", templateId }, { "Reason", "tenant-removed-from-template" } });
new Dictionary<string, string> { { "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;
}
/// <summary>
/// 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" }.
/// </summary>
[Function("AssignTenantTemplate")]
[Function("AssignTenantGroup")]
[Authorize]
public async Task<HttpResponseData> AssignTenantTemplate(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/tenant-templates/{templateId}/assignees")] HttpRequestData req,
string templateId, FunctionContext context)
public async Task<HttpResponseData> 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<AssignTemplateRequest>();
var body = await req.ReadFromJsonAsync<AssignGroupRequest>();
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<string, string>
{
{ "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;
}
/// <summary>DELETE /api/global/tenant-templates/{templateId}/assignees/{upn} — unassign a UPN. GlobalAdminOnly.</summary>
[Function("UnassignTenantTemplate")]
/// <summary>DELETE /api/global/tenant-groups/{groupId}/assignees/{upn} — unassign a UPN. GlobalAdminOnly.</summary>
[Function("UnassignTenantGroup")]
[Authorize]
public async Task<HttpResponseData> UnassignTenantTemplate(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/tenant-templates/{templateId}/assignees/{upn}")] HttpRequestData req,
string templateId, string upn, FunctionContext context)
public async Task<HttpResponseData> 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<string, string>
{
{ "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<HttpResponseData> 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; }
@@ -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),
@@ -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<List<DelegatedAdminEntry>> 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.
/// <summary>Read-through: all templates with tenant members + assignee counts (management UI).</summary>
public Task<List<TenantTemplate>> GetAllTemplatesAsync() => _adminRepo.GetAllTenantTemplatesAsync();
/// <summary>Read-through: all groups with tenant members + assignee counts (management UI).</summary>
public Task<List<TenantGroup>> GetAllGroupsAsync() => _adminRepo.GetAllTenantGroupsAsync();
/// <summary>Read-through: one template with its tenant members + assignee count.</summary>
public Task<TenantTemplate?> GetTemplateAsync(string templateId) => _adminRepo.GetTenantTemplateAsync(NormalizeTemplateId(templateId));
/// <summary>Read-through: one group with its tenant members + assignee count.</summary>
public Task<TenantGroup?> GetGroupAsync(string groupId) => _adminRepo.GetTenantGroupAsync(NormalizeGroupId(groupId));
/// <summary>Read-through: all UPNs assigned to a template (management UI).</summary>
public Task<List<TenantTemplateAssignment>> GetTemplateAssigneesAsync(string templateId)
=> _adminRepo.GetTemplateAssigneesAsync(NormalizeTemplateId(templateId));
/// <summary>Read-through: all UPNs assigned to a group (management UI).</summary>
public Task<List<TenantGroupAssignment>> GetGroupAssigneesAsync(string groupId)
=> _adminRepo.GetGroupAssigneesAsync(NormalizeGroupId(groupId));
/// <summary>Creates a template (no scope effect — a fresh template has no assignees).</summary>
public Task<string> CreateTemplateAsync(string name, string createdBy)
=> _adminRepo.CreateTenantTemplateAsync(name, createdBy);
/// <summary>Creates a group (no scope effect — a fresh group has no assignees).</summary>
public Task<string> CreateGroupAsync(string name, string createdBy)
=> _adminRepo.CreateTenantGroupAsync(name, createdBy);
/// <summary>Renames a template (name only — no scope effect, no invalidation needed).</summary>
public Task<bool> RenameTemplateAsync(string templateId, string name)
=> _adminRepo.RenameTenantTemplateAsync(NormalizeTemplateId(templateId), name);
/// <summary>Renames a group (name only — no scope effect, no invalidation needed).</summary>
public Task<bool> RenameGroupAsync(string groupId, string name)
=> _adminRepo.RenameTenantGroupAsync(NormalizeGroupId(groupId), name);
/// <summary>Deletes a template and all its assignments; invalidates every (former) assignee's scope.</summary>
public async Task<bool> DeleteTemplateAsync(string templateId)
/// <summary>Deletes a group and all its assignments; invalidates every (former) assignee's scope.</summary>
public async Task<bool> 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;
}
/// <summary>
/// Adds a tenant to a template; every assignee gains it — invalidate them so it takes effect now.
/// Returns false (no-op) if the template does not exist (meta-backed) — prevents a "ghost" template
/// 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.
/// </summary>
public async Task<bool> AddTenantToTemplateAsync(string templateId, string tenantId)
public async Task<bool> 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;
}
/// <summary>
/// Removes a tenant from a template (REVOKE flow); invalidate every assignee immediately. Returns false
/// (no-op, no invalidation) if the template does not exist or the tenant is not currently a member — so a
/// 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.
/// </summary>
public async Task<bool> RemoveTenantFromTemplateAsync(string templateId, string tenantId)
public async Task<bool> 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;
}
/// <summary>
/// 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.
/// </summary>
public async Task<bool> AssignTemplateAsync(string upn, string templateId, string role, bool isEnabled, string assignedBy)
public async Task<bool> 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;
}
/// <summary>
/// Unassigns a UPN from a template (REVOKE flow); invalidates that UPN's scope immediately. Returns false
/// (no-op, no invalidation) if the UPN is not currently assigned to the template — so a mistyped UPN can't
/// 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.
/// </summary>
public async Task<bool> UnassignTemplateAsync(string upn, string templateId)
public async Task<bool> 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;
}
/// <summary>Invalidates the cached scope of every current assignee of a template.</summary>
private async Task InvalidateTemplateAssigneesAsync(string templateId)
=> InvalidateAll(await _adminRepo.GetTemplateAssigneesAsync(templateId));
/// <summary>Invalidates the cached scope of every current assignee of a group.</summary>
private async Task InvalidateGroupAssigneesAsync(string groupId)
=> InvalidateAll(await _adminRepo.GetGroupAssigneesAsync(groupId));
private void InvalidateAll(IEnumerable<TenantTemplateAssignment> assignees)
private void InvalidateAll(IEnumerable<TenantGroupAssignment> 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()}");
/// <summary>Normalizes the opaque templateId to lowercase so the storage key is case-insensitive to callers.</summary>
private static string NormalizeTemplateId(string templateId) => (templateId ?? string.Empty).ToLowerInvariant();
/// <summary>Normalizes the opaque groupId to lowercase so the storage key is case-insensitive to callers.</summary>
private static string NormalizeGroupId(string groupId) => (groupId ?? string.Empty).ToLowerInvariant();
/// <summary>Empty/missing role defaults to the least-privileged DelegatedReader; an unrecognized
/// role is dropped (fail-closed) rather than silently granting access.</summary>
@@ -389,21 +389,21 @@ public class DelegatedAdminEntity : ITableEntity
}
/// <summary>
/// A row in the <see cref="Constants.TableNames.TenantTemplates"/> table. Two row layouts share the
/// A row in the <see cref="Constants.TableNames.TenantGroups"/> table. Two row layouts share the
/// class, discriminated by <see cref="RowKey"/>:
/// <list type="bullet">
/// <item>PK = templateId, RK = <see cref="MetaRowKey"/> — template metadata (<see cref="Name"/>, creator).</item>
/// <item>PK = templateId, RK = tenantId (lowercase) — one membership row per tenant in the template.</item>
/// <item>PK = groupId, RK = <see cref="MetaRowKey"/> — group metadata (<see cref="Name"/>, creator).</item>
/// <item>PK = groupId, RK = tenantId (lowercase) — one membership row per tenant in the group.</item>
/// </list>
/// A template is an app-internal named bundle of tenants; a delegated admin assigned to it (see
/// <see cref="TenantTemplateAssignmentEntity"/>) gains read scope to every tenant member.
/// A group is an app-internal named bundle of tenants; a delegated admin assigned to it (see
/// <see cref="TenantGroupAssignmentEntity"/>) gains read scope to every tenant member.
/// </summary>
public class TenantTemplateEntity : ITableEntity
public class TenantGroupEntity : ITableEntity
{
/// <summary>The reserved RowKey of the per-template metadata row. Tenant IDs are GUIDs and never collide with it.</summary>
/// <summary>The reserved RowKey of the per-group metadata row. Tenant IDs are GUIDs and never collide with it.</summary>
public const string MetaRowKey = "meta";
public string PartitionKey { get; set; } = string.Empty; // templateId
public string 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
/// <summary>Display name (meta row only; empty on membership rows).</summary>
public string Name { get; set; } = string.Empty;
/// <summary>UPN of the GlobalAdmin who created the template (meta row only).</summary>
/// <summary>UPN of the GlobalAdmin who created the group (meta row only).</summary>
public string CreatedBy { get; set; } = string.Empty;
/// <summary>
/// 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).
/// </summary>
@@ -426,23 +426,23 @@ public class TenantTemplateEntity : ITableEntity
}
/// <summary>
/// A row in the <see cref="Constants.TableNames.TenantTemplateAssignments"/> table: delegated-admin UPN X
/// is assigned to template T at role <see cref="Role"/>. PK = UPN (lowercase), RK = templateId. Resolving
/// a UPN's scope point-scans this by PK, then expands each template into its tenant members. Operator-managed
/// A row in the <see cref="Constants.TableNames.TenantGroupAssignments"/> table: delegated-admin UPN X
/// is assigned to group T at role <see cref="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.
/// </summary>
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; }
/// <summary>The delegated admin's UPN (lowercase) — denormalized copy of PartitionKey.</summary>
public string Upn { get; set; } = string.Empty;
/// <summary>The template ID — denormalized copy of RowKey.</summary>
public string TemplateId { get; set; } = string.Empty;
/// <summary>The group ID — denormalized copy of RowKey.</summary>
public string GroupId { get; set; } = string.Empty;
/// <summary><see cref="Constants.DelegatedRoles"/>: DelegatedReader (default) or DelegatedAdmin.</summary>
public string Role { get; set; } = Constants.DelegatedRoles.DelegatedReader;
@@ -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
+11 -11
View File
@@ -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,
@@ -48,28 +48,28 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<bool> SetDelegatedAdminEnabledAsync(string upn, string tenantId, bool isEnabled);
Task<bool> RemoveDelegatedAdminAsync(string upn, string tenantId);
// --- Tenant Templates (app-internal tenant bundles for delegated admins / "MSP mode") ---
/// <summary>Creates a template (meta row) and returns the generated templateId.</summary>
Task<string> CreateTenantTemplateAsync(string name, string createdBy);
/// <summary>Renames a template (meta row). Returns false if the template does not exist.</summary>
Task<bool> RenameTenantTemplateAsync(string templateId, string name);
/// <summary>Deletes a template: all rows in its partition (meta + membership) AND every UPN
// --- Tenant Groups (app-internal tenant bundles for delegated admins / "MSP mode") ---
/// <summary>Creates a group (meta row) and returns the generated groupId.</summary>
Task<string> CreateTenantGroupAsync(string name, string createdBy);
/// <summary>Renames a group (meta row). Returns false if the group does not exist.</summary>
Task<bool> RenameTenantGroupAsync(string groupId, string name);
/// <summary>Deletes a group: all rows in its partition (meta + membership) AND every UPN
/// assignment referencing it (cross-partition RowKey scan of the assignments table).</summary>
Task<bool> DeleteTenantTemplateAsync(string templateId);
/// <summary>All templates with their tenant members + assignee counts — for the management UI (not hot path).</summary>
Task<List<TenantTemplate>> GetAllTenantTemplatesAsync();
Task<TenantTemplate?> GetTenantTemplateAsync(string templateId);
Task<bool> AddTenantToTemplateAsync(string templateId, string tenantId);
Task<bool> RemoveTenantFromTemplateAsync(string templateId, string tenantId);
/// <summary>Tenant IDs (lowercase) in a template, excluding the meta row (HOT PATH — PartitionKey scan).</summary>
Task<List<string>> GetTemplateTenantsAsync(string templateId);
/// <summary>Creates or replaces a UPN→template assignment.</summary>
Task<bool> AssignTemplateAsync(string upn, string templateId, string role, bool isEnabled, string assignedBy);
Task<bool> UnassignTemplateAsync(string upn, string templateId);
/// <summary>All template assignments for one UPN (HOT PATH — PartitionKey point-scan; scope resolution).</summary>
Task<List<TenantTemplateAssignment>> GetTemplateAssignmentsForUpnAsync(string upn);
/// <summary>All UPNs assigned to one template (cross-partition RowKey scan — management UI, not hot path).</summary>
Task<List<TenantTemplateAssignment>> GetTemplateAssigneesAsync(string templateId);
Task<bool> DeleteTenantGroupAsync(string groupId);
/// <summary>All groups with their tenant members + assignee counts — for the management UI (not hot path).</summary>
Task<List<TenantGroup>> GetAllTenantGroupsAsync();
Task<TenantGroup?> GetTenantGroupAsync(string groupId);
Task<bool> AddTenantToGroupAsync(string groupId, string tenantId);
Task<bool> RemoveTenantFromGroupAsync(string groupId, string tenantId);
/// <summary>Tenant IDs (lowercase) in a group, excluding the meta row (HOT PATH — PartitionKey scan).</summary>
Task<List<string>> GetGroupTenantsAsync(string groupId);
/// <summary>Creates or replaces a UPN→group assignment.</summary>
Task<bool> AssignGroupAsync(string upn, string groupId, string role, bool isEnabled, string assignedBy);
Task<bool> UnassignGroupAsync(string upn, string groupId);
/// <summary>All group assignments for one UPN (HOT PATH — PartitionKey point-scan; scope resolution).</summary>
Task<List<TenantGroupAssignment>> GetGroupAssignmentsForUpnAsync(string upn);
/// <summary>All UPNs assigned to one group (cross-partition RowKey scan — management UI, not hot path).</summary>
Task<List<TenantGroupAssignment>> GetGroupAssigneesAsync(string groupId);
// --- Tenant Members ---
Task<List<TenantMember>> GetTenantMembersAsync(string tenantId);
@@ -112,29 +112,29 @@ namespace AutopilotMonitor.Shared.DataAccess
}
/// <summary>
/// A Tenant Template: an app-internal named bundle of tenants. A delegated admin assigned to the
/// template (see <see cref="TenantTemplateAssignment"/>) gains read scope to every tenant in
/// <see cref="TenantIds"/>. Adding a tenant to the template grants it to all assignees at once.
/// A Tenant Group: an app-internal named bundle of tenants. A delegated admin assigned to the
/// group (see <see cref="TenantGroupAssignment"/>) gains read scope to every tenant in
/// <see cref="TenantIds"/>. Adding a tenant to the group grants it to all assignees at once.
/// </summary>
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; }
/// <summary>Tenant IDs (lowercase) in this template.</summary>
/// <summary>Tenant IDs (lowercase) in this group.</summary>
public List<string> TenantIds { get; set; } = new();
/// <summary>Number of UPNs assigned to this template (== <see cref="Assignees"/>.Count).</summary>
/// <summary>Number of UPNs assigned to this group (== <see cref="Assignees"/>.Count).</summary>
public int AssigneeCount { get; set; }
/// <summary>The UPNs assigned to this template (for the management UI).</summary>
public List<TenantTemplateAssignment> Assignees { get; set; } = new();
/// <summary>The UPNs assigned to this group (for the management UI).</summary>
public List<TenantGroupAssignment> Assignees { get; set; } = new();
}
/// <summary>One UPN→template assignment. PK=UPN, RK=templateId in storage.</summary>
public class TenantTemplateAssignment
/// <summary>One UPN→group assignment. PK=UPN, RK=groupId in storage.</summary>
public class TenantGroupAssignment
{
public string Upn { get; set; } = string.Empty;
public string TemplateId { get; set; } = string.Empty;
public string GroupId { get; set; } = string.Empty;
/// <summary>Constants.DelegatedRoles: "DelegatedReader" (default) or "DelegatedAdmin".</summary>
public string Role { get; set; } = string.Empty;
public bool IsEnabled { get; set; } = true;
@@ -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<SettingsSectionId, React.ComponentType> = {
"diagnostics-log-paths": SectionDiagnosticsLogPaths,
"mcp-users": SectionMcpUsers,
"delegated-admins": SectionDelegatedAdmins,
"tenant-groups": SectionTenantTemplates,
"tenant-groups": SectionTenantGroups,
"config-reseed": SectionConfigReseed,
"usage-plans": SectionUsagePlans,
"alerts": SectionAlerts,
@@ -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<string, string> = {
@@ -31,17 +31,17 @@ const ROLE_LABELS: Record<string, string> = {
};
/**
* 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<TenantTemplate[]>([]);
const [groups, setGroups] = useState<TenantGroup[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
@@ -50,7 +50,7 @@ export function SectionTenantTemplates() {
const [creating, setCreating] = useState(false);
const [busyKey, setBusyKey] = useState<string | null>(null);
// Per-template input state (keyed by templateId).
// Per-group input state (keyed by groupId).
const [tenantToAdd, setTenantToAdd] = useState<Record<string, string>>({});
const [assignUpn, setAssignUpn] = useState<Record<string, string>>({});
const [assignRole, setAssignRole] = useState<Record<string, string>>({});
@@ -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() {
</p>
)}
{/* Templates */}
{/* Groups */}
{!loading && sorted.length > 0 && (
<div className="space-y-4">
{sorted.map((t) => {
@@ -310,8 +310,8 @@ export function SectionTenantTemplates() {
(x) => !t.tenantIds.some((id) => id.toLowerCase() === x.tenantId.toLowerCase()),
);
return (
<div key={t.templateId} className="border border-gray-200 rounded-lg">
{/* Template header */}
<div key={t.groupId} className="border border-gray-200 rounded-lg">
{/* Group header */}
<div className="px-4 py-2 border-b border-gray-100 bg-gray-50 rounded-t-lg flex items-center justify-between gap-2">
<div className="min-w-0">
<span className="font-medium text-gray-900 truncate">{t.name}</span>
@@ -328,7 +328,7 @@ export function SectionTenantTemplates() {
Rename
</button>
<button
onClick={() => handleDeleteTemplate(t)}
onClick={() => handleDeleteGroup(t)}
className="px-3 py-1 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50 transition-colors"
>
Delete
@@ -349,7 +349,7 @@ export function SectionTenantTemplates() {
<span className="truncate text-gray-900">{domainOf(id) || id}</span>
<button
onClick={() => handleRemoveTenant(t, id)}
disabled={busyKey === `rmtenant:${t.templateId}:${id}`}
disabled={busyKey === `rmtenant:${t.groupId}:${id}`}
className="text-red-600 hover:text-red-800 disabled:opacity-50 text-xs"
aria-label={`Remove ${domainOf(id) || id}`}
>
@@ -361,8 +361,8 @@ export function SectionTenantTemplates() {
)}
<div className="flex gap-2 pt-1">
<select
value={tenantToAdd[t.templateId] || ""}
onChange={(e) => setTenantToAdd((p) => ({ ...p, [t.templateId]: e.target.value }))}
value={tenantToAdd[t.groupId] || ""}
onChange={(e) => setTenantToAdd((p) => ({ ...p, [t.groupId]: e.target.value }))}
className="flex-1 min-w-0 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
>
<option value="">Add tenant…</option>
@@ -374,7 +374,7 @@ export function SectionTenantTemplates() {
</select>
<button
onClick={() => handleAddTenant(t)}
disabled={!tenantToAdd[t.templateId] || busyKey === `addtenant:${t.templateId}`}
disabled={!tenantToAdd[t.groupId] || busyKey === `addtenant:${t.groupId}`}
className="px-3 py-1.5 text-sm bg-sky-600 text-white rounded-lg hover:bg-sky-700 disabled:opacity-50 transition-colors"
>
Add
@@ -399,7 +399,7 @@ export function SectionTenantTemplates() {
</span>
<button
onClick={() => handleUnassign(t, a.upn)}
disabled={busyKey === `unassign:${t.templateId}:${a.upn}`}
disabled={busyKey === `unassign:${t.groupId}:${a.upn}`}
className="text-red-600 hover:text-red-800 disabled:opacity-50 text-xs shrink-0"
aria-label={`Remove ${a.upn}`}
>
@@ -412,8 +412,8 @@ export function SectionTenantTemplates() {
<div className="flex gap-2 pt-1">
<input
type="email"
value={assignUpn[t.templateId] || ""}
onChange={(e) => setAssignUpn((p) => ({ ...p, [t.templateId]: e.target.value }))}
value={assignUpn[t.groupId] || ""}
onChange={(e) => setAssignUpn((p) => ({ ...p, [t.groupId]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -425,8 +425,8 @@ export function SectionTenantTemplates() {
className="flex-1 min-w-0 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
/>
<select
value={assignRole[t.templateId] || "DelegatedReader"}
onChange={(e) => setAssignRole((p) => ({ ...p, [t.templateId]: e.target.value }))}
value={assignRole[t.groupId] || "DelegatedReader"}
onChange={(e) => setAssignRole((p) => ({ ...p, [t.groupId]: e.target.value }))}
className="px-2 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
>
<option value="DelegatedReader">Reader</option>
@@ -434,7 +434,7 @@ export function SectionTenantTemplates() {
</select>
<button
onClick={() => handleAssign(t)}
disabled={!(assignUpn[t.templateId] || "").trim() || busyKey === `assign:${t.templateId}`}
disabled={!(assignUpn[t.groupId] || "").trim() || busyKey === `assign:${t.groupId}`}
className="px-3 py-1.5 text-sm bg-sky-600 text-white rounded-lg hover:bg-sky-700 disabled:opacity-50 transition-colors"
>
Assign
+16 -16
View File
@@ -511,22 +511,22 @@ export const api = {
`${API_BASE_URL}/api/global/delegated-admins/${encodeURIComponent(upn)}/${encodeURIComponent(tenantId)}/disable`,
},
// ── Tenant Templates (MSP mode — named bundles of tenants for delegated admins) ──
tenantTemplates: {
list: () => `${API_BASE_URL}/api/global/tenant-templates`,
create: () => `${API_BASE_URL}/api/global/tenant-templates`,
rename: (templateId: string) =>
`${API_BASE_URL}/api/global/tenant-templates/${encodeURIComponent(templateId)}`,
remove: (templateId: string) =>
`${API_BASE_URL}/api/global/tenant-templates/${encodeURIComponent(templateId)}`,
addTenant: (templateId: string) =>
`${API_BASE_URL}/api/global/tenant-templates/${encodeURIComponent(templateId)}/tenants`,
removeTenant: (templateId: string, tenantId: string) =>
`${API_BASE_URL}/api/global/tenant-templates/${encodeURIComponent(templateId)}/tenants/${encodeURIComponent(tenantId)}`,
assign: (templateId: string) =>
`${API_BASE_URL}/api/global/tenant-templates/${encodeURIComponent(templateId)}/assignees`,
unassign: (templateId: string, upn: string) =>
`${API_BASE_URL}/api/global/tenant-templates/${encodeURIComponent(templateId)}/assignees/${encodeURIComponent(upn)}`,
// ── Tenant Groups (MSP mode — named bundles of tenants for delegated admins) ──
tenantGroups: {
list: () => `${API_BASE_URL}/api/global/tenant-groups`,
create: () => `${API_BASE_URL}/api/global/tenant-groups`,
rename: (groupId: string) =>
`${API_BASE_URL}/api/global/tenant-groups/${encodeURIComponent(groupId)}`,
remove: (groupId: string) =>
`${API_BASE_URL}/api/global/tenant-groups/${encodeURIComponent(groupId)}`,
addTenant: (groupId: string) =>
`${API_BASE_URL}/api/global/tenant-groups/${encodeURIComponent(groupId)}/tenants`,
removeTenant: (groupId: string, tenantId: string) =>
`${API_BASE_URL}/api/global/tenant-groups/${encodeURIComponent(groupId)}/tenants/${encodeURIComponent(tenantId)}`,
assign: (groupId: string) =>
`${API_BASE_URL}/api/global/tenant-groups/${encodeURIComponent(groupId)}/assignees`,
unassign: (groupId: string, upn: string) =>
`${API_BASE_URL}/api/global/tenant-groups/${encodeURIComponent(groupId)}/assignees/${encodeURIComponent(upn)}`,
},
// ── MCP Users ─────────────────────────────────────────────────────────────