mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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);
|
||||
}
|
||||
|
||||
+106
-106
@@ -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,
|
||||
|
||||
+110
-110
@@ -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": "<guid>" }.</summary>
|
||||
[Function("AddTenantToTemplate")]
|
||||
/// <summary>POST /api/global/tenant-groups/{groupId}/tenants — add a tenant. GlobalAdminOnly. Body: { "tenantId": "<guid>" }.</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;
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+58
-58
@@ -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
|
||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user