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