diff --git a/docs b/docs index 7c5d936b..9100388f 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 7c5d936b6473bc03028994063d754407313d24fd +Subproject commit 9100388fa6def5f2f309245a3057ea149e2b4433 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs index 8f1b4072..8df9a610 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs @@ -428,7 +428,9 @@ public class ConfigWireParityTests trialExpiresUtc, trialConsumed = true, effectiveEdition = "pro", - retentionGraceEndsUtc + retentionGraceEndsUtc, + maxDelegatedTenants = 5, + maxDelegatedTenantsOverride = 5 }, new SetTenantPlanTierResponse { @@ -437,7 +439,9 @@ public class ConfigWireParityTests TrialExpiresUtc = trialExpiresUtc, TrialConsumed = true, EffectiveEdition = "pro", - RetentionGraceEndsUtc = retentionGraceEndsUtc + RetentionGraceEndsUtc = retentionGraceEndsUtc, + MaxDelegatedTenants = 5, + MaxDelegatedTenantsOverride = 5 }); } @@ -447,6 +451,7 @@ public class ConfigWireParityTests var tenantId = "1b1b46b3-41c3-4a3a-8b2c-7e0a2b3c4d5e"; DateTime? trialExpiresUtc = null; DateTime? retentionGraceEndsUtc = null; + int? maxDelegatedTenantsOverride = null; AssertParity( new @@ -456,7 +461,9 @@ public class ConfigWireParityTests trialExpiresUtc, trialConsumed = false, effectiveEdition = "community", - retentionGraceEndsUtc + retentionGraceEndsUtc, + maxDelegatedTenants = 0, + maxDelegatedTenantsOverride }, new SetTenantPlanTierResponse { @@ -465,7 +472,9 @@ public class ConfigWireParityTests TrialExpiresUtc = null, TrialConsumed = false, EffectiveEdition = "community", - RetentionGraceEndsUtc = null + RetentionGraceEndsUtc = null, + MaxDelegatedTenants = 0, + MaxDelegatedTenantsOverride = null }); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminEditionGateTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminEditionGateTests.cs index 267b5050..22abc8d3 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminEditionGateTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminEditionGateTests.cs @@ -118,8 +118,8 @@ public class DelegatedAdminEditionGateTests AssignedBy = "ga@vendor.example", } }); - repo.Setup(r => r.GetGroupTenantsAsync("tpl-aaaaaaaa")) - .ReturnsAsync(new List { ManagedTenantA, ManagedTenantB }); + repo.Setup(r => r.GetGroupMembershipAsync("tpl-aaaaaaaa")) + .ReturnsAsync(new TenantGroupMembership { GroupId = "tpl-aaaaaaaa", TenantIds = new List { ManagedTenantA, ManagedTenantB } }); var svc = new DelegatedAdminService( repo.Object, diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs index c0eb048f..0f1cfc34 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs @@ -67,7 +67,16 @@ public class DelegatedAdminServiceTests repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny())).ReturnsAsync(assignments.ToList()); private static void GroupTenants(Mock repo, string groupId, params string[] tenantIds) => - repo.Setup(r => r.GetGroupTenantsAsync(groupId)).ReturnsAsync(tenantIds.ToList()); + GroupMembership(repo, groupId, chargeHomeTenantQuota: false, tenantIds); + + /// Stubs the hot-path membership read (tenants + meta flags from one partition read). + private static void GroupMembership(Mock repo, string groupId, bool chargeHomeTenantQuota, params string[] tenantIds) => + repo.Setup(r => r.GetGroupMembershipAsync(groupId)).ReturnsAsync(new TenantGroupMembership + { + GroupId = groupId, + TenantIds = tenantIds.ToList(), + ChargeHomeTenantQuota = chargeHomeTenantQuota, + }); private static DelegatedAdminEntry Row( string tenantId, @@ -378,7 +387,97 @@ public class DelegatedAdminServiceTests await svc.GetScopeAsync(Id(Upn)); repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny()), Times.Once); - repo.Verify(r => r.GetGroupTenantsAsync(GroupId1), Times.Once); + repo.Verify(r => r.GetGroupMembershipAsync(GroupId1), Times.Once); + } + + // --- ChargeHomeTenantQuota: MCP billing attribution rides in the scope (never authorization) --- + + [Fact] + public async Task GetScope_FlaggedGroup_MarksItsTenantsHomeCharged() + { + var (svc, repo) = Build(); + ReturnsGroups(repo, Assignment(GroupId1)); + GroupMembership(repo, GroupId1, chargeHomeTenantQuota: true, TenantA, TenantB); + + var scope = await svc.GetScopeAsync(Id(Upn)); + + Assert.True(scope.Covers(TenantA)); + Assert.True(scope.ChargesHome(TenantA)); + Assert.True(scope.ChargesHome(TenantB)); + Assert.Equal(2, scope.HomeChargedTenantIds.Count); + } + + [Fact] + public async Task GetScope_UnflaggedGroup_NothingIsHomeCharged() + { + var (svc, repo) = Build(); + ReturnsGroups(repo, Assignment(GroupId1)); + GroupTenants(repo, GroupId1, TenantA); + + var scope = await svc.GetScopeAsync(Id(Upn)); + + Assert.True(scope.Covers(TenantA)); + Assert.False(scope.ChargesHome(TenantA)); + Assert.Empty(scope.HomeChargedTenantIds); + } + + [Fact] + public async Task GetScope_DirectGrantAndFlaggedGroup_TheFlagWins() + { + // The operator's explicit group flag is the more privileged choice — a parallel direct grant on the + // same tenant does not re-attribute the reads to the managed tenant. + var (svc, repo) = Build(); + Returns(repo, Row(TenantA, Constants.DelegatedRoles.DelegatedAdmin)); + ReturnsGroups(repo, Assignment(GroupId1)); + GroupMembership(repo, GroupId1, chargeHomeTenantQuota: true, TenantA); + + var scope = await svc.GetScopeAsync(Id(Upn)); + + Assert.Equal(Constants.DelegatedRoles.DelegatedAdmin, scope.RoleFor(TenantA)); // stronger role kept + Assert.True(scope.ChargesHome(TenantA)); + } + + [Fact] + public async Task GetScope_DisabledAssignmentToFlaggedGroup_ConfersNothing() + { + var (svc, repo) = Build(); + ReturnsGroups(repo, Assignment(GroupId1, enabled: false)); + GroupMembership(repo, GroupId1, chargeHomeTenantQuota: true, TenantA); + + var scope = await svc.GetScopeAsync(Id(Upn)); + + Assert.False(scope.Covers(TenantA)); + Assert.False(scope.ChargesHome(TenantA)); + } + + [Fact] + public async Task SetChargeHomeTenantQuota_InvalidatesEveryAssignee() + { + var (svc, repo) = Build(); + ReturnsGroups(repo, Assignment(GroupId1)); + GroupMembership(repo, GroupId1, chargeHomeTenantQuota: false, TenantA); + repo.Setup(r => r.SetTenantGroupChargeHomeTenantQuotaAsync(GroupId1, true)).ReturnsAsync(true); + repo.Setup(r => r.GetGroupAssigneesAsync(GroupId1)).ReturnsAsync(new List { Assignment(GroupId1) }); + + await svc.GetScopeAsync(Id(Upn)); + var ok = await svc.SetChargeHomeTenantQuotaAsync(GroupId1, true); + await svc.GetScopeAsync(Id(Upn)); + + Assert.True(ok); + // The flag rides in the cached scope, so the flip forces a re-resolution for the assignee. + repo.Verify(r => r.GetGroupMembershipAsync(GroupId1), Times.Exactly(2)); + } + + [Fact] + public async Task SetChargeHomeTenantQuota_UnknownGroup_IsFalse_AndInvalidatesNobody() + { + var (svc, repo) = Build(); + repo.Setup(r => r.SetTenantGroupChargeHomeTenantQuotaAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + + var ok = await svc.SetChargeHomeTenantQuotaAsync("no-such-group", true); + + Assert.False(ok); + repo.Verify(r => r.GetGroupAssigneesAsync(It.IsAny()), Times.Never); } // --- Group mutations go through the service and invalidate cached scope (no stale auth) --- @@ -432,8 +531,8 @@ public class DelegatedAdminServiceTests Assert.True(unassigned); // Verify re-resolution via the tenant expansion (the unassign now also reads assignments itself, - // so asserting on GetGroupTenantsAsync isolates the two scope resolutions cleanly). - repo.Verify(r => r.GetGroupTenantsAsync(GroupId1), Times.Exactly(2)); + // so asserting on GetGroupMembershipAsync isolates the two scope resolutions cleanly). + repo.Verify(r => r.GetGroupMembershipAsync(GroupId1), Times.Exactly(2)); } [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedSlotServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedSlotServiceTests.cs new file mode 100644 index 00000000..6911523a --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedSlotServiceTests.cs @@ -0,0 +1,353 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for : the per-home-tenant count of DISTINCT managed tenants +/// (direct grants ∪ assigned group tenants, attributed via identity bindings), the plan/override limit, and +/// the pure required-slots / violation math behind the 409 DelegatedSlotLimitReached. +/// +public class DelegatedSlotServiceTests +{ + private const string Home = "99999999-9999-9999-9999-999999999999"; + private const string TenantA = "11111111-1111-1111-1111-111111111111"; + private const string TenantB = "22222222-2222-2222-2222-222222222222"; + private const string TenantC = "33333333-3333-3333-3333-333333333333"; + private const string Alice = "alice@partner.example"; + private const string Bob = "bob@partner.example"; + private static readonly DateTime Now = new(2026, 9, 2, 12, 0, 0, DateTimeKind.Utc); + + private static readonly List InvitationRows = new(); + + private static (DelegatedSlotService Svc, Mock Repo, Mock Config) Build( + TenantConfiguration? homeConfig, params string[] boundUpns) + { + InvitationRows.Clear(); + var repo = new Mock(); + repo.Setup(r => r.GetIdentityBindingsByHomeTenantAsync(It.IsAny())) + .ReturnsAsync(boundUpns.Select(u => new AdminIdentityBinding { Upn = u, TenantId = Home }).ToList()); + repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny())).ReturnsAsync(new List()); + repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny())).ReturnsAsync(new List()); + repo.Setup(r => r.GetGroupTenantsAsync(It.IsAny())).ReturnsAsync(new List()); + repo.Setup(r => r.GetIdentityBindingAsync(It.IsAny())).ReturnsAsync((AdminIdentityBinding?)null); + foreach (var upn in boundUpns) + repo.Setup(r => r.GetIdentityBindingAsync(upn)).ReturnsAsync(new AdminIdentityBinding { Upn = upn, TenantId = Home }); + + var config = new Mock(); + config.Setup(c => c.GetTenantConfigurationAsync(It.IsAny())).ReturnsAsync(homeConfig); + + var invitations = new Mock(); + invitations.Setup(i => i.GetByHomeTenantAsync(It.IsAny())).ReturnsAsync(() => InvitationRows.ToList()); + invitations.Setup(i => i.CreateAsync(It.IsAny())).Returns((DelegationInvitation r) => { InvitationRows.Add(r); return Task.CompletedTask; }); + invitations.Setup(i => i.SetStatusAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string _, string id, string status, DateTime _2, string? _3, DateTime? hold) => + { + var row = InvitationRows.FirstOrDefault(r => r.InvitationId == id); + if (row == null) return false; + row.Status = status; row.HoldUntilUtc = hold ?? row.HoldUntilUtc; + return true; + }); + + var svc = new DelegatedSlotService( + repo.Object, config.Object, invitations.Object, new MemoryCache(new MemoryCacheOptions()), + NullLogger.Instance, new TestTimeProvider(Now)); + return (svc, repo, config); + } + + private static TenantConfiguration Pro(int? overrideLimit = null) + => new() { TenantId = Home, DomainName = "partner.example", PlanTier = "pro", MaxDelegatedTenantsOverride = overrideLimit }; + + private static void Direct(Mock repo, string upn, params (string TenantId, bool Enabled, string Status)[] rows) + => repo.Setup(r => r.GetDelegatedTenantsAsync(upn)).ReturnsAsync(rows.Select(r => new DelegatedAdminEntry + { + Upn = upn, TenantId = r.TenantId, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = r.Enabled, Status = r.Status, + }).ToList()); + + private static void Group(Mock repo, string upn, string groupId, bool enabled, params string[] tenantIds) + { + repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(upn)).ReturnsAsync(new List + { + new() { Upn = upn, GroupId = groupId, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = enabled }, + }); + repo.Setup(r => r.GetGroupTenantsAsync(groupId)).ReturnsAsync(tenantIds.ToList()); + } + + // ── Counting ──────────────────────────────────────────────────────────────── + + [Fact] + public async Task Usage_DirectAndGroupTenants_AreDistinct_HomeExcluded() + { + var (svc, repo, _) = Build(Pro(), Alice); + Direct(repo, Alice, (TenantA, true, Constants.DelegatedStatus.Active), (Home, true, Constants.DelegatedStatus.Active)); + Group(repo, Alice, "grp-1", enabled: true, TenantA, TenantB); + + var usage = await svc.GetUsageAsync(Home); + + Assert.Equal(2, usage.Used); + Assert.Equal(new[] { TenantA, TenantB }, usage.ManagedTenantIds.OrderBy(t => t)); + Assert.Equal(2, usage.Limit); + Assert.Equal(2, usage.CatalogLimit); + Assert.Null(usage.OverrideLimit); + Assert.Equal("partner.example", usage.HomeTenantDomain); + Assert.Equal(0, usage.Free); + } + + [Fact] + public async Task Usage_TwoUsersSameHome_ShareTheSlots() + { + var (svc, repo, _) = Build(Pro(), Alice, Bob); + Direct(repo, Alice, (TenantA, true, Constants.DelegatedStatus.Active)); + Direct(repo, Bob, (TenantA, true, Constants.DelegatedStatus.Active), (TenantB, true, Constants.DelegatedStatus.Active)); + + var usage = await svc.GetUsageAsync(Home); + + Assert.Equal(2, usage.Used); + } + + [Fact] + public async Task Usage_DisabledCounts_RevokedDoesNot() + { + // A disabled grant is a paused relationship and keeps its slot; a Revoked row confers nothing. + var (svc, repo, _) = Build(Pro(), Alice); + Direct(repo, Alice, + (TenantA, false, Constants.DelegatedStatus.Active), + (TenantB, true, Constants.DelegatedStatus.Revoked)); + Group(repo, Alice, "grp-1", enabled: false, TenantC); + + var usage = await svc.GetUsageAsync(Home); + + Assert.Equal(new[] { TenantA, TenantC }, usage.ManagedTenantIds.OrderBy(t => t)); + } + + [Fact] + public async Task Usage_UnboundUpnsAreNotAttributed() + { + // No binding homed here ⇒ nothing counts (such grants are inert anyway). + var (svc, repo, _) = Build(Pro()); + Direct(repo, Alice, (TenantA, true, Constants.DelegatedStatus.Active)); + + var usage = await svc.GetUsageAsync(Home); + + Assert.Equal(0, usage.Used); + repo.Verify(r => r.GetDelegatedTenantsAsync(It.IsAny()), Times.Never); + } + + [Theory] + [InlineData("community", null, 0)] + [InlineData("pro", null, 2)] + [InlineData("pro", 20, 20)] + [InlineData("community", 5, 5)] // override applies regardless of edition (pre-provisioned package) + [InlineData("pro", 0, 0)] // explicit zero is a real limit, not "unset" + public async Task Usage_Limit_OverrideBeatsCatalog(string tier, int? overrideLimit, int expected) + { + var (svc, _, _) = Build(new TenantConfiguration { TenantId = Home, PlanTier = tier, MaxDelegatedTenantsOverride = overrideLimit }); + var usage = await svc.GetUsageAsync(Home); + Assert.Equal(expected, usage.Limit); + Assert.Equal(overrideLimit, usage.OverrideLimit); + } + + [Fact] + public async Task Usage_NoConfigRow_LimitZero() + { + var (svc, _, _) = Build(homeConfig: null, Alice); + var usage = await svc.GetUsageAsync(Home); + Assert.Equal(0, usage.Limit); + Assert.Null(usage.HomeTenantDomain); + } + + [Fact] + public async Task Usage_IsCached_ButCheckReadsFresh() + { + var (svc, repo, config) = Build(Pro(), Alice); + + await svc.GetUsageAsync(Home); + await svc.GetUsageAsync(Home); + await svc.CheckAsync(Home, new[] { TenantA }); + + repo.Verify(r => r.GetIdentityBindingsByHomeTenantAsync(It.IsAny()), Times.Exactly(2)); + config.Verify(c => c.GetTenantConfigurationAsync(It.IsAny()), Times.Exactly(2)); + } + + // ── Enforcement ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Check_NewTenantWithinLimit_NoViolation() + { + var (svc, repo, _) = Build(Pro(), Alice); + Direct(repo, Alice, (TenantA, true, Constants.DelegatedStatus.Active)); + + Assert.Null(await svc.CheckAsync(Home, new[] { TenantB })); + } + + [Fact] + public async Task Check_AlreadyManagedOrHome_NeedsNoSlot() + { + var (svc, repo, _) = Build(Pro(), Alice); + Direct(repo, Alice, (TenantA, true, Constants.DelegatedStatus.Active), (TenantB, true, Constants.DelegatedStatus.Active)); + + Assert.Null(await svc.CheckAsync(Home, new[] { TenantA.ToUpperInvariant(), Home, TenantB })); + } + + [Fact] + public async Task Check_OverLimit_ReportsUsedLimitRequiredAndDomain() + { + var (svc, repo, _) = Build(Pro(), Alice); + Direct(repo, Alice, (TenantA, true, Constants.DelegatedStatus.Active), (TenantB, true, Constants.DelegatedStatus.Active)); + + var v = await svc.CheckAsync(Home, new[] { TenantC }); + + Assert.NotNull(v); + Assert.Equal(Home, v!.HomeTenantId); + Assert.Equal("partner.example", v.HomeTenantDomain); + Assert.Equal(2, v.Used); + Assert.Equal(2, v.Limit); + Assert.Equal(1, v.Required); + } + + [Fact] + public async Task Check_GroupAssignment_CountsTheWholeGroup() + { + // Assigning a 3-tenant group to a user of a 2-slot tenant needs 3 slots at once. + var (svc, _, _) = Build(Pro(), Alice); + var v = await svc.CheckAsync(Home, new[] { TenantA, TenantB, TenantC, TenantC }); + Assert.NotNull(v); + Assert.Equal(3, v!.Required); + } + + [Fact] + public async Task Check_RaisedOverride_IsSeenOnTheNextCheck() + { + // The GA "raise, then retry" round trip: the limit is read uncached on every check. + var (svc, repo, config) = Build(Pro(), Alice); + Direct(repo, Alice, (TenantA, true, Constants.DelegatedStatus.Active), (TenantB, true, Constants.DelegatedStatus.Active)); + + Assert.NotNull(await svc.CheckAsync(Home, new[] { TenantC })); + config.Setup(c => c.GetTenantConfigurationAsync(It.IsAny())).ReturnsAsync(Pro(overrideLimit: 3)); + Assert.Null(await svc.CheckAsync(Home, new[] { TenantC })); + } + + [Fact] + public async Task CheckAddTenantToGroup_ChecksEveryAssigneesHome_SkipsUnbound() + { + var (svc, repo, _) = Build(Pro(), Alice); + Direct(repo, Alice, (TenantA, true, Constants.DelegatedStatus.Active), (TenantB, true, Constants.DelegatedStatus.Active)); + + var v = await svc.CheckAddTenantToGroupAsync(new[] { Alice, "nobody@nowhere.example" }, TenantC); + + Assert.NotNull(v); + Assert.Equal(Home, v!.HomeTenantId); + Assert.Null(await svc.CheckAddTenantToGroupAsync(new[] { "nobody@nowhere.example" }, TenantC)); + } + + // ── Pure helpers ──────────────────────────────────────────────────────────── + + [Fact] + public void Evaluate_NoRequired_NeverViolates_EvenOverLimit() + { + var usage = new DelegatedSlotUsage(Home, null, 1, 1, null, + new HashSet(new[] { TenantA, TenantB }, StringComparer.OrdinalIgnoreCase), 0, Array.Empty()); + Assert.Null(DelegatedSlotService.Evaluate(usage, 0)); + Assert.NotNull(DelegatedSlotService.Evaluate(usage, 1)); + } + + [Fact] + public void RequiredSlots_PendingAndHolds_OccupySlots() + { + // Phase C: a pending invitation and a release hold each hold a slot. + var hold = new DelegationInvitation { InvitationId = "h1", HomeTenantId = Home, Status = Constants.DelegationInvitationStatus.Released, HoldUntilUtc = Now.AddHours(1) }; + var usage = new DelegatedSlotUsage(Home, null, 3, 2, 3, + new HashSet(new[] { TenantA }, StringComparer.OrdinalIgnoreCase), PendingInvitations: 1, Holds: new[] { hold }); + Assert.Equal(3, usage.Used); + Assert.Equal(0, usage.Free); + Assert.NotNull(DelegatedSlotService.Evaluate(usage, DelegatedSlotService.RequiredSlots(usage, new[] { TenantB }))); + } + + // ── Phase C: owned group, invitations, holds ──────────────────────────────── + + [Fact] + public async Task OwnedGroup_CountsEvenWithoutAssignees() + { + // A customer that accepted an invitation occupies a slot before anyone in the MSP is assigned. + var (svc, repo, _) = Build(Pro()); + repo.Setup(r => r.GetGroupTenantsAsync(Constants.TenantGroupIds.ForHomeTenant(Home))).ReturnsAsync(new List { TenantA }); + + var usage = await svc.GetUsageAsync(Home); + + Assert.Equal(new[] { TenantA }, usage.ManagedTenantIds); + } + + [Fact] + public async Task PendingInvitationsAndActiveHolds_OccupySlots_ExpiredAndLapsedDoNot() + { + var (svc, _, _) = Build(Pro()); + InvitationRows.Add(new DelegationInvitation { InvitationId = "p", HomeTenantId = Home, Status = Constants.DelegationInvitationStatus.Pending, ExpiresAt = Now.AddDays(1) }); + InvitationRows.Add(new DelegationInvitation { InvitationId = "x", HomeTenantId = Home, Status = Constants.DelegationInvitationStatus.Pending, ExpiresAt = Now }); + InvitationRows.Add(new DelegationInvitation { InvitationId = "h", HomeTenantId = Home, Status = Constants.DelegationInvitationStatus.Released, HoldUntilUtc = Now.AddHours(5) }); + InvitationRows.Add(new DelegationInvitation { InvitationId = "l", HomeTenantId = Home, Status = Constants.DelegationInvitationStatus.Released, HoldUntilUtc = Now.AddHours(-1) }); + + var usage = await svc.GetUsageAsync(Home); + + Assert.Equal(1, usage.PendingInvitations); + Assert.Equal(1, usage.ActiveHolds); + Assert.Equal("h", usage.Holds.Single().InvitationId); + Assert.Equal(2, usage.Used); + Assert.Equal(0, usage.Free); + Assert.NotNull(await svc.CheckReserveAsync(Home, 1)); + } + + [Fact] + public async Task RecordRelease_FlipsTheAcceptedRow_ElseSynthesizes_HoldIs24h() + { + var (svc, _, _) = Build(Pro()); + InvitationRows.Add(new DelegationInvitation { InvitationId = "a", HomeTenantId = Home, Status = Constants.DelegationInvitationStatus.Accepted, TenantId = TenantA, AcceptedAt = Now.AddDays(-2) }); + + var fromRow = await svc.RecordReleaseAsync(Home, TenantA, "admin@partner.example"); + var synthetic = await svc.RecordReleaseAsync(Home, TenantB, "ga@vendor.example"); + + Assert.Equal("a", fromRow.InvitationId); + Assert.Equal(Constants.DelegationInvitationStatus.Released, fromRow.Status); + Assert.Equal(Now.AddHours(24), fromRow.HoldUntilUtc); + Assert.NotEqual("a", synthetic.InvitationId); + Assert.Equal(TenantB, synthetic.TenantId); + Assert.Equal(Constants.DelegatedSource.OperatorGranted, synthetic.Source); + Assert.Equal(2, InvitationRows.Count); + Assert.Equal(2, (await svc.GetUsageAsync(Home)).ActiveHolds); + } + + [Fact] + public async Task ReleaseHold_OneOrAll_EndsTheHoldNow() + { + var (svc, _, _) = Build(Pro()); + InvitationRows.Add(new DelegationInvitation { InvitationId = "h1", HomeTenantId = Home, Status = Constants.DelegationInvitationStatus.Released, HoldUntilUtc = Now.AddHours(5) }); + InvitationRows.Add(new DelegationInvitation { InvitationId = "h2", HomeTenantId = Home, Status = Constants.DelegationInvitationStatus.Released, HoldUntilUtc = Now.AddHours(5) }); + + Assert.Equal(1, await svc.ReleaseHoldAsync(Home, "h1", releaseAll: false, "ga@vendor.example")); + Assert.Equal(1, (await svc.GetUsageAsync(Home)).ActiveHolds); + Assert.Equal(0, await svc.ReleaseHoldAsync(Home, "nope", releaseAll: false, "ga@vendor.example")); + Assert.Equal(1, await svc.ReleaseHoldAsync(Home, null, releaseAll: true, "ga@vendor.example")); + Assert.Equal(0, (await svc.GetUsageAsync(Home)).ActiveHolds); + } + + [Fact] + public async Task CheckAccept_NetZero_UnlessLimitWasLowered() + { + var (svc, repo, config) = Build(Pro()); + InvitationRows.Add(new DelegationInvitation { InvitationId = "p", HomeTenantId = Home, Status = Constants.DelegationInvitationStatus.Pending, ExpiresAt = Now.AddDays(1) }); + repo.Setup(r => r.GetGroupTenantsAsync(Constants.TenantGroupIds.ForHomeTenant(Home))).ReturnsAsync(new List { TenantA }); + + Assert.Null(await svc.CheckAcceptAsync(Home, TenantB)); // 1 managed + 1 pending = 2 of 2, pending converts + config.Setup(c => c.GetTenantConfigurationAsync(It.IsAny())).ReturnsAsync(Pro(overrideLimit: 1)); + var v = await svc.CheckAcceptAsync(Home, TenantB); + Assert.NotNull(v); + Assert.Equal(1, v!.Used); + Assert.Equal(1, v.Limit); + Assert.Null(await svc.CheckAcceptAsync(Home, TenantA)); // already managed needs nothing + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DelegationInviteTicketTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DelegationInviteTicketTests.cs new file mode 100644 index 00000000..c0503ff9 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DelegationInviteTicketTests.cs @@ -0,0 +1,86 @@ +using AutopilotMonitor.Shared.Delegation; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Round-trip + tamper + replay tests for the self-service delegation invitation ticket. The ticket only +/// LOCATES the invitation row (home tenant + invitation id); the row's status/ETag is the one-shot authority, +/// and the accepting tenant is always the caller's JWT tenant. Signing key injected process-wide by TestSetup. +/// +public class DelegationInviteTicketTests +{ + private const string Home = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string InvitationId = "0f3c2a9b1d4e4f5a8b6c7d8e9f0a1b2c"; + private static readonly DateTimeOffset Now = new(2026, 9, 2, 12, 0, 0, TimeSpan.Zero); + + static DelegationInviteTicketTests() + { + DelegationInviteTicket.SetSigningKeyForTesting(Convert.FromBase64String("dGVzdC1zaWduaW5nLWtleS0zMi1ieXRlcy1sb25nISEhISE=")); + } + + [Fact] + public void RoundTrip_ValidTicket_DecodesToSameValues_Lowercased() + { + var token = DelegationInviteTicket.Encode(Home.ToUpperInvariant(), InvitationId, Now); + + var ok = DelegationInviteTicket.TryDecode(token, out var home, out var iid, out var reason, Now.AddDays(1)); + + Assert.True(ok); + Assert.Null(reason); + Assert.Equal(Home, home); + Assert.Equal(InvitationId, iid); + } + + [Fact] + public void Tampered_Payload_Rejected() + { + var token = DelegationInviteTicket.Encode(Home, InvitationId, Now); + var chars = token.ToCharArray(); + var mid = chars.Length / 2; + chars[mid] = chars[mid] == 'A' ? 'B' : 'A'; + + var ok = DelegationInviteTicket.TryDecode(new string(chars), out _, out _, out var reason, Now); + + Assert.False(ok); + Assert.NotNull(reason); + } + + [Fact] + public void Expired_AfterTtl_Rejected_ButValidJustBefore() + { + var token = DelegationInviteTicket.Encode(Home, InvitationId, Now); + + Assert.True(DelegationInviteTicket.TryDecode(token, out _, out _, out _, Now.Add(DelegationInviteTicket.DefaultTtl))); + var ok = DelegationInviteTicket.TryDecode(token, out _, out _, out var reason, Now.Add(DelegationInviteTicket.DefaultTtl).AddSeconds(1)); + Assert.False(ok); + Assert.Equal("expired", reason); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-base64!!")] + [InlineData("eyJ4IjoxfQ")] // {"x":1} — well-formed base64url, wrong shape + public void Garbage_Rejected(string raw) + { + Assert.False(DelegationInviteTicket.TryDecode(raw, out _, out _, out var reason, Now)); + Assert.NotNull(reason); + } + + [Fact] + public void DiagnosticsTicket_IsNotAnInvitation_DomainSeparation() + { + // Same signing key, different purpose + shape: a download ticket must never redeem as an invitation. + var diag = AutopilotMonitor.Shared.Diagnostics.DiagnosticsDownloadTicket.Encode(Home, "blob.zip", "Hosted", Now); + Assert.False(DelegationInviteTicket.TryDecode(diag, out _, out _, out var reason, Now)); + Assert.NotNull(reason); + } + + [Fact] + public void Encode_RequiresBothIds() + { + Assert.Throws(() => DelegationInviteTicket.Encode("", InvitationId)); + Assert.Throws(() => DelegationInviteTicket.Encode(Home, " ")); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DelegationSelfServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DelegationSelfServiceTests.cs new file mode 100644 index 00000000..99921272 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DelegationSelfServiceTests.cs @@ -0,0 +1,436 @@ +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Delegation; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for — the customer-facing delegation flow: invitation minting +/// (Pro gate, slot reservation), the ordered accept chain (every rejection code), removal with the 24-hour +/// hold and dual audit, assignee management bound to tenant membership, and the managed tenant's +/// "who manages me" view. Storage is mocked; DelegatedAdminService / DelegatedSlotService are real. +/// +public class DelegationSelfServiceTests +{ + private const string Home = "99999999-9999-9999-9999-999999999999"; + private const string Customer = "11111111-1111-1111-1111-111111111111"; + private const string Other = "22222222-2222-2222-2222-222222222222"; + private const string MspAdmin = "admin@partner.example"; + private const string MspUser = "analyst@partner.example"; + private const string CustomerAdmin = "owner@customer.example"; + private static readonly DateTime Now = new(2026, 9, 2, 12, 0, 0, DateTimeKind.Utc); + private static readonly string GroupId = Constants.TenantGroupIds.ForHomeTenant(Home); + + static DelegationSelfServiceTests() + { + DelegationInviteTicket.SetSigningKeyForTesting(Convert.FromBase64String("dGVzdC1zaWduaW5nLWtleS0zMi1ieXRlcy1sb25nISEhISE=")); + } + + private sealed class Harness + { + public required DelegationSelfService Svc { get; init; } + public required Mock Repo { get; init; } + public required Mock Invitations { get; init; } + public required Mock Audit { get; init; } + public required FakeSignalRNotificationService SignalR { get; init; } + public required List Rows { get; init; } + public required List GroupTenants { get; init; } + } + + private static Harness Build(TenantEdition homeEdition = TenantEdition.Pro, int? slotOverride = null) + { + var rows = new List(); + var groupTenants = new List(); + + var repo = new Mock(); + repo.Setup(r => r.GetGroupTenantsAsync(GroupId)).ReturnsAsync(() => groupTenants.ToList()); + repo.Setup(r => r.GetGroupTenantsAsync(It.Is(g => g != GroupId))).ReturnsAsync(new List()); + repo.Setup(r => r.GetIdentityBindingsByHomeTenantAsync(It.IsAny())).ReturnsAsync(new List()); + repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny())).ReturnsAsync(new List()); + repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny())).ReturnsAsync(new List()); + repo.Setup(r => r.GetIdentityBindingAsync(It.IsAny())).ReturnsAsync((AdminIdentityBinding?)null); + repo.Setup(r => r.GetTenantGroupAsync(GroupId)).ReturnsAsync(() => new TenantGroup { GroupId = GroupId, Name = "Customers of partner.example", OwnerTenantId = Home, TenantIds = groupTenants.ToList() }); + repo.Setup(r => r.GetGroupAssigneesAsync(GroupId)).ReturnsAsync(new List + { + new() { Upn = MspUser, GroupId = GroupId, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true, AssignedBy = MspAdmin }, + }); + repo.Setup(r => r.AddTenantToGroupAsync(GroupId, It.IsAny())).ReturnsAsync((string _, string t) => { groupTenants.Add(t); return true; }); + repo.Setup(r => r.RemoveTenantFromGroupAsync(GroupId, It.IsAny())).ReturnsAsync((string _, string t) => groupTenants.Remove(t)); + repo.Setup(r => r.EnsureOwnedTenantGroupAsync(It.IsAny(), It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + repo.Setup(r => r.GetTenantMemberAsync(Home, MspUser)).ReturnsAsync(new TenantMember { Upn = MspUser, TenantId = Home, Role = "Operator" }); + repo.Setup(r => r.GetTenantMemberAsync(It.IsAny(), It.Is(u => u != MspUser))).ReturnsAsync((TenantMember?)null); + repo.Setup(r => r.AssignGroupAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); + repo.Setup(r => r.UnassignGroupAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + repo.Setup(r => r.GetDelegatedAssigneesAsync(It.IsAny())).ReturnsAsync(new List()); + repo.Setup(r => r.GetGroupIdsContainingTenantAsync(It.IsAny())).ReturnsAsync(new List()); + + var invitations = new Mock(); + invitations.Setup(i => i.GetByHomeTenantAsync(It.IsAny())).ReturnsAsync(() => rows.ToList()); + invitations.Setup(i => i.GetAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string h, string id) => rows.FirstOrDefault(r => r.HomeTenantId == h && r.InvitationId == id)); + invitations.Setup(i => i.CreateAsync(It.IsAny())).Returns((DelegationInvitation r) => { rows.Add(r); return Task.CompletedTask; }); + invitations.Setup(i => i.TryAcceptAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string h, string id, string _, string t, string by, DateTime at) => + { + var row = rows.FirstOrDefault(r => r.HomeTenantId == h && r.InvitationId == id && r.Status == Constants.DelegationInvitationStatus.Pending); + if (row == null) return false; + row.Status = Constants.DelegationInvitationStatus.Accepted; row.TenantId = t; row.AcceptedBy = by; row.AcceptedAt = at; + return true; + }); + invitations.Setup(i => i.SetStatusAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string h, string id, string status, DateTime at, string? by, DateTime? hold) => + { + var row = rows.FirstOrDefault(r => r.HomeTenantId == h && r.InvitationId == id); + if (row == null) return false; + row.Status = status; row.HoldUntilUtc = hold ?? row.HoldUntilUtc; + return true; + }); + + var configRepo = new Mock(); + configRepo.Setup(c => c.GetTenantConfigurationAsync(Home)).ReturnsAsync(new TenantConfiguration + { + TenantId = Home, DomainName = "partner.example", PlanTier = homeEdition == TenantEdition.Pro ? "pro" : "community", MaxDelegatedTenantsOverride = slotOverride, + }); + configRepo.Setup(c => c.GetTenantConfigurationAsync(Customer)).ReturnsAsync(new TenantConfiguration { TenantId = Customer, DomainName = "customer.example", PlanTier = "community" }); + configRepo.Setup(c => c.GetTenantConfigurationAsync(Other)).ReturnsAsync((TenantConfiguration?)null); + + var cache = new MemoryCache(new MemoryCacheOptions()); + var time = new TestTimeProvider(Now); + var entitlements = new StubTenantEntitlementService(t => t == Home ? homeEdition : TenantEdition.Community); + var delegatedAdmins = new DelegatedAdminService(repo.Object, new StubAdminIdentityBindingService(bound: true), entitlements, cache, NullLogger.Instance); + var slots = new DelegatedSlotService(repo.Object, configRepo.Object, invitations.Object, cache, NullLogger.Instance, time); + var audit = new Mock(); + audit.Setup(a => a.LogAuditEntryAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>())) + .ReturnsAsync(true); + var signalR = new FakeSignalRNotificationService(); + + var svc = new DelegationSelfService(repo.Object, invitations.Object, delegatedAdmins, slots, entitlements, configRepo.Object, audit.Object, signalR, + NullLogger.Instance, time); + return new Harness { Svc = svc, Repo = repo, Invitations = invitations, Audit = audit, SignalR = signalR, Rows = rows, GroupTenants = groupTenants }; + } + + private static DelegationInvitation Pending(string id = "inv1", DateTime? expires = null) => new() + { + InvitationId = id, HomeTenantId = Home, Status = Constants.DelegationInvitationStatus.Pending, + Role = Constants.DelegatedRoles.DelegatedReader, Source = Constants.DelegatedSource.CustomerDelegated, + CreatedBy = MspAdmin, CreatedAt = Now.AddHours(-1), ExpiresAt = expires ?? Now.AddDays(6), ETag = "W/\"1\"", + }; + + private static string Token(string id = "inv1") => DelegationInviteTicket.Encode(Home, id, new DateTimeOffset(Now, TimeSpan.Zero)); + + private static void VerifyAudit(Mock audit, string tenant, string action, string entity, Times times) + => audit.Verify(a => a.LogAuditEntryAsync(tenant, action, entity, It.IsAny(), It.IsAny(), It.IsAny?>()), times); + + // ── Invitations ───────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateInvitation_CommunityHome_403() + { + var h = Build(homeEdition: TenantEdition.Community); + var r = await h.Svc.CreateInvitationAsync(Home, MspAdmin); + Assert.False(r.Ok); + Assert.Equal(403, r.Failure!.Status); + Assert.Equal(Constants.DelegationCodes.DelegatedAdminNotAllowed, r.Failure.Code); + Assert.Empty(h.Rows); + } + + [Fact] + public async Task CreateInvitation_NoFreeSlot_409_WithViolation() + { + var h = Build(); + h.GroupTenants.Add(Customer); + h.GroupTenants.Add(Other); // Pro = 2 slots, both used + + var r = await h.Svc.CreateInvitationAsync(Home, MspAdmin); + + Assert.False(r.Ok); + Assert.Equal(409, r.Failure!.Status); + Assert.Equal(Constants.DelegatedSlots.LimitReachedCode, r.Failure.Code); + Assert.NotNull(r.Failure.SlotViolation); + Assert.Equal(2, r.Failure.SlotViolation!.Used); + Assert.Empty(h.Rows); + } + + [Fact] + public async Task CreateInvitation_Ok_PendingRow_TokenLocatesIt_AuditedOnHome() + { + var h = Build(); + + var r = await h.Svc.CreateInvitationAsync(Home, "Admin@Partner.Example"); + + Assert.True(r.Ok); + var (row, token) = r.Value; + Assert.Equal(Constants.DelegationInvitationStatus.Pending, row.Status); + Assert.Equal(Constants.DelegatedSource.CustomerDelegated, row.Source); + Assert.Equal(Now.Add(DelegationInviteTicket.DefaultTtl), row.ExpiresAt); + Assert.Equal(MspAdmin, row.CreatedBy); + Assert.True(DelegationInviteTicket.TryDecode(token, out var home, out var iid, out _, new DateTimeOffset(Now, TimeSpan.Zero))); + Assert.Equal(Home, home); + Assert.Equal(row.InvitationId, iid); + VerifyAudit(h.Audit, Home, "CREATE", "DelegationInvitation", Times.Once()); + // The pending row now occupies a slot. + var usage = await h.Svc.ListManagedAsync(Home); + Assert.Equal(1, usage.Slots.PendingInvitations); + } + + [Fact] + public async Task CancelInvitation_OnlyPending_404Otherwise() + { + var h = Build(); + h.Rows.Add(Pending("p")); + h.Rows.Add(new DelegationInvitation { InvitationId = "a", HomeTenantId = Home, Status = Constants.DelegationInvitationStatus.Accepted, TenantId = Customer }); + + Assert.True((await h.Svc.CancelInvitationAsync(Home, "p", MspAdmin)).Ok); + Assert.Equal(Constants.DelegationInvitationStatus.Cancelled, h.Rows[0].Status); + var again = await h.Svc.CancelInvitationAsync(Home, "a", MspAdmin); + Assert.Equal(404, again.Failure!.Status); + Assert.Equal(404, (await h.Svc.CancelInvitationAsync(Home, "nope", MspAdmin)).Failure!.Status); + } + + // ── Accept chain ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Accept_InvalidToken_400() + { + var h = Build(); + var r = await h.Svc.AcceptAsync("garbage", Customer, CustomerAdmin); + Assert.Equal(400, r.Failure!.Status); + Assert.Equal(Constants.DelegationCodes.InvalidInvitation, r.Failure.Code); + } + + [Fact] + public async Task Accept_UnknownRow_400() + { + var h = Build(); + var r = await h.Svc.AcceptAsync(Token("missing"), Customer, CustomerAdmin); + Assert.Equal(400, r.Failure!.Status); + Assert.Equal(Constants.DelegationCodes.InvalidInvitation, r.Failure.Code); + } + + [Theory] + [InlineData(Constants.DelegationInvitationStatus.Cancelled, Constants.DelegationCodes.InvitationCancelled)] + [InlineData(Constants.DelegationInvitationStatus.Accepted, Constants.DelegationCodes.InvitationAlreadyUsed)] + [InlineData(Constants.DelegationInvitationStatus.Released, Constants.DelegationCodes.InvitationAlreadyUsed)] + public async Task Accept_TerminalRow_409(string status, string expectedCode) + { + var h = Build(); + var row = Pending(); row.Status = status; h.Rows.Add(row); + var r = await h.Svc.AcceptAsync(Token(), Customer, CustomerAdmin); + Assert.Equal(409, r.Failure!.Status); + Assert.Equal(expectedCode, r.Failure.Code); + } + + [Fact] + public async Task Accept_ExpiredRow_409() + { + var h = Build(); + h.Rows.Add(Pending(expires: Now)); + var r = await h.Svc.AcceptAsync(Token(), Customer, CustomerAdmin); + Assert.Equal(Constants.DelegationCodes.InvitationExpired, r.Failure!.Code); + } + + [Fact] + public async Task Accept_ByInvitingTenant_409() + { + var h = Build(); + h.Rows.Add(Pending()); + var r = await h.Svc.AcceptAsync(Token(), Home, MspAdmin); + Assert.Equal(Constants.DelegationCodes.CannotAcceptOwnInvitation, r.Failure!.Code); + } + + [Fact] + public async Task Accept_AlreadyManaged_409() + { + var h = Build(); + h.Rows.Add(Pending()); + h.GroupTenants.Add(Customer); + var r = await h.Svc.AcceptAsync(Token(), Customer, CustomerAdmin); + Assert.Equal(Constants.DelegationCodes.AlreadyManaged, r.Failure!.Code); + } + + [Fact] + public async Task Accept_ManagerLostPro_409() + { + var h = Build(homeEdition: TenantEdition.Community); + h.Rows.Add(Pending()); + var r = await h.Svc.AcceptAsync(Token(), Customer, CustomerAdmin); + Assert.Equal(Constants.DelegationCodes.ManagerNotEntitled, r.Failure!.Code); + } + + [Fact] + public async Task Accept_LimitLoweredSinceInvitation_409_SlotViolation() + { + // 1 slot, already used by another customer; the pending row was sent when there were 2. + var h = Build(slotOverride: 1); + h.Rows.Add(Pending()); + h.GroupTenants.Add(Other); + var r = await h.Svc.AcceptAsync(Token(), Customer, CustomerAdmin); + Assert.Equal(Constants.DelegatedSlots.LimitReachedCode, r.Failure!.Code); + Assert.NotNull(r.Failure.SlotViolation); + Assert.Equal(Constants.DelegationInvitationStatus.Pending, h.Rows[0].Status); // untouched + } + + [Fact] + public async Task Accept_ConcurrentConsumer_409_AlreadyUsed() + { + var h = Build(); + h.Rows.Add(Pending()); + h.Invitations.Setup(i => i.TryAcceptAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(false); // ETag mismatch + var r = await h.Svc.AcceptAsync(Token(), Customer, CustomerAdmin); + Assert.Equal(Constants.DelegationCodes.InvitationAlreadyUsed, r.Failure!.Code); + h.Repo.Verify(x => x.AddTenantToGroupAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Accept_Ok_JoinsOwnedGroup_AuditsBothTenants() + { + var h = Build(); + h.Rows.Add(Pending()); + + var r = await h.Svc.AcceptAsync(Token(), Customer.ToUpperInvariant(), "Owner@Customer.Example"); + + Assert.True(r.Ok); + Assert.Equal(Home, r.Value!.HomeTenantId); + Assert.Equal("partner.example", r.Value.HomeTenantDomain); + Assert.Equal(Customer, r.Value.ManagedTenantId); + Assert.Contains(Customer, h.GroupTenants); + Assert.Equal(Constants.DelegationInvitationStatus.Accepted, h.Rows[0].Status); + Assert.Equal(Customer, h.Rows[0].TenantId); + h.Repo.Verify(x => x.EnsureOwnedTenantGroupAsync(GroupId, "Customers of partner.example", Home), Times.Once); + // Customer's trail: per current assignee + the acceptance itself. Home's trail: the acceptance. + h.Audit.Verify(a => a.LogAuditEntryAsync(Customer, "CREATE", "DelegatedGroupAccess", MspUser, CustomerAdmin, + It.Is?>(d => d != null && d["Reason"] == "customer-accepted-invitation" && d["Source"] == Constants.DelegatedSource.CustomerDelegated)), Times.Once); + VerifyAudit(h.Audit, Customer, "ACCEPT", "DelegationInvitation", Times.Once()); + VerifyAudit(h.Audit, Home, "ACCEPT", "DelegationInvitation", Times.Once()); + } + + [Fact] + public async Task Preview_ReportsStatusAndDomains_WithoutMutating() + { + var h = Build(); + h.Rows.Add(Pending(expires: Now)); // derived Expired + + var r = await h.Svc.PreviewAsync(Token(), Customer); + + Assert.True(r.Ok); + Assert.Equal(Constants.DelegationInvitationStatus.Expired, r.Value!.Status); + Assert.Equal("partner.example", r.Value.HomeTenantDomain); + Assert.Equal("customer.example", r.Value.TargetTenantDomain); + Assert.Equal(Constants.DelegationInvitationStatus.Pending, h.Rows[0].Status); + } + + // ── Removal + hold ────────────────────────────────────────────────────────── + + [Fact] + public async Task RemoveManaged_NotSelfService_404() + { + var h = Build(); + var r = await h.Svc.RemoveManagedAsync(Home, Customer, MspAdmin); + Assert.Equal(404, r.Failure!.Status); + Assert.Equal(Constants.DelegationCodes.NotManagedBySelfService, r.Failure.Code); + VerifyAudit(h.Audit, Customer, "DELETE", "DelegatedGroupAccess", Times.Never()); + } + + [Fact] + public async Task RemoveManaged_Ok_HoldsSlot24h_AuditsBoth_DisconnectsAssignees() + { + var h = Build(); + h.GroupTenants.Add(Customer); + h.Rows.Add(new DelegationInvitation { InvitationId = "a1", HomeTenantId = Home, Status = Constants.DelegationInvitationStatus.Accepted, TenantId = Customer, AcceptedAt = Now.AddDays(-3), ExpiresAt = Now, CreatedAt = Now.AddDays(-4) }); + + var r = await h.Svc.RemoveManagedAsync(Home, Customer, MspAdmin); + + Assert.True(r.Ok); + Assert.DoesNotContain(Customer, h.GroupTenants); + Assert.Equal(Constants.DelegationInvitationStatus.Released, h.Rows[0].Status); + Assert.Equal(Now.Add(DelegatedSlotService.ReleaseHold), h.Rows[0].HoldUntilUtc); + var usage = await h.Svc.ListManagedAsync(Home); + Assert.Equal(1, usage.Slots.ActiveHolds); + Assert.Equal(1, usage.Slots.Used); // the hold still occupies the slot + h.Audit.Verify(a => a.LogAuditEntryAsync(Customer, "DELETE", "DelegatedGroupAccess", MspUser, MspAdmin, + It.Is?>(d => d != null && d["Reason"] == "customer-removed-by-manager")), Times.Once); + VerifyAudit(h.Audit, Home, "DELETE", "DelegationManagedTenant", Times.Once()); + Assert.Contains(MspUser, h.SignalR.DisconnectedUsers); + } + + [Fact] + public async Task RevokeManager_ByCustomer_SameEffect_DifferentReason() + { + var h = Build(); + h.GroupTenants.Add(Customer); // operator added it by hand — no Accepted row → synthetic hold + + var r = await h.Svc.RevokeManagerAsync(Customer, Home, CustomerAdmin); + + Assert.True(r.Ok); + var hold = Assert.Single(h.Rows); + Assert.Equal(Constants.DelegationInvitationStatus.Released, hold.Status); + Assert.Equal(Constants.DelegatedSource.OperatorGranted, hold.Source); + Assert.Equal(Customer, hold.TenantId); + h.Audit.Verify(a => a.LogAuditEntryAsync(Customer, "DELETE", "DelegatedGroupAccess", MspUser, CustomerAdmin, + It.Is?>(d => d != null && d["Reason"] == "customer-revoked")), Times.Once); + } + + // ── Assignees ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task Assign_NonMember_400_Member_Ok() + { + var h = Build(); + var bad = await h.Svc.AssignAsync(Home, "stranger@elsewhere.example", MspAdmin); + Assert.Equal(400, bad.Failure!.Status); + Assert.Equal(Constants.DelegationCodes.NotATenantMember, bad.Failure.Code); + + var ok = await h.Svc.AssignAsync(Home, "Analyst@Partner.Example", MspAdmin); + Assert.True(ok.Ok); + Assert.Equal(MspUser, ok.Value!.Upn); + h.Repo.Verify(x => x.AssignGroupAsync(MspUser, GroupId, Constants.DelegatedRoles.DelegatedReader, true, MspAdmin), Times.Once); + h.Repo.Verify(x => x.EnsureOwnedTenantGroupAsync(GroupId, It.IsAny(), Home), Times.Once); + } + + [Fact] + public async Task Unassign_NotAssigned_404() + { + var h = Build(); + h.Repo.Setup(x => x.GetGroupAssignmentsForUpnAsync(It.IsAny())).ReturnsAsync(new List()); + var r = await h.Svc.UnassignAsync(Home, MspUser, MspAdmin); + Assert.Equal(404, r.Failure!.Status); + } + + // ── Managers (customer side) ──────────────────────────────────────────────── + + [Fact] + public async Task ListManagers_OwnerGroupIsRevocable_DirectGrantsAreNot() + { + var h = Build(); + h.GroupTenants.Add(Customer); + h.Rows.Add(new DelegationInvitation { InvitationId = "a1", HomeTenantId = Home, Status = Constants.DelegationInvitationStatus.Accepted, TenantId = Customer, AcceptedAt = Now.AddDays(-3) }); + h.Repo.Setup(x => x.GetGroupIdsContainingTenantAsync(Customer)).ReturnsAsync(new List { GroupId }); + h.Repo.Setup(x => x.GetDelegatedAssigneesAsync(Customer)).ReturnsAsync(new List + { + new() { Upn = "ops@vendor.example", TenantId = Customer, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true, Status = Constants.DelegatedStatus.Active, GrantedAt = Now.AddDays(-10) }, + new() { Upn = "gone@vendor.example", TenantId = Customer, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true, Status = Constants.DelegatedStatus.Revoked }, + }); + + var managers = await h.Svc.ListManagersAsync(Customer); + + Assert.Equal(2, managers.Count); + var owned = managers.Single(m => m.GroupId == GroupId); + Assert.Equal(Home, owned.OwnerTenantId); + Assert.Equal("partner.example", owned.OwnerDomain); + Assert.Equal(DelegationSelfService.SourceSelfService, owned.Source); + Assert.True(owned.Revocable); + Assert.Equal(Now.AddDays(-3), owned.SinceUtc); + var direct = managers.Single(m => m.GroupId == null); + Assert.Equal(DelegationSelfService.SourceOperator, direct.Source); + Assert.False(direct.Revocable); + Assert.Single(direct.Assignees); // the revoked row confers nothing + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DelegationWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DelegationWireParityTests.cs new file mode 100644 index 00000000..53eed294 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DelegationWireParityTests.cs @@ -0,0 +1,213 @@ +using AutopilotMonitor.Functions.Functions.Admin; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Wire parity for the delegated (MSP) slot DTOs: the 409 DelegatedSlotLimitReached body and the +/// GA slot-usage read. Anonymous literal on the left = the exact JSON the web/MCP consume. +/// +public class DelegationWireParityTests +{ + private const string Home = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0001"; + private const string TenantA = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0002"; + private const string TenantB = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0003"; + + [Fact] + public void DelegatedSlotLimitReachedResponse_matches_the_409_shape() + { + var body = DelegatedSlotResponses.Build(new DelegatedSlotViolation(Home, "partner.example", 2, 2, 1)); + + AssertParity( + new + { + error = "Delegated tenant slot limit reached for partner.example: 2 of 2 slot(s) in use, 1 more needed. Raise the tenant's limit (plan package or Global Admin override) and retry.", + code = "DelegatedSlotLimitReached", + homeTenantId = Home, + homeTenantDomain = "partner.example", + used = 2, + limit = 2, + required = 1, + }, + body); + } + + [Fact] + public void DelegatedSlotLimitReachedResponse_omits_a_null_domain_and_names_the_id() + { + var body = DelegatedSlotResponses.Build(new DelegatedSlotViolation(Home, null, 3, 3, 2)); + string? homeTenantDomain = null; + + AssertParity( + new + { + error = $"Delegated tenant slot limit reached for {Home}: 3 of 3 slot(s) in use, 2 more needed. Raise the tenant's limit (plan package or Global Admin override) and retry.", + code = "DelegatedSlotLimitReached", + homeTenantId = Home, + homeTenantDomain, + used = 3, + limit = 3, + required = 2, + }, + body); + } + + [Fact] + public void DelegatedSlotUsageResponse_matches_the_usage_shape() + { + var usage = new DelegatedSlotUsage(Home, "partner.example", 3, 2, 3, + new HashSet(new[] { TenantB, TenantA }, StringComparer.OrdinalIgnoreCase), 0, Array.Empty()); + + AssertParity( + new + { + homeTenantId = Home, + limit = 3, + catalogLimit = 2, + overrideLimit = 3, + used = 2, + managedTenantIds = new[] { TenantA, TenantB }, + pendingInvitations = 0, + holds = Array.Empty(), + }, + DelegatedSlotManagementFunction.ToResponse(usage)); + } + + [Fact] + public void DelegatedSlotUsageResponse_omits_a_null_override() + { + var usage = new DelegatedSlotUsage(Home, null, 2, 2, null, new HashSet(), 0, Array.Empty()); + int? overrideLimit = null; + + AssertParity( + new + { + homeTenantId = Home, + limit = 2, + catalogLimit = 2, + overrideLimit, + used = 0, + managedTenantIds = Array.Empty(), + pendingInvitations = 0, + holds = Array.Empty(), + }, + DelegatedSlotManagementFunction.ToResponse(usage)); + } + + private static void AssertParity(object anonymousLiteral, IApiResponse typed) + => ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed); + + // ---- Self-service delegation ------------------------------------------------------------ + + [Fact] + public void ManagedTenantListResponse_matches_the_managed_shape_and_omits_null_usage() + { + var since = new DateTime(2026, 9, 1, 8, 0, 0, DateTimeKind.Utc); + var slots = new DelegatedSlotUsageResponse { HomeTenantId = Home, Limit = 2, CatalogLimit = 2, Used = 1, ManagedTenantIds = new[] { TenantA }, PendingInvitations = 0, Holds = Array.Empty() }; + string? domainB = null; + ManagedTenantQuotaUsage? usageB = null; + DateTime? sinceB = null; + + AssertParity( + new + { + homeTenantId = Home, + slots, + tenants = new[] + { + new { tenantId = TenantA, domain = (string?)"customer.example", source = "self-service", sinceUtc = (DateTime?)since, removable = true, + usage = (ManagedTenantQuotaUsage?)new ManagedTenantQuotaUsage { TenantPlan = "community", TenantDailyLimit = 300, TenantMonthlyLimit = 9000, TenantDailyUsed = 12, TenantMonthlyUsed = 340 } }, + new { tenantId = TenantB, domain = domainB, source = "operator", sinceUtc = sinceB, removable = false, usage = usageB }, + }, + }, + new ManagedTenantListResponse + { + HomeTenantId = Home, + Slots = slots, + Tenants = new List + { + new() { TenantId = TenantA, Domain = "customer.example", Source = "self-service", SinceUtc = since, Removable = true, + Usage = new ManagedTenantQuotaUsage { TenantPlan = "community", TenantDailyLimit = 300, TenantMonthlyLimit = 9000, TenantDailyUsed = 12, TenantMonthlyUsed = 340 } }, + new() { TenantId = TenantB, Source = "operator", Removable = false }, + }, + }); + } + + [Fact] + public void DelegationInvitationListResponse_matches_and_omits_absent_optionals() + { + var created = new DateTime(2026, 9, 2, 10, 0, 0, DateTimeKind.Utc); + DateTime? acceptedUtc = null; string? acceptedBy = null; string? tenantId = null; string? tenantDomain = null; DateTime? holdUntilUtc = null; + + AssertParity( + new + { + homeTenantId = Home, + invitations = new[] + { + new { invitationId = "inv1", status = "Pending", createdBy = "admin@partner.example", createdUtc = created, expiresUtc = created.AddDays(7), + acceptedUtc, acceptedBy, tenantId, tenantDomain, holdUntilUtc }, + }, + }, + new DelegationInvitationListResponse + { + HomeTenantId = Home, + Invitations = new List + { + new() { InvitationId = "inv1", Status = "Pending", CreatedBy = "admin@partner.example", CreatedUtc = created, ExpiresUtc = created.AddDays(7) }, + }, + }); + } + + [Fact] + public void CreateAndAcceptResponses_match() + { + var expires = new DateTime(2026, 9, 9, 10, 0, 0, DateTimeKind.Utc); + AssertParity( + new { invitationId = "inv1", token = "abc.def", expiresUtc = expires }, + new CreateDelegationInvitationResponse { InvitationId = "inv1", Token = "abc.def", ExpiresUtc = expires }); + AssertParity( + new { homeTenantId = Home, homeTenantDomain = "partner.example", expiresUtc = expires, status = "Pending", targetTenantId = TenantA, targetTenantDomain = "customer.example" }, + new DelegationAcceptPreviewResponse { HomeTenantId = Home, HomeTenantDomain = "partner.example", ExpiresUtc = expires, Status = "Pending", TargetTenantId = TenantA, TargetTenantDomain = "customer.example" }); + AssertParity( + new { homeTenantId = Home, homeTenantDomain = "partner.example", managedTenantId = TenantA }, + new AcceptDelegationInvitationResponse { HomeTenantId = Home, HomeTenantDomain = "partner.example", ManagedTenantId = TenantA }); + AssertParity( + new { homeTenantId = Home, released = 2 }, + new ReleaseDelegatedSlotHoldResponse { HomeTenantId = Home, Released = 2 }); + } + + [Fact] + public void TenantManagerListResponse_matches_owner_and_operator_entries() + { + var since = new DateTime(2026, 9, 1, 8, 0, 0, DateTimeKind.Utc); + string? nullGroup = null; string? nullOwner = null; string? nullDomain = null; + var assignees = new[] { new { upn = "analyst@partner.example", role = "DelegatedReader", isEnabled = true } }; + + AssertParity( + new + { + tenantId = TenantA, + managers = new object[] + { + new { groupId = (string?)("msp-" + Home), ownerTenantId = (string?)Home, ownerDomain = (string?)"partner.example", name = "Customers of partner.example", source = "self-service", assignees, sinceUtc = (DateTime?)since, revocable = true }, + new { groupId = nullGroup, ownerTenantId = nullOwner, ownerDomain = nullDomain, name = "Platform operators", source = "operator", assignees, sinceUtc = (DateTime?)null, revocable = false }, + }, + }, + new TenantManagerListResponse + { + TenantId = TenantA, + Managers = new List + { + new() { GroupId = "msp-" + Home, OwnerTenantId = Home, OwnerDomain = "partner.example", Name = "Customers of partner.example", Source = "self-service", + Assignees = new List { new() { Upn = "analyst@partner.example", Role = "DelegatedReader", IsEnabled = true } }, SinceUtc = since, Revocable = true }, + new() { Name = "Platform operators", Source = "operator", + Assignees = new List { new() { Upn = "analyst@partner.example", Role = "DelegatedReader", IsEnabled = true } }, Revocable = false }, + }, + }); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs index 11f30c04..df9fc375 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs @@ -398,4 +398,25 @@ public class EndpointPolicyCatalogCompletenessTests Assert.True(match.Success); Assert.Equal(expectedTenantId, match.Groups["tenantId"].Value); } + + [Fact] + public void SelfServiceDelegationRoutes_NeverCarryTenantIdParam_AndAreTenantAdminTiers() + { + // The managing tenant acts on CUSTOMER tenants (remove, revoke) and a customer accepts an invitation + // from a FOREIGN tenant: a {tenantId} template would put every such call behind the cross-tenant guard. + // The tenant is therefore ALWAYS the caller's JWT tenant, and the tiers are the tenant-admin ones. + var routes = EndpointAccessPolicyCatalog.Entries.Where(e => e.RouteTemplate.StartsWith("delegations/", StringComparison.Ordinal)).ToList(); + Assert.NotEmpty(routes); + foreach (var route in routes) + { + Assert.DoesNotContain("{tenantId}", route.RouteTemplate); + Assert.NotEqual(TenantScoping.RouteParam, route.TenantScoping); + Assert.NotEqual(TenantScoping.QueryParam, route.TenantScoping); + Assert.True(route.Policy is EndpointPolicy.TenantAdminOrGlobalReader or EndpointPolicy.TenantAdminOrGA, + $"{route.HttpMethod} {route.RouteTemplate} must be a tenant-admin tier, was {route.Policy}"); + } + // Mutations never admit the read-only Global Reader. + foreach (var route in routes.Where(r => r.HttpMethod != "GET")) + Assert.Equal(EndpointPolicy.TenantAdminOrGA, route.Policy); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs index 44e0f391..cf5714fb 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs @@ -129,4 +129,15 @@ public class FeatureEntitlementCatalogTests var e = FeatureEntitlementCatalog.Get((TenantEdition)42); Assert.Equal(TenantEdition.Community, e.Edition); } + + // ── Delegated (MSP) tenant slots ───────────────────────────────────────────── + + [Fact] + public void Entitlements_MaxDelegatedTenants_CommunityZero_ProTwo() + { + // Community cannot delegate at all (DelegatedAdminAllowed=false) — its slot count is 0 by construction; + // Pro includes 2 managed tenants, more via the per-tenant override (packages). + Assert.Equal(0, FeatureEntitlementCatalog.Get(TenantEdition.Community).MaxDelegatedTenants); + Assert.Equal(2, FeatureEntitlementCatalog.Get(TenantEdition.Pro).MaxDelegatedTenants); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs index a96a21cc..00843af0 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs @@ -219,6 +219,16 @@ public class GetTenantFeatureFlagsPayloadTests Assert.Equal(90, element.GetProperty("entitlements").GetProperty("retentionCapDays").GetInt32()); Assert.False(element.GetProperty("entitlements").GetProperty("delegatedAdminAllowed").GetBoolean()); Assert.Equal("community", element.GetProperty("entitlements").GetProperty("mcpUsagePlan").GetString()); + Assert.Equal(0, element.GetProperty("entitlements").GetProperty("maxDelegatedTenants").GetInt32()); + } + + [Fact] + public void Payload_Entitlements_MaxDelegatedTenants_ProTwo_OverrideWins() + { + Assert.Equal(2, Serialize(new TenantConfiguration { PlanTier = "pro" }) + .GetProperty("entitlements").GetProperty("maxDelegatedTenants").GetInt32()); + Assert.Equal(7, Serialize(new TenantConfiguration { PlanTier = "pro", MaxDelegatedTenantsOverride = 7 }) + .GetProperty("entitlements").GetProperty("maxDelegatedTenants").GetInt32()); } [Theory] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs index 260dd8a2..2db8e76b 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs @@ -723,6 +723,58 @@ public class InfrastructureWireParityTests }); } + [Fact] + public void McpQuotaExceededResponse_matches_the_managed_tenant_blocked_shape() + { + // A delegated (MSP) read charged to a MANAGED Community tenant whose organization window is spent: + // level=tenant, the managed tenant is named (label + targetTenantId) and the upgrade path is spelled out. + var decision = McpQuotaService.BuildDecision( + new McpPlanLimits("pro", 1000, 20000, "community", 300, 9000), + dailyUsed: 12, monthlyUsed: 340, tenantDailyUsed: 300, tenantMonthlyUsed: 2100, + new DateTime(2026, 8, 30, 15, 0, 0, DateTimeKind.Utc), + targetTenantId: "7aa20c11-0002-4b7c-a1d2-52f3aaaa0002"); + + AssertParity( + new + { + quotaExceeded = true, + plan = "pro", + scope = "daily", + level = "tenant", + limit = 300, + used = 300L, + resetUtc = "2026-08-31T00:00:00Z", + message = "MCP daily request quota of the managed tenant 'customer.example' exceeded (tenant plan 'community', shared by all its members and delegated admins). Upgrading that tenant to Pro lifts its organization windows. Resets at 2026-08-31T00:00:00Z.", + targetTenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0002" + }, + McpQuotaEnforcementMiddleware.BuildExceededResponse(decision, "customer.example")); + } + + [Fact] + public void McpQuotaExceededResponse_matches_the_all_managed_tenants_exhausted_shape() + { + // The fleet aggregate where EVERY managed tenant is spent: no single target, the count is named. + var decision = McpQuotaService.BuildDecision( + new McpPlanLimits("pro", 1000, 20000, "community", 300, 9000), + dailyUsed: 12, monthlyUsed: 340, tenantDailyUsed: 300, tenantMonthlyUsed: 2100, + new DateTime(2026, 8, 30, 15, 0, 0, DateTimeKind.Utc), + targetTenantId: "7aa20c11-0002-4b7c-a1d2-52f3aaaa0002"); + + AssertParity( + new + { + quotaExceeded = true, + plan = "pro", + scope = "daily", + level = "tenant", + limit = 300, + used = 300L, + resetUtc = "2026-08-31T00:00:00Z", + message = "MCP request quota exceeded for all 2 managed tenants in scope (each managed tenant's own plan governs its organization windows; upgrading a managed tenant to Pro lifts them). Earliest reset at 2026-08-31T00:00:00Z." + }, + McpQuotaEnforcementMiddleware.BuildExceededResponse(decision, exhaustedTenantCount: 2)); + } + private static void AssertParity(object anonymousLiteral, IApiResponse typed) => ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/McpOrganizationUsageAggregationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/McpOrganizationUsageAggregationTests.cs new file mode 100644 index 00000000..4858a092 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/McpOrganizationUsageAggregationTests.cs @@ -0,0 +1,97 @@ +using AutopilotMonitor.Functions.Functions.Metrics; +using AutopilotMonitor.Shared.DataAccess; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for the pure fold behind GET metrics/mcp-usage/organization: one item per account, the +/// delegated (MSP) marker from the row's home tenant, and the three windows (today / month / range). +/// +public class McpOrganizationUsageAggregationTests +{ + private const string Tenant = "11111111-1111-1111-1111-111111111111"; + private const string MspHome = "99999999-9999-9999-9999-999999999999"; + private const string Member = "aaaaaaaa-0000-0000-0000-000000000001"; + private const string MspAdmin = "bbbbbbbb-0000-0000-0000-000000000002"; + + private static TenantUsageRecord Row(string user, string date, long count, string upn = "", string home = "", DateTime? last = null) + => new() { TenantId = Tenant, UserId = user, Date = date, RequestCount = count, UserPrincipalName = upn, HomeTenantId = home, LastRequestAt = last }; + + [Fact] + public void Aggregate_MarksDelegatedAccounts_ByForeignHomeTenant() + { + var rows = new[] + { + Row(Member, "20260902", 10, "alice@contoso.com"), + Row(MspAdmin, "20260902", 40, "msp@partner.example", MspHome), + }; + + var items = McpUsageMetricsFunction.AggregateOrganizationUsage(rows, Tenant, "20260901", "20260902", "20260902", "20260901"); + + var msp = Assert.Single(items, i => i.UserId == MspAdmin); + Assert.True(msp.Delegated); + Assert.Equal(MspHome, msp.HomeTenantId); + Assert.Equal("msp@partner.example", msp.UserPrincipalName); + var member = Assert.Single(items, i => i.UserId == Member); + Assert.False(member.Delegated); + Assert.Null(member.HomeTenantId); + } + + [Fact] + public void Aggregate_OwnTenantAsHome_IsNotDelegated() + { + // Legacy/own rows may carry the tenant itself (or nothing) as home — both mean "own member". + var rows = new[] { Row(Member, "20260902", 1, home: Tenant), Row(Member, "20260901", 1) }; + var items = McpUsageMetricsFunction.AggregateOrganizationUsage(rows, Tenant, "20260901", "20260902", "20260902", "20260901"); + var item = Assert.Single(items); + Assert.False(item.Delegated); + Assert.Null(item.UserPrincipalName); + } + + [Fact] + public void Aggregate_Windows_TodayMonthAndRange_AreIndependent() + { + var rows = new[] + { + Row(Member, "20260815", 100), // in range only (last month) + Row(Member, "20260901", 20), // month + range + Row(Member, "20260902", 5), // today + month + range + Row(Member, "20260903", 7), // read window past the range end (never counted in range) + }; + + var items = McpUsageMetricsFunction.AggregateOrganizationUsage(rows, Tenant, "20260815", "20260902", "20260902", "20260901"); + + var item = Assert.Single(items); + Assert.Equal(5, item.RequestsToday); + Assert.Equal(25, item.RequestsThisMonth); + Assert.Equal(125, item.RequestsInRange); + } + + [Fact] + public void Aggregate_OrdersByThisMonth_AndKeepsLatestRequestTime() + { + var older = new DateTime(2026, 9, 1, 8, 0, 0, DateTimeKind.Utc); + var newer = new DateTime(2026, 9, 2, 9, 0, 0, DateTimeKind.Utc); + var rows = new[] + { + Row(Member, "20260901", 3, last: newer), + Row(Member, "20260902", 1, last: older), + Row(MspAdmin, "20260902", 50, home: MspHome, last: older), + }; + + var items = McpUsageMetricsFunction.AggregateOrganizationUsage(rows, Tenant, "20260901", "20260902", "20260902", "20260901"); + + Assert.Equal(new[] { MspAdmin, Member }, items.Select(i => i.UserId)); + Assert.Equal(newer, items[1].LastRequestAt); + } + + [Theory] + [InlineData("2026-09-02", "20260902")] + [InlineData("20260902", "20260902")] + [InlineData("", null)] + [InlineData("yesterday", null)] + [InlineData("2026-9-2", null)] + public void NormalizeDay_AcceptsIsoAndCompact_RejectsTheRest(string raw, string? expected) + => Assert.Equal(expected, McpUsageMetricsFunction.NormalizeDay(raw)); +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaEnforcementMiddlewareTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaEnforcementMiddlewareTests.cs new file mode 100644 index 00000000..dba496ea --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaEnforcementMiddlewareTests.cs @@ -0,0 +1,254 @@ +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Middleware; +using AutopilotMonitor.Functions.Services; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for the pure seams of : which tenant a request is +/// charged to ("the budget follows the data"), the bound-narrowing isolation invariant, and the 429 texts +/// that name whose budget is exhausted. +/// +public class McpQuotaEnforcementMiddlewareTests +{ + private const string Home = "aaaaaaaa-0000-0000-0000-000000000001"; + private const string TenantB = "bbbbbbbb-0000-0000-0000-000000000002"; + private const string TenantC = "cccccccc-0000-0000-0000-000000000003"; + private const string TenantD = "dddddddd-0000-0000-0000-000000000004"; + private static readonly DateTime Now = new(2026, 9, 2, 15, 0, 0, DateTimeKind.Utc); + + // ── ResolveChargeScope ───────────────────────────────────────────────────────── + + [Fact] + public void Member_ReadingOwnTenant_ChargesHome() + { + var ctx = new RequestContext { TenantId = Home, TargetTenantId = Home }; + var scope = McpQuotaEnforcementMiddleware.ResolveChargeScope(ctx, Home); + Assert.Equal(McpChargeKind.Home, scope.Kind); + Assert.Null(scope.TargetTenantId); + } + + [Fact] + public void Delegated_SingleManagedTarget_ChargesThatTarget() + { + var ctx = new RequestContext + { + TenantId = Home, TargetTenantId = TenantB, IsDelegatedReader = true, + AllowedTenantIds = new[] { TenantB, TenantC }, + }; + var scope = McpQuotaEnforcementMiddleware.ResolveChargeScope(ctx, Home); + Assert.Equal(McpChargeKind.SingleTarget, scope.Kind); + Assert.Equal(TenantB, scope.TargetTenantId); + } + + [Fact] + public void Delegated_ReadingHomeTenant_ChargesHome() + { + // The subset tier admits a delegated caller for ?tenantId= too (crossTenant=false) — that read + // is the caller's OWN tenant and draws on its own budget. + var ctx = new RequestContext + { + TenantId = Home, TargetTenantId = Home, IsDelegatedReader = true, + AllowedTenantIds = new[] { TenantB }, + }; + var scope = McpQuotaEnforcementMiddleware.ResolveChargeScope(ctx, Home); + Assert.Equal(McpChargeKind.Home, scope.Kind); + } + + [Fact] + public void Delegated_TargetReachedThroughHomeChargedGroup_ChargesHome() + { + var ctx = new RequestContext + { + TenantId = Home, TargetTenantId = TenantC, IsDelegatedReader = true, + AllowedTenantIds = new[] { TenantB, TenantC }, + HomeChargedTenantIds = new[] { TenantC }, + }; + var scope = McpQuotaEnforcementMiddleware.ResolveChargeScope(ctx, Home); + Assert.Equal(McpChargeKind.Home, scope.Kind); + Assert.Null(scope.TargetTenantId); + } + + [Fact] + public void Delegated_FleetAggregate_ChargesEveryManagedTenant() + { + var ctx = new RequestContext + { + TenantId = Home, TargetTenantId = Home, IsDelegatedReader = true, IsDelegatedAggregate = true, + AllowedTenantIds = new[] { TenantB, TenantC }, + }; + var scope = McpQuotaEnforcementMiddleware.ResolveChargeScope(ctx, Home); + Assert.Equal(McpChargeKind.BoundedAggregate, scope.Kind); + Assert.Equal(new[] { TenantB, TenantC }, scope.ChargeMap!.Keys.OrderBy(k => k)); + Assert.Equal(new[] { TenantB }, scope.TargetsOf(new[] { TenantB })); + } + + [Fact] + public void Delegated_FleetAggregate_FoldsHomeChargedTenantsOntoHome() + { + var ctx = new RequestContext + { + TenantId = Home, TargetTenantId = Home, IsDelegatedReader = true, IsDelegatedAggregate = true, + AllowedTenantIds = new[] { TenantB, TenantC, TenantD }, + HomeChargedTenantIds = new[] { TenantC, TenantD }, + }; + var scope = McpQuotaEnforcementMiddleware.ResolveChargeScope(ctx, Home); + Assert.Equal(McpChargeKind.BoundedAggregate, scope.Kind); + Assert.Equal(new[] { Home, TenantB }, scope.ChargeMap!.Keys.OrderBy(k => k)); + // Home is charged ONCE for both operator-managed tenants; they are served or dropped together. + Assert.Equal(new[] { TenantC, TenantD }, scope.TargetsOf(new[] { Home }).OrderBy(t => t)); + Assert.Equal(new[] { TenantB }, scope.TargetsOf(new[] { TenantB })); + } + + [Fact] + public void Delegated_SubsetTierDirectoryListing_IsNotAnAggregate_ChargesHome() + { + // config/all: subset tier but TenantScoping.None → the policy middleware never marks it an aggregate. + var ctx = new RequestContext + { + TenantId = Home, TargetTenantId = Home, IsDelegatedReader = true, IsDelegatedAggregate = false, + AllowedTenantIds = new[] { TenantB, TenantC }, + }; + var scope = McpQuotaEnforcementMiddleware.ResolveChargeScope(ctx, Home); + Assert.Equal(McpChargeKind.Home, scope.Kind); + } + + [Fact] + public void GlobalAdmin_IsNeverDelegated_ResolvesHome() + { + // The policy middleware short-circuits delegated resolution for platform scope, so a GA reading + // ?tenantId= carries no delegated flags — the charge scope is Home (and Invoke tracks + // GA on home only, exempt from the check). + var ctx = new RequestContext { TenantId = Home, TargetTenantId = TenantB, IsGlobalAdmin = true }; + var scope = McpQuotaEnforcementMiddleware.ResolveChargeScope(ctx, Home); + Assert.Equal(McpChargeKind.Home, scope.Kind); + } + + // ── Narrow — ISOLATION INVARIANT ─────────────────────────────────────────────── + + [Fact] + public void Narrow_WithoutBound_LeavesContextUntouched() + { + // null = unbounded (GA/Reader). Narrowing must neither introduce a bound nor touch the context. + var ctx = new RequestContext { TenantId = Home, AllowedTenantIds = null }; + var narrowed = McpQuotaEnforcementMiddleware.Narrow(ctx, new[] { TenantB }, new[] { TenantC }); + Assert.Same(ctx, narrowed); + Assert.Null(narrowed.AllowedTenantIds); + Assert.Null(narrowed.QuotaExcludedTenantIds); + } + + [Theory] + [InlineData(new[] { TenantB, TenantC }, new[] { TenantB }, new[] { TenantC })] + [InlineData(new[] { TenantB, TenantC }, new string[0], new[] { TenantB, TenantC })] + [InlineData(new[] { TenantB, TenantC }, new[] { TenantB, TenantC }, new string[0])] + [InlineData(new[] { TenantB }, new[] { TenantB, TenantD }, new[] { TenantD })] + [InlineData(new[] { TenantB, TenantC, TenantD }, new[] { TenantD, TenantB }, new[] { TenantC })] + public void Narrow_NeverWidens_NeverNull(string[] bound, string[] admitted, string[] excluded) + { + var ctx = new RequestContext { TenantId = Home, AllowedTenantIds = bound, IsDelegatedReader = true }; + + var narrowed = McpQuotaEnforcementMiddleware.Narrow(ctx, admitted, excluded); + + Assert.NotNull(narrowed.AllowedTenantIds); + var boundSet = new HashSet(bound, StringComparer.OrdinalIgnoreCase); + Assert.All(narrowed.AllowedTenantIds!, t => Assert.Contains(t, boundSet)); + Assert.True(narrowed.AllowedTenantIds!.Count <= bound.Length); + // A foreign tenant sneaking into "admitted" never widens the bound (TenantD ∉ bound in row 4). + Assert.DoesNotContain(narrowed.AllowedTenantIds!, t => !boundSet.Contains(t)); + if (narrowed.QuotaExcludedTenantIds != null) + Assert.All(narrowed.QuotaExcludedTenantIds, t => Assert.Contains(t, boundSet)); + // The rest of the context survives the copy. + Assert.True(narrowed.IsDelegatedReader); + Assert.Equal(Home, narrowed.TenantId); + } + + [Fact] + public void Narrow_EmptyAdmittedSet_PublishesEmptyBound_NotNull() + { + // Empty ⇒ the repository serves an empty page. NEVER null (= all tenants). + var ctx = new RequestContext { TenantId = Home, AllowedTenantIds = new[] { TenantB, TenantC } }; + var narrowed = McpQuotaEnforcementMiddleware.Narrow(ctx, Array.Empty(), new[] { TenantB, TenantC }); + Assert.NotNull(narrowed.AllowedTenantIds); + Assert.Empty(narrowed.AllowedTenantIds!); + Assert.Equal(new[] { TenantB, TenantC }, narrowed.QuotaExcludedTenantIds!.OrderBy(t => t)); + } + + [Fact] + public void Narrow_NothingExcluded_OmitsTheExcludedSet() + { + var ctx = new RequestContext { TenantId = Home, AllowedTenantIds = new[] { TenantB, TenantC } }; + var narrowed = McpQuotaEnforcementMiddleware.Narrow(ctx, new[] { TenantB, TenantC }, Array.Empty()); + Assert.Equal(2, narrowed.AllowedTenantIds!.Count); + Assert.Null(narrowed.QuotaExcludedTenantIds); + } + + // ── BuildExceededResponse — whose budget ─────────────────────────────────────── + + private static McpQuotaDecision Blocked(string tenantPlan, string? target, long tenantDailyUsed = 300, int tenantDaily = 300) + => McpQuotaService.BuildDecision( + new McpPlanLimits("pro", 1000, 20000, tenantPlan, tenantDaily, 60000), + dailyUsed: 5, monthlyUsed: 50, tenantDailyUsed: tenantDailyUsed, tenantMonthlyUsed: 400, + Now, target); + + [Fact] + public void ManagedCommunityTarget_NamesTheTenant_AndTheUpgradePath() + { + var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", TenantB), "customer.example"); + + Assert.Equal("tenant", body.Level); + Assert.Equal(TenantB, body.TargetTenantId); + Assert.Contains("managed tenant 'customer.example'", body.Message); + Assert.Contains("tenant plan 'community'", body.Message); + Assert.Contains("Upgrading that tenant to Pro", body.Message); + Assert.DoesNotContain("your organization", body.Message); + } + + [Fact] + public void ManagedTarget_WithoutLabel_FallsBackToTheId() + { + var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", TenantB)); + Assert.Contains($"managed tenant '{TenantB}'", body.Message); + } + + [Fact] + public void ManagedProTarget_OmitsTheUpgradeHint() + { + var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("pro", TenantB, 3000, 3000), "customer.example"); + Assert.Equal(TenantB, body.TargetTenantId); + Assert.DoesNotContain("Upgrading", body.Message); + Assert.Contains("shared by all its members and delegated admins", body.Message); + } + + [Fact] + public void OwnTenant_KeepsTheOrganizationWording_AndNoTarget() + { + var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", target: null)); + Assert.Null(body.TargetTenantId); + Assert.Contains("of your organization", body.Message); + Assert.DoesNotContain("managed tenant", body.Message); + } + + [Fact] + public void AllManagedTenantsExhausted_NamesTheCount_AndNoSingleTarget() + { + var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", TenantB), exhaustedTenantCount: 3); + Assert.Null(body.TargetTenantId); + Assert.Equal("tenant", body.Level); + Assert.Contains("all 3 managed tenants", body.Message); + Assert.Contains("Earliest reset at 2026-09-03T00:00:00Z", body.Message); + } + + [Fact] + public void UserLevelBlock_IgnoresTargetAndCount() + { + // The caller's OWN budget is exhausted — the target tenant is irrelevant and must not be named. + var decision = McpQuotaService.BuildDecision( + new McpPlanLimits("community", 100, 3000, "pro", 3000, 60000), + dailyUsed: 100, monthlyUsed: 500, tenantDailyUsed: 1, tenantMonthlyUsed: 1, Now, TenantB); + var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(decision, "customer.example", exhaustedTenantCount: 2); + Assert.Equal("user", body.Level); + Assert.Null(body.TargetTenantId); + Assert.Equal("MCP daily request quota exceeded for plan 'community'. Resets at 2026-09-03T00:00:00Z.", body.Message); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs index 77b5a402..776b40aa 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs @@ -125,7 +125,8 @@ public class McpQuotaServiceTests string? mcpUserPlanOverride = null, TenantEdition edition = TenantEdition.Community, IMemoryCache? cache = null, - bool bound = true) + bool bound = true, + Func? editionResolver = null) { var adminRepo = new Mock(); adminRepo.Setup(r => r.GetMcpUserAsync(It.IsAny())) @@ -152,7 +153,7 @@ public class McpQuotaServiceTests usageRepo.Object, mcpUserService, adminConfigService, - new StubTenantEntitlementService(edition), + new StubTenantEntitlementService(editionResolver ?? (_ => edition)), cache, NullLogger.Instance, new TestTimeProvider(Now)); @@ -357,7 +358,7 @@ public class McpQuotaServiceTests { new() { UserId = Oid, Date = "20260707", RequestCount = 150 } }); - cache.Remove($"mcp-quota:{Oid}"); // stands in for the 60s TTL elapsing + cache.Remove(McpQuotaService.UserCacheKey(Oid)); // stands in for the 60s TTL elapsing var decision = await svc.CheckAsync(Oid, Upn, TenantId); @@ -477,4 +478,156 @@ public class McpQuotaServiceTests Assert.Equal(-1, first.TenantDailyUsed); repo.Verify(r => r.GetTenantUsageAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); } + + // ── Delegated (MSP) reads: the budget follows the data ─────────────────────── + // The CHARGED tenant (the managed customer) supplies the organization windows and counters; the + // caller's own windows still follow their HOME tenant's plan (and their per-user override). + + private const string Target = "22222222-2222-2222-2222-222222222222"; + private const string Target2 = "33333333-3333-3333-3333-333333333333"; + private const string OtherOid = "00000000-0000-0000-0000-000000000002"; + + private static void TenantRows(Mock repo, string tenantId, params (string Date, long Count)[] rows) + => repo.Setup(r => r.GetTenantUsageAsync(tenantId, It.IsAny(), It.IsAny())) + .ReturnsAsync(rows.Select(r => new TenantUsageRecord + { + TenantId = tenantId, + UserId = "some-member-or-msp", + Date = r.Date, + RequestCount = r.Count + }).ToList()); + + private static TenantEdition HomeProTargetsCommunity(string? tenantId) + => tenantId == TenantId ? TenantEdition.Pro : TenantEdition.Community; + + [Fact] + public async Task Check_DelegatedTarget_ChargesTheManagedTenantsPlanAndCounters() + { + // Pro MSP reads a Community customer whose organization window (300/day) other readers burned. + var repo = UsageRepo(("20260707", 5)); + TenantRows(repo, Target, ("20260707", 300)); + var svc = Build(repo, editionResolver: HomeProTargetsCommunity); + + var d = await svc.CheckAsync(Oid, Upn, homeTenantId: TenantId, chargeTenantId: Target); + + Assert.False(d.Allowed); + Assert.Equal("tenant", d.Level); + Assert.Equal("daily", d.Scope); + Assert.Equal("community", d.TenantPlan); + Assert.Equal(300, d.TenantDailyLimit); + Assert.Equal(Target, d.TargetTenantId); + // The caller's OWN windows still come from the Pro home tenant. + Assert.Equal("pro", d.Plan); + Assert.Equal(1000, d.DailyLimit); + repo.Verify(r => r.GetTenantUsageAsync(TenantId, It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Check_DelegatedTarget_UserBudgetStillFollowsHome() + { + // Community home (100/day) reading a Pro customer: the caller's own daily window bites first. + var repo = UsageRepo(("20260707", 100)); + TenantRows(repo, Target, ("20260707", 1)); + var svc = Build(repo, editionResolver: t => t == Target ? TenantEdition.Pro : TenantEdition.Community); + + var d = await svc.CheckAsync(Oid, Upn, homeTenantId: TenantId, chargeTenantId: Target); + + Assert.False(d.Allowed); + Assert.Equal("user", d.Level); + Assert.Equal("community", d.Plan); + Assert.Equal("pro", d.TenantPlan); + Assert.Equal(Target, d.TargetTenantId); + } + + [Fact] + public async Task Check_OwnTenant_NamesNoTarget() + { + var svc = Build(UsageRepo(("20260707", 1))); + var legacy = await svc.CheckAsync(Oid, Upn, TenantId); + var explicitHome = await svc.CheckAsync(Oid, Upn, TenantId, TenantId); + Assert.Null(legacy.TargetTenantId); + Assert.Null(explicitHome.TargetTenantId); + } + + [Fact] + public async Task Check_TenantSnapshot_IsSharedAcrossCallersOfTheSameTenant() + { + // Two different callers charged to the same customer read its organization counters ONCE per TTL. + var repo = UsageRepo(("20260707", 1)); + repo.Setup(r => r.GetUsageByUserAsync(OtherOid, It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + TenantRows(repo, Target, ("20260707", 10)); + var svc = Build(repo, editionResolver: HomeProTargetsCommunity); + + await svc.CheckAsync(Oid, Upn, TenantId, Target); + await svc.CheckAsync(OtherOid, "bob@contoso.com", TenantId, Target); + + repo.Verify(r => r.GetTenantUsageAsync(Target, It.IsAny(), It.IsAny()), Times.Once); + repo.Verify(r => r.GetUsageByUserAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task CheckMany_ExcludesExhaustedTenants_AdmitsTheRest() + { + var repo = UsageRepo(("20260707", 1)); + TenantRows(repo, Target, ("20260707", 300)); + TenantRows(repo, Target2, ("20260707", 10)); + var svc = Build(repo, editionResolver: HomeProTargetsCommunity); + + var r = await svc.CheckManyAsync(Oid, Upn, TenantId, new[] { Target, Target2 }); + + Assert.True(r.Allowed); + Assert.Null(r.BlockingDecision); + Assert.True(r.UserDecision.Allowed); + Assert.Equal(new[] { Target2 }, r.AdmittedTenantIds); + Assert.Equal(new[] { Target }, r.ExcludedTenantIds); + } + + [Fact] + public async Task CheckMany_AllExhausted_BlocksWithTheEarliestReset() + { + var repo = UsageRepo(("20260707", 1)); + TenantRows(repo, Target, ("20260707", 300)); // daily window → resets tomorrow + TenantRows(repo, Target2, ("20260701", 9000), ("20260707", 1)); // monthly window → resets on the 1st + var svc = Build(repo, editionResolver: HomeProTargetsCommunity); + + var r = await svc.CheckManyAsync(Oid, Upn, TenantId, new[] { Target, Target2 }); + + Assert.False(r.Allowed); + Assert.Empty(r.AdmittedTenantIds); + Assert.Equal(2, r.ExcludedTenantIds.Count); + Assert.Equal("tenant", r.BlockingDecision!.Level); + Assert.Equal("daily", r.BlockingDecision.Scope); + Assert.Equal(new DateTime(2026, 7, 8, 0, 0, 0, DateTimeKind.Utc), r.BlockingDecision.ResetUtc); + } + + [Fact] + public async Task CheckMany_UserExhausted_SkipsEveryTenantRead() + { + var repo = UsageRepo(("20260707", 100)); // Community home: 100/day + var svc = Build(repo); + + var r = await svc.CheckManyAsync(Oid, Upn, TenantId, new[] { Target, Target2 }); + + Assert.False(r.Allowed); + Assert.Equal("user", r.BlockingDecision!.Level); + Assert.Equal(2, r.ExcludedTenantIds.Count); + repo.Verify(r2 => r2.GetTenantUsageAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task CheckMany_TenantReadFails_AdmitsThatTenant_FailOpen() + { + var repo = UsageRepo(("20260707", 1)); + repo.Setup(r => r.GetTenantUsageAsync(Target, It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("storage down")); + TenantRows(repo, Target2, ("20260707", 10)); + var svc = Build(repo, editionResolver: HomeProTargetsCommunity); + + var r = await svc.CheckManyAsync(Oid, Upn, TenantId, new[] { Target, Target2 }); + + Assert.True(r.Allowed); + Assert.Equal(new[] { Target, Target2 }, r.AdmittedTenantIds.OrderBy(t => t)); + Assert.Empty(r.ExcludedTenantIds); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/MetricsWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/MetricsWireParityTests.cs index 5c4e4cce..b1ec46e6 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/MetricsWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/MetricsWireParityTests.cs @@ -665,6 +665,102 @@ public class MetricsWireParityTests }); } + // ---- GetMcpOrganizationUsage -------------------------------------------------------- + + [Fact] + public void GetMcpOrganizationUsageResponse_matches_the_by_user_shape() + { + var tenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0001"; + var userId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0009"; + var homeTenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0002"; + var lastRequestAt = new DateTime(2026, 9, 2, 14, 5, 0, DateTimeKind.Utc); + + AssertParity( + new + { + tenantId, + dateFrom = "20260901", + dateTo = "20260902", + users = new[] + { + new + { + userId, + userPrincipalName = "msp@partner.example", + delegated = true, + homeTenantId, + requestsToday = 4L, + requestsThisMonth = 120L, + requestsInRange = 120L, + lastRequestAt, + }, + }, + }, + new GetMcpOrganizationUsageResponse + { + TenantId = tenantId, + DateFrom = "20260901", + DateTo = "20260902", + Users = new List + { + new() + { + UserId = userId, + UserPrincipalName = "msp@partner.example", + Delegated = true, + HomeTenantId = homeTenantId, + RequestsToday = 4, + RequestsThisMonth = 120, + RequestsInRange = 120, + LastRequestAt = lastRequestAt, + }, + }, + }); + } + + [Fact] + public void GetMcpOrganizationUsageResponse_omits_null_upn_home_and_lastRequest() + { + // A row written before the attribution columns existed: own member, no UPN, no timestamp. + var tenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0001"; + var userId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0009"; + string? userPrincipalName = null; + string? homeTenantId = null; + DateTime? lastRequestAt = null; + + AssertParity( + new + { + tenantId, + dateFrom = "20260901", + dateTo = "20260902", + users = new[] + { + new + { + userId, + userPrincipalName, + delegated = false, + homeTenantId, + requestsToday = 0L, + requestsThisMonth = 3L, + requestsInRange = 3L, + lastRequestAt, + }, + }, + }, + new GetMcpOrganizationUsageResponse + { + TenantId = tenantId, + DateFrom = "20260901", + DateTo = "20260902", + Users = new List + { + new() { UserId = userId, Delegated = false, RequestsToday = 0, RequestsThisMonth = 3, RequestsInRange = 3 }, + }, + }); + } + private static void AssertParity(object anonymousLiteral, IApiResponse typed) => ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PlanManagementTransitionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PlanManagementTransitionTests.cs index fcd10f7d..fdc9d65e 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/PlanManagementTransitionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PlanManagementTransitionTests.cs @@ -142,4 +142,45 @@ public class PlanManagementTransitionTests PlanManagementFunction.ApplyPlanChanges(config, "community", false, null, Caller, later, changes); Assert.Equal(later, config.ProDowngradedUtc); } + + // ── Delegated (MSP) slot override ──────────────────────────────────────────── + + [Fact] + public void SlotChange_NotProvided_IsANoOp() + { + var config = new TenantConfiguration { TenantId = "t1", MaxDelegatedTenantsOverride = 3 }; + var changes = new Dictionary(); + PlanManagementFunction.ApplyDelegatedSlotChange(config, provided: false, maxDelegatedTenants: null, changes); + Assert.Equal(3, config.MaxDelegatedTenantsOverride); + Assert.Empty(changes); + } + + [Fact] + public void SlotChange_SetFromCatalog_RecordsAndApplies() + { + var config = new TenantConfiguration { TenantId = "t1" }; + var changes = new Dictionary(); + PlanManagementFunction.ApplyDelegatedSlotChange(config, provided: true, maxDelegatedTenants: 4, changes); + Assert.Equal(4, config.MaxDelegatedTenantsOverride); + Assert.Equal("(catalog) -> 4", changes["MaxDelegatedTenantsOverride"]); + } + + [Fact] + public void SlotChange_ClearToCatalog_RecordsAndApplies() + { + var config = new TenantConfiguration { TenantId = "t1", MaxDelegatedTenantsOverride = 4 }; + var changes = new Dictionary(); + PlanManagementFunction.ApplyDelegatedSlotChange(config, provided: true, maxDelegatedTenants: null, changes); + Assert.Null(config.MaxDelegatedTenantsOverride); + Assert.Equal("4 -> (catalog)", changes["MaxDelegatedTenantsOverride"]); + } + + [Fact] + public void SlotChange_SameValue_RecordsNothing() + { + var config = new TenantConfiguration { TenantId = "t1", MaxDelegatedTenantsOverride = 4 }; + var changes = new Dictionary(); + PlanManagementFunction.ApplyDelegatedSlotChange(config, provided: true, maxDelegatedTenants: 4, changes); + Assert.Empty(changes); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs index 83dce944..001605ab 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs @@ -799,6 +799,91 @@ public class PolicyEnforcementMiddlewareTests Assert.False(rc.IsGlobalReader); Assert.NotNull(rc.AllowedTenantIds); Assert.Contains(TenantB.ToLowerInvariant(), rc.AllowedTenantIds!); + // …and it is the DATA aggregate the MCP quota layer may narrow (charged per managed tenant). + Assert.True(rc.IsDelegatedAggregate); + Assert.Null(rc.HomeChargedTenantIds); + } + + [Fact] + public async Task Delegated_GlobalSessions_WithTenantId_IsADrill_NotAnAggregate() + { + const string upn = "msp@partner.example"; + var h = BuildHarness(); + h.AsDelegated(TenantB); + + var result = await h.Middleware.DecideAsync("GET", "/api/global/sessions", TenantB, AuthedPrincipal(TenantA, upn)); + + Assert.True(result.Allowed); + var rc = result.Context!; + Assert.True(rc.IsDelegatedReader); + Assert.Equal(TenantB, rc.TargetTenantId); + Assert.False(rc.IsDelegatedAggregate); + Assert.Null(rc.HomeChargedTenantIds); + } + + [Fact] + public async Task Delegated_ConfigAll_IsADirectoryListing_NotAnAggregate() + { + // Subset tier with TenantScoping.None: admitted + bounded, but no data fan-out — the quota layer + // charges it to the caller's home tenant, never to the managed tenants. + const string upn = "msp@partner.example"; + var h = BuildHarness(); + h.AsDelegated(TenantB); + + var result = await h.Middleware.DecideAsync("GET", "/api/config/all", null, AuthedPrincipal(TenantA, upn)); + + Assert.True(result.Allowed); + var rc = result.Context!; + Assert.True(rc.IsDelegatedReader); + Assert.NotNull(rc.AllowedTenantIds); + Assert.False(rc.IsDelegatedAggregate); + } + + private static void AsHomeChargedGroupMember(Harness h, string groupId, string upn, params string[] tenantIds) + { + h.Repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny())) + .ReturnsAsync(new List + { + new() { Upn = upn, GroupId = groupId, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true, AssignedBy = "ga@vendor.example" }, + }); + h.Repo.Setup(r => r.GetGroupMembershipAsync(groupId)) + .ReturnsAsync(new TenantGroupMembership + { + GroupId = groupId, + TenantIds = tenantIds.Select(t => t.ToLowerInvariant()).ToList(), + ChargeHomeTenantQuota = true, + }); + } + + [Fact] + public async Task Delegated_HomeChargedGroup_MarksTheDrillTarget() + { + const string upn = "ops@vendor.example"; + var h = BuildHarness(); + AsHomeChargedGroupMember(h, "grp-managed-service", upn, TenantB); + + var result = await h.Middleware.DecideAsync("GET", "/api/global/sessions", TenantB, AuthedPrincipal(TenantA, upn)); + + Assert.True(result.Allowed); + var rc = result.Context!; + Assert.True(rc.IsDelegatedReader); + Assert.Equal(new[] { TenantB.ToLowerInvariant() }, rc.HomeChargedTenantIds); + } + + [Fact] + public async Task Delegated_HomeChargedGroup_MarksTheAggregateMembers() + { + const string upn = "ops@vendor.example"; + var h = BuildHarness(); + AsHomeChargedGroupMember(h, "grp-managed-service", upn, TenantB); + + var result = await h.Middleware.DecideAsync("GET", "/api/global/sessions", null, AuthedPrincipal(TenantA, upn)); + + Assert.True(result.Allowed); + var rc = result.Context!; + Assert.True(rc.IsDelegatedAggregate); + Assert.Contains(TenantB.ToLowerInvariant(), rc.AllowedTenantIds!); + Assert.Equal(new[] { TenantB.ToLowerInvariant() }, rc.HomeChargedTenantIds); } [Fact] @@ -989,6 +1074,35 @@ public class PolicyEnforcementMiddlewareTests Assert.Equal(403, result.StatusCode); } + [Fact] + public async Task Delegated_DelegatedSlots_OnManagedTenant_IsForbidden_ExcludeDelegated() + { + // global/delegated-slots/{tenantId} is the operator's slot accounting of a MANAGING tenant — + // platform-operational, never a managed customer's telemetry; excludeDelegated keeps it GA/Reader-only. + const string upn = "msp@partner.example"; + var h = BuildHarness(); + h.AsDelegated(TenantB); + + var result = await h.Middleware.DecideAsync( + "GET", $"/api/global/delegated-slots/{TenantB}", TenantB, AuthedPrincipal(TenantA, upn)); + + Assert.False(result.Allowed); + Assert.Equal(403, result.StatusCode); + } + + [Fact] + public async Task GlobalReader_DelegatedSlots_IsAllowed() + { + const string upn = "reader@vendor.example"; + var h = BuildHarness(); + h.AsGlobalRole(Constants.GlobalRoles.GlobalReader); + + var result = await h.Middleware.DecideAsync( + "GET", $"/api/global/delegated-slots/{TenantB}", TenantB, AuthedPrincipal(TenantA, upn)); + + Assert.True(result.Allowed); + } + [Fact] public async Task Delegated_PreviewNotificationEmail_OnManagedTenant_IsForbidden_ExcludeDelegated() { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RevokeEnforcementTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RevokeEnforcementTests.cs index ee131084..2da2b2b1 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/RevokeEnforcementTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/RevokeEnforcementTests.cs @@ -44,6 +44,11 @@ public class RevokeEnforcementTests new MemoryCache(new MemoryCacheOptions()), NullLogger.Instance); + /// Slot service over the same mocked repo (the revoke/validation seams never reach it). + private static DelegatedSlotService BuildSlots(Mock repo) => + new(repo.Object, Mock.Of(), Mock.Of(), + new MemoryCache(new MemoryCacheOptions()), NullLogger.Instance); + private static Mock BuildAuditRepo() { var audit = new Mock(); @@ -66,7 +71,7 @@ public class RevokeEnforcementTests var audit = BuildAuditRepo(); var signalR = new FakeSignalRNotificationService(); var fn = new DelegatedAdminManagementFunction( - NullLogger.Instance, BuildService(repo), Resolver(), audit.Object, signalR); + NullLogger.Instance, BuildService(repo), Resolver(), audit.Object, signalR, BuildSlots(repo)); return (fn, repo, audit, signalR); } @@ -83,10 +88,55 @@ public class RevokeEnforcementTests var audit = BuildAuditRepo(); var signalR = new FakeSignalRNotificationService(); var fn = new TenantGroupManagementFunction( - NullLogger.Instance, BuildService(repo), Resolver(), audit.Object, signalR); + NullLogger.Instance, BuildService(repo), Resolver(), audit.Object, signalR, BuildSlots(repo)); return (fn, repo, audit, signalR); } + // --- ChargeHomeTenantQuota flip: audited under every tenant, no-op on an unchanged value --- + + [Fact] + public async Task SetChargeMode_UnknownGroup_False_NoAudit() + { + var (fn, _, audit, _) = BuildGroupFn(); + var ok = await fn.SetChargeModeCoreAsync("no-such-group", true, "ga@vendor.example"); + Assert.False(ok); + VerifyNoAudit(audit); + } + + [Fact] + public async Task SetChargeMode_UnchangedValue_IsNoOp_NoAudit() + { + var (fn, repo, audit, _) = BuildGroupFn(); + repo.Setup(r => r.GetTenantGroupAsync(GroupId)).ReturnsAsync( + new TenantGroup { GroupId = GroupId, Name = "Managed Service", TenantIds = { TenantA }, ChargeHomeTenantQuota = true }); + + var ok = await fn.SetChargeModeCoreAsync(GroupId, true, "ga@vendor.example"); + + Assert.True(ok); + repo.Verify(r => r.SetTenantGroupChargeHomeTenantQuotaAsync(It.IsAny(), It.IsAny()), Times.Never); + VerifyNoAudit(audit); + } + + [Fact] + public async Task SetChargeMode_RealChange_AuditsUnderEveryTenantInTheGroup() + { + var (fn, repo, audit, _) = BuildGroupFn(); + repo.Setup(r => r.GetTenantGroupAsync(GroupId)).ReturnsAsync( + new TenantGroup { GroupId = GroupId, Name = "Managed Service", TenantIds = { TenantA, TenantB } }); + repo.Setup(r => r.SetTenantGroupChargeHomeTenantQuotaAsync(GroupId, true)).ReturnsAsync(true); + + var ok = await fn.SetChargeModeCoreAsync(GroupId, true, "ga@vendor.example"); + + Assert.True(ok); + foreach (var tenant in new[] { TenantA, TenantB }) + { + audit.Verify(a => a.LogAuditEntryAsync(tenant, "UPDATE", "DelegatedGroupAccess", "*", "ga@vendor.example", + It.Is?>(d => d != null + && d["Reason"] == "quota-charge-mode-changed" + && d["ChargeHomeTenantQuota"] == "true")), Times.Once); + } + } + // --- Delegated grant validation (fail-closed) --- [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionsWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionsWireParityTests.cs index 76735c07..5c12090b 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SessionsWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionsWireParityTests.cs @@ -978,6 +978,45 @@ public class SessionsWireParityTests }); } + // ---- Delegated (MSP) MCP fleet aggregate: quota-excluded tenants --------------------- + + [Fact] + public void SessionListResponse_carries_quotaExcludedTenants_on_a_narrowed_fleet_aggregate() + { + IReadOnlyList items = new List { SampleSummary("0b6f7a37-1111-4d61-9c93-0aa111111111") }; + string? nextLink = null; + var quotaExcludedTenants = new[] { "7aa20c11-0002-4b7c-a1d2-52f3aaaa0002" }; + + AssertParity( + new + { + success = true, + count = items.Count, + sessions = items, + nextLink, + quotaExcludedTenants, + }, + new SessionListResponse + { + Success = true, + Count = items.Count, + Sessions = items, + NextLink = null, + QuotaExcludedTenants = quotaExcludedTenants, + }); + } + + [Fact] + public void SessionStatsResponse_carries_quotaExcludedTenants_on_a_narrowed_fleet_aggregate() + { + var stats = new SessionStats { Days = 7, ComputedAt = new DateTime(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc) }; + var quotaExcludedTenants = new[] { "7aa20c11-0002-4b7c-a1d2-52f3aaaa0002", "7aa20c11-0002-4b7c-a1d2-52f3aaaa0003" }; + + AssertParity( + new { success = true, stats, quotaExcludedTenants }, + new SessionStatsResponse { Success = true, Stats = stats, QuotaExcludedTenants = quotaExcludedTenants }); + } + private static void AssertParity(object anonymousLiteral, IApiResponse typed) => ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TableDelegationInvitationRepositoryTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TableDelegationInvitationRepositoryTests.cs new file mode 100644 index 00000000..01a1f394 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TableDelegationInvitationRepositoryTests.cs @@ -0,0 +1,104 @@ +using AutopilotMonitor.Functions.DataAccess.TableStorage; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Store↔Map round-trip for the DelegationInvitations table (project rule "table-serialization": every model +/// field in BOTH directions) plus the derived-state predicates the slot count relies on. +/// +public class TableDelegationInvitationRepositoryTests +{ + private const string Home = "99999999-9999-9999-9999-999999999999"; + private const string Managed = "11111111-1111-1111-1111-111111111111"; + private static readonly DateTime Now = new(2026, 9, 2, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void Roundtrip_FullRow_SurvivesBuildAndMap() + { + var row = new DelegationInvitation + { + InvitationId = "abc123", + HomeTenantId = Home.ToUpperInvariant(), + Status = Constants.DelegationInvitationStatus.Released, + Role = Constants.DelegatedRoles.DelegatedReader, + Source = Constants.DelegatedSource.CustomerDelegated, + CreatedBy = "Admin@Partner.Example", + CreatedAt = Now, + ExpiresAt = Now.AddDays(7), + AcceptedAt = Now.AddHours(2), + AcceptedBy = "Owner@Customer.Example", + TenantId = Managed.ToUpperInvariant(), + ReleasedAt = Now.AddDays(1), + ReleasedBy = "admin@partner.example", + HoldUntilUtc = Now.AddDays(2), + }; + + var entity = TableDelegationInvitationRepository.Build(row); + var mapped = TableDelegationInvitationRepository.Map(entity); + + Assert.Equal(Home, entity.PartitionKey); + Assert.Equal("abc123", entity.RowKey); + Assert.Equal("abc123", mapped.InvitationId); + Assert.Equal(Home, mapped.HomeTenantId); + Assert.Equal(Constants.DelegationInvitationStatus.Released, mapped.Status); + Assert.Equal(Constants.DelegatedRoles.DelegatedReader, mapped.Role); + Assert.Equal(Constants.DelegatedSource.CustomerDelegated, mapped.Source); + Assert.Equal("admin@partner.example", mapped.CreatedBy); + Assert.Equal(Now, mapped.CreatedAt); + Assert.Equal(Now.AddDays(7), mapped.ExpiresAt); + Assert.Equal(Now.AddHours(2), mapped.AcceptedAt); + Assert.Equal("owner@customer.example", mapped.AcceptedBy); + Assert.Equal(Managed, mapped.TenantId); // offboarding property wipe matches the lowercase GUID + Assert.Equal(Now.AddDays(1), mapped.ReleasedAt); + Assert.Equal("admin@partner.example", mapped.ReleasedBy); + Assert.Equal(Now.AddDays(2), mapped.HoldUntilUtc); + } + + [Fact] + public void Roundtrip_PendingRow_OptionalColumnsStayAbsent() + { + var row = new DelegationInvitation + { + InvitationId = "p1", + HomeTenantId = Home, + Status = Constants.DelegationInvitationStatus.Pending, + Role = Constants.DelegatedRoles.DelegatedReader, + Source = Constants.DelegatedSource.CustomerDelegated, + CreatedBy = "admin@partner.example", + CreatedAt = Now, + ExpiresAt = Now.AddDays(7), + }; + + var entity = TableDelegationInvitationRepository.Build(row); + var mapped = TableDelegationInvitationRepository.Map(entity); + + // A pending row must NOT carry TenantId — the managed tenant's offboarding wipe keys on it. + Assert.False(entity.ContainsKey("TenantId")); + Assert.False(entity.ContainsKey("HoldUntilDate")); + Assert.Null(mapped.TenantId); + Assert.Null(mapped.AcceptedAt); + Assert.Null(mapped.HoldUntilUtc); + Assert.Equal(Constants.DelegationInvitationStatus.Pending, mapped.Status); + } + + [Fact] + public void Predicates_PendingExpiry_AndHoldLapse() + { + var pending = new DelegationInvitation { Status = Constants.DelegationInvitationStatus.Pending, ExpiresAt = Now.AddDays(1) }; + var expired = new DelegationInvitation { Status = Constants.DelegationInvitationStatus.Pending, ExpiresAt = Now }; + var hold = new DelegationInvitation { Status = Constants.DelegationInvitationStatus.Released, HoldUntilUtc = Now.AddHours(1) }; + var lapsed = new DelegationInvitation { Status = Constants.DelegationInvitationStatus.Released, HoldUntilUtc = Now }; + var accepted = new DelegationInvitation { Status = Constants.DelegationInvitationStatus.Accepted, ExpiresAt = Now.AddDays(1) }; + + Assert.True(DelegatedSlotService.IsPending(pending, Now)); + Assert.False(DelegatedSlotService.IsPending(expired, Now)); + Assert.False(DelegatedSlotService.IsPending(accepted, Now)); + Assert.True(DelegatedSlotService.IsActiveHold(hold, Now)); + Assert.False(DelegatedSlotService.IsActiveHold(lapsed, Now)); + Assert.False(DelegatedSlotService.IsActiveHold(accepted, Now)); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TableLifecycleBucketTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TableLifecycleBucketTests.cs index e0a129cc..cf9ea8c0 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TableLifecycleBucketTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TableLifecycleBucketTests.cs @@ -72,11 +72,14 @@ public class TableLifecycleBucketTests /// /// Tables allowed in MORE than one offboarding bucket. BootstrapSessions has main rows - /// under PK=tenantId AND CodeLookup rows under the discriminator PK. + /// under PK=tenantId AND CodeLookup rows under the discriminator PK. DelegationInvitations is + /// two-sided by design: PK = the MANAGING tenant (exact-partition wipe when it offboards) and the + /// TenantId property = the MANAGED tenant (property wipe when that one offboards). /// private static readonly string[] AllowedMultiBucket = { Constants.TableNames.BootstrapSessions, + Constants.TableNames.DelegationInvitations, }; [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs index 331674fe..c9adc7d2 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs @@ -27,7 +27,7 @@ public class TenantConfigProjectionTests private static readonly string[] AllSafeKeys = { - "tenantId", "domainName", "planTier", "trialExpiresUtc", "trialConsumed", + "tenantId", "domainName", "planTier", "trialExpiresUtc", "trialConsumed", "maxDelegatedTenantsOverride", "disabled", "disabledReason", "onboardedAt", "onboardedBy", "lastUpdated", "dataRetentionDays", "homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince", diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs index 7ad747cb..8f6af755 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs @@ -212,4 +212,17 @@ public class TenantConfigTableSerializationTests Assert.Null(mapped.LastAuthClientId); Assert.Null(mapped.LastAuthClientIdSince); } + + [Theory] + [InlineData(null)] + [InlineData(0)] + [InlineData(20)] + public void Roundtrip_MaxDelegatedTenantsOverride_SurvivesStoreAndMap(int? overrideLimit) + { + var config = new TenantConfiguration { TenantId = TenantId, DomainName = "contoso.com", UpdatedBy = "ga@operator.example", MaxDelegatedTenantsOverride = overrideLimit }; + + var mapped = TableConfigRepository.ConvertFromTenantTableEntity(TableConfigRepository.ConvertToTenantTableEntity(config)); + + Assert.Equal(overrideLimit, mapped.MaxDelegatedTenantsOverride); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs index b4108f81..87e0d311 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs @@ -295,4 +295,40 @@ public class TenantEntitlementServiceTests }; Assert.Equal(60, TenantEntitlementService.GetEffectiveRetentionDays(below, Now.AddDays(5))); } + + // ── Delegated (MSP) tenant slot limit ──────────────────────────────────────── + + [Theory] + [InlineData("community", null, 0)] + [InlineData("pro", null, 2)] + [InlineData("pro", 20, 20)] + [InlineData("community", 4, 4)] // override applies regardless of edition (pre-provisioned package) + [InlineData("pro", 0, 0)] // an explicit zero is a limit, not "unset" + public void GetMaxDelegatedTenants_OverrideBeatsTheCatalog(string tier, int? overrideLimit, int expected) + { + var config = new TenantConfiguration { TenantId = TenantId, PlanTier = tier, MaxDelegatedTenantsOverride = overrideLimit }; + Assert.Equal(expected, TenantEntitlementService.GetMaxDelegatedTenants(config, Now)); + } + + [Fact] + public void GetMaxDelegatedTenants_ActiveTrial_GetsTheProCount() + { + var config = new TenantConfiguration { TenantId = TenantId, PlanTier = "free", TrialExpiresUtc = Now.AddDays(3) }; + Assert.Equal(2, TenantEntitlementService.GetMaxDelegatedTenants(config, Now)); + } + + [Fact] + public async Task GetMaxDelegatedTenantsAsync_NoRow_FailsClosedToZero() + { + var (svc, _) = Build(config: null); + Assert.Equal(0, await svc.GetMaxDelegatedTenantsAsync(TenantId)); + Assert.Equal(0, await svc.GetMaxDelegatedTenantsAsync(null)); + } + + [Fact] + public async Task GetMaxDelegatedTenantsAsync_ReadsTheOverride() + { + var (svc, _) = Build(new TenantConfiguration { TenantId = TenantId, PlanTier = "pro", MaxDelegatedTenantsOverride = 7 }); + Assert.Equal(7, await svc.GetMaxDelegatedTenantsAsync(TenantId)); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs index 10d7cd45..243114f1 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs @@ -387,6 +387,7 @@ public class TypedContractCompletionWireParityTests userRateLimitPerMinute = (int?)null, // platform default → key vanishes delegatedAdminAllowed = false, mcpUsagePlan = "community", + maxDelegatedTenants = 0, }, }, new TenantFeatureFlagsResponse @@ -411,6 +412,7 @@ public class TypedContractCompletionWireParityTests UserRateLimitPerMinute = null, DelegatedAdminAllowed = false, McpUsagePlan = "community", + MaxDelegatedTenants = 0, }, }); } diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs index 5a4ce905..69817984 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs @@ -39,6 +39,7 @@ namespace AutopilotMonitor.Functions.DataAccess services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs index e795a5ff..1d9889f3 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs @@ -4,6 +4,7 @@ using AutopilotMonitor.Shared.DataAccess; using Azure; using Azure.Data.Tables; using Microsoft.Extensions.Logging; +using AutopilotMonitor.Functions.Security; namespace AutopilotMonitor.Functions.DataAccess.TableStorage { @@ -450,6 +451,53 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage return groupId; } + public async Task EnsureOwnedTenantGroupAsync(string groupId, string name, string ownerTenantId) + { + try + { + await _tenantGroupsTableClient.GetEntityAsync(groupId, TenantGroupEntity.MetaRowKey); + return; // exists — never overwrite a name or flags an operator may have edited + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + } + + try + { + await _tenantGroupsTableClient.AddEntityAsync(new TenantGroupEntity + { + PartitionKey = groupId, + RowKey = TenantGroupEntity.MetaRowKey, + Name = name?.Trim() ?? string.Empty, + CreatedBy = "self-service", + CreatedDate = DateTime.UtcNow, + OwnerTenantId = ownerTenantId.ToLowerInvariant(), + }); + } + catch (RequestFailedException ex) when (ex.Status == 409) + { + // Two accepts raced on the first customer — the other one created it. + } + } + + public async Task> GetGroupIdsContainingTenantAsync(string tenantId) + { + var groupIds = new List(); + if (string.IsNullOrWhiteSpace(tenantId)) + return groupIds; + + // Cross-partition scan on RowKey (== tenantId membership rows) — the managed tenant's "who manages + // me" view, not the hot auth path. Typed predicate overload escapes safely. + var normalizedTenantId = tenantId.ToLowerInvariant(); + await foreach (var entity in _tenantGroupsTableClient.QueryAsync( + e => e.RowKey == normalizedTenantId)) + { + if (!groupIds.Contains(entity.PartitionKey, StringComparer.Ordinal)) + groupIds.Add(entity.PartitionKey); + } + return groupIds; + } + public async Task RenameTenantGroupAsync(string groupId, string name) { if (string.IsNullOrWhiteSpace(groupId)) @@ -472,6 +520,28 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage } } + public async Task SetTenantGroupChargeHomeTenantQuotaAsync(string groupId, bool chargeHomeTenantQuota) + { + if (string.IsNullOrWhiteSpace(groupId)) + return false; + + try + { + var result = await _tenantGroupsTableClient.GetEntityAsync( + groupId, TenantGroupEntity.MetaRowKey); + var entity = result.Value; + if (entity == null) return false; + + entity.ChargeHomeTenantQuota = chargeHomeTenantQuota; + await _tenantGroupsTableClient.UpdateEntityAsync(entity, ETag.All); + return true; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return false; + } + } + public async Task DeleteTenantGroupAsync(string groupId) { if (string.IsNullOrWhiteSpace(groupId)) @@ -608,6 +678,32 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage return metaBacked ? tenants : new List(); } + public async Task GetGroupMembershipAsync(string groupId) + { + if (string.IsNullOrWhiteSpace(groupId)) + return null; + + // HOT PATH (scope resolution): the same single PartitionKey scan as GetGroupTenantsAsync, but it + // also lifts the meta-row flags the scope depends on. Same meta-backed invariant: no meta row ⇒ + // no group ⇒ null (never scope from stray membership rows). + var membership = new TenantGroupMembership { GroupId = groupId }; + var metaBacked = false; + await foreach (var entity in _tenantGroupsTableClient.QueryAsync( + e => e.PartitionKey == groupId)) + { + if (entity.RowKey == TenantGroupEntity.MetaRowKey) + { + metaBacked = true; + membership.ChargeHomeTenantQuota = entity.ChargeHomeTenantQuota ?? false; + } + else + { + membership.TenantIds.Add(entity.RowKey); + } + } + return metaBacked ? membership : null; + } + public async Task AssignGroupAsync(string upn, string groupId, string role, bool isEnabled, string assignedBy) { if (string.IsNullOrWhiteSpace(upn) || string.IsNullOrWhiteSpace(groupId)) @@ -704,6 +800,21 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage return bindings; } + public async Task> GetIdentityBindingsByHomeTenantAsync(string homeTenantId) + { + var bindings = new List(); + if (string.IsNullOrWhiteSpace(homeTenantId)) + return bindings; + + // Single partition ("Bindings") + TenantId property filter: admin-scale rows, no cross-partition scan. + await foreach (var entity in _identityBindingsTableClient.QueryAsync( + filter: $"PartitionKey eq '{AdminIdentityBindingEntity.Partition}' and TenantId eq '{ODataSanitizer.EscapeValue(homeTenantId.ToLowerInvariant())}'")) + { + bindings.Add(MapToBinding(entity)); + } + return bindings; + } + public async Task UpsertIdentityBindingAsync(string upn, string tenantId, string? objectId, string boundBy) { var now = DateTime.UtcNow; @@ -933,6 +1044,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage group.Name = entity.Name; group.CreatedBy = entity.CreatedBy; group.CreatedAt = entity.CreatedDate ?? default; + group.ChargeHomeTenantQuota = entity.ChargeHomeTenantQuota ?? false; + group.OwnerTenantId = string.IsNullOrEmpty(entity.OwnerTenantId) ? null : entity.OwnerTenantId; return true; } diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index 7ddeaab7..7af931a6 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -744,6 +744,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { "TrialConsumed", config.TrialConsumed }, { "TrialGrantedBy", config.TrialGrantedBy }, { "ProDowngradedUtc", config.ProDowngradedUtc }, + { "MaxDelegatedTenantsOverride", config.MaxDelegatedTenantsOverride }, // SLA targets { "SlaTargetSuccessRate", config.SlaTargetSuccessRate.HasValue ? (double)config.SlaTargetSuccessRate.Value : (double?)null }, { "SlaTargetMaxDurationMinutes", config.SlaTargetMaxDurationMinutes }, @@ -859,6 +860,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage TrialConsumed = entity.GetBoolean("TrialConsumed") ?? false, TrialGrantedBy = entity.GetString("TrialGrantedBy"), ProDowngradedUtc = entity.GetDateTime("ProDowngradedUtc"), + MaxDelegatedTenantsOverride = entity.GetInt32("MaxDelegatedTenantsOverride"), // SLA targets SlaTargetSuccessRate = entity.GetDouble("SlaTargetSuccessRate") != null ? (decimal)entity.GetDouble("SlaTargetSuccessRate")! : null, SlaTargetMaxDurationMinutes = entity.GetInt32("SlaTargetMaxDurationMinutes"), diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDelegationInvitationRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDelegationInvitationRepository.cs new file mode 100644 index 00000000..02a76627 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDelegationInvitationRepository.cs @@ -0,0 +1,175 @@ +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using Azure; +using Azure.Data.Tables; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage +{ + /// + /// Table Storage implementation of . + /// DelegationInvitations — PartitionKey: home (managing) tenant id, RowKey: invitation id (Guid "N"). + /// The managed tenant is denormalized into the TenantId column so the offboarding property wipe + /// purges rows for an offboarded MANAGED tenant; the home tenant's own offboarding wipes the partition. + /// + public class TableDelegationInvitationRepository : IDelegationInvitationRepository + { + private readonly TableClient _table; + private readonly ILogger _logger; + + public TableDelegationInvitationRepository(TableStorageService storage, ILogger logger) + { + _table = storage.GetTableClient(Constants.TableNames.DelegationInvitations); + _logger = logger; + } + + public async Task CreateAsync(DelegationInvitation invitation) + { + // Fresh GUID row — AddEntity (not Upsert) so a (vanishingly unlikely) collision fails loud. + await _table.AddEntityAsync(Build(invitation)); + } + + public async Task GetAsync(string homeTenantId, string invitationId) + { + if (string.IsNullOrWhiteSpace(homeTenantId) || string.IsNullOrWhiteSpace(invitationId)) + return null; + try + { + var result = await _table.GetEntityAsync(homeTenantId.ToLowerInvariant(), invitationId); + return Map(result.Value); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + } + + public async Task> GetByHomeTenantAsync(string homeTenantId) + { + var rows = new List(); + if (string.IsNullOrWhiteSpace(homeTenantId)) + return rows; + await foreach (var entity in _table.QueryAsync( + filter: $"PartitionKey eq '{ODataSanitizer.EscapeValue(homeTenantId.ToLowerInvariant())}'")) + { + rows.Add(Map(entity)); + } + return rows; + } + + public async Task TryAcceptAsync(string homeTenantId, string invitationId, string etag, string acceptedTenantId, string acceptedBy, DateTime nowUtc) + { + try + { + var result = await _table.GetEntityAsync(homeTenantId.ToLowerInvariant(), invitationId); + var entity = result.Value; + if (!string.Equals(entity.GetString("Status"), Constants.DelegationInvitationStatus.Pending, StringComparison.Ordinal)) + return false; + entity["Status"] = Constants.DelegationInvitationStatus.Accepted; + entity["AcceptedDate"] = new DateTimeOffset(DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc)); + entity["AcceptedBy"] = acceptedBy.ToLowerInvariant(); + entity["TenantId"] = acceptedTenantId.ToLowerInvariant(); + // The ETag the caller READ is the guard: a concurrent accept/cancel changed it → 412 → false. + await _table.UpdateEntityAsync(entity, new ETag(etag), TableUpdateMode.Replace); + return true; + } + catch (RequestFailedException ex) when (ex.Status is 404 or 412) + { + return false; + } + } + + public async Task SetStatusAsync(string homeTenantId, string invitationId, string status, DateTime nowUtc, string? actor, DateTime? holdUntilUtc) + { + try + { + var result = await _table.GetEntityAsync(homeTenantId.ToLowerInvariant(), invitationId); + var entity = result.Value; + entity["Status"] = status; + if (status == Constants.DelegationInvitationStatus.Released) + { + entity["ReleasedDate"] = new DateTimeOffset(DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc)); + if (!string.IsNullOrEmpty(actor)) entity["ReleasedBy"] = actor.ToLowerInvariant(); + } + if (holdUntilUtc.HasValue) + entity["HoldUntilDate"] = new DateTimeOffset(DateTime.SpecifyKind(holdUntilUtc.Value, DateTimeKind.Utc)); + await _table.UpdateEntityAsync(entity, ETag.All, TableUpdateMode.Replace); + return true; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return false; + } + } + + public async Task DeleteOlderThanAsync(DateTime cutoffUtc) + { + var deleted = 0; + try + { + var filter = $"CreatedDate lt datetime'{cutoffUtc:yyyy-MM-ddTHH:mm:ss}Z'"; + await foreach (var entity in _table.QueryAsync(filter: filter, select: new[] { "PartitionKey", "RowKey" })) + { + try + { + await _table.DeleteEntityAsync(entity.PartitionKey, entity.RowKey); + deleted++; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete delegation invitation row {PK}/{RK}", entity.PartitionKey, entity.RowKey); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to sweep old delegation invitation rows"); + } + return deleted; + } + + /// Model → entity (project rule "table-serialization": every field in BOTH directions). + internal static TableEntity Build(DelegationInvitation i) + { + var e = new TableEntity(i.HomeTenantId.ToLowerInvariant(), i.InvitationId) + { + ["InvitationId"] = i.InvitationId, + ["HomeTenantId"] = i.HomeTenantId.ToLowerInvariant(), + ["Status"] = i.Status, + ["Role"] = i.Role, + ["Source"] = i.Source, + ["CreatedBy"] = i.CreatedBy.ToLowerInvariant(), + ["CreatedDate"] = new DateTimeOffset(DateTime.SpecifyKind(i.CreatedAt, DateTimeKind.Utc)), + ["ExpiresDate"] = new DateTimeOffset(DateTime.SpecifyKind(i.ExpiresAt, DateTimeKind.Utc)), + }; + if (i.AcceptedAt.HasValue) e["AcceptedDate"] = new DateTimeOffset(DateTime.SpecifyKind(i.AcceptedAt.Value, DateTimeKind.Utc)); + if (!string.IsNullOrEmpty(i.AcceptedBy)) e["AcceptedBy"] = i.AcceptedBy.ToLowerInvariant(); + if (!string.IsNullOrEmpty(i.TenantId)) e["TenantId"] = i.TenantId.ToLowerInvariant(); + if (i.ReleasedAt.HasValue) e["ReleasedDate"] = new DateTimeOffset(DateTime.SpecifyKind(i.ReleasedAt.Value, DateTimeKind.Utc)); + if (!string.IsNullOrEmpty(i.ReleasedBy)) e["ReleasedBy"] = i.ReleasedBy.ToLowerInvariant(); + if (i.HoldUntilUtc.HasValue) e["HoldUntilDate"] = new DateTimeOffset(DateTime.SpecifyKind(i.HoldUntilUtc.Value, DateTimeKind.Utc)); + return e; + } + + internal static DelegationInvitation Map(TableEntity e) => new() + { + InvitationId = e.GetString("InvitationId") ?? e.RowKey, + HomeTenantId = e.GetString("HomeTenantId") ?? e.PartitionKey, + Status = e.GetString("Status") ?? string.Empty, + Role = e.GetString("Role") ?? Constants.DelegatedRoles.DelegatedReader, + Source = e.GetString("Source") ?? Constants.DelegatedSource.CustomerDelegated, + CreatedBy = e.GetString("CreatedBy") ?? string.Empty, + CreatedAt = e.GetDateTimeOffset("CreatedDate")?.UtcDateTime ?? DateTime.MinValue, + ExpiresAt = e.GetDateTimeOffset("ExpiresDate")?.UtcDateTime ?? DateTime.MinValue, + AcceptedAt = e.GetDateTimeOffset("AcceptedDate")?.UtcDateTime, + AcceptedBy = e.GetString("AcceptedBy"), + TenantId = e.GetString("TenantId"), + ReleasedAt = e.GetDateTimeOffset("ReleasedDate")?.UtcDateTime, + ReleasedBy = e.GetString("ReleasedBy"), + HoldUntilUtc = e.GetDateTimeOffset("HoldUntilDate")?.UtcDateTime, + ETag = e.ETag.ToString(), + }; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableUserUsageRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableUserUsageRepository.cs index 36198cf7..ef6e9905 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableUserUsageRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableUserUsageRepository.cs @@ -208,7 +208,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage // ---- McpTenantUsage (organization-wide quota counters) ---- - public async Task IncrementTenantUsageAsync(string tenantId, string userId) + public async Task IncrementTenantUsageAsync(string tenantId, string userId, string? userPrincipalName, string? homeTenantId) { if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(userId)) return; @@ -226,6 +226,12 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage var count = entity.TryGetValue("RequestCount", out var c) ? Convert.ToInt64(c) : 0L; entity["RequestCount"] = count + 1; entity["LastRequestAt"] = DateTimeOffset.UtcNow; + // Attribution columns (added 2026-09): the UPN as last seen, and the caller's home tenant + // when this row was charged by a delegated (MSP) read. Refreshed on every increment so a + // row created before the columns existed heals on its next hit. + if (!string.IsNullOrEmpty(userPrincipalName)) + entity["UserPrincipalName"] = userPrincipalName; + entity["HomeTenantId"] = homeTenantId ?? string.Empty; await _tenantTableClient.UpdateEntityAsync(entity, entity.ETag); return; } @@ -235,6 +241,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { ["Date"] = date, ["UserId"] = userId, + ["UserPrincipalName"] = userPrincipalName ?? string.Empty, + ["HomeTenantId"] = homeTenantId ?? string.Empty, ["RequestCount"] = 1L, ["LastRequestAt"] = DateTimeOffset.UtcNow, }; @@ -270,8 +278,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { TenantId = entity.PartitionKey, UserId = entity.GetString("UserId") ?? string.Empty, + UserPrincipalName = entity.GetString("UserPrincipalName") ?? string.Empty, + HomeTenantId = entity.GetString("HomeTenantId") ?? string.Empty, Date = entity.GetString("Date") ?? entity.RowKey.Split('_')[0], RequestCount = entity.TryGetValue("RequestCount", out var rc) ? Convert.ToInt64(rc) : 0L, + LastRequestAt = entity.GetDateTimeOffset("LastRequestAt")?.UtcDateTime, }); } return records; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegatedAdminManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegatedAdminManagementFunction.cs index 249bdfe1..3e8f0630 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegatedAdminManagementFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegatedAdminManagementFunction.cs @@ -28,19 +28,22 @@ public class DelegatedAdminManagementFunction private readonly AdminIdentityResolver _identityResolver; private readonly IMaintenanceRepository _maintenanceRepo; private readonly ISignalRNotificationService _signalRService; + private readonly DelegatedSlotService _slots; public DelegatedAdminManagementFunction( ILogger logger, DelegatedAdminService delegatedAdminService, AdminIdentityResolver identityResolver, IMaintenanceRepository maintenanceRepo, - ISignalRNotificationService signalRService) + ISignalRNotificationService signalRService, + DelegatedSlotService slots) { _logger = logger; _delegatedAdminService = delegatedAdminService; _identityResolver = identityResolver; _maintenanceRepo = maintenanceRepo; _signalRService = signalRService; + _slots = slots; } /// GET /api/global/delegated-admins — list every delegated assignment. GlobalReadOrAdmin. @@ -82,6 +85,12 @@ public class DelegatedAdminManagementFunction if (identity == null) return await Unresolved(req); + // Slot limit of the HOME tenant (the MSP seat's plan): a new managed tenant needs a free slot. Fresh + // read, so a Global Admin's "raise the limit, then retry" round trip works on every instance. + var violation = await _slots.CheckAsync(identity.Value.TenantId, new[] { body.TenantId }); + if (violation != null) + return await DelegatedSlotResponses.ConflictAsync(req, violation); + DelegatedAdminEntry entry; try { @@ -105,6 +114,7 @@ public class DelegatedAdminManagementFunction { "HomeTenantId", identity.Value.TenantId.ToLowerInvariant() }, }); + _slots.Invalidate(identity.Value.TenantId); _logger.LogInformation("Delegated grant: {Upn} -> {TenantId} ({Role}) by {By}", entry.Upn, entry.TenantId, entry.Role, currentUpn); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegatedSlotManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegatedSlotManagementFunction.cs new file mode 100644 index 00000000..06332b89 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegatedSlotManagementFunction.cs @@ -0,0 +1,98 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Admin; + +/// +/// Operator surface for a managing (MSP) tenant's delegated tenant slots — how many distinct +/// customer tenants its users may manage against its plan limit or Global Admin override — and the escape +/// hatch that ends a 24-hour release hold early (support call: "I need one back in today"). Platform-operational +/// (excludeDelegated): a delegated caller never reads another tenant's slot accounting here. +/// +public class DelegatedSlotManagementFunction +{ + private readonly ILogger _logger; + private readonly DelegatedSlotService _slots; + private readonly IMaintenanceRepository _audit; + + public DelegatedSlotManagementFunction(ILogger logger, DelegatedSlotService slots, IMaintenanceRepository audit) + { + _logger = logger; + _slots = slots; + _audit = audit; + } + + /// GET /api/global/delegated-slots/{tenantId} — slot usage of the managing tenant. GlobalReadOrAdmin. + [Function("GetDelegatedSlotUsage")] + [Authorize] + public async Task GetUsage( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/delegated-slots/{tenantId}")] HttpRequestData req, + string tenantId) + { + var target = req.GetRequestContext().TargetTenantId; + _logger.LogInformation("Delegated slot usage requested for {TenantId}", target); + + var usage = await _slots.GetUsageAsync(target); + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(ToResponse(usage)); + return response; + } + + /// + /// POST /api/global/delegated-slots/{tenantId}/release-hold — end one hold ({ "invitationId" }) or every + /// active hold ({ "all": true }) now. GlobalAdminOnly. Audited under the managing tenant. + /// + [Function("ReleaseDelegatedSlotHold")] + [Authorize] + public async Task ReleaseHold( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/delegated-slots/{tenantId}/release-hold")] HttpRequestData req, + string tenantId) + { + var ctx = req.GetRequestContext(); + var target = ctx.TargetTenantId; + var body = await req.ReadFromJsonAsync(); + if (body == null || (!body.All && string.IsNullOrWhiteSpace(body.InvitationId))) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { error = "invitationId or all=true is required" }); + return bad; + } + + var released = await _slots.ReleaseHoldAsync(target, body.InvitationId, body.All, ctx.UserPrincipalName); + if (released > 0) + { + await _audit.LogAuditEntryAsync(target, "UPDATE", "DelegatedSlotHold", body.All ? "*" : body.InvitationId!, ctx.UserPrincipalName, + new Dictionary { { "Reason", "hold-released-early" }, { "Released", released.ToString() } }); + } + _logger.LogInformation("Delegated slot holds released for {TenantId}: {Count} by {By}", target, released, ctx.UserPrincipalName); + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new ReleaseDelegatedSlotHoldResponse { HomeTenantId = target, Released = released }); + return response; + } + + internal static DelegatedSlotUsageResponse ToResponse(DelegatedSlotUsage usage) => new() + { + HomeTenantId = usage.HomeTenantId, + Limit = usage.Limit, + CatalogLimit = usage.CatalogLimit, + OverrideLimit = usage.OverrideLimit, + Used = usage.Used, + ManagedTenantIds = usage.ManagedTenantIds.OrderBy(t => t, StringComparer.Ordinal).ToList(), + PendingInvitations = usage.PendingInvitations, + Holds = usage.Holds.Select(h => new DelegatedSlotHold + { + InvitationId = h.InvitationId, + TenantId = h.TenantId, + HoldUntilUtc = h.HoldUntilUtc ?? DateTime.MinValue, + ReleasedBy = h.ReleasedBy ?? string.Empty, + }).ToList(), + }; +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegationManagedTenantFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegationManagedTenantFunction.cs new file mode 100644 index 00000000..8f6be1ad --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegationManagedTenantFunction.cs @@ -0,0 +1,80 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Admin; + +/// +/// The MANAGED tenant's side of delegated administration: "who can read my tenant" and the customer's own +/// revoke of a self-service delegation. Always the caller's JWT tenant (no {tenantId} template). +/// Operator-provisioned grants are listed but not revocable here — they were consciously provisioned by +/// platform operators and carry no slot/hold semantics (the trust pages point customers to support). +/// +public class DelegationManagedTenantFunction +{ + private readonly ILogger _logger; + private readonly DelegationSelfService _svc; + + public DelegationManagedTenantFunction(ILogger logger, DelegationSelfService svc) + { + _logger = logger; + _svc = svc; + } + + /// GET /api/delegations/managers — every party with delegated read access to the caller's tenant. TenantAdminOrGlobalReader. + [Function("GetTenantManagers")] + [Authorize] + public async Task GetManagers( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "delegations/managers")] HttpRequestData req) + { + var tenantId = req.GetRequestContext().TenantId; + var managers = await _svc.ListManagersAsync(tenantId); + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new TenantManagerListResponse + { + TenantId = tenantId, + Managers = managers.Select(m => new TenantManagerItem + { + GroupId = m.GroupId, + OwnerTenantId = m.OwnerTenantId, + OwnerDomain = m.OwnerDomain, + Name = m.Name, + Source = m.Source, + Assignees = m.Assignees.Select(a => new TenantManagerAssignee { Upn = a.Upn, Role = a.Role, IsEnabled = a.IsEnabled }).ToList(), + SinceUtc = m.SinceUtc, + Revocable = m.Revocable, + }).ToList(), + }); + return response; + } + + /// POST /api/delegations/managers/revoke { homeTenantId } — end a self-service delegation from the customer's side. TenantAdminOrGA. + [Function("RevokeTenantManager")] + [Authorize] + public async Task Revoke( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "delegations/managers/revoke")] HttpRequestData req) + { + var ctx = req.GetRequestContext(); + var body = await req.ReadFromJsonAsync(); + if (body == null || !Guid.TryParse(body.HomeTenantId, out _)) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { error = "a valid homeTenantId (GUID) is required" }); + return bad; + } + + var result = await _svc.RevokeManagerAsync(ctx.TenantId, body.HomeTenantId, ctx.UserPrincipalName); + if (!result.Ok) + return await DelegationSelfServiceFunction.FailAsync(req, result.Failure!); + + _logger.LogInformation("[Delegation] {Tenant} revoked self-service access of {Home} by {By}", ctx.TenantId, body.HomeTenantId, ctx.UserPrincipalName); + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new { message = "Access revoked" }); + return response; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegationSelfServiceFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegationSelfServiceFunction.cs new file mode 100644 index 00000000..735eecd8 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegationSelfServiceFunction.cs @@ -0,0 +1,311 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Admin; + +/// +/// HTTP surface of self-service delegation for the MANAGING (Pro / MSP) tenant, plus the accept endpoints a +/// customer's tenant admin uses. Every route acts on the caller's JWT tenant (no {tenantId} in the +/// templates — the cross-tenant guard would otherwise deny a managing tenant acting on a customer); the +/// core logic and every audit/hold rule live in . +/// +public class DelegationSelfServiceFunction +{ + /// Managed-tenant MCP usage is resolved for at most this many tenants per listing (Global Admin overrides can be large). + internal const int ManagedUsageCap = 25; + + private readonly ILogger _logger; + private readonly DelegationSelfService _svc; + private readonly DelegatedSlotService _slots; + private readonly McpQuotaService _quota; + private readonly IUserUsageRepository _usage; + + public DelegationSelfServiceFunction( + ILogger logger, + DelegationSelfService svc, + DelegatedSlotService slots, + McpQuotaService quota, + IUserUsageRepository usage) + { + _logger = logger; + _svc = svc; + _slots = slots; + _quota = quota; + _usage = usage; + } + + // ── Slots + managed tenants ────────────────────────────────────────────────── + + /// GET /api/delegations/slots — the caller's tenant's slot usage. TenantAdminOrGlobalReader. + [Function("GetDelegationSlots")] + [Authorize] + public async Task GetSlots( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "delegations/slots")] HttpRequestData req) + { + var usage = await _slots.GetUsageAsync(req.GetRequestContext().TenantId); + return await OkAsync(req, DelegatedSlotManagementFunction.ToResponse(usage)); + } + + /// GET /api/delegations/managed — the tenants the caller's tenant manages, with their MCP organization budget. + [Function("GetDelegationManagedTenants")] + [Authorize] + public async Task GetManaged( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "delegations/managed")] HttpRequestData req) + { + var home = req.GetRequestContext().TenantId; + var view = await _svc.ListManagedAsync(home); + + var items = new List(view.Tenants.Count); + var resolved = 0; + foreach (var t in view.Tenants) + { + ManagedTenantQuotaUsage? usage = null; + if (resolved < ManagedUsageCap) + { + usage = await ReadManagedUsageAsync(t.TenantId); + resolved++; + } + items.Add(new ManagedTenantItem + { + TenantId = t.TenantId, + Domain = await _svc.DomainAsync(t.TenantId), + Source = t.Source, + SinceUtc = t.SinceUtc, + Removable = t.Removable, + Usage = usage, + }); + } + + return await OkAsync(req, new ManagedTenantListResponse + { + HomeTenantId = home, + Slots = DelegatedSlotManagementFunction.ToResponse(view.Slots), + Tenants = items, + }); + } + + /// POST /api/delegations/managed/remove — end a self-service delegation to one managed tenant (24 h slot hold). TenantAdminOrGA. + [Function("RemoveDelegationManagedTenant")] + [Authorize] + public async Task RemoveManaged( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "delegations/managed/remove")] HttpRequestData req) + { + var ctx = req.GetRequestContext(); + var body = await req.ReadFromJsonAsync(); + if (body == null || !Guid.TryParse(body.TenantId, out _)) + return await BadAsync(req, "a valid tenantId (GUID) is required"); + + var result = await _svc.RemoveManagedAsync(ctx.TenantId, body.TenantId, ctx.UserPrincipalName); + return result.Ok ? await OkAsync(req, new { message = "Delegation ended" }) : await FailAsync(req, result.Failure!); + } + + // ── Invitations ────────────────────────────────────────────────────────────── + + /// GET /api/delegations/invitations — every invitation of the caller's tenant (never the token). + [Function("GetDelegationInvitations")] + [Authorize] + public async Task GetInvitations( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "delegations/invitations")] HttpRequestData req) + { + var home = req.GetRequestContext().TenantId; + var rows = await _svc.ListInvitationsAsync(home); + var items = new List(rows.Count); + foreach (var r in rows.OrderByDescending(r => r.CreatedAt)) + { + items.Add(new DelegationInvitationItem + { + InvitationId = r.InvitationId, + Status = _svc.EffectiveStatus(r), + CreatedBy = r.CreatedBy, + CreatedUtc = r.CreatedAt, + ExpiresUtc = r.ExpiresAt, + AcceptedUtc = r.AcceptedAt, + AcceptedBy = r.AcceptedBy, + TenantId = r.TenantId, + TenantDomain = string.IsNullOrEmpty(r.TenantId) ? null : await _svc.DomainAsync(r.TenantId), + HoldUntilUtc = r.HoldUntilUtc, + }); + } + return await OkAsync(req, new DelegationInvitationListResponse { HomeTenantId = home, Invitations = items }); + } + + /// POST /api/delegations/invitations — mint a single-use invitation link (token shown once). TenantAdminOrGA. + [Function("CreateDelegationInvitation")] + [Authorize] + public async Task CreateInvitation( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "delegations/invitations")] HttpRequestData req) + { + var ctx = req.GetRequestContext(); + var result = await _svc.CreateInvitationAsync(ctx.TenantId, ctx.UserPrincipalName); + if (!result.Ok) + return await FailAsync(req, result.Failure!); + + var (row, token) = result.Value; + var response = req.CreateResponse(HttpStatusCode.Created); + await response.WriteAsJsonAsync(new CreateDelegationInvitationResponse { InvitationId = row.InvitationId, Token = token, ExpiresUtc = row.ExpiresAt }); + return response; + } + + /// DELETE /api/delegations/invitations/{invitationId} — cancel a pending invitation (frees its slot at once). + [Function("CancelDelegationInvitation")] + [Authorize] + public async Task CancelInvitation( + [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "delegations/invitations/{invitationId}")] HttpRequestData req, + string invitationId) + { + var ctx = req.GetRequestContext(); + var result = await _svc.CancelInvitationAsync(ctx.TenantId, invitationId, ctx.UserPrincipalName); + return result.Ok ? await OkAsync(req, new { message = "Invitation cancelled" }) : await FailAsync(req, result.Failure!); + } + + // ── Assignees (the managing tenant's own users) ────────────────────────────── + + /// GET /api/delegations/assignees — who in the caller's tenant may read the managed tenants. + [Function("GetDelegationAssignees")] + [Authorize] + public async Task GetAssignees( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "delegations/assignees")] HttpRequestData req) + { + var home = req.GetRequestContext().TenantId; + var assignees = await _svc.ListAssigneesAsync(home); + return await OkAsync(req, new DelegationAssigneeListResponse + { + HomeTenantId = home, + GroupId = Constants.TenantGroupIds.ForHomeTenant(home), + Assignees = assignees.OrderBy(a => a.Upn, StringComparer.Ordinal).ToList(), + }); + } + + /// POST /api/delegations/assignees — assign one of the caller's tenant members (read-only). TenantAdminOrGA. + [Function("AssignDelegationAssignee")] + [Authorize] + public async Task Assign( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "delegations/assignees")] HttpRequestData req) + { + var ctx = req.GetRequestContext(); + var body = await req.ReadFromJsonAsync(); + if (body == null || string.IsNullOrWhiteSpace(body.Upn)) + return await BadAsync(req, "upn is required"); + + var result = await _svc.AssignAsync(ctx.TenantId, body.Upn, ctx.UserPrincipalName); + if (!result.Ok) + return await FailAsync(req, result.Failure!); + var response = req.CreateResponse(HttpStatusCode.Created); + await response.WriteAsJsonAsync(new DelegationAssignResponse { Assignment = result.Value! }); + return response; + } + + /// DELETE /api/delegations/assignees/{upn} — unassign. TenantAdminOrGA. + [Function("UnassignDelegationAssignee")] + [Authorize] + public async Task Unassign( + [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "delegations/assignees/{upn}")] HttpRequestData req, + string upn) + { + var ctx = req.GetRequestContext(); + var result = await _svc.UnassignAsync(ctx.TenantId, upn, ctx.UserPrincipalName); + return result.Ok ? await OkAsync(req, new { message = "Unassigned" }) : await FailAsync(req, result.Failure!); + } + + // ── Accept (the customer's tenant admin) ───────────────────────────────────── + + /// GET /api/delegations/accept?token= — what accepting would do; no mutation. TenantAdminOrGA. + [Function("PreviewDelegationInvitation")] + [Authorize] + public async Task Preview( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "delegations/accept")] HttpRequestData req) + { + var ctx = req.GetRequestContext(); + var token = req.Query["token"] ?? string.Empty; + var result = await _svc.PreviewAsync(token, ctx.TenantId); + if (!result.Ok) + return await FailAsync(req, result.Failure!); + var p = result.Value!; + return await OkAsync(req, new DelegationAcceptPreviewResponse + { + HomeTenantId = p.HomeTenantId, + HomeTenantDomain = p.HomeTenantDomain, + ExpiresUtc = p.ExpiresUtc, + Status = p.Status, + TargetTenantId = p.TargetTenantId, + TargetTenantDomain = p.TargetTenantDomain, + }); + } + + /// POST /api/delegations/accept { token } — the caller's tenant joins the inviting tenant's managed set. TenantAdminOrGA. + [Function("AcceptDelegationInvitation")] + [Authorize] + public async Task Accept( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "delegations/accept")] HttpRequestData req) + { + var ctx = req.GetRequestContext(); + var body = await req.ReadFromJsonAsync(); + if (body == null || string.IsNullOrWhiteSpace(body.Token)) + return await BadAsync(req, "token is required"); + + var result = await _svc.AcceptAsync(body.Token, ctx.TenantId, ctx.UserPrincipalName); + if (!result.Ok) + return await FailAsync(req, result.Failure!); + var o = result.Value!; + return await OkAsync(req, new AcceptDelegationInvitationResponse { HomeTenantId = o.HomeTenantId, HomeTenantDomain = o.HomeTenantDomain, ManagedTenantId = o.ManagedTenantId }); + } + + // ── Helpers ───────────────────────────────────────────────────────────────── + + /// The managed tenant's organization MCP budget — its own plan's windows and counters. + private async Task ReadManagedUsageAsync(string tenantId) + { + try + { + var limits = await _quota.ResolvePlanAsync(null, tenantId); + var nowUtc = DateTime.UtcNow; + var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc).ToString("yyyyMMdd"); + var today = nowUtc.ToString("yyyyMMdd"); + var rows = await _usage.GetTenantUsageAsync(tenantId, monthStart, today); + return new ManagedTenantQuotaUsage + { + TenantPlan = limits.TenantPlan, + TenantDailyLimit = limits.TenantDailyLimit, + TenantMonthlyLimit = limits.TenantMonthlyLimit, + TenantDailyUsed = rows.Where(r => r.Date == today).Sum(r => r.RequestCount), + TenantMonthlyUsed = rows.Sum(r => r.RequestCount), + }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Delegation] Managed tenant usage lookup failed for {TenantId}", tenantId); + return null; + } + } + + internal static async Task FailAsync(HttpRequestData req, DelegationFailure failure) + { + if (failure.SlotViolation != null) + return await DelegatedSlotResponses.ConflictAsync(req, failure.SlotViolation); + var response = req.CreateResponse((HttpStatusCode)failure.Status); + await response.WriteAsJsonAsync(new { error = failure.Message, code = failure.Code }); + return response; + } + + private static async Task OkAsync(HttpRequestData req, object body) + { + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(body); + return response; + } + + private static async Task BadAsync(HttpRequestData req, string error) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { error }); + return bad; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantGroupManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantGroupManagementFunction.cs index d14db0a1..40fe8f16 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantGroupManagementFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantGroupManagementFunction.cs @@ -33,6 +33,7 @@ public class TenantGroupManagementFunction private readonly AdminIdentityResolver _identityResolver; private readonly IMaintenanceRepository _maintenanceRepo; private readonly ISignalRNotificationService _signalRService; + private readonly DelegatedSlotService _slots; private const string AuditEntity = "DelegatedGroupAccess"; @@ -41,13 +42,15 @@ public class TenantGroupManagementFunction DelegatedAdminService delegatedAdminService, AdminIdentityResolver identityResolver, IMaintenanceRepository maintenanceRepo, - ISignalRNotificationService signalRService) + ISignalRNotificationService signalRService, + DelegatedSlotService slots) { _logger = logger; _delegatedAdminService = delegatedAdminService; _identityResolver = identityResolver; _maintenanceRepo = maintenanceRepo; _signalRService = signalRService; + _slots = slots; } /// GET /api/global/tenant-groups — list every group with tenants + assignee count. GlobalReadOrAdmin. @@ -84,30 +87,69 @@ public class TenantGroupManagementFunction return response; } - /// PATCH /api/global/tenant-groups/{groupId} — rename. GlobalAdminOnly. Body: { "name": "..." }. - [Function("RenameTenantGroup")] + /// + /// PATCH /api/global/tenant-groups/{groupId} — update group metadata. GlobalAdminOnly. + /// Body: { "name"?: "...", "chargeHomeTenantQuota"?: true|false } — at least one field. A rename has no + /// scope effect; the quota flag re-attributes every assignee's MCP reads into the group's tenants (charged + /// to the assignee's home tenant instead of the managed tenant) and is audited under each tenant. + /// + [Function("UpdateTenantGroup")] [Authorize] - public async Task RenameTenantGroup( + public async Task UpdateTenantGroup( [HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "global/tenant-groups/{groupId}")] HttpRequestData req, string groupId, FunctionContext context) { var currentUpn = context.GetRequestContext().UserPrincipalName; - var body = await req.ReadFromJsonAsync(); - if (body == null || string.IsNullOrWhiteSpace(body.Name)) - return await Bad(req, "name is required"); + var body = await req.ReadFromJsonAsync(); + if (body == null || (string.IsNullOrWhiteSpace(body.Name) && body.ChargeHomeTenantQuota == null)) + return await Bad(req, "name and/or chargeHomeTenantQuota is required"); - var ok = await _delegatedAdminService.RenameGroupAsync(groupId, body.Name); - if (!ok) + if (!string.IsNullOrWhiteSpace(body.Name)) + { + if (!await _delegatedAdminService.RenameGroupAsync(groupId, body.Name)) + return await NotFound(req); + _logger.LogInformation("Tenant group renamed: {GroupId} -> '{Name}' by {By}", groupId, body.Name, currentUpn); + } + + if (body.ChargeHomeTenantQuota is bool chargeHomeTenantQuota + && !await SetChargeModeCoreAsync(groupId, chargeHomeTenantQuota, currentUpn)) return await NotFound(req); - _logger.LogInformation("Tenant group renamed: {GroupId} -> '{Name}' by {By}", groupId, body.Name, currentUpn); - var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new { message = "Group renamed" }); + await response.WriteAsJsonAsync(new { message = "Group updated" }); return response; } + /// + /// Testable core of the quota-charge-mode flip (no HTTP plumbing). Returns false when the group does not + /// exist. An unchanged value is a no-op (no audit); a real change is audited under EVERY tenant in the group + /// — the customer's trail records whose budget the group's assignees draw on from now on. + /// + internal async Task SetChargeModeCoreAsync(string groupId, bool chargeHomeTenantQuota, string? currentUpn) + { + var group = await _delegatedAdminService.GetGroupAsync(groupId); + if (group == null) + return false; + if (group.ChargeHomeTenantQuota == chargeHomeTenantQuota) + return true; + + if (!await _delegatedAdminService.SetChargeHomeTenantQuotaAsync(groupId, chargeHomeTenantQuota)) + return false; + + await AuditPerTenantAsync(group.TenantIds, "UPDATE", "*", currentUpn, + new Dictionary + { + { "Group", group.Name }, + { "GroupId", groupId }, + { "Reason", "quota-charge-mode-changed" }, + { "ChargeHomeTenantQuota", chargeHomeTenantQuota ? "true" : "false" }, + }); + + _logger.LogInformation("Tenant group {GroupId} chargeHomeTenantQuota={Flag} by {By}", groupId, chargeHomeTenantQuota, currentUpn); + return true; + } + /// DELETE /api/global/tenant-groups/{groupId} — delete group + all its assignments. GlobalAdminOnly. [Function("DeleteTenantGroup")] [Authorize] @@ -168,13 +210,21 @@ public class TenantGroupManagementFunction return await Bad(req, "a valid tenantId (GUID) is required"); var tenantId = body.TenantId.ToLowerInvariant(); + // Every current assignee's HOME tenant gains this tenant — each must have a free slot (fresh read). + var assignees = await _delegatedAdminService.GetGroupAssigneesAsync(groupId); + var violation = await _slots.CheckAddTenantToGroupAsync(assignees.Select(a => a.Upn), tenantId); + if (violation != null) + return await DelegatedSlotResponses.ConflictAsync(req, violation); + // The service refuses to add to a non-existent group (no ghost from a lone membership row). var added = await _delegatedAdminService.AddTenantToGroupAsync(groupId, tenantId); if (!added) return await NotFound(req); + foreach (var home in await _slots.ResolveHomeTenantsAsync(assignees.Select(a => a.Upn))) + _slots.Invalidate(home); + // Every current assignee just gained access to this tenant — audit under that tenant per assignee. - var assignees = await _delegatedAdminService.GetGroupAssigneesAsync(groupId); foreach (var assignee in assignees) { await _maintenanceRepo.LogAuditEntryAsync( @@ -268,6 +318,11 @@ public class TenantGroupManagementFunction return unresolved; } + // Slot limit of the assignee's HOME tenant: the whole group's tenant set must fit (fresh read). + var violation = await _slots.CheckAsync(identity.Value.TenantId, group.TenantIds); + if (violation != null) + return await DelegatedSlotResponses.ConflictAsync(req, violation); + // The service re-checks existence (covers a delete racing this assign) — skip the audit if it no-ops. bool assigned; try @@ -283,6 +338,7 @@ public class TenantGroupManagementFunction } if (!assigned) return await NotFound(req); + _slots.Invalidate(identity.Value.TenantId); // The UPN just gained read access to every tenant in the group — audit under each. await AuditPerTenantAsync(group.TenantIds, "CREATE", upn, currentUpn, @@ -382,9 +438,12 @@ public class CreateTenantGroupRequest public string Name { get; set; } = string.Empty; } -public class RenameTenantGroupRequest +public class UpdateTenantGroupRequest { - public string Name { get; set; } = string.Empty; + /// New display name; omitted/blank = unchanged. + public string? Name { get; set; } + /// See ; omitted = unchanged. + public bool? ChargeHomeTenantQuota { get; set; } } public class AddGroupTenantRequest diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs index f9ecc7bc..f2298ee0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs @@ -121,7 +121,9 @@ namespace AutopilotMonitor.Functions.Functions.Config RetentionCapDays = entitlements.RetentionCapDays, UserRateLimitPerMinute = entitlements.UserRateLimitPerMinute, DelegatedAdminAllowed = entitlements.DelegatedAdminAllowed, - McpUsagePlan = entitlements.McpUsagePlanName + McpUsagePlan = entitlements.McpUsagePlanName, + // Effective slot limit (override beats the catalog) — a count, computed from the config row. + MaxDelegatedTenants = TenantEntitlementService.GetMaxDelegatedTenants(config, nowUtc) } }; } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs index e41b76c6..dd3ed209 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs @@ -59,7 +59,8 @@ namespace AutopilotMonitor.Functions.Functions.Config /// /// PATCH /api/config/{tenantId}/plan — GlobalAdminOnly (catalog-enforced). - /// Body: { "planTier"?: "community"|"pro", "trialExpiresUtc"?: ISO-8601 | null }. + /// Body: { "planTier"?: "community"|"pro", "trialExpiresUtc"?: ISO-8601 | null, "maxDelegatedTenants"?: int | null }. + /// maxDelegatedTenants sets the delegated (MSP) slot override (null clears it — the plan entitlement applies). /// The legacy stored value "enterprise" stays readable (resolves as Pro) but is no longer /// accepted on writes. /// Setting a trial date grants/extends the trial (TrialConsumed is NOT touched — GA @@ -90,6 +91,8 @@ namespace AutopilotMonitor.Functions.Functions.Config string? newPlanTier = null; bool trialProvided = false; DateTime? newTrialExpiresUtc = null; + bool slotsProvided = false; + int? newMaxDelegatedTenants = null; using (doc) { @@ -127,10 +130,27 @@ namespace AutopilotMonitor.Functions.Functions.Config return await BadRequestAsync(req, "trialExpiresUtc must be an ISO-8601 date-time string or null"); } } + + if (doc.RootElement.TryGetProperty("maxDelegatedTenants", out var slotsProp)) + { + slotsProvided = true; + if (slotsProp.ValueKind == JsonValueKind.Null) + { + newMaxDelegatedTenants = null; // explicit null = clear the override + } + else if (slotsProp.ValueKind == JsonValueKind.Number && slotsProp.TryGetInt32(out var slots) && slots >= 0) + { + newMaxDelegatedTenants = slots; + } + else + { + return await BadRequestAsync(req, "maxDelegatedTenants must be a non-negative integer or null"); + } + } } - if (newPlanTier == null && !trialProvided) - return await BadRequestAsync(req, "Provide planTier and/or trialExpiresUtc"); + if (newPlanTier == null && !trialProvided && !slotsProvided) + return await BadRequestAsync(req, "Provide planTier, trialExpiresUtc and/or maxDelegatedTenants"); var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId); if (config == null) @@ -145,6 +165,7 @@ namespace AutopilotMonitor.Functions.Functions.Config var (editionBefore, editionAfter) = ApplyPlanChanges(config, newPlanTier, trialProvided, newTrialExpiresUtc, caller, nowUtc, changes); + ApplyDelegatedSlotChange(config, slotsProvided, newMaxDelegatedTenants, changes); if (changes.Count > 0) { @@ -199,7 +220,9 @@ namespace AutopilotMonitor.Functions.Functions.Config TrialExpiresUtc = config.TrialExpiresUtc, TrialConsumed = config.TrialConsumed, EffectiveEdition = editionAfter.ToString().ToLowerInvariant(), - RetentionGraceEndsUtc = TenantEntitlementService.GetRetentionGraceEndUtc(config, nowUtc) + RetentionGraceEndsUtc = TenantEntitlementService.GetRetentionGraceEndUtc(config, nowUtc), + MaxDelegatedTenants = TenantEntitlementService.GetMaxDelegatedTenants(config, nowUtc), + MaxDelegatedTenantsOverride = config.MaxDelegatedTenantsOverride, }); return response; } @@ -267,6 +290,20 @@ namespace AutopilotMonitor.Functions.Functions.Config return (before, after); } + /// + /// Pure mutation core for the delegated (MSP) slot override: records and applies the change only + /// when a value was provided AND differs. The audit entry names "(catalog)" for the absent override. + /// + internal static void ApplyDelegatedSlotChange( + TenantConfiguration config, bool provided, int? maxDelegatedTenants, Dictionary changes) + { + if (!provided || config.MaxDelegatedTenantsOverride == maxDelegatedTenants) + return; + changes["MaxDelegatedTenantsOverride"] = + $"{config.MaxDelegatedTenantsOverride?.ToString() ?? "(catalog)"} -> {maxDelegatedTenants?.ToString() ?? "(catalog)"}"; + config.MaxDelegatedTenantsOverride = maxDelegatedTenants; + } + /// /// POST /api/config/{tenantId}/trial — TenantAdminOrGA (catalog-enforced). Self-service /// 30-day Pro trial, exactly once per tenant. 409 when the trial was already diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs index d71751e7..5a7654da 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs @@ -102,6 +102,111 @@ namespace AutopilotMonitor.Functions.Functions.Metrics } } + /// + /// GET /api/metrics/mcp-usage/organization?dateFrom=&dateTo= — the caller's OWN tenant's organization + /// budget broken down by account: its members and any delegated (MSP) administrators whose reads were + /// charged to this tenant (flagged, with their home tenant). Always the JWT tenant (TenantScoping.None) + /// — a delegated caller can never list a managed tenant's accounts through this route. Reads the + /// organization counters (McpTenantUsage, one partition range), never the per-user log. + /// + [Function("GetMcpOrganizationUsage")] + public async Task GetOrganizationUsage( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "metrics/mcp-usage/organization")] HttpRequestData req) + { + var ctx = req.GetRequestContext(); + var tenantId = ctx.TenantId; + if (string.IsNullOrWhiteSpace(tenantId)) + { + var unauthorized = req.CreateResponse(HttpStatusCode.Unauthorized); + await unauthorized.WriteAsJsonAsync(new { error = "Unable to determine tenant identity" }); + return unauthorized; + } + + try + { + var nowUtc = DateTime.UtcNow; + var today = nowUtc.ToString("yyyyMMdd"); + var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc).ToString("yyyyMMdd"); + var dateFrom = NormalizeDay(req.Query["dateFrom"]) ?? monthStart; + var dateTo = NormalizeDay(req.Query["dateTo"]) ?? today; + + // One read covers the requested range AND the current month/day windows (yyyyMMdd sorts as text). + var readFrom = string.CompareOrdinal(dateFrom, monthStart) < 0 ? dateFrom : monthStart; + var readTo = string.CompareOrdinal(dateTo, today) > 0 ? dateTo : today; + var records = await _userUsageRepo.GetTenantUsageAsync(tenantId, readFrom, readTo); + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new GetMcpOrganizationUsageResponse + { + TenantId = tenantId, + DateFrom = dateFrom, + DateTo = dateTo, + Users = AggregateOrganizationUsage(records, tenantId, dateFrom, dateTo, today, monthStart), + }); + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting organization MCP usage"); + var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); + await errorResponse.WriteAsJsonAsync(new { error = "Internal server error" }); + return errorResponse; + } + } + + /// + /// Pure: folds the organization counter rows into one item per account. A row whose HomeTenantId is set + /// and differs from the tenant was charged by a delegated (MSP) read. Ordered by this month's usage. + /// + internal static List AggregateOrganizationUsage( + IEnumerable records, string tenantId, string dateFrom, string dateTo, string today, string monthStart) + { + var byUser = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var record in records) + { + if (string.IsNullOrEmpty(record.UserId)) + continue; + if (!byUser.TryGetValue(record.UserId, out var item)) + { + item = new McpOrganizationUsageItem { UserId = record.UserId }; + byUser[record.UserId] = item; + } + + if (!string.IsNullOrEmpty(record.UserPrincipalName)) + item.UserPrincipalName = record.UserPrincipalName; + if (!string.IsNullOrEmpty(record.HomeTenantId) + && !string.Equals(record.HomeTenantId, tenantId, StringComparison.OrdinalIgnoreCase)) + { + item.Delegated = true; + item.HomeTenantId = record.HomeTenantId; + } + if (record.LastRequestAt is DateTime last && (item.LastRequestAt == null || last > item.LastRequestAt)) + item.LastRequestAt = last; + + var day = record.Date; + if (day == today) item.RequestsToday += record.RequestCount; + if (string.CompareOrdinal(day, monthStart) >= 0 && string.CompareOrdinal(day, today) <= 0) + item.RequestsThisMonth += record.RequestCount; + if (string.CompareOrdinal(day, dateFrom) >= 0 && string.CompareOrdinal(day, dateTo) <= 0) + item.RequestsInRange += record.RequestCount; + } + + return byUser.Values + .OrderByDescending(i => i.RequestsThisMonth) + .ThenByDescending(i => i.RequestsInRange) + .ThenBy(i => i.UserPrincipalName ?? i.UserId, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// Accepts yyyy-MM-dd or yyyyMMdd; null for anything else. + internal static string? NormalizeDay(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + var digits = raw.Replace("-", string.Empty); + return digits.Length == 8 && digits.All(char.IsDigit) ? digits : null; + } + /// /// GET /api/metrics/mcp-usage/user/{userId}?dateFrom=&dateTo= — Usage for a specific user. /// diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionStatsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionStatsFunction.cs index 6ba4f842..8d727224 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionStatsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionStatsFunction.cs @@ -67,7 +67,13 @@ namespace AutopilotMonitor.Functions.Functions.Sessions callerTenantId, tenantIdFilter ?? "none", days, allowedTenantIds != null); var stats = await _sessionRepo.GetAllSessionStatsAsync(tenantIdFilter, days, allowedTenantIds); - return await req.OkAsync(new SessionStatsResponse { Success = true, Stats = stats }); + return await req.OkAsync(new SessionStatsResponse + { + Success = true, + Stats = stats, + // MCP fleet aggregate only: managed tenants the quota layer dropped (see GetAllSessions). + QuotaExcludedTenants = req.GetRequestContext().QuotaExcludedTenantIds?.ToArray(), + }); } catch (Exception ex) { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs index 4327771d..dfa3984b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs @@ -106,6 +106,9 @@ namespace AutopilotMonitor.Functions.Functions.Sessions Count = page.Items.Count, Sessions = page.Items, NextLink = nextLink, + // MCP fleet aggregate only: managed tenants the quota layer dropped (exhausted organization + // budget). The bound above is already narrowed; this just tells the caller what is missing. + QuotaExcludedTenants = req.GetRequestContext().QuotaExcludedTenantIds?.ToArray(), }); } catch (Exception ex) diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/DelegatedSlotResponses.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/DelegatedSlotResponses.cs new file mode 100644 index 00000000..65d442c7 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/DelegatedSlotResponses.cs @@ -0,0 +1,31 @@ +using System.Net; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; +using Microsoft.Azure.Functions.Worker.Http; + +namespace AutopilotMonitor.Functions.Helpers; + +/// The one 409 shape every slot-limited mutation writes (see ). +public static class DelegatedSlotResponses +{ + public static DelegatedSlotLimitReachedResponse Build(DelegatedSlotViolation violation) + { + var who = string.IsNullOrWhiteSpace(violation.HomeTenantDomain) ? violation.HomeTenantId : violation.HomeTenantDomain; + return new DelegatedSlotLimitReachedResponse + { + Error = $"Delegated tenant slot limit reached for {who}: {violation.Used} of {violation.Limit} slot(s) in use, {violation.Required} more needed. Raise the tenant's limit (plan package or Global Admin override) and retry.", + HomeTenantId = violation.HomeTenantId, + HomeTenantDomain = violation.HomeTenantDomain, + Used = violation.Used, + Limit = violation.Limit, + Required = violation.Required, + }; + } + + public static async Task ConflictAsync(HttpRequestData req, DelegatedSlotViolation violation) + { + var conflict = req.CreateResponse(HttpStatusCode.Conflict); + await conflict.WriteAsJsonAsync(Build(violation)); + return conflict; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/RequestContext.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/RequestContext.cs index d722a2e9..2df25664 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/RequestContext.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/RequestContext.cs @@ -88,10 +88,34 @@ public sealed record RequestContext /// The set of tenant IDs (lowercase) the caller is authorized for via delegated assignments, or null /// when the caller is not a delegated admin (or full platform scope, which is unbounded = "all tenants"). /// Populated on cross-tenant delegated reads; consumed by cross-tenant aggregation to bound the fan-out - /// to the delegated subset (Phase 2). Null ⇒ no delegated bound applies. + /// to the delegated subset (Phase 2). Null ⇒ no delegated bound applies. For an MCP fleet aggregate + /// McpQuotaEnforcementMiddleware may NARROW this set (never widen it, never null it) to the managed + /// tenants whose organization budget is not exhausted — see . /// public IReadOnlyCollection? AllowedTenantIds { get; init; } + /// + /// True when a delegated caller was admitted to a subset-tier DATA aggregate (GlobalReadOrDelegatedSubset + + /// TenantScoping.QueryParam, no tenantId named): the handler fans out over . + /// False for config/all (TenantScoping.None — a directory listing) and for every single-target drill. + /// + public bool IsDelegatedAggregate { get; init; } + + /// + /// Managed tenants dropped from by McpQuotaEnforcementMiddleware because + /// their organization MCP budget is exhausted. Null when nothing was excluded (or not an MCP aggregate). + /// Handlers echo it as quotaExcludedTenants. + /// + public IReadOnlyCollection? QuotaExcludedTenantIds { get; init; } + + /// + /// The subset of this request's managed target tenants (the single target, or members of + /// ) that were reached through a Tenant Group flagged + /// ChargeHomeTenantQuota: MCP reads into them are charged to the caller's HOME tenant instead of + /// the managed tenant. Null when none apply. Billing attribution only — never an authorization input. + /// + public IReadOnlyCollection? HomeChargedTenantIds { get; init; } + /// True if the user is a Tenant Admin of their own tenant. public bool IsTenantAdmin { get; init; } diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs index 2f1a8980..fe678652 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs @@ -24,6 +24,8 @@ namespace AutopilotMonitor.Functions.Helpers ("planTier", c => c.PlanTier), ("trialExpiresUtc", c => c.TrialExpiresUtc), ("trialConsumed", c => c.TrialConsumed), + // Delegated (MSP) slot override — a count, not a secret; the GA tenant list renders it. + ("maxDelegatedTenantsOverride", c => c.MaxDelegatedTenantsOverride), ("disabled", c => c.Disabled), ("disabledReason", c => c.DisabledReason), ("onboardedAt", c => c.OnboardedAt), diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs index 19a6efa5..03930ea5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs @@ -1,6 +1,7 @@ using System.Net; using AutopilotMonitor.Functions.Extensions; using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; @@ -13,11 +14,27 @@ namespace AutopilotMonitor.Functions.Middleware; /// /// Enforces the MCP daily/monthly request quota — the caller's own budget AND the organization-wide -/// budget of the token's tenant (both counted here, both must be free). Runs after +/// budget of the CHARGED tenant (both counted here, both must be free). Runs after /// (per-minute burst control) — this is the budget layer on /// top. Applies ONLY to HTTP requests marked X-Client-Source: mcp with an authenticated -/// principal (oid); everything else passes through untouched. Global Admins are exempt from the -/// quota (but their usage is still tracked for the metrics surface). +/// principal (oid); everything else passes through untouched. +/// +/// Which tenant is charged ("the budget follows the data"): +/// +/// A member reading their own tenant: the home tenant (JWT tid). +/// A delegated (MSP) admin reading ONE managed tenant (?tenantId=): that managed tenant — its +/// plan governs the organization windows, and its admins see the consumption on their MCP Usage page. +/// Exception: a tenant reached through a Tenant Group flagged ChargeHomeTenantQuota (operator-run +/// managed service) is charged to the admin's HOME tenant instead. +/// A delegated admin's bounded fleet aggregate (no tenantId on a subset-tier route): every charged +/// tenant in the managed set. Tenants whose budget is exhausted are EXCLUDED — +/// is NARROWED to the admitted ones and the dropped set is published as +/// for the handler to echo — instead of failing the whole request; only "everything exhausted" is a 429. +/// Global Admins are exempt from the quota and tracked on their HOME tenant only — never on a +/// target tenant's counters (they read every tenant for platform operations). +/// +/// Isolation invariant: narrowing only ever SHRINKS the published bound and never turns it into +/// null (null = unbounded in the repository). See . /// /// This middleware also OWNS the usage-counter increment (moved here from AuthenticationMiddleware, /// Codex finding 2026-07-07): check-then-increment, and only for requests that are actually @@ -25,14 +42,14 @@ namespace AutopilotMonitor.Functions.Middleware; /// request can never be blocked by its OWN in-flight increment. The increment stays /// fire-and-forget (never blocks the request path). /// -/// The quota boundary is deliberately SOFT, not exact. McpQuotaService caches the -/// per-user decision for 60 seconds (per instance), so an ALLOWED decision keeps admitting -/// requests inside that window even after the async increments push the stored counters past -/// the limit. Worst-case overshoot is bounded: ~60s × the caller's request rate (× instance +/// The quota boundary is deliberately SOFT, not exact. McpQuotaService caches the usage +/// snapshots for 60 seconds (per instance; per user and per charged tenant), so an ALLOWED decision keeps +/// admitting requests inside that window even after the async increments push the stored counters past +/// the limit. Worst-case overshoot is bounded: ~60s × the request rate against that counter (× instance /// count on scaled-out Flex Consumption) — the same deliberate posture as the sliding-window -/// rate limiter, trading exactness for one counter read per user per minute instead of per +/// rate limiter, trading exactness for one counter read per user / per tenant per minute instead of per /// request. Pinned by McpQuotaServiceTests soft-boundary tests; do NOT re-document this as an -/// exact limit without reworking the decision cache. +/// exact limit without reworking the snapshot cache. /// /// Over-quota requests get 429 with a structured body, Retry-After, and /// X-MCP-Quota-* headers; allowed MCP requests get the quota headers too so the MCP @@ -43,15 +60,18 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware { private readonly McpQuotaService _quotaService; private readonly IUserUsageRepository _userUsageRepo; + private readonly TenantConfigurationService _tenantConfigService; private readonly ILogger _logger; public McpQuotaEnforcementMiddleware( McpQuotaService quotaService, IUserUsageRepository userUsageRepo, + TenantConfigurationService tenantConfigService, ILogger logger) { _quotaService = quotaService; _userUsageRepo = userUsageRepo; + _tenantConfigService = tenantConfigService; _logger = logger; } @@ -82,63 +102,194 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware } var upn = principal.GetUserPrincipalName(); - var tenantId = principal.GetTenantId(); - - // GA exempt from the QUOTA — but their usage is still tracked (metrics surface). + var homeTenantId = principal.GetTenantId() ?? string.Empty; var requestContext = context.GetRequestContext(); + + // GA is exempt from the quota and is tracked on its HOME tenant only — never on a target tenant's + // counters (platform operations must not consume, or be blocked by, a customer's budget). if (requestContext.IsGlobalAdmin) { - TrackUsage(httpContext, oid, upn, tenantId); + TrackUsage(httpContext, oid, upn, homeTenantId, new[] { homeTenantId }); await next(context); return; } + var scope = ResolveChargeScope(requestContext, homeTenantId); + if (scope.Kind == McpChargeKind.BoundedAggregate) + { + await InvokeAggregateAsync(context, next, httpContext, requestContext, oid, upn, homeTenantId, scope); + return; + } + + var chargeTenantId = scope.TargetTenantId ?? homeTenantId; McpQuotaDecision decision; try { - decision = await _quotaService.CheckAsync(oid, upn, tenantId); + decision = await _quotaService.CheckAsync(oid, upn, homeTenantId, chargeTenantId); } catch (Exception ex) { // Belt-and-braces fail-open: the quota layer must never take MCP down. _logger.LogError(ex, "[McpQuota] Quota check threw for oid={Oid} — allowing request (fail-open)", oid); - TrackUsage(httpContext, oid, upn, tenantId); + TrackUsage(httpContext, oid, upn, homeTenantId, new[] { chargeTenantId }); await next(context); return; } - StampQuotaHeaders(httpContext, decision); + StampQuotaHeaders(httpContext, decision, chargeTenantId); if (decision.Allowed) { // Check-then-increment: the decision above reflects previously SERVED requests only. - TrackUsage(httpContext, oid, upn, tenantId); + TrackUsage(httpContext, oid, upn, homeTenantId, new[] { chargeTenantId }); await next(context); return; } - var retryAfterSeconds = Math.Max(1, (int)(decision.ResetUtc - DateTime.UtcNow).TotalSeconds); - _logger.LogWarning( - "[McpQuota] BLOCKED oid={Oid} tenant={TenantId} level={Level} scope={Scope} plan={Plan} daily={DailyUsed}/{DailyLimit} monthly={MonthlyUsed}/{MonthlyLimit} tenantDaily={TenantDailyUsed}/{TenantDailyLimit} tenantMonthly={TenantMonthlyUsed}/{TenantMonthlyLimit}", - oid, tenantId, decision.Level, decision.Scope, decision.Plan, decision.DailyUsed, decision.DailyLimit, decision.MonthlyUsed, decision.MonthlyLimit, - decision.TenantDailyUsed, decision.TenantDailyLimit, decision.TenantMonthlyUsed, decision.TenantMonthlyLimit); + LogBlocked(oid, chargeTenantId, decision); + var targetLabel = decision.TargetTenantId != null ? await ResolveTenantLabelAsync(decision.TargetTenantId) : null; + await WriteExceededAsync(httpContext, decision, BuildExceededResponse(decision, targetLabel)); + } - httpContext.Response.StatusCode = (int)HttpStatusCode.TooManyRequests; - httpContext.Response.Headers["Retry-After"] = retryAfterSeconds.ToString(); - httpContext.Response.ContentType = "application/json"; - await httpContext.Response.WriteAsJsonAsync(BuildExceededResponse(decision)); + /// + /// The bounded fleet aggregate: every charged tenant is checked; exhausted ones are dropped from the + /// published bound (never the whole request), and the handler echoes them as quotaExcludedTenants. + /// + private async Task InvokeAggregateAsync( + FunctionContext context, FunctionExecutionDelegate next, HttpContext httpContext, RequestContext requestContext, + string oid, string? upn, string homeTenantId, McpChargeScope scope) + { + var chargeTenantIds = scope.ChargeMap!.Keys.ToList(); + McpAggregateQuotaResult result; + try + { + result = await _quotaService.CheckManyAsync(oid, upn, homeTenantId, chargeTenantIds); + } + catch (Exception ex) + { + _logger.LogError(ex, "[McpQuota] Aggregate quota check threw for oid={Oid} — allowing request (fail-open)", oid); + TrackUsage(httpContext, oid, upn, homeTenantId, chargeTenantIds); + await next(context); + return; + } + + var excludedTargets = scope.TargetsOf(result.ExcludedTenantIds); + if (!result.Allowed) + { + var blocking = result.BlockingDecision!; + StampQuotaHeaders(httpContext, blocking, chargeTenantId: null); + LogBlocked(oid, $"aggregate({chargeTenantIds.Count})", blocking); + // A user-level block names the caller's own plan; "every managed tenant exhausted" names the count. + var exhausted = blocking.Level == McpQuotaLevel.Tenant ? excludedTargets.Count : (int?)null; + await WriteExceededAsync(httpContext, blocking, BuildExceededResponse(blocking, exhaustedTenantCount: exhausted)); + return; + } + + var admittedTargets = scope.TargetsOf(result.AdmittedTenantIds); + context.Items[RequestContext.ItemsKey] = Narrow(requestContext, admittedTargets, excludedTargets); + + StampQuotaHeaders(httpContext, result.UserDecision, chargeTenantId: null, includeTenantWindows: false); + httpContext.Response.Headers["X-MCP-Quota-Excluded-Tenants"] = excludedTargets.Count.ToString(); + + TrackUsage(httpContext, oid, upn, homeTenantId, result.AdmittedTenantIds); + await next(context); + } + + /// + /// Pure: which tenant(s) this request draws on. Not delegated ⇒ home. A delegated single-target read ⇒ + /// that target, unless it was reached through a home-charged group ⇒ home. A delegated bounded aggregate ⇒ + /// one charge per managed tenant, home-charged ones mapped onto the home tenant (checked and counted + /// ONCE, never excluded on the managed tenant's behalf). + /// + internal static McpChargeScope ResolveChargeScope(RequestContext ctx, string homeTenantId) + { + if (!ctx.IsDelegated) + return McpChargeScope.Home(homeTenantId); + + var homeCharged = new HashSet(ctx.HomeChargedTenantIds ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); + + if (ctx.IsDelegatedAggregate && ctx.AllowedTenantIds is { Count: > 0 }) + { + var map = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var target in ctx.AllowedTenantIds) + { + if (string.IsNullOrWhiteSpace(target)) + continue; + var charge = homeCharged.Contains(target) ? homeTenantId : target; + if (!map.TryGetValue(charge, out var targets)) + map[charge] = targets = new List(); + targets.Add(target); + } + if (map.Count > 0) + return new McpChargeScope(McpChargeKind.BoundedAggregate, homeTenantId, null, map); + } + + var single = ctx.TargetTenantId; + if (!string.IsNullOrEmpty(single) + && !string.Equals(single, homeTenantId, StringComparison.OrdinalIgnoreCase) + && !homeCharged.Contains(single)) + return new McpChargeScope(McpChargeKind.SingleTarget, homeTenantId, single, null); + + return McpChargeScope.Home(homeTenantId); + } + + /// + /// Pure. ISOLATION INVARIANT: the published bound is only ever NARROWED — the result is a non-null + /// list that is a subset of the input bound (empty allowed: the repository then serves an empty page), + /// and a context without a bound (null = GA/Reader, unbounded) is returned untouched. Never produce + /// null here: null means "all tenants" to the repository. + /// + internal static RequestContext Narrow(RequestContext ctx, IReadOnlyCollection admittedTargets, IReadOnlyCollection excludedTargets) + { + if (ctx.AllowedTenantIds == null) + return ctx; + + var bound = new HashSet(ctx.AllowedTenantIds, StringComparer.OrdinalIgnoreCase); + var narrowed = admittedTargets.Where(bound.Contains).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var excluded = excludedTargets.Where(bound.Contains).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + return ctx with + { + AllowedTenantIds = narrowed, + QuotaExcludedTenantIds = excluded.Count > 0 ? excluded : null, + }; } /// /// The 429 body for a blocked decision. The message names WHOSE budget is exhausted — a member hitting the - /// organization-wide window must not conclude their own plan is too small. + /// organization-wide window must not conclude their own plan is too small, and a delegated admin hitting + /// a MANAGED tenant's window must learn that the managed tenant's plan (not their own) governs it. /// - internal static McpQuotaExceededResponse BuildExceededResponse(McpQuotaDecision decision) + /// Display label (domain) of ; falls back to the id. + /// Set on the aggregate block where EVERY managed tenant is exhausted. + internal static McpQuotaExceededResponse BuildExceededResponse( + McpQuotaDecision decision, string? targetTenantLabel = null, int? exhaustedTenantCount = null) { - var reset = $"Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."; - var message = decision.Level == McpQuotaLevel.Tenant - ? $"MCP {decision.Scope} request quota of your organization exceeded (tenant plan '{decision.TenantPlan}', shared by all its members). {reset}" - : $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. {reset}"; + var resetStamp = $"{decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z"; + var reset = $"Resets at {resetStamp}."; + string message; + string? targetTenantId = null; + + if (exhaustedTenantCount is int exhausted && decision.Level == McpQuotaLevel.Tenant) + { + message = $"MCP request quota exceeded for all {exhausted} managed tenants in scope (each managed tenant's own plan governs its organization windows; upgrading a managed tenant to Pro lifts them). Earliest reset at {resetStamp}."; + } + else if (decision.Level == McpQuotaLevel.Tenant && decision.TargetTenantId != null) + { + targetTenantId = decision.TargetTenantId; + var label = string.IsNullOrWhiteSpace(targetTenantLabel) ? decision.TargetTenantId : targetTenantLabel; + var upgrade = FeatureEntitlementCatalog.IsPermanentProTier(decision.TenantPlan) + ? string.Empty + : " Upgrading that tenant to Pro lifts its organization windows."; + message = $"MCP {decision.Scope} request quota of the managed tenant '{label}' exceeded (tenant plan '{decision.TenantPlan}', shared by all its members and delegated admins).{upgrade} {reset}"; + } + else if (decision.Level == McpQuotaLevel.Tenant) + { + message = $"MCP {decision.Scope} request quota of your organization exceeded (tenant plan '{decision.TenantPlan}', shared by all its members). {reset}"; + } + else + { + message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. {reset}"; + } return new McpQuotaExceededResponse { @@ -148,17 +299,53 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware Level = decision.Level ?? McpQuotaLevel.User, Limit = decision.ExceededLimit, Used = decision.ExceededUsed, - ResetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"), - Message = message + ResetUtc = resetStamp, + Message = message, + TargetTenantId = targetTenantId, }; } + /// Blocked-path only (no hot-path read): the managed tenant's domain for the 429 text, else its id. + private async Task ResolveTenantLabelAsync(string tenantId) + { + try + { + var config = await _tenantConfigService.GetConfigurationIfExistsAsync(tenantId); + return string.IsNullOrWhiteSpace(config?.DomainName) ? tenantId : config!.DomainName; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[McpQuota] Tenant label lookup failed for {TenantId}", LogSanitizer.Clean(tenantId)); + return tenantId; + } + } + + private void LogBlocked(string oid, string chargedTenant, McpQuotaDecision decision) + { + _logger.LogWarning( + "[McpQuota] BLOCKED oid={Oid} charged={ChargedTenant} level={Level} scope={Scope} plan={Plan} daily={DailyUsed}/{DailyLimit} monthly={MonthlyUsed}/{MonthlyLimit} tenantDaily={TenantDailyUsed}/{TenantDailyLimit} tenantMonthly={TenantMonthlyUsed}/{TenantMonthlyLimit}", + oid, LogSanitizer.Clean(chargedTenant), decision.Level, decision.Scope, decision.Plan, decision.DailyUsed, decision.DailyLimit, decision.MonthlyUsed, decision.MonthlyLimit, + decision.TenantDailyUsed, decision.TenantDailyLimit, decision.TenantMonthlyUsed, decision.TenantMonthlyLimit); + } + + private static async Task WriteExceededAsync(HttpContext httpContext, McpQuotaDecision decision, McpQuotaExceededResponse body) + { + var retryAfterSeconds = Math.Max(1, (int)(decision.ResetUtc - DateTime.UtcNow).TotalSeconds); + httpContext.Response.StatusCode = (int)HttpStatusCode.TooManyRequests; + httpContext.Response.Headers["Retry-After"] = retryAfterSeconds.ToString(); + httpContext.Response.ContentType = "application/json"; + await httpContext.Response.WriteAsJsonAsync(body); + } + /// /// Fire-and-forget usage increment (same posture as the previous AuthenticationMiddleware - /// tracking — never blocks or fails the request path). Endpoint is normalized and prefixed - /// with the X-MCP-Tool-Name when the MCP server supplies it. + /// tracking — never blocks or fails the request path). The per-user row (UserUsageLog) is ALWAYS + /// attributed to the caller's HOME tenant — so a delegated admin's own rows never surface under a + /// customer's per-user usage views — while the organization counter (McpTenantUsage) is written once + /// per CHARGED tenant, carrying the caller's home tenant when it differs. Endpoint is normalized and + /// prefixed with the X-MCP-Tool-Name when the MCP server supplies it. /// - private void TrackUsage(HttpContext httpContext, string oid, string? upn, string? tenantId) + private void TrackUsage(HttpContext httpContext, string oid, string? upn, string homeTenantId, IReadOnlyCollection chargeTenantIds) { var normalizedEndpoint = EndpointNormalizer.Normalize(httpContext.Request.Path.Value ?? string.Empty); var mcpToolName = httpContext.Request.Headers["X-MCP-Tool-Name"].FirstOrDefault(); @@ -168,48 +355,101 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware var repo = _userUsageRepo; var logger = _logger; var upnValue = upn ?? "unknown"; - var tidValue = tenantId ?? ""; + var charges = chargeTenantIds.Where(t => !string.IsNullOrEmpty(t)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); _ = Task.Run(async () => { try { - await repo.IncrementUsageAsync(oid, upnValue, tidValue, normalizedEndpoint); + await repo.IncrementUsageAsync(oid, upnValue, homeTenantId, normalizedEndpoint); } catch (Exception ex) { logger.LogWarning(ex, "[McpQuota] Failed to record usage: user={UserId}, endpoint={Endpoint}", LogSanitizer.Clean(oid), LogSanitizer.Clean(normalizedEndpoint)); } - // Organization-wide counter (the tenant quota's source). Separate try so a failure on one - // table never starves the other; GA usage lands here too (exempt from the check, not from the count). - if (tidValue.Length == 0) - return; - try + // Organization-wide counters (the tenant quota's source). Separate try per tenant so a failure on + // one row never starves the others. + foreach (var chargeTenantId in charges) { - await repo.IncrementTenantUsageAsync(tidValue, oid); - } - catch (Exception ex) - { - logger.LogWarning(ex, "[McpQuota] Failed to record tenant usage: tenant={TenantId}, user={UserId}", LogSanitizer.Clean(tidValue), LogSanitizer.Clean(oid)); + var isForeign = !string.Equals(chargeTenantId, homeTenantId, StringComparison.OrdinalIgnoreCase); + try + { + await repo.IncrementTenantUsageAsync(chargeTenantId, oid, upn, isForeign ? homeTenantId : null); + } + catch (Exception ex) + { + logger.LogWarning(ex, "[McpQuota] Failed to record tenant usage: tenant={TenantId}, user={UserId}", LogSanitizer.Clean(chargeTenantId), LogSanitizer.Clean(oid)); + } } }); } - private static void StampQuotaHeaders(HttpContext httpContext, McpQuotaDecision decision) + /// + /// Quota headers. The tenant windows describe the CHARGED tenant (named in X-MCP-Quota-Tenant-Id); on + /// the aggregate path no single tenant applies, so only the caller's own windows are stamped. + /// + private static void StampQuotaHeaders(HttpContext httpContext, McpQuotaDecision decision, string? chargeTenantId, bool includeTenantWindows = true) { // Direct-write pattern — same as UserRateLimitMiddleware's X-RateLimit-* headers. httpContext.Response.Headers["X-MCP-Quota-Plan"] = decision.Plan; httpContext.Response.Headers["X-MCP-Quota-Daily-Limit"] = decision.DailyLimit.ToString(); httpContext.Response.Headers["X-MCP-Quota-Monthly-Limit"] = decision.MonthlyLimit.ToString(); - httpContext.Response.Headers["X-MCP-Quota-Tenant-Plan"] = decision.TenantPlan; - httpContext.Response.Headers["X-MCP-Quota-Tenant-Daily-Limit"] = decision.TenantDailyLimit.ToString(); - httpContext.Response.Headers["X-MCP-Quota-Tenant-Monthly-Limit"] = decision.TenantMonthlyLimit.ToString(); if (decision.DailyUsed >= 0) { httpContext.Response.Headers["X-MCP-Quota-Daily-Used"] = decision.DailyUsed.ToString(); httpContext.Response.Headers["X-MCP-Quota-Monthly-Used"] = decision.MonthlyUsed.ToString(); + } + + if (!includeTenantWindows) + return; + + if (!string.IsNullOrEmpty(chargeTenantId)) + httpContext.Response.Headers["X-MCP-Quota-Tenant-Id"] = chargeTenantId; + httpContext.Response.Headers["X-MCP-Quota-Tenant-Plan"] = decision.TenantPlan; + httpContext.Response.Headers["X-MCP-Quota-Tenant-Daily-Limit"] = decision.TenantDailyLimit.ToString(); + httpContext.Response.Headers["X-MCP-Quota-Tenant-Monthly-Limit"] = decision.TenantMonthlyLimit.ToString(); + if (decision.TenantDailyUsed >= 0) + { httpContext.Response.Headers["X-MCP-Quota-Tenant-Daily-Used"] = decision.TenantDailyUsed.ToString(); httpContext.Response.Headers["X-MCP-Quota-Tenant-Monthly-Used"] = decision.TenantMonthlyUsed.ToString(); } } } + +/// How an MCP request is charged — see . +internal enum McpChargeKind +{ + /// The caller's home tenant (JWT tid). + Home, + /// One managed tenant of a delegated (MSP) read. + SingleTarget, + /// A delegated bounded fleet aggregate: one charge per managed tenant (home-charged ones folded onto home). + BoundedAggregate, +} + +/// +/// Pure charge plan for one request. (aggregate only) maps each CHARGED tenant to +/// the managed TARGET tenants it pays for — a home-charged group's tenants all map onto the home tenant. +/// +internal sealed record McpChargeScope( + McpChargeKind Kind, + string HomeTenantId, + string? TargetTenantId, + IReadOnlyDictionary>? ChargeMap) +{ + public static McpChargeScope Home(string homeTenantId) => new(McpChargeKind.Home, homeTenantId, null, null); + + /// The managed target tenants paid for by the given charged tenants (aggregate only). + public IReadOnlyList TargetsOf(IEnumerable chargeTenantIds) + { + if (ChargeMap == null) + return Array.Empty(); + var targets = new List(); + foreach (var charge in chargeTenantIds) + { + if (ChargeMap.TryGetValue(charge, out var mapped)) + targets.AddRange(mapped); + } + return targets; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs index 12c3d602..9e04a09e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs @@ -164,6 +164,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware // keeps the common authenticated path free of the extra table read. string? delegatedRole = null; IReadOnlyCollection? allowedTenantIds = null; + IReadOnlyCollection? homeChargedTenantIds = null; var isScopedRoute = catalogEntry.TenantScoping is TenantScoping.RouteParam or TenantScoping.QueryParam; if (!hasGlobalScope && !string.IsNullOrEmpty(upn) && isScopedRoute && crossTenant) { @@ -172,7 +173,12 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware var scope = await _delegatedAdminService.GetScopeAsync(AdminIdentity.FromPrincipal(principal)); delegatedRole = scope.RoleFor(namedTarget); if (delegatedRole != null) + { allowedTenantIds = scope.TenantIds; + // Billing attribution for the MCP quota layer: a target reached through a home-charged group. + if (scope.ChargesHome(namedTarget)) + homeChargedTenantIds = new[] { namedTarget!.ToLowerInvariant() }; + } } // A delegated grant only applies to the READ tiers — never to write tiers (TenantAdminOrGA, // GlobalAdminOnly, …). This is the single fact that keeps delegation read-only in this phase and @@ -272,6 +278,12 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware IsDelegatedReader = delegatedRole == Constants.DelegatedRoles.DelegatedReader || admittedViaDelegatedSubset, IsDelegatedAdmin = delegatedRole == Constants.DelegatedRoles.DelegatedAdmin, AllowedTenantIds = effectiveAllowedTenantIds, + // The bounded DATA aggregate: subset tier on a QueryParam route with no tenantId named. config/all + // (subset tier, TenantScoping.None) is a directory listing, not an aggregate — it stays false there. + IsDelegatedAggregate = admittedViaDelegatedSubset + && namedTarget == null + && catalogEntry.TenantScoping == TenantScoping.QueryParam, + HomeChargedTenantIds = homeChargedTenantIds ?? decision.HomeChargedTenantIds, // Additive: a caller's tenant-admin status is its OWN-TENANT role, independent of any // platform role. Evaluators that admit via a platform-role branch still surface the // resolved tenant role on decision.TenantRole; pure-tenant branches leave it null and @@ -611,7 +623,8 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware if (!scope.IsEmpty) return CatalogDecisionResult.Allow( userIdentifier, Constants.DelegatedRoles.DelegatedReader, "DelegatedSubset", - allowedTenantIds: scope.TenantIds); + allowedTenantIds: scope.TenantIds, + homeChargedTenantIds: scope.HomeChargedTenantIds.Count > 0 ? scope.HomeChargedTenantIds : null); return CatalogDecisionResult.Deny(userIdentifier, "NonGlobal", "NotGlobalOrDelegated"); } @@ -687,9 +700,15 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware /// public IReadOnlyCollection? AllowedTenantIds { get; private init; } + /// + /// Subset tier only: the members of reached through a home-charged + /// Tenant Group (MCP quota billing attribution, see RequestContext.HomeChargedTenantIds). Null when none. + /// + public IReadOnlyCollection? HomeChargedTenantIds { get; private init; } + public static CatalogDecisionResult Allow(string user, string role, string reason, string? tenantRole = null, - IReadOnlyCollection? allowedTenantIds = null) - => new() { IsAllowed = true, UserIdentifier = user, UserRole = role, Reason = reason, TenantRole = tenantRole, AllowedTenantIds = allowedTenantIds }; + IReadOnlyCollection? allowedTenantIds = null, IReadOnlyCollection? homeChargedTenantIds = null) + => new() { IsAllowed = true, UserIdentifier = user, UserRole = role, Reason = reason, TenantRole = tenantRole, AllowedTenantIds = allowedTenantIds, HomeChargedTenantIds = homeChargedTenantIds }; public static CatalogDecisionResult Deny(string user, string role, string reason) => new() { IsAllowed = false, UserIdentifier = user, UserRole = role, Reason = reason }; diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 218bffcb..fff4f940 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -253,6 +253,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Shared activation path (whitelist add + auto-promote + welcome email) — used by the diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 45a8d40b..80e92568 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -278,6 +278,9 @@ public static class EndpointAccessPolicyCatalog new("GET", "metrics/software-inventory", EndpointPolicy.MemberRead), new("GET", "metrics/geographic", EndpointPolicy.MemberRead), new("GET", "metrics/mcp-usage/user/{userId}", EndpointPolicy.TenantAdminOrGlobalReader), + // Organization usage by account, ALWAYS the caller's own JWT tenant (None): admin information (UPNs), + // and a delegated caller must never enumerate a managed tenant's accounts through it. + new("GET", "metrics/mcp-usage/organization", EndpointPolicy.TenantAdminOrGlobalReader), new("GET", "metrics/geographic/sessions", EndpointPolicy.MemberRead), new("GET", "metrics/ime-versions", EndpointPolicy.MemberRead), new("GET", "metrics/ime-pattern-health", EndpointPolicy.GlobalReadOrAdmin), @@ -600,6 +603,26 @@ public static class EndpointAccessPolicyCatalog // (audit "who can actually use this grant, from which tenant"); rebind/remove are GlobalAdminOnly and // are the ONLY way to re-home a UPN or re-pin its object id (grants refuse to overwrite a binding). new("GET", "global/identity-bindings", EndpointPolicy.GlobalReadOrAdmin), + // Delegated (MSP) slot accounting of a managing tenant — platform-operational (excludeDelegated). + new("GET", "global/delegated-slots/{tenantId}", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.RouteParam, excludeDelegated: true), + new("POST", "global/delegated-slots/{tenantId}/release-hold", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam), + // Self-service delegation — the managing Pro tenant's own surface plus the managed tenant's "who manages + // me". Deliberately NO {tenantId} in these templates: the tenant is ALWAYS the caller's JWT tenant (a + // RouteParam template would put the managing tenant's mutations on a customer behind the cross-tenant + // guard). Reads admit the read-only Global Reader; mutations are tenant-admin (or GA) only. + new("GET", "delegations/slots", EndpointPolicy.TenantAdminOrGlobalReader), + new("GET", "delegations/managed", EndpointPolicy.TenantAdminOrGlobalReader), + new("POST", "delegations/managed/remove", EndpointPolicy.TenantAdminOrGA), + new("GET", "delegations/invitations", EndpointPolicy.TenantAdminOrGlobalReader), + new("POST", "delegations/invitations", EndpointPolicy.TenantAdminOrGA), + new("DELETE", "delegations/invitations/{invitationId}", EndpointPolicy.TenantAdminOrGA), + new("GET", "delegations/assignees", EndpointPolicy.TenantAdminOrGlobalReader), + new("POST", "delegations/assignees", EndpointPolicy.TenantAdminOrGA), + new("DELETE", "delegations/assignees/{upn}", EndpointPolicy.TenantAdminOrGA), + new("GET", "delegations/accept", EndpointPolicy.TenantAdminOrGA), + new("POST", "delegations/accept", EndpointPolicy.TenantAdminOrGA), + new("GET", "delegations/managers", EndpointPolicy.TenantAdminOrGlobalReader), + new("POST", "delegations/managers/revoke", EndpointPolicy.TenantAdminOrGA), new("PUT", "global/identity-bindings/{upn}", EndpointPolicy.GlobalAdminOnly), new("DELETE", "global/identity-bindings/{upn}", EndpointPolicy.GlobalAdminOnly), new("PATCH", "config/{tenantId}/plan", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam), diff --git a/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs index 600760fa..0992f116 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs @@ -48,6 +48,15 @@ namespace AutopilotMonitor.Functions.Security /// public bool DelegatedAdminAllowed { get; init; } + /// + /// How many DISTINCT customer tenants the users homed in a tenant of this edition may manage + /// (delegated "MSP" slots) — direct grants ∪ assigned Tenant Groups, plus pending self-service + /// invitations and release holds. A Global Admin may raise it per tenant via + /// TenantConfiguration.MaxDelegatedTenantsOverride (plan packages, sales). Counted and enforced by + /// DelegatedSlotService; the Pro gate on USING delegation stays . + /// + public int MaxDelegatedTenants { get; init; } + /// /// Whether the OOBE bootstrap feature (bootstrap sessions/codes) is included in the plan. /// When true, bootstrap endpoints work without the per-tenant GA flag @@ -121,6 +130,7 @@ namespace AutopilotMonitor.Functions.Security UserRateLimitPerMinute = null, // AdminConfiguration default (120) applies DeviceRateLimitPerMinute = null, // AdminConfiguration default (100) applies DelegatedAdminAllowed = false, + MaxDelegatedTenants = 0, BootstrapIncluded = false, UnrestrictedModeAvailable = false, McpUsagePlanName = CommunityTierName, @@ -137,6 +147,7 @@ namespace AutopilotMonitor.Functions.Security UserRateLimitPerMinute = 150, DeviceRateLimitPerMinute = 150, DelegatedAdminAllowed = true, + MaxDelegatedTenants = 2, BootstrapIncluded = true, UnrestrictedModeAvailable = true, McpUsagePlanName = ProTierName, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs b/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs index ec251baf..e47058de 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs @@ -114,6 +114,11 @@ public class DelegatedAdminService // set on the group. Same fail-closed + stronger-role-wins merge as direct grants; a tenant // present both directly and via a group keeps the stronger role. Membership changes converge // within the cache TTL (no separate per-group cache by design). + // A group flagged ChargeHomeTenantQuota (operator-run managed service) marks its tenants as + // home-charged for the MCP quota layer: reads into them draw on the assignee's HOME tenant budget, + // never the managed tenant's. The flag wins over a parallel direct grant on the same tenant — + // it is the operator's explicit, more privileged choice. Billing attribution only, never authz. + var homeCharged = new HashSet(StringComparer.OrdinalIgnoreCase); var groupAssignments = await _adminRepo.GetGroupAssignmentsForUpnAsync(upn); foreach (var assignment in groupAssignments) { @@ -124,11 +129,16 @@ public class DelegatedAdminService if (role == null) continue; - var groupTenants = await _adminRepo.GetGroupTenantsAsync(assignment.GroupId); - foreach (var tenantId in groupTenants) + // Members + meta flags from ONE partition read (meta-backed: a missing meta row ⇒ no group, no scope). + var membership = await _adminRepo.GetGroupMembershipAsync(assignment.GroupId); + if (membership == null) + continue; + foreach (var tenantId in membership.TenantIds) { if (string.IsNullOrWhiteSpace(tenantId)) continue; + if (membership.ChargeHomeTenantQuota) + homeCharged.Add(tenantId); if (tenantRoles.TryGetValue(tenantId, out var existing) && IsStronger(existing, role)) continue; tenantRoles[tenantId] = role; @@ -141,7 +151,7 @@ public class DelegatedAdminService // NOT of the managed targets. A Pro MSP may manage Community customers. Resolution // failure / unknown home tenant counts as Community (fail-closed inside the entitlement // service), which empties the scope. Grant rows stay untouched — inert until upgrade/trial. - var scope = new DelegatedScope(tenantRoles); + var scope = new DelegatedScope(tenantRoles, homeCharged); _cache.Set(cacheKey, scope, _cacheDuration); return scope; } @@ -262,6 +272,24 @@ public class DelegatedAdminService public Task RenameGroupAsync(string groupId, string name) => _adminRepo.RenameTenantGroupAsync(NormalizeGroupId(groupId), name); + /// Creates a tenant-OWNED (self-service) group's meta row if missing — no scope effect (a fresh group has no members). + public Task EnsureOwnedGroupAsync(string groupId, string name, string ownerTenantId) + => _adminRepo.EnsureOwnedTenantGroupAsync(NormalizeGroupId(groupId), name, ownerTenantId); + + /// + /// Sets the group's flag. The flag rides in every + /// assignee's cached scope (), so every current assignee + /// is invalidated. Returns false if the group does not exist. + /// + public async Task SetChargeHomeTenantQuotaAsync(string groupId, bool chargeHomeTenantQuota) + { + groupId = NormalizeGroupId(groupId); + var ok = await _adminRepo.SetTenantGroupChargeHomeTenantQuotaAsync(groupId, chargeHomeTenantQuota); + if (ok) + await InvalidateGroupAssigneesAsync(groupId); + return ok; + } + /// Deletes a group and all its assignments; invalidates every (former) assignee's scope. public async Task DeleteGroupAsync(string groupId) { @@ -400,13 +428,32 @@ public sealed class DelegatedScope public static readonly DelegatedScope Empty = new(new Dictionary()); private readonly IReadOnlyDictionary _tenantRoles; + private readonly HashSet _homeCharged; public DelegatedScope(IReadOnlyDictionary tenantRoles) - => _tenantRoles = tenantRoles; + : this(tenantRoles, null) + { + } + + public DelegatedScope(IReadOnlyDictionary tenantRoles, IReadOnlyCollection? homeChargedTenantIds) + { + _tenantRoles = tenantRoles; + _homeCharged = new HashSet(homeChargedTenantIds ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); + } /// Tenant IDs (lowercase) this scope grants access to. public IReadOnlyCollection TenantIds => (IReadOnlyCollection)_tenantRoles.Keys; + /// + /// The covered tenants reached through a Tenant Group flagged : + /// MCP reads into them are charged to the caller's HOME tenant. Billing attribution only — never authz. + /// + public IReadOnlyCollection HomeChargedTenantIds => _homeCharged; + + /// True if MCP reads into the given tenant are charged to the caller's home tenant. + public bool ChargesHome(string? tenantId) + => !string.IsNullOrEmpty(tenantId) && _homeCharged.Contains(tenantId); + public bool IsEmpty => _tenantRoles.Count == 0; /// True if the scope grants access to the given tenant (any delegated role). @@ -493,6 +540,15 @@ public class TenantGroupEntity : ITableEntity /// The managed tenant ID (lowercase) — denormalized copy of RowKey on membership rows; empty on meta. public string TenantId { get; set; } = string.Empty; + + /// + /// Meta row only (null/omitted on membership rows): see . + /// Nullable so a membership-row write never carries a spurious false column. + /// + public bool? ChargeHomeTenantQuota { get; set; } + + /// Meta row only: the managing tenant that owns this self-service group (see ); null on operator-created groups. + public string? OwnerTenantId { get; set; } } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/DelegatedSlotService.cs b/src/Backend/AutopilotMonitor.Functions/Services/DelegatedSlotService.cs new file mode 100644 index 00000000..b490b0ea --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/DelegatedSlotService.cs @@ -0,0 +1,317 @@ +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services; + +/// +/// Slot usage of one HOME (managing / MSP) tenant: how many DISTINCT managed target tenants its users may +/// reach, against the tenant's slot limit. also counts pending self-service invitations +/// and 24-hour release holds — a slot is occupied from the moment it is promised until the hold lapses. +/// +public sealed record DelegatedSlotUsage( + string HomeTenantId, + string? HomeTenantDomain, + int Limit, + int CatalogLimit, + int? OverrideLimit, + IReadOnlySet ManagedTenantIds, + int PendingInvitations, + IReadOnlyList Holds) +{ + public int ActiveHolds => Holds.Count; + public int Used => ManagedTenantIds.Count + PendingInvitations + ActiveHolds; + public int Free => Math.Max(0, Limit - Used); +} + +/// A mutation that would push a home tenant over its slot limit — rendered as 409 DelegatedSlotLimitReached. +public sealed record DelegatedSlotViolation(string HomeTenantId, string? HomeTenantDomain, int Used, int Limit, int Required); + +/// +/// Counts and enforces the delegated tenant slots of a managing (MSP) tenant: the number of DISTINCT +/// customer tenants that users HOMED in it may manage — direct DelegatedAdmins grants ∪ every tenant of every +/// Tenant Group assigned to any of those users ∪ the tenant's own self-service group (even before anyone is +/// assigned to it) — plus pending self-service invitations and 24-hour release holds. The limit is the +/// tenant's plan entitlement (: Community 0, +/// Pro 2) unless a Global Admin set . +/// +/// Attribution rides on the identity binding: a UPN counts toward the tenant it is bound to. A grant for a +/// UPN WITHOUT a binding is not attributable — and inert anyway (DelegatedAdminService.GetScopeAsync confers +/// nothing without a binding). Disabled grants/assignments DO count (a paused relationship still holds its +/// slot); Revoked rows do not. +/// +/// Two read paths: (30 s per-instance cache, read surfaces) and the Check* +/// methods (ALWAYS fresh — the limit is read from the config row uncached so a Global Admin's "raise the +/// limit, then retry" round trip lands on every instance at once). There is no global lock: two concurrent +/// grants can overshoot the limit by one — acceptable for an admin-scale surface. +/// +public class DelegatedSlotService +{ + private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(30); + /// A removed customer's slot stays occupied this long — no rotating customers through a small allowance. + public static readonly TimeSpan ReleaseHold = TimeSpan.FromHours(24); + + private readonly IAdminRepository _adminRepo; + private readonly IConfigRepository _configRepo; + private readonly IDelegationInvitationRepository _invitations; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private readonly TimeProvider _time; + + public DelegatedSlotService( + IAdminRepository adminRepo, + IConfigRepository configRepo, + IDelegationInvitationRepository invitations, + IMemoryCache cache, + ILogger logger) + : this(adminRepo, configRepo, invitations, cache, logger, TimeProvider.System) + { + } + + /// Test seam — inject a fake for deterministic edition/hold math. + public DelegatedSlotService( + IAdminRepository adminRepo, + IConfigRepository configRepo, + IDelegationInvitationRepository invitations, + IMemoryCache cache, + ILogger logger, + TimeProvider time) + { + _adminRepo = adminRepo; + _configRepo = configRepo; + _invitations = invitations; + _cache = cache; + _logger = logger; + _time = time; + } + + internal static string CacheKey(string homeTenantId) => $"delegated-slots:{homeTenantId.ToLowerInvariant()}"; + + /// The home tenant's current slot usage. Cached briefly unless . + public virtual async Task GetUsageAsync(string homeTenantId, bool bypassCache = false) + { + var home = homeTenantId.ToLowerInvariant(); + var key = CacheKey(home); + if (!bypassCache && _cache.TryGetValue(key, out var cached) && cached != null) + return cached; + + var nowUtc = _time.GetUtcNow().UtcDateTime; + var (limit, catalogLimit, overrideLimit, domain) = await ResolveLimitAsync(home, nowUtc); + var managed = new HashSet(StringComparer.OrdinalIgnoreCase); + + var bindings = await _adminRepo.GetIdentityBindingsByHomeTenantAsync(home); + // The tenant's own self-service group counts even before anyone is assigned to it: an accepted + // customer occupies the slot from the moment it accepted. + var groupIds = new HashSet(StringComparer.Ordinal) { Constants.TenantGroupIds.ForHomeTenant(home) }; + foreach (var binding in bindings) + { + foreach (var row in await _adminRepo.GetDelegatedTenantsAsync(binding.Upn)) + { + if (row.Status == Constants.DelegatedStatus.Revoked || string.IsNullOrWhiteSpace(row.TenantId)) + continue; + if (!string.Equals(row.TenantId, home, StringComparison.OrdinalIgnoreCase)) + managed.Add(row.TenantId.ToLowerInvariant()); + } + foreach (var assignment in await _adminRepo.GetGroupAssignmentsForUpnAsync(binding.Upn)) + groupIds.Add(assignment.GroupId); + } + + foreach (var groupId in groupIds) + { + foreach (var tenantId in await _adminRepo.GetGroupTenantsAsync(groupId)) + { + if (!string.IsNullOrWhiteSpace(tenantId) && !string.Equals(tenantId, home, StringComparison.OrdinalIgnoreCase)) + managed.Add(tenantId.ToLowerInvariant()); + } + } + + var rows = await _invitations.GetByHomeTenantAsync(home); + var pending = rows.Count(r => IsPending(r, nowUtc)); + var holds = rows.Where(r => IsActiveHold(r, nowUtc)).OrderBy(r => r.HoldUntilUtc).ToList(); + + var usage = new DelegatedSlotUsage(home, domain, limit, catalogLimit, overrideLimit, managed, pending, holds); + _cache.Set(key, usage, CacheDuration); + return usage; + } + + /// + /// Would granting the home tenant's users access to exceed the limit? + /// Only tenants NOT already managed (and not the home tenant itself) need a slot. Always a fresh read. + /// + public virtual async Task CheckAsync(string homeTenantId, IEnumerable candidateTenantIds) + { + var usage = await GetUsageAsync(homeTenantId, bypassCache: true); + return Evaluate(usage, RequiredSlots(usage, candidateTenantIds)); + } + + /// Would reserving new slots (a pending invitation) exceed the limit? Fresh read. + public virtual async Task CheckReserveAsync(string homeTenantId, int count) + { + var usage = await GetUsageAsync(homeTenantId, bypassCache: true); + return Evaluate(usage, count); + } + + /// + /// Accept-time check: the pending invitation being redeemed already holds a slot and converts into the + /// managed tenant, so the net demand is zero — unless the limit was LOWERED after the invitation was sent + /// (then Used already exceeds Limit). An already-managed tenant needs nothing. + /// + public virtual async Task CheckAcceptAsync(string homeTenantId, string tenantId) + { + var usage = await GetUsageAsync(homeTenantId, bypassCache: true); + if (usage.ManagedTenantIds.Contains(tenantId.ToLowerInvariant())) + return null; + var usedWithoutThisInvitation = Math.Max(0, usage.Used - 1); + return usedWithoutThisInvitation + 1 > usage.Limit + ? new DelegatedSlotViolation(usage.HomeTenantId, usage.HomeTenantDomain, usedWithoutThisInvitation, usage.Limit, 1) + : null; + } + + /// + /// Group-side check for "add tenant X to group G": every assignee's home tenant gains X. Returns the first + /// home tenant that would exceed its limit (assignees without a binding are skipped — inert anyway). + /// + public virtual async Task CheckAddTenantToGroupAsync(IEnumerable assigneeUpns, string tenantId) + { + foreach (var home in await ResolveHomeTenantsAsync(assigneeUpns)) + { + var violation = await CheckAsync(home, new[] { tenantId }); + if (violation != null) + return violation; + } + return null; + } + + /// Distinct home tenants (lowercase) of the given UPNs, via their identity bindings. + public virtual async Task> ResolveHomeTenantsAsync(IEnumerable upns) + { + var homes = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var upn in upns.Distinct(StringComparer.OrdinalIgnoreCase)) + { + var binding = await _adminRepo.GetIdentityBindingAsync(upn.ToLowerInvariant()); + if (!string.IsNullOrWhiteSpace(binding?.TenantId)) + homes.Add(binding!.TenantId.ToLowerInvariant()); + } + return homes; + } + + /// + /// A managed tenant left the home tenant's self-service group: its slot stays occupied for + /// . The Accepted invitation row that brought the tenant in becomes the hold; + /// when none exists (the operator added the tenant by hand) a synthetic Released row carries the hold. + /// + public virtual async Task RecordReleaseAsync(string homeTenantId, string tenantId, string actor) + { + var home = homeTenantId.ToLowerInvariant(); + var managed = tenantId.ToLowerInvariant(); + var nowUtc = _time.GetUtcNow().UtcDateTime; + var holdUntil = nowUtc.Add(ReleaseHold); + + var accepted = (await _invitations.GetByHomeTenantAsync(home)) + .Where(r => r.Status == Constants.DelegationInvitationStatus.Accepted + && string.Equals(r.TenantId, managed, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(r => r.AcceptedAt) + .FirstOrDefault(); + + DelegationInvitation hold; + if (accepted != null && await _invitations.SetStatusAsync(home, accepted.InvitationId, Constants.DelegationInvitationStatus.Released, nowUtc, actor, holdUntil)) + { + hold = accepted; + hold.Status = Constants.DelegationInvitationStatus.Released; + hold.ReleasedAt = nowUtc; + hold.ReleasedBy = actor.ToLowerInvariant(); + hold.HoldUntilUtc = holdUntil; + } + else + { + hold = new DelegationInvitation + { + InvitationId = Guid.NewGuid().ToString("N"), + HomeTenantId = home, + Status = Constants.DelegationInvitationStatus.Released, + Role = Constants.DelegatedRoles.DelegatedReader, + Source = Constants.DelegatedSource.OperatorGranted, + CreatedBy = actor.ToLowerInvariant(), + CreatedAt = nowUtc, + ExpiresAt = nowUtc, + TenantId = managed, + ReleasedAt = nowUtc, + ReleasedBy = actor.ToLowerInvariant(), + HoldUntilUtc = holdUntil, + }; + await _invitations.CreateAsync(hold); + } + + Invalidate(home); + return hold; + } + + /// Global Admin escape hatch: ends one hold (or every active hold) now. Returns how many were released. + public virtual async Task ReleaseHoldAsync(string homeTenantId, string? invitationId, bool releaseAll, string actor) + { + var home = homeTenantId.ToLowerInvariant(); + var nowUtc = _time.GetUtcNow().UtcDateTime; + var holds = (await _invitations.GetByHomeTenantAsync(home)) + .Where(r => IsActiveHold(r, nowUtc) && (releaseAll || string.Equals(r.InvitationId, invitationId, StringComparison.Ordinal))) + .ToList(); + + var released = 0; + foreach (var hold in holds) + { + if (await _invitations.SetStatusAsync(home, hold.InvitationId, Constants.DelegationInvitationStatus.Released, nowUtc, actor, holdUntilUtc: nowUtc)) + released++; + } + if (released > 0) + Invalidate(home); + return released; + } + + public void Invalidate(string homeTenantId) => _cache.Remove(CacheKey(homeTenantId)); + + /// Pure: a Pending row that has not expired. + internal static bool IsPending(DelegationInvitation row, DateTime nowUtc) + => row.Status == Constants.DelegationInvitationStatus.Pending && row.ExpiresAt > nowUtc; + + /// Pure: a Released row whose hold has not lapsed. + internal static bool IsActiveHold(DelegationInvitation row, DateTime nowUtc) + => row.Status == Constants.DelegationInvitationStatus.Released && row.HoldUntilUtc.HasValue && row.HoldUntilUtc.Value > nowUtc; + + /// Pure: the number of NEW slots the candidates need (unmanaged, not the home tenant, distinct). + internal static int RequiredSlots(DelegatedSlotUsage usage, IEnumerable candidateTenantIds) + => candidateTenantIds + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.ToLowerInvariant()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(t => !string.Equals(t, usage.HomeTenantId, StringComparison.OrdinalIgnoreCase) + && !usage.ManagedTenantIds.Contains(t)); + + /// Pure: a violation when the required slots do not fit; null when they do (or none are needed). + internal static DelegatedSlotViolation? Evaluate(DelegatedSlotUsage usage, int required) + => required > 0 && usage.Used + required > usage.Limit + ? new DelegatedSlotViolation(usage.HomeTenantId, usage.HomeTenantDomain, usage.Used, usage.Limit, required) + : null; + + /// + /// The effective limit from the config row read UNCACHED (so a just-raised override is seen at once on every + /// instance). No row or a storage error ⇒ catalog Community (0) — fail-closed, and the plan endpoint 404s + /// for such a tenant anyway. + /// + private async Task<(int Limit, int CatalogLimit, int? OverrideLimit, string? Domain)> ResolveLimitAsync(string homeTenantId, DateTime nowUtc) + { + try + { + var config = await _configRepo.GetTenantConfigurationAsync(homeTenantId); + if (config == null) + return (0, 0, null, null); + var catalog = Security.FeatureEntitlementCatalog.Get(TenantEntitlementService.ResolveEdition(config, nowUtc)).MaxDelegatedTenants; + return (TenantEntitlementService.GetMaxDelegatedTenants(config, nowUtc), catalog, config.MaxDelegatedTenantsOverride, + string.IsNullOrWhiteSpace(config.DomainName) ? null : config.DomainName); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[DelegatedSlots] Limit resolution failed for {HomeTenantId} — treating as 0 (fail-closed)", homeTenantId); + return (0, 0, null, null); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/DelegationSelfService.cs b/src/Backend/AutopilotMonitor.Functions/Services/DelegationSelfService.cs new file mode 100644 index 00000000..c62232ae --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/DelegationSelfService.cs @@ -0,0 +1,424 @@ +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Delegation; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services; + +/// A failed self-service delegation step: HTTP status + machine-readable code + human message. A slot violation rides along for the 409 shape. +public sealed record DelegationFailure(int Status, string Code, string Message, DelegatedSlotViolation? SlotViolation = null); + +/// Outcome of a self-service delegation step. +public sealed class DelegationResult +{ + public T? Value { get; init; } + public DelegationFailure? Failure { get; init; } + public bool Ok => Failure == null; + public static DelegationResult Success(T value) => new() { Value = value }; + public static DelegationResult Fail(int status, string code, string message, DelegatedSlotViolation? violation = null) + => new() { Failure = new DelegationFailure(status, code, message, violation) }; +} + +public sealed record AcceptPreview(string HomeTenantId, string? HomeTenantDomain, DateTime ExpiresUtc, string Status, string TargetTenantId, string? TargetTenantDomain); +public sealed record AcceptOutcome(string HomeTenantId, string? HomeTenantDomain, string ManagedTenantId); +public sealed record ManagedTenantView(string TenantId, string Source, DateTime? SinceUtc, bool Removable); +public sealed record ManagedTenantsView(DelegatedSlotUsage Slots, IReadOnlyList Tenants); +public sealed record TenantManagerView(string? GroupId, string? OwnerTenantId, string? OwnerDomain, string Name, string Source, + IReadOnlyList Assignees, DateTime? SinceUtc, bool Revocable); + +/// +/// The customer-facing half of delegated ("MSP") administration — no Global Admin involved: +/// +/// A Pro (managing) tenant owns exactly ONE implicit Tenant Group, msp-{tenantId} +/// (). Its members are the customer tenants that ACCEPTED an invitation; +/// its assignees are the managing tenant's own users. DelegatedAdminService.GetScopeAsync, the policy +/// middleware and the MCP server need no change — the group is an ordinary group to them. +/// Invitations are signed single-use links (); the row's Pending +/// status + ETag flip is the one-shot authority. The ACCEPTING tenant is always the caller's validated JWT +/// tenant — a link never names its target. +/// Removing a customer (by either side) frees the slot only after +/// (24 h) — no rotating customers through a small allowance. A Global Admin can release a hold early. +/// Every access change is audited under BOTH tenants: the managed customer's trail records who can read +/// it, the managing tenant's trail records its own actions. Live streams of affected users are cut. +/// +/// Self-service grants are always (read-only). +/// +public class DelegationSelfService +{ + public const string SourceSelfService = "self-service"; + public const string SourceOperator = "operator"; + private const string AuditGroupAccess = "DelegatedGroupAccess"; + private const string AuditInvitation = "DelegationInvitation"; + private const string AuditManagedTenant = "DelegationManagedTenant"; + + private readonly IAdminRepository _adminRepo; + private readonly IDelegationInvitationRepository _invitations; + private readonly DelegatedAdminService _delegatedAdmins; + private readonly DelegatedSlotService _slots; + private readonly TenantEntitlementService _entitlements; + private readonly IConfigRepository _configRepo; + private readonly IMaintenanceRepository _audit; + private readonly ISignalRNotificationService _signalR; + private readonly ILogger _logger; + private readonly TimeProvider _time; + + public DelegationSelfService( + IAdminRepository adminRepo, + IDelegationInvitationRepository invitations, + DelegatedAdminService delegatedAdmins, + DelegatedSlotService slots, + TenantEntitlementService entitlements, + IConfigRepository configRepo, + IMaintenanceRepository audit, + ISignalRNotificationService signalR, + ILogger logger) + : this(adminRepo, invitations, delegatedAdmins, slots, entitlements, configRepo, audit, signalR, logger, TimeProvider.System) + { + } + + /// Test seam — inject a fake . + public DelegationSelfService( + IAdminRepository adminRepo, + IDelegationInvitationRepository invitations, + DelegatedAdminService delegatedAdmins, + DelegatedSlotService slots, + TenantEntitlementService entitlements, + IConfigRepository configRepo, + IMaintenanceRepository audit, + ISignalRNotificationService signalR, + ILogger logger, + TimeProvider time) + { + _adminRepo = adminRepo; + _invitations = invitations; + _delegatedAdmins = delegatedAdmins; + _slots = slots; + _entitlements = entitlements; + _configRepo = configRepo; + _audit = audit; + _signalR = signalR; + _logger = logger; + _time = time; + } + + // ── Invitations (managing tenant) ──────────────────────────────────────────── + + public async Task> CreateInvitationAsync(string homeTenantId, string createdBy) + { + var home = homeTenantId.ToLowerInvariant(); + if (!(await _entitlements.GetEntitlementsAsync(home)).DelegatedAdminAllowed) + return DelegationResult<(DelegationInvitation, string)>.Fail(403, Constants.DelegationCodes.DelegatedAdminNotAllowed, + "Delegated administration is a Pro capability. Upgrade your plan to invite tenants."); + + var violation = await _slots.CheckReserveAsync(home, 1); + if (violation != null) + return DelegationResult<(DelegationInvitation, string)>.Fail(409, Constants.DelegatedSlots.LimitReachedCode, + "No free delegated tenant slot: every slot is in use, promised to a pending invitation, or held after a removal.", violation); + + var nowUtc = _time.GetUtcNow().UtcDateTime; + var row = new DelegationInvitation + { + InvitationId = Guid.NewGuid().ToString("N"), + HomeTenantId = home, + Status = Constants.DelegationInvitationStatus.Pending, + Role = Constants.DelegatedRoles.DelegatedReader, + Source = Constants.DelegatedSource.CustomerDelegated, + CreatedBy = createdBy.ToLowerInvariant(), + CreatedAt = nowUtc, + ExpiresAt = nowUtc.Add(DelegationInviteTicket.DefaultTtl), + }; + await _invitations.CreateAsync(row); + var token = DelegationInviteTicket.Encode(home, row.InvitationId, new DateTimeOffset(nowUtc, TimeSpan.Zero)); + + await _audit.LogAuditEntryAsync(home, "CREATE", AuditInvitation, row.InvitationId, row.CreatedBy, + new Dictionary { { "ExpiresUtc", row.ExpiresAt.ToString("O") } }); + _slots.Invalidate(home); + _logger.LogInformation("[Delegation] Invitation {InvitationId} created by {By} for home tenant {Home}", row.InvitationId, row.CreatedBy, home); + return DelegationResult<(DelegationInvitation, string)>.Success((row, token)); + } + + public async Task> CancelInvitationAsync(string homeTenantId, string invitationId, string actor) + { + var home = homeTenantId.ToLowerInvariant(); + var row = await _invitations.GetAsync(home, invitationId); + if (row == null || row.Status != Constants.DelegationInvitationStatus.Pending) + return DelegationResult.Fail(404, Constants.DelegationCodes.InvitationNotFound, "No pending invitation with that id."); + + var nowUtc = _time.GetUtcNow().UtcDateTime; + await _invitations.SetStatusAsync(home, invitationId, Constants.DelegationInvitationStatus.Cancelled, nowUtc, actor, holdUntilUtc: null); + await _audit.LogAuditEntryAsync(home, "DELETE", AuditInvitation, invitationId, actor.ToLowerInvariant(), + new Dictionary { { "Reason", "cancelled" } }); + _slots.Invalidate(home); + return DelegationResult.Success(true); + } + + public Task> ListInvitationsAsync(string homeTenantId) + => _invitations.GetByHomeTenantAsync(homeTenantId.ToLowerInvariant()); + + /// Wire status of a row: a Pending row past its expiry reads as Expired (never written). + public string EffectiveStatus(DelegationInvitation row) + => row.Status == Constants.DelegationInvitationStatus.Pending && row.ExpiresAt <= _time.GetUtcNow().UtcDateTime + ? Constants.DelegationInvitationStatus.Expired + : row.Status; + + // ── Accept (customer tenant) ───────────────────────────────────────────────── + + public async Task> PreviewAsync(string token, string callerTenantId) + { + var located = await LocateAsync(token); + if (!located.Ok) + return DelegationResult.Fail(located.Failure!.Status, located.Failure.Code, located.Failure.Message); + var row = located.Value!; + var target = callerTenantId.ToLowerInvariant(); + return DelegationResult.Success(new AcceptPreview( + row.HomeTenantId, await DomainAsync(row.HomeTenantId), row.ExpiresAt, EffectiveStatus(row), target, await DomainAsync(target))); + } + + /// The accept chain. Every rejection is a distinct code so the accept page can explain it. + public async Task> AcceptAsync(string token, string callerTenantId, string callerUpn) + { + var located = await LocateAsync(token); + if (!located.Ok) + return DelegationResult.Fail(located.Failure!.Status, located.Failure.Code, located.Failure.Message); + var row = located.Value!; + var home = row.HomeTenantId; + var nowUtc = _time.GetUtcNow().UtcDateTime; + + switch (row.Status) + { + case Constants.DelegationInvitationStatus.Cancelled: + return DelegationResult.Fail(409, Constants.DelegationCodes.InvitationCancelled, "This invitation was cancelled by the managing organization. Ask them for a new link."); + case Constants.DelegationInvitationStatus.Accepted: + case Constants.DelegationInvitationStatus.Released: + return DelegationResult.Fail(409, Constants.DelegationCodes.InvitationAlreadyUsed, "This invitation has already been used. Ask the managing organization for a new link."); + } + if (row.ExpiresAt <= nowUtc) + return DelegationResult.Fail(409, Constants.DelegationCodes.InvitationExpired, "This invitation has expired. Ask the managing organization for a new link."); + + var target = callerTenantId.ToLowerInvariant(); + if (string.Equals(target, home, StringComparison.OrdinalIgnoreCase)) + return DelegationResult.Fail(409, Constants.DelegationCodes.CannotAcceptOwnInvitation, "An invitation must be accepted by an administrator of the tenant to be managed, not by the inviting tenant."); + + var groupId = Constants.TenantGroupIds.ForHomeTenant(home); + if ((await _adminRepo.GetGroupTenantsAsync(groupId)).Any(t => string.Equals(t, target, StringComparison.OrdinalIgnoreCase))) + return DelegationResult.Fail(409, Constants.DelegationCodes.AlreadyManaged, "Your tenant is already managed by this organization."); + + if (!(await _entitlements.GetEntitlementsAsync(home)).DelegatedAdminAllowed) + return DelegationResult.Fail(409, Constants.DelegationCodes.ManagerNotEntitled, "The inviting organization is no longer on a plan that includes delegated administration."); + + var violation = await _slots.CheckAcceptAsync(home, target); + if (violation != null) + return DelegationResult.Fail(409, Constants.DelegatedSlots.LimitReachedCode, + "The managing organization has no free delegated tenant slot. Ask them to raise their limit and try again.", violation); + + // One-shot: the ETag read above guards against a concurrent accept/cancel. + if (!await _invitations.TryAcceptAsync(home, row.InvitationId, row.ETag ?? string.Empty, target, callerUpn, nowUtc)) + return DelegationResult.Fail(409, Constants.DelegationCodes.InvitationAlreadyUsed, "This invitation has just been used or cancelled. Ask the managing organization for a new link."); + + await EnsureOwnedGroupAsync(home); + await _delegatedAdmins.AddTenantToGroupAsync(groupId, target); + + var actor = callerUpn.ToLowerInvariant(); + var assignees = await _delegatedAdmins.GetGroupAssigneesAsync(groupId); + foreach (var assignee in assignees) + { + await _audit.LogAuditEntryAsync(target, "CREATE", AuditGroupAccess, assignee.Upn, actor, + new Dictionary + { + { "GroupId", groupId }, + { "Reason", "customer-accepted-invitation" }, + { "Source", Constants.DelegatedSource.CustomerDelegated }, + { "HomeTenantId", home }, + }); + } + await _audit.LogAuditEntryAsync(target, "ACCEPT", AuditInvitation, row.InvitationId, actor, + new Dictionary { { "HomeTenantId", home } }); + await _audit.LogAuditEntryAsync(home, "ACCEPT", AuditInvitation, row.InvitationId, actor, + new Dictionary { { "ManagedTenantId", target } }); + + _slots.Invalidate(home); + _logger.LogInformation("[Delegation] Tenant {Target} accepted invitation {InvitationId} of {Home}", target, row.InvitationId, home); + return DelegationResult.Success(new AcceptOutcome(home, await DomainAsync(home), target)); + } + + // ── Managed tenants (managing side) ────────────────────────────────────────── + + public async Task ListManagedAsync(string homeTenantId) + { + var home = homeTenantId.ToLowerInvariant(); + var usage = await _slots.GetUsageAsync(home); + var selfService = new HashSet(await _adminRepo.GetGroupTenantsAsync(Constants.TenantGroupIds.ForHomeTenant(home)), StringComparer.OrdinalIgnoreCase); + var accepted = (await _invitations.GetByHomeTenantAsync(home)) + .Where(r => r.Status == Constants.DelegationInvitationStatus.Accepted && !string.IsNullOrEmpty(r.TenantId)) + .GroupBy(r => r.TenantId!, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.Max(r => r.AcceptedAt), StringComparer.OrdinalIgnoreCase); + + var tenants = usage.ManagedTenantIds + .OrderBy(t => t, StringComparer.Ordinal) + .Select(t => selfService.Contains(t) + ? new ManagedTenantView(t, SourceSelfService, accepted.TryGetValue(t, out var since) ? since : null, Removable: true) + : new ManagedTenantView(t, SourceOperator, null, Removable: false)) + .ToList(); + return new ManagedTenantsView(usage, tenants); + } + + /// The managing tenant removes a customer it invited. Access ends now; the slot is held 24 h. + public Task> RemoveManagedAsync(string homeTenantId, string tenantId, string actor) + => EndSelfServiceAccessAsync(homeTenantId, tenantId, actor, "customer-removed-by-manager"); + + /// The managed tenant's admin ends the managing tenant's access. Same effect, audited as a customer revoke. + public Task> RevokeManagerAsync(string managedTenantId, string homeTenantId, string actor) + => EndSelfServiceAccessAsync(homeTenantId, managedTenantId, actor, "customer-revoked"); + + private async Task> EndSelfServiceAccessAsync(string homeTenantId, string tenantId, string actor, string reason) + { + var home = homeTenantId.ToLowerInvariant(); + var target = tenantId.ToLowerInvariant(); + var groupId = Constants.TenantGroupIds.ForHomeTenant(home); + var assignees = await _delegatedAdmins.GetGroupAssigneesAsync(groupId); + + if (!await _delegatedAdmins.RemoveTenantFromGroupAsync(groupId, target)) + return DelegationResult.Fail(404, Constants.DelegationCodes.NotManagedBySelfService, "That tenant is not managed through a self-service delegation."); + + var hold = await _slots.RecordReleaseAsync(home, target, actor); + var by = actor.ToLowerInvariant(); + foreach (var assignee in assignees) + { + await _audit.LogAuditEntryAsync(target, "DELETE", AuditGroupAccess, assignee.Upn, by, + new Dictionary { { "GroupId", groupId }, { "Reason", reason }, { "HomeTenantId", home } }); + await _signalR.DisconnectUserAsync(assignee.Upn); + } + await _audit.LogAuditEntryAsync(home, "DELETE", AuditManagedTenant, target, by, + new Dictionary { { "Reason", reason }, { "SlotHeldUntilUtc", hold.HoldUntilUtc?.ToString("O") ?? string.Empty } }); + _logger.LogInformation("[Delegation] {Target} removed from {Home} by {By} ({Reason}); slot held until {HoldUntil}", target, home, by, reason, hold.HoldUntilUtc); + return DelegationResult.Success(true); + } + + // ── Assignees (managing tenant's own users) ────────────────────────────────── + + public Task> ListAssigneesAsync(string homeTenantId) + => _delegatedAdmins.GetGroupAssigneesAsync(Constants.TenantGroupIds.ForHomeTenant(homeTenantId.ToLowerInvariant())); + + public async Task> AssignAsync(string homeTenantId, string upn, string actor) + { + var home = homeTenantId.ToLowerInvariant(); + var normalizedUpn = upn.ToLowerInvariant(); + if (!(await _entitlements.GetEntitlementsAsync(home)).DelegatedAdminAllowed) + return DelegationResult.Fail(403, Constants.DelegationCodes.DelegatedAdminNotAllowed, "Delegated administration is a Pro capability."); + if (await _adminRepo.GetTenantMemberAsync(home, normalizedUpn) == null) + return DelegationResult.Fail(400, Constants.DelegationCodes.NotATenantMember, "Only members of your own tenant (Access Management) can be assigned."); + + var groupId = await EnsureOwnedGroupAsync(home); + try + { + // Bound to THIS tenant as home; the object id is pinned on the person's next sign-in. No slot + // check: the group's tenants already occupy this tenant's slots regardless of who is assigned. + await _delegatedAdmins.AssignGroupAsync(normalizedUpn, groupId, Constants.DelegatedRoles.DelegatedReader, true, actor.ToLowerInvariant(), home, objectId: null); + } + catch (IdentityBindingConflictException ex) + { + return DelegationResult.Fail(409, Constants.DelegationCodes.IdentityBindingConflict, ex.Message); + } + + var tenants = await _adminRepo.GetGroupTenantsAsync(groupId); + foreach (var tenant in tenants) + { + await _audit.LogAuditEntryAsync(tenant, "CREATE", AuditGroupAccess, normalizedUpn, actor.ToLowerInvariant(), + new Dictionary { { "GroupId", groupId }, { "Role", Constants.DelegatedRoles.DelegatedReader }, { "Reason", "self-service-assigned" }, { "HomeTenantId", home } }); + } + var assignment = (await _delegatedAdmins.GetGroupAssigneesAsync(groupId)).FirstOrDefault(a => a.Upn == normalizedUpn) + ?? new TenantGroupAssignment { Upn = normalizedUpn, GroupId = groupId, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true, AssignedBy = actor.ToLowerInvariant() }; + return DelegationResult.Success(assignment); + } + + public async Task> UnassignAsync(string homeTenantId, string upn, string actor) + { + var home = homeTenantId.ToLowerInvariant(); + var normalizedUpn = upn.ToLowerInvariant(); + var groupId = Constants.TenantGroupIds.ForHomeTenant(home); + if (!await _delegatedAdmins.UnassignGroupAsync(normalizedUpn, groupId)) + return DelegationResult.Fail(404, Constants.DelegationCodes.AssigneeNotFound, "That person is not assigned."); + + await _signalR.DisconnectUserAsync(normalizedUpn); + foreach (var tenant in await _adminRepo.GetGroupTenantsAsync(groupId)) + { + await _audit.LogAuditEntryAsync(tenant, "DELETE", AuditGroupAccess, normalizedUpn, actor.ToLowerInvariant(), + new Dictionary { { "GroupId", groupId }, { "Reason", "self-service-unassigned" }, { "HomeTenantId", home } }); + } + return DelegationResult.Success(true); + } + + // ── Managers (managed tenant's side) ───────────────────────────────────────── + + public async Task> ListManagersAsync(string managedTenantId) + { + var target = managedTenantId.ToLowerInvariant(); + var result = new List(); + + foreach (var groupId in await _adminRepo.GetGroupIdsContainingTenantAsync(target)) + { + var group = await _adminRepo.GetTenantGroupAsync(groupId); + if (group == null) + continue; + var owner = string.IsNullOrEmpty(group.OwnerTenantId) ? null : group.OwnerTenantId; + DateTime? since = null; + if (owner != null) + { + since = (await _invitations.GetByHomeTenantAsync(owner)) + .Where(r => r.Status == Constants.DelegationInvitationStatus.Accepted && string.Equals(r.TenantId, target, StringComparison.OrdinalIgnoreCase)) + .Select(r => r.AcceptedAt) + .Max(); + } + result.Add(new TenantManagerView(group.GroupId, owner, owner != null ? await DomainAsync(owner) : null, + group.Name, owner != null ? SourceSelfService : SourceOperator, group.Assignees, since, Revocable: owner != null)); + } + + var direct = (await _adminRepo.GetDelegatedAssigneesAsync(target)) + .Where(r => r.Status == Constants.DelegatedStatus.Active) + .Select(r => new TenantGroupAssignment { Upn = r.Upn, GroupId = string.Empty, Role = r.Role, IsEnabled = r.IsEnabled, AssignedBy = r.GrantedBy, AssignedAt = r.GrantedAt }) + .ToList(); + if (direct.Count > 0) + result.Add(new TenantManagerView(null, null, null, "Platform operators", SourceOperator, direct, direct.Min(d => d.AssignedAt), Revocable: false)); + + return result; + } + + // ── Helpers ───────────────────────────────────────────────────────────────── + + /// Decodes the ticket and loads the row. 400 for anything the link itself is wrong about. + private async Task> LocateAsync(string token) + { + if (!DelegationInviteTicket.TryDecode(token ?? string.Empty, out var home, out var invitationId, out var reason, _time.GetUtcNow())) + { + _logger.LogWarning("[Delegation] Invitation ticket rejected ({Reason})", reason); + return DelegationResult.Fail(400, Constants.DelegationCodes.InvalidInvitation, "This invitation link is not valid. Ask the managing organization for a new link."); + } + var row = await _invitations.GetAsync(home, invitationId); + return row == null + ? DelegationResult.Fail(400, Constants.DelegationCodes.InvalidInvitation, "This invitation link is not valid. Ask the managing organization for a new link.") + : DelegationResult.Success(row); + } + + /// The managing tenant's implicit group (meta row) — created on first use, named after its domain. + private async Task EnsureOwnedGroupAsync(string home) + { + var groupId = Constants.TenantGroupIds.ForHomeTenant(home); + var label = await DomainAsync(home) ?? home; + await _delegatedAdmins.EnsureOwnedGroupAsync(groupId, $"Customers of {label}", home); + return groupId; + } + + public async Task DomainAsync(string tenantId) + { + try + { + var config = await _configRepo.GetTenantConfigurationAsync(tenantId); + return string.IsNullOrWhiteSpace(config?.DomainName) ? null : config!.DomainName; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Delegation] Domain lookup failed for {TenantId}", tenantId); + return null; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs index 2c9596e2..fd6071ec 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs @@ -1052,6 +1052,19 @@ namespace AutopilotMonitor.Functions.Services _logger.LogWarning(ex, "Failed to cleanup old hardware-rejection tracker rows"); } + try + { + // Self-service delegation invitations / slot holds: all rows are terminal long before 90 days + // (7-day invitation TTL, 24-hour release hold) — sweep them with the audit-grade retention. + var deleted = await _delegationInvitationRepo.DeleteOlderThanAsync(now.AddDays(-90)); + if (deleted > 0) + _logger.LogInformation("Delegation invitation cleanup: deleted {Count} rows older than 90 days", deleted); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to cleanup old delegation invitation rows"); + } + try { var deleted = await _maintenanceRepo.DeleteAuditLogsOlderThanAsync(now.AddDays(-auditLogRetentionDays)); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs index c90afcda..67f8bd2d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs @@ -41,6 +41,7 @@ namespace AutopilotMonitor.Functions.Services private readonly INotificationRepository _notificationRepo; private readonly ITenantNotificationRepository _tenantNotificationRepo; private readonly IHardwareRejectionNotificationTracker _hardwareRejectionTracker; + private readonly IDelegationInvitationRepository _delegationInvitationRepo; private readonly DataAccess.TableStorage.BackupJobsRepository _backupJobsRepo; private readonly OpsEventService _opsEventService; private readonly IRuleRepository _ruleRepo; @@ -74,6 +75,7 @@ namespace AutopilotMonitor.Functions.Services INotificationRepository notificationRepo, ITenantNotificationRepository tenantNotificationRepo, IHardwareRejectionNotificationTracker hardwareRejectionTracker, + IDelegationInvitationRepository delegationInvitationRepo, DataAccess.TableStorage.BackupJobsRepository backupJobsRepo, OpsEventService opsEventService, IRuleRepository ruleRepo, @@ -102,6 +104,7 @@ namespace AutopilotMonitor.Functions.Services _notificationRepo = notificationRepo; _tenantNotificationRepo = tenantNotificationRepo; _hardwareRejectionTracker = hardwareRejectionTracker; + _delegationInvitationRepo = delegationInvitationRepo; _backupJobsRepo = backupJobsRepo; _opsEventService = opsEventService; _ruleRepo = ruleRepo; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs b/src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs index 200c62b1..556c5757 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Shared.DataAccess; @@ -12,30 +14,38 @@ namespace AutopilotMonitor.Functions.Services { /// /// Resolves and enforces the MCP request quota: a per-USER budget (daily + monthly) AND a per-TENANT - /// budget (daily + monthly) that every member's requests count against. Both must be free for a - /// request to pass; the tenant budget is what makes "just create ten more accounts" pointless. + /// budget (daily + monthly) that every request charged to that tenant counts against. Both must be free + /// for a request to pass; the tenant budget is what makes "just create ten more accounts" pointless. /// /// User plan precedence: explicit per-user override (McpUsers.UsagePlan — honoured only for the identity - /// the UPN is bound to, tid + oid, see McpUserService.GetBoundMcpUserAsync) → the caller's home-tenant + /// the UPN is bound to, tid + oid, see McpUserService.GetBoundMcpUserAsync) → the caller's HOME-tenant /// edition default (FeatureEntitlementCatalog.McpUsagePlanName). Limits come from the admin-editable /// SectionUsagePlans definitions (AdminConfiguration.PlanTierDefinitionsJson); when no definition matches /// the plan name, the static catalog fallbacks apply. An override naming a plan that exists nowhere /// resolves to the Community fallback (fail-closed). /// - /// Tenant plan: ALWAYS the tenant's edition plan — a per-user override lifts that person's own budget, - /// never the organization's. A definition without tenant limits falls back to the edition's catalog - /// tenant limits; an explicit 0 lifts that window. + /// Tenant plan: ALWAYS the CHARGED tenant's edition plan — a per-user override lifts that person's own + /// budget, never an organization's. The charged tenant is the caller's own tenant, or, for a delegated + /// (MSP) read, the MANAGED tenant whose data is read ("the budget follows the data": a Community customer + /// is read with Community windows even by a Pro MSP). A definition without tenant limits falls back to + /// the edition's catalog tenant limits; an explicit 0 lifts that window. /// /// Counters: user counters ride on the UserUsageLog table (PK = oid), tenant counters on the - /// McpTenantUsage table (PK = tenantId, RK = {yyyyMMdd}_{oid} — one partition read per check, no hot - /// row); both are written fire-and-forget by McpQuotaEnforcementMiddleware for X-Client-Source: mcp - /// requests. Daily = today's rows, monthly = the sum over the month. The decision is cached per user - /// for 60 seconds, so the worst-case overshoot is bounded (limit + 60s × request-rate) — the same - /// posture as the sliding-window rate limiter. + /// McpTenantUsage table (PK = charged tenantId, RK = {yyyyMMdd}_{oid} — one partition read per check, no + /// hot row); both are written fire-and-forget by McpQuotaEnforcementMiddleware for X-Client-Source: mcp + /// requests. Daily = today's rows, monthly = the sum over the month. The usage SNAPSHOTS are cached for + /// 60 seconds — per user (oid) and per charged tenant — so the worst-case overshoot is bounded + /// (limit + 60s × request-rate) — the same posture as the sliding-window rate limiter. Limits are + /// re-resolved per check from services that carry their own caches, so a plan change applies at once. /// public class McpQuotaService { private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(60); + /// Concurrent tenant-snapshot reads for one bounded (fleet) aggregate check. + private const int AggregateParallelism = 8; + + internal static string UserCacheKey(string oid) => $"mcp-quota:user:{oid}"; + internal static string TenantCacheKey(string tenantId) => $"mcp-quota:tenant:{tenantId.ToLowerInvariant()}"; private readonly IUserUsageRepository _usageRepo; private readonly McpUserService _mcpUserService; @@ -76,58 +86,126 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Resolves the caller's effective plans + limits and checks the current user AND tenant usage against - /// them. is the token's tid — a delegated (MSP) admin therefore draws on - /// their HOME tenant's budget, never on a managed customer's. Fail-open on counter/storage errors (a - /// broken quota check must not take down MCP); fail-closed on plan resolution (unknown plan → Community). + /// Checks the caller against their own budget and their OWN tenant's organization budget + /// ( = the token's tid, charged and homed alike). The self-service usage + /// endpoint and every non-delegated MCP request use this shape. /// - public virtual async Task CheckAsync(string oid, string? upn, string? tenantId) + public virtual Task CheckAsync(string oid, string? upn, string? tenantId) + => CheckAsync(oid, upn, tenantId, tenantId); + + /// + /// Resolves the caller's effective plans + limits and checks the current user AND charged-tenant usage + /// against them. is the token's tid (the identity the per-user override + /// is bound to, and the edition the user's own plan follows); is the + /// tenant whose organization budget this request draws on — the home tenant, or the MANAGED tenant of a + /// delegated (MSP) read. Fail-open on counter/storage errors (a broken quota check must not take down + /// MCP); fail-closed on plan resolution (unknown plan → Community). + /// + public virtual async Task CheckAsync(string oid, string? upn, string? homeTenantId, string? chargeTenantId) { - var cacheKey = $"mcp-quota:{oid}"; - if (_cache.TryGetValue(cacheKey, out var cached) && cached != null) - return cached; - - var limits = await ResolvePlanAsync(AdminIdentity.Create(upn, tenantId, oid), tenantId); - var nowUtc = _time.GetUtcNow().UtcDateTime; - long dailyUsed = 0, monthlyUsed = 0, tenantDailyUsed = 0, tenantMonthlyUsed = 0; - try - { - var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc).ToString("yyyyMMdd"); - var today = nowUtc.ToString("yyyyMMdd"); + var limits = await ResolvePlanAsync(AdminIdentity.Create(upn, homeTenantId, oid), homeTenantId, chargeTenantId); + var targetTenantId = TargetOf(homeTenantId, chargeTenantId); - var records = await _usageRepo.GetUsageByUserAsync(oid, monthStart, today); - monthlyUsed = records.Sum(r => r.RequestCount); - dailyUsed = records.Where(r => r.Date == today).Sum(r => r.RequestCount); + var user = await ReadUserUsageAsync(oid, nowUtc); + if (user == null) + return McpQuotaDecision.FailOpen(limits, targetTenantId); - // The tenant read is skipped when nothing could ever block on it (no tenant, or both windows lifted). - if (!string.IsNullOrWhiteSpace(tenantId) && (limits.TenantDailyLimit > 0 || limits.TenantMonthlyLimit > 0)) - { - var tenantRecords = await _usageRepo.GetTenantUsageAsync(tenantId, monthStart, today); - tenantMonthlyUsed = tenantRecords.Sum(r => r.RequestCount); - tenantDailyUsed = tenantRecords.Where(r => r.Date == today).Sum(r => r.RequestCount); - } - } - catch (Exception ex) - { - // Fail-open: usage counters unavailable → allow. Do NOT cache the fail-open - // decision — the next request retries the read. - _logger.LogWarning(ex, "[McpQuota] Usage lookup failed for oid={Oid} — allowing (fail-open)", oid); - return McpQuotaDecision.FailOpen(limits); - } + var tenant = await ReadTenantUsageAsync(chargeTenantId, limits, nowUtc); + if (tenant == null) + return McpQuotaDecision.FailOpen(limits, targetTenantId); - var decision = BuildDecision(limits, dailyUsed, monthlyUsed, tenantDailyUsed, tenantMonthlyUsed, nowUtc); - _cache.Set(cacheKey, decision, CacheDuration); - return decision; + return BuildDecision(limits, user.DailyUsed, user.MonthlyUsed, tenant.DailyUsed, tenant.MonthlyUsed, nowUtc, targetTenantId); } /// - /// Plan resolution only (no counter read) — used by the self-service usage endpoint. - /// is the caller's validated (upn, tid, oid); the per-user override applies - /// only when that identity IS the one the McpUsers UPN is bound to — a null / unbound identity gets the - /// tenant edition default. The tenant limits always follow the tenant's edition plan. + /// Bounded (fleet) aggregate check for a delegated (MSP) caller: the caller's own budget once, then + /// every charged tenant's organization budget. A tenant whose budget is exhausted is EXCLUDED (soft — + /// the aggregate proceeds over the rest); the result is blocked only when the caller's own budget is + /// exhausted or when every charged tenant is. A tenant whose counters cannot be read is admitted + /// (fail-open per tenant, nothing cached). Reads run with bounded parallelism. /// - public virtual async Task ResolvePlanAsync(AdminIdentity? identity, string? tenantId) + public virtual async Task CheckManyAsync( + string oid, string? upn, string? homeTenantId, IReadOnlyCollection chargeTenantIds) + { + var nowUtc = _time.GetUtcNow().UtcDateTime; + var identity = AdminIdentity.Create(upn, homeTenantId, oid); + var (userLimits, definitions) = await ResolveUserLimitsAsync(identity, homeTenantId); + var userOnly = Compose(userLimits, new TenantPlanLimits(userLimits.PlanName, 0, 0)); + + var user = await ReadUserUsageAsync(oid, nowUtc); + var userDecision = user == null + ? McpQuotaDecision.FailOpen(userOnly) + : BuildDecision(userOnly, user.DailyUsed, user.MonthlyUsed, 0, 0, nowUtc); + if (!userDecision.Allowed) + { + return new McpAggregateQuotaResult + { + Allowed = false, + UserDecision = userDecision, + BlockingDecision = userDecision, + ExcludedTenantIds = chargeTenantIds.Distinct(StringComparer.OrdinalIgnoreCase).ToList(), + }; + } + + var decisions = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + using var gate = new SemaphoreSlim(AggregateParallelism); + await Task.WhenAll(chargeTenantIds.Distinct(StringComparer.OrdinalIgnoreCase).Select(async tenantId => + { + await gate.WaitAsync(); + try + { + var limits = Compose(userLimits, await ResolveTenantLimitsAsync(tenantId, definitions)); + var targetTenantId = TargetOf(homeTenantId, tenantId); + var tenant = await ReadTenantUsageAsync(tenantId, limits, nowUtc); + decisions[tenantId] = tenant == null + ? McpQuotaDecision.FailOpen(limits, targetTenantId) + : BuildDecision(limits, user?.DailyUsed ?? -1, user?.MonthlyUsed ?? -1, + tenant.DailyUsed, tenant.MonthlyUsed, nowUtc, targetTenantId); + } + finally + { + gate.Release(); + } + })); + + var admitted = decisions.Where(kv => kv.Value.Allowed).Select(kv => kv.Key).ToList(); + var excluded = decisions.Where(kv => !kv.Value.Allowed).Select(kv => kv.Key).ToList(); + var allExhausted = admitted.Count == 0 && excluded.Count > 0; + + return new McpAggregateQuotaResult + { + Allowed = !allExhausted, + UserDecision = userDecision, + // Retry-After for "everything is exhausted" is the moment at least ONE tenant becomes usable again. + BlockingDecision = allExhausted ? excluded.Select(t => decisions[t]).MinBy(d => d.ResetUtc) : null, + AdmittedTenantIds = admitted, + ExcludedTenantIds = excluded, + }; + } + + /// + /// Plan resolution only (no counter read) for a caller whose charged tenant IS their home tenant — + /// used by the self-service usage endpoint. See the four-argument overload. + /// + public virtual Task ResolvePlanAsync(AdminIdentity? identity, string? tenantId) + => ResolvePlanAsync(identity, tenantId, tenantId); + + /// + /// Plan resolution only (no counter read). is the caller's validated + /// (upn, tid, oid); the per-user override applies only when that identity IS the one the McpUsers UPN + /// is bound to — a null / unbound identity gets the home tenant's edition default. The user windows + /// follow 's edition; the tenant windows ALWAYS follow the CHARGED + /// tenant's edition plan (). + /// + public virtual async Task ResolvePlanAsync(AdminIdentity? identity, string? homeTenantId, string? chargeTenantId) + { + var (userLimits, definitions) = await ResolveUserLimitsAsync(identity, homeTenantId); + return Compose(userLimits, await ResolveTenantLimitsAsync(chargeTenantId, definitions)); + } + + private async Task<(UserPlanLimits Limits, List Definitions)> ResolveUserLimitsAsync( + AdminIdentity? identity, string? homeTenantId) { // 1. Per-user override wins when set — for the BOUND identity only. string? overridePlan = null; @@ -144,22 +222,12 @@ namespace AutopilotMonitor.Functions.Services } } - // 2. Tenant edition default (fail-closed → Community inside the entitlement service). - var entitlements = await _entitlementService.GetEntitlementsAsync(tenantId); - var tenantPlan = entitlements.McpUsagePlanName; - var planName = overridePlan ?? tenantPlan; + // 2. Home-tenant edition default (fail-closed → Community inside the entitlement service). + var homeEntitlements = await _entitlementService.GetEntitlementsAsync(homeTenantId); + var planName = overridePlan ?? homeEntitlements.McpUsagePlanName; // 3. Limits: admin-edited SectionUsagePlans definitions, else catalog fallbacks. - var definitions = new List(); - try - { - var adminConfig = await _adminConfigService.GetConfigurationAsync(); - definitions = PlanTierDefinitionParser.Parse(adminConfig.PlanTierDefinitionsJson); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "[McpQuota] Plan definitions unavailable — using catalog fallback for plan {Plan}", planName); - } + var definitions = await LoadDefinitionsAsync(planName); // User limits: the definition for the (possibly overridden) plan name, else the catalog fallback // for the edition plans, else Community (fail-closed for overrides naming a plan that exists nowhere). @@ -170,22 +238,117 @@ namespace AutopilotMonitor.Functions.Services var dailyLimit = userDefinition?.DailyRequestLimit ?? userFallback.McpDailyRequestLimit; var monthlyLimit = userDefinition?.MonthlyRequestLimit ?? userFallback.McpMonthlyRequestLimit; - // Tenant limits: the TENANT plan's definition when it carries tenant limits (null = not set → the - // edition's catalog tenant limits; an explicit 0 lifts the window), never the per-user override. - var tenantDefinition = Find(definitions, tenantPlan); - var tenantDailyLimit = tenantDefinition?.TenantDailyRequestLimit ?? entitlements.McpTenantDailyRequestLimit; - var tenantMonthlyLimit = tenantDefinition?.TenantMonthlyRequestLimit ?? entitlements.McpTenantMonthlyRequestLimit; - - return new McpPlanLimits(planName, dailyLimit, monthlyLimit, tenantPlan, tenantDailyLimit, tenantMonthlyLimit); + return (new UserPlanLimits(planName, dailyLimit, monthlyLimit), definitions); } + /// + /// The CHARGED tenant's organization windows: its edition plan's definition when that carries tenant + /// limits (null = not set → the edition's catalog tenant limits; an explicit 0 lifts the window), never + /// a per-user override. + /// + private async Task ResolveTenantLimitsAsync(string? chargeTenantId, List definitions) + { + var entitlements = await _entitlementService.GetEntitlementsAsync(chargeTenantId); + var tenantPlan = entitlements.McpUsagePlanName; + var tenantDefinition = Find(definitions, tenantPlan); + return new TenantPlanLimits( + tenantPlan, + tenantDefinition?.TenantDailyRequestLimit ?? entitlements.McpTenantDailyRequestLimit, + tenantDefinition?.TenantMonthlyRequestLimit ?? entitlements.McpTenantMonthlyRequestLimit); + } + + private async Task> LoadDefinitionsAsync(string planName) + { + try + { + var adminConfig = await _adminConfigService.GetConfigurationAsync(); + return PlanTierDefinitionParser.Parse(adminConfig.PlanTierDefinitionsJson); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[McpQuota] Plan definitions unavailable — using catalog fallback for plan {Plan}", planName); + return new List(); + } + } + + /// The caller's own counters (cached snapshot). Null = read failed (fail-open, nothing cached). + private async Task ReadUserUsageAsync(string oid, DateTime nowUtc) + { + var cacheKey = UserCacheKey(oid); + if (_cache.TryGetValue(cacheKey, out var cached) && cached != null) + return cached; + + try + { + var (monthStart, today) = Window(nowUtc); + var records = await _usageRepo.GetUsageByUserAsync(oid, monthStart, today); + var snapshot = new UsageSnapshot( + records.Where(r => r.Date == today).Sum(r => r.RequestCount), + records.Sum(r => r.RequestCount)); + _cache.Set(cacheKey, snapshot, CacheDuration); + return snapshot; + } + catch (Exception ex) + { + // Fail-open: usage counters unavailable → allow. Do NOT cache — the next request retries the read. + _logger.LogWarning(ex, "[McpQuota] Usage lookup failed for oid={Oid} — allowing (fail-open)", oid); + return null; + } + } + + /// + /// The charged tenant's organization counters (cached snapshot, shared by every caller charged to that + /// tenant). Skipped — zero counters, no read, nothing cached — when nothing could ever block on it (no + /// tenant, or both windows lifted). Null = read failed (fail-open, nothing cached). + /// + private async Task ReadTenantUsageAsync(string? chargeTenantId, McpPlanLimits limits, DateTime nowUtc) + { + if (string.IsNullOrWhiteSpace(chargeTenantId) || (limits.TenantDailyLimit <= 0 && limits.TenantMonthlyLimit <= 0)) + return UsageSnapshot.Zero; + + var cacheKey = TenantCacheKey(chargeTenantId); + if (_cache.TryGetValue(cacheKey, out var cached) && cached != null) + return cached; + + try + { + var (monthStart, today) = Window(nowUtc); + var records = await _usageRepo.GetTenantUsageAsync(chargeTenantId, monthStart, today); + var snapshot = new UsageSnapshot( + records.Where(r => r.Date == today).Sum(r => r.RequestCount), + records.Sum(r => r.RequestCount)); + _cache.Set(cacheKey, snapshot, CacheDuration); + return snapshot; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[McpQuota] Tenant usage lookup failed for tenant={TenantId} — allowing (fail-open)", chargeTenantId); + return null; + } + } + + private static (string MonthStart, string Today) Window(DateTime nowUtc) + => (new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc).ToString("yyyyMMdd"), + nowUtc.ToString("yyyyMMdd")); + + /// The managed tenant a decision names — null when the charged tenant IS the home tenant. + private static string? TargetOf(string? homeTenantId, string? chargeTenantId) + => !string.IsNullOrWhiteSpace(chargeTenantId) + && !string.Equals(chargeTenantId, homeTenantId, StringComparison.OrdinalIgnoreCase) + ? chargeTenantId + : null; + + private static McpPlanLimits Compose(UserPlanLimits user, TenantPlanLimits tenant) + => new(user.PlanName, user.DailyLimit, user.MonthlyLimit, tenant.TenantPlan, tenant.TenantDailyLimit, tenant.TenantMonthlyLimit); + private static PlanTierDefinition? Find(List definitions, string planName) => definitions.FirstOrDefault(t => string.Equals(t.Name, planName, StringComparison.OrdinalIgnoreCase)); internal static McpQuotaDecision BuildDecision( McpPlanLimits limits, long dailyUsed, long monthlyUsed, long tenantDailyUsed, long tenantMonthlyUsed, - DateTime nowUtc) + DateTime nowUtc, + string? targetTenantId = null) { // Daily windows reset at midnight UTC; monthly on the 1st. 0/negative limit = unlimited // for that window (an operator can deliberately lift a window via SectionUsagePlans). @@ -216,16 +379,27 @@ namespace AutopilotMonitor.Functions.Services TenantMonthlyLimit = limits.TenantMonthlyLimit, TenantDailyUsed = tenantDailyUsed, TenantMonthlyUsed = tenantMonthlyUsed, - ResetUtc = resetUtc + ResetUtc = resetUtc, + TargetTenantId = targetTenantId, }; } private static bool Exceeded(int limit, long used) => limit > 0 && used >= limit; + + /// Cached counter pair (today / this month) for one user or one charged tenant. + private sealed record UsageSnapshot(long DailyUsed, long MonthlyUsed) + { + public static readonly UsageSnapshot Zero = new(0, 0); + } + + private sealed record UserPlanLimits(string PlanName, int DailyLimit, int MonthlyLimit); + private sealed record TenantPlanLimits(string TenantPlan, int TenantDailyLimit, int TenantMonthlyLimit); } /// - /// Resolved plan names and limits for one caller: the user's plan (override or tenant edition) with the - /// user windows, and the tenant's edition plan with the organization-wide windows. 0 = unlimited. + /// Resolved plan names and limits for one caller against one charged tenant: the user's plan (override + /// or home edition) with the user windows, and the charged tenant's edition plan with the + /// organization-wide windows. 0 = unlimited. /// public sealed record McpPlanLimits( string PlanName, int DailyLimit, int MonthlyLimit, @@ -251,7 +425,7 @@ namespace AutopilotMonitor.Functions.Services public int MonthlyLimit { get; init; } public long DailyUsed { get; init; } public long MonthlyUsed { get; init; } - /// The tenant's edition plan — the organization-wide windows follow it, never the override. + /// The CHARGED tenant's edition plan — the organization-wide windows follow it, never the override. public string TenantPlan { get; init; } = string.Empty; public int TenantDailyLimit { get; init; } public int TenantMonthlyLimit { get; init; } @@ -259,6 +433,11 @@ namespace AutopilotMonitor.Functions.Services public long TenantMonthlyUsed { get; init; } /// When the exceeded (or daily, when allowed) window resets. public DateTime ResetUtc { get; init; } + /// + /// The MANAGED tenant whose organization windows this decision reflects (a delegated read charged to + /// the managed tenant); null when the charged tenant is the caller's own home tenant. + /// + public string? TargetTenantId { get; init; } /// Limit of the exceeded window (0 when allowed). public int ExceededLimit => Level == McpQuotaLevel.Tenant @@ -270,7 +449,7 @@ namespace AutopilotMonitor.Functions.Services ? (Scope == "monthly" ? TenantMonthlyUsed : TenantDailyUsed) : (Scope == "monthly" ? MonthlyUsed : DailyUsed); - public static McpQuotaDecision FailOpen(McpPlanLimits limits) => new() + public static McpQuotaDecision FailOpen(McpPlanLimits limits, string? targetTenantId = null) => new() { Allowed = true, Plan = limits.PlanName, @@ -283,7 +462,26 @@ namespace AutopilotMonitor.Functions.Services TenantMonthlyLimit = limits.TenantMonthlyLimit, TenantDailyUsed = -1, TenantMonthlyUsed = -1, - ResetUtc = DateTime.MinValue + ResetUtc = DateTime.MinValue, + TargetTenantId = targetTenantId, }; } + + /// + /// Outcome of a bounded (fleet) aggregate check: which charged tenants may be served and which are + /// dropped because their organization budget is exhausted. is false only when the + /// caller's own budget is exhausted (then is user-level and every tenant is + /// excluded) or when EVERY charged tenant is exhausted (then it is the excluded decision with the earliest + /// reset). + /// + public sealed class McpAggregateQuotaResult + { + public bool Allowed { get; init; } + /// The caller's own windows (tenant windows zero) — for the response headers. + public McpQuotaDecision UserDecision { get; init; } = default!; + /// Set when is false. + public McpQuotaDecision? BlockingDecision { get; init; } + public IReadOnlyList AdmittedTenantIds { get; init; } = Array.Empty(); + public IReadOnlyList ExcludedTenantIds { get; init; } = Array.Empty(); + } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs index dd60a4dc..12661638 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs @@ -103,6 +103,9 @@ namespace AutopilotMonitor.Functions.Services.Offboarding Constants.TableNames.ScriptNameCache, // Organization-wide MCP quota counters (PK=tenantId, RK={yyyyMMdd}_{oid}); also 90d retention. Constants.TableNames.McpTenantUsage, + // Self-service delegation invitations + slot holds this tenant issued as a MANAGING tenant + // (PK=home tenant id). Rows where it is the MANAGED tenant go via the property wipe below. + Constants.TableNames.DelegationInvitations, }; // Variant D — RowKey-anchored wipes for tables whose ROW key is the tenant id. @@ -160,8 +163,11 @@ namespace AutopilotMonitor.Functions.Services.Offboarding Constants.TableNames.TenantGroups, // Identity bindings HOMED in this tenant (PK="Bindings", RK=UPN, TenantId = the admin's home // tenant). Once the home tenant is gone its admins' cross-tenant role rows must be inert; the - // rows themselves are UPN-keyed and survive, but without a binding they resolve nothing. + // tenant; tenant offboarding purges bindings homed in that tenant. Constants.TableNames.AdminIdentityBindings, + // Delegation invitation / hold rows where this tenant is the MANAGED side (TenantId property set on + // accept). Pending rows carry no TenantId and belong to the managing tenant's partition. + Constants.TableNames.DelegationInvitations, }; // PR3.B plan §3 — Customs rules tables: archive each row to @@ -984,6 +990,15 @@ namespace AutopilotMonitor.Functions.Services.Offboarding ct.ThrowIfCancellationRequested(); counts[table] = await _safeWipe.WipeByRowKeyAsync(table, tenantId, ct); } + + // The tenant's own self-service Tenant Group ("msp-{tenantId}": PK=groupId, which no bucket above + // matches) and the assignment rows pointing at it — the managing side of self-service delegation. + ct.ThrowIfCancellationRequested(); + var ownedGroupId = Constants.TenantGroupIds.ForHomeTenant(tenantId); + counts[$"{Constants.TableNames.TenantGroups}/owned"] = + await _safeWipe.WipeByExactPartitionAsync(Constants.TableNames.TenantGroups, ownedGroupId, ct); + counts[$"{Constants.TableNames.TenantGroupAssignments}/owned"] = + await _safeWipe.WipeByRowKeyAsync(Constants.TableNames.TenantGroupAssignments, ownedGroupId, ct); } // ── Failure path ──────────────────────────────────────────────────────── diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs index da42eec8..d0dcaea3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs @@ -112,7 +112,7 @@ namespace AutopilotMonitor.Functions.Services "LastUpdated", "UpdatedBy", "OnboardedAt", "OnboardedBy", "HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince", "PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy", - "ProDowngradedUtc", + "ProDowngradedUtc", "MaxDelegatedTenantsOverride", }; /// @@ -159,7 +159,7 @@ namespace AutopilotMonitor.Functions.Services "HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince", "OnboardedBy", "OnboardedAt", "PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy", - "ProDowngradedUtc", + "ProDowngradedUtc", "MaxDelegatedTenantsOverride", }; private static readonly Dictionary ModelProperties = @@ -507,6 +507,7 @@ namespace AutopilotMonitor.Functions.Services "HomedAppClientId" => "system-owned — written only by the app-homing flow", "LastAuthClientId" or "LastAuthClientIdSince" => "system-owned auth provenance", "PlanTier" or "TrialExpiresUtc" or "TrialStartedUtc" or "TrialConsumed" or "TrialGrantedBy" + or "MaxDelegatedTenantsOverride" => "mutable only via the dedicated plan/trial endpoints", _ => "not writable via the field patch", }; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs index c1b9a468..7a91bcb7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs @@ -85,6 +85,32 @@ namespace AutopilotMonitor.Functions.Services => FeatureEntitlementCatalog.Get(ResolveEdition(config, nowUtc)).BootstrapIncluded || config.BootstrapTokenEnabled; + /// + /// The effective delegated ("MSP") tenant slot limit: the Global Admin override when set (it applies + /// regardless of edition — a package can be provisioned ahead of the plan flip; USING delegation still + /// needs Pro via DelegatedAdminAllowed), else the edition's catalog value (Community 0, Pro 2). + /// + public static int GetMaxDelegatedTenants(TenantConfiguration config, DateTime nowUtc) + => config.MaxDelegatedTenantsOverride + ?? FeatureEntitlementCatalog.Get(ResolveEdition(config, nowUtc)).MaxDelegatedTenants; + + /// Cached read-time variant of . No row / any error ⇒ 0 (fail-closed). + public virtual async Task GetMaxDelegatedTenantsAsync(string? homeTenantId) + { + if (string.IsNullOrWhiteSpace(homeTenantId)) + return 0; + try + { + var config = await _configService.GetConfigurationIfExistsAsync(homeTenantId); + return config == null ? 0 : GetMaxDelegatedTenants(config, _time.GetUtcNow().UtcDateTime); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Entitlement] Delegated slot limit resolution failed for tenant {TenantId} — treating as 0 (fail-closed)", homeTenantId); + return 0; + } + } + /// /// Whether Unrestricted Mode is effectively ACTIVE for this config. Requires all three: /// the edition allows it (Pro — read-time, so trial expiry / downgrade re-arms the diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/error-handler.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/error-handler.test.ts index 0e3ad276..fd76bc05 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/error-handler.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/error-handler.test.ts @@ -99,4 +99,29 @@ describe('toolError', () => { expect(text).toContain('shared by every member of the tenant'); expect(text).not.toContain('larger usage plan'); }); + + it("names the MANAGED tenant when a delegated read exhausted that tenant's budget", () => { + // "The budget follows the data": the managed Community customer's window blocked the read, so the + // hint points at that tenant's plan, not at the caller's organization — and the other managed + // tenants stay usable. + const err = new ApiError(429, JSON.stringify({ + quotaExceeded: true, + plan: 'pro', + scope: 'daily', + level: 'tenant', + limit: 300, + used: 300, + resetUtc: '2026-09-03T00:00:00Z', + message: "MCP daily request quota of the managed tenant 'customer.example' exceeded (tenant plan 'community', shared by all its members and delegated admins). Upgrading that tenant to Pro lifts its organization windows. Resets at 2026-09-03T00:00:00Z.", + targetTenantId: '7aa20c11-0002-4b7c-a1d2-52f3aaaa0002', + })); + const res = toolError('search_sessions', {}, err); + const text = res.content[0].text; + expect(text).toContain("managed tenant 'customer.example'"); + expect(text).toContain('do not retry THIS tenant'); + expect(text).toContain('upgrading it to Pro'); + expect(text).toContain('other managed tenants remain available'); + expect(text).not.toContain('shared by every member of the tenant'); + expect(text).not.toContain('larger usage plan'); + }); }); diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts index 70126ea7..d1333886 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts @@ -117,6 +117,9 @@ describe('role catalog snapshot — privilege-leak guard', () => { // role-aware routing to the GlobalReadOrAdmin fleet variant — same placement as // get_time_attribution (F2 counterpart). 'get_device_history', + // Platform scope + delegated (MSP): the bounded fleet aggregate over the two + // GlobalReadOrDelegatedSubset routes (global/stats/sessions + global/sessions). + 'get_fleet_overview', 'get_geographic_metrics', 'get_geographic_sessions', 'get_ime_pattern_health', @@ -197,6 +200,7 @@ describe('role catalog snapshot — privilege-leak guard', () => { 'annotate_session', 'get_agent_efficiency_metrics', 'get_api_usage', + 'get_fleet_overview', 'get_ime_pattern_health', 'get_ops_events', 'get_platform_metrics', @@ -220,7 +224,9 @@ describe('role catalog snapshot — privilege-leak guard', () => { // for tenant users — is ADDED back so a delegated caller can resolve its managed // tenants' display names (backend bounds config/all to the managed subset). const DELEGATED_HIDDEN = ['get_ime_version_history']; - const DELEGATED_ADDED = ['list_tenants']; + // get_fleet_overview is the ONE delegated tool without a tenantId: the backend bounds the two + // subset-tier routes it calls to the caller's managed set (never a client-side fan-out). + const DELEGATED_ADDED = ['get_fleet_overview', 'list_tenants']; const without = (set: string[], remove: string[]) => set.filter((n) => !remove.includes(n)); @@ -237,7 +243,7 @@ describe('role catalog snapshot — privilege-leak guard', () => { expect(registeredToolNames(false, false, false)).toEqual(without(GA_FULL, PLATFORM_ONLY)); }); - it('delegated (MSP) caller = tenant user minus the global IME archive, plus list_tenants', () => { + it('delegated (MSP) caller = tenant user minus the global IME archive, plus list_tenants + get_fleet_overview', () => { const tenant = without(GA_FULL, PLATFORM_ONLY); const expected = [...without(tenant, DELEGATED_HIDDEN), ...DELEGATED_ADDED].sort(); expect(registeredToolNames(false, false, true)).toEqual(expected); diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-delegated.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-delegated.test.ts index 9e65ede0..5ddfeaf7 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-delegated.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-delegated.test.ts @@ -311,3 +311,76 @@ describe('get_software_inventory exposes a required tenantId selector to delegat expect(ok).toBeDefined(); }); }); + +describe('get_fleet_overview — the ONE delegated tool without a tenantId (bounded server-side)', () => { + /** Stub fetch with per-path bodies; capture every URL. */ + function stubFleetFetch(bodies: Record): { urls: string[] } { + const urls: string[] = []; + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + const u = String(url); + urls.push(u); + const key = Object.keys(bodies).find((k) => u.includes(k)); + const body = key ? bodies[key] : { success: true, sessions: [], count: 0 }; + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response; + })); + return { urls }; + } + + it('is registered for a delegated caller and a platform caller, never for a plain tenant user', () => { + expect(() => handlerFor('get_fleet_overview', { delegated: true })).not.toThrow(); + expect(() => handlerFor('get_fleet_overview', { ga: true })).not.toThrow(); + expect(() => handlerFor('get_fleet_overview', {})).toThrow(/not registered/); + }); + + it('calls the two subset-tier routes WITHOUT a tenantId and merges quotaExcludedTenants', async () => { + const handler = handlerFor('get_fleet_overview', { delegated: true }); + const { urls } = stubFleetFetch({ + '/api/global/stats/sessions': { success: true, stats: { days: 7, activeCount: 1 }, quotaExcludedTenants: ['cccc-3333'] }, + '/api/global/sessions': { success: true, count: 1, sessions: [{ sessionId: 's1' }], nextLink: '/api/global/sessions?days=7&pageSize=25&continuation=x', quotaExcludedTenants: ['cccc-3333', 'dddd-4444'] }, + }); + + const r = await runWithCaller({ token: 'msp', isGlobalAdmin: false, delegatedTenantIds: [MANAGED, 'cccc-3333', 'dddd-4444'] }, () => + handler({ days: 7 }, extra)); + + expect(r.isError).toBeFalsy(); + expect(urls).toHaveLength(2); + expect(urls.some((u) => u.includes('/api/global/stats/sessions?') && u.includes('days=7'))).toBe(true); + expect(urls.some((u) => u.includes('/api/global/sessions?') && u.includes('days=7'))).toBe(true); + expect(urls.every((u) => !u.includes('tenantId='))).toBe(true); + const body = JSON.parse(resultText(r)); + expect(body.stats).toEqual({ days: 7, activeCount: 1 }); + expect(body.sessions).toEqual([{ sessionId: 's1' }]); + expect(body.nextLink).toContain('/api/global/sessions'); + expect(body.managedTenants).toEqual([MANAGED, 'cccc-3333', 'dddd-4444']); + expect(body.quotaExcludedTenants).toEqual(['cccc-3333', 'dddd-4444']); + expect(body.quotaNote).toMatch(/2 managed tenant\(s\) skipped/); + }); + + it('a follow-up page fetches only the session list (stats are first-page only) and omits the quota keys when nothing was excluded', async () => { + const handler = handlerFor('get_fleet_overview', { delegated: true }); + const { urls } = stubFleetFetch({ + '/api/global/sessions': { success: true, count: 0, sessions: [] }, + }); + + const r = await runWithCaller({ token: 'msp', isGlobalAdmin: false, delegatedTenantIds: [MANAGED] }, () => + handler({ continuation: '/api/global/sessions?days=7&pageSize=25&continuation=abc' }, extra)); + + expect(urls).toHaveLength(1); + expect(urls[0]).toContain('/api/global/sessions?days=7&pageSize=25&continuation=abc'); + const body = JSON.parse(resultText(r)); + expect(body).not.toHaveProperty('stats'); + expect(body).not.toHaveProperty('quotaExcludedTenants'); + expect(body).not.toHaveProperty('quotaNote'); + }); + + it('every OTHER delegated tool still requires a tenantId (the invariant is unchanged)', async () => { + const handler = handlerFor('search_sessions', { delegated: true }); + const { urls } = stubFleetFetch({}); + const r = await runWithCaller({ token: 'msp', isGlobalAdmin: false, delegatedTenantIds: [MANAGED] }, () => + handler({}, extra)); + expect(urls).toHaveLength(0); + expect(r.isError).toBe(true); + expect(resultText(r)).toMatch(/tenantId is required/i); + expect(resultText(r)).toMatch(/get_fleet_overview/); + }); +}); diff --git a/src/McpServer/autopilot-monitor-mcp/src/client.ts b/src/McpServer/autopilot-monitor-mcp/src/client.ts index 2b45cdf6..23807d64 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/client.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/client.ts @@ -214,7 +214,10 @@ export function pickGlobalOrTenantPath(globalPath: string, tenantPath: string, e * platform GA/Reader behavior — where tenantId is optional — is untouched. * * This makes the "delegated must name a managed tenant; no aggregate" invariant explicit at the tool - * boundary, on top of the backend middleware bound (which authorizes but cannot guess a tenantId). + * boundary, on top of the backend middleware bound (which authorizes but cannot guess a tenantId). The ONE + * deliberate exception is get_fleet_overview (admin.ts), which never goes through this guard: it calls the + * two GlobalReadOrDelegatedSubset routes (global/sessions, global/stats/sessions) whose handlers the backend + * bounds to the caller's managed set server-side — the same bounded aggregate the delegated web dashboard uses. */ export function enforceDelegatedTenant(tenantId?: string): string | undefined { if (!isDelegated()) return tenantId; @@ -230,7 +233,7 @@ export function enforceDelegatedTenant(tenantId?: string): string | undefined { 'tenantId is required: as a delegated (MSP) user you must name the tenant to query. ' + `Managed tenants: ${allowed.join(', ')}` + (home ? `; or your own home tenant: ${home}.` : '.') + - ' Call list_tenants to see them with display names.', + ' Call list_tenants to see them with display names, or get_fleet_overview for a bounded aggregate across all of them.', ); } if (!allowed.includes(t) && t !== home) { diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index 81273801..2e9e8698 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -7,6 +7,13 @@ // (optional = key absent under WhenWritingNull) and names mirror the C# wire exactly. +/** Response of POST delegations/accept. */ +export interface AcceptDelegationInvitationResponse { + homeTenantId: string; + homeTenantDomain?: string; + managedTenantId: string; +} + /** One currently active web user as listed by GET global/presence. */ export interface ActiveUserItem { tenantId: string; @@ -768,6 +775,13 @@ export interface CreateAnalyzeRuleFromTemplateResponse { message: string; } +/** Response of POST delegations/invitations: the token is shown ONCE (the link is copy-only). */ +export interface CreateDelegationInvitationResponse { + invitationId: string; + token: string; + expiresUtc: string; +} + /** Response of POST global/tenant-groups: the created group's id and (trimmed) name. */ export interface CreateTenantGroupResponse { groupId: string; @@ -899,6 +913,97 @@ export interface DelegatedAdminListResponse { assignments: DelegatedAdminEntry[]; } +/** One release hold nested in DelegatedSlotUsageResponse. */ +export interface DelegatedSlotHold { + invitationId: string; + /** The managed tenant that was removed; absent when unknown. */ + tenantId?: string; + holdUntilUtc: string; + releasedBy: string; +} + +/** 409 body when a delegated-admin mutation (grant, group assign, add tenant to group, self-service invitation or accept) would push a managing (MSP) tenant over its delegated tenant slot limit. error comes first so generic error rendering keeps working; code lets the GA UI offer the "raise the limit and retry" flow. */ +export interface DelegatedSlotLimitReachedResponse { + error: string; + code: string; + /** The managing (home) tenant whose slots are exhausted. */ + homeTenantId: string; + /** Its display name; absent when the config row carries none. */ + homeTenantDomain?: string; + /** Slots in use (distinct managed tenants + pending invitations + release holds). */ + used: number; + /** The effective limit (plan entitlement or the Global Admin override). */ + limit: number; + /** New slots the rejected mutation needed. */ + required: number; +} + +/** Response of GET global/delegated-slots/{tenantId} and GET delegations/slots: a managing tenant's slot usage. */ +export interface DelegatedSlotUsageResponse { + homeTenantId: string; + /** Effective limit (override when set, else the plan entitlement). */ + limit: number; + /** The plan entitlement (Community 0, Pro 2). */ + catalogLimit: number; + /** The Global Admin override; absent when the catalog value applies. */ + overrideLimit?: number; + used: number; + /** Distinct managed tenant ids (lowercase) reachable by users homed in this tenant. */ + managedTenantIds: string[]; + /** Pending self-service invitations (each holds a slot until accepted, cancelled or expired). */ + pendingInvitations: number; + /** Release holds: slots freed by a removal that stay occupied for 24 hours. */ + holds: DelegatedSlotHold[]; +} + +/** Response of GET delegations/accept?token=: what accepting would do — no mutation. */ +export interface DelegationAcceptPreviewResponse { + /** The inviting (managing) tenant. */ + homeTenantId: string; + homeTenantDomain?: string; + expiresUtc: string; + /** Pending | Accepted | Cancelled | Released | Expired. */ + status: string; + /** The caller's tenant — the one that would be managed. */ + targetTenantId: string; + targetTenantDomain?: string; +} + +/** Response of POST delegations/assignees. */ +export interface DelegationAssignResponse { + assignment: TenantGroupAssignment; +} + +/** Response of GET delegations/assignees: the managing tenant's own users on its self-service group. */ +export interface DelegationAssigneeListResponse { + homeTenantId: string; + groupId: string; + assignees: TenantGroupAssignment[]; +} + +/** One invitation row as the managing tenant sees it. Never carries the token. */ +export interface DelegationInvitationItem { + invitationId: string; + /** Pending | Accepted | Cancelled | Released | Expired (derived: pending past its expiry). */ + status: string; + createdBy: string; + createdUtc: string; + expiresUtc: string; + acceptedUtc?: string; + acceptedBy?: string; + /** The managed tenant (accepted / released rows). */ + tenantId?: string; + tenantDomain?: string; + /** Released rows: while in the future the slot is still occupied. */ + holdUntilUtc?: string; +} + +/** Response of GET delegations/invitations. */ +export interface DelegationInvitationListResponse { + homeTenantId: string; + invitations: DelegationInvitationItem[]; +} + /** One sampled table row key (delete preview / stored manifest summary). */ export interface DeletionRowKeySample { pk: string; @@ -1508,6 +1613,16 @@ export interface GetLatestVersionsResponse { source: string; } +/** Organization-wide MCP usage by user for the caller's OWN tenant (GetMcpOrganizationUsage): every account whose requests were charged to this tenant's organization budget — its own members and any delegated (MSP) administrators reading the tenant. Built from the tenant's organization counters. */ +export interface GetMcpOrganizationUsageResponse { + tenantId: string; + /** Effective range start (yyyyMMdd). */ + dateFrom: string; + /** Effective range end (yyyyMMdd, inclusive). */ + dateTo: string; + users: McpOrganizationUsageItem[]; +} + /** Per-user MCP/API usage envelope (GetMcpUserUsage). Non-global callers only receive the records attributed to their own tenant; a foreign oid and an unknown oid are indistinguishable (both 200 with empty records). The MCP server paginates over the records key — its name is wire-critical. */ export interface GetMcpUserUsageResponse { userId: string; @@ -2214,12 +2329,56 @@ export interface MaintenanceResult { contactEmailsBackfilled: number; } +/** One managed tenant as the managing tenant sees it. */ +export interface ManagedTenantItem { + tenantId: string; + domain?: string; + /** self-service (joined by invitation, removable here) | operator (provisioned by platform operators). */ + source: string; + sinceUtc?: string; + removable: boolean; + /** Absent when not resolved (cap reached or read failure). */ + usage?: ManagedTenantQuotaUsage; +} + +/** Response of GET delegations/managed. */ +export interface ManagedTenantListResponse { + homeTenantId: string; + slots: DelegatedSlotUsageResponse; + tenants: ManagedTenantItem[]; +} + +/** MCP organization budget of a managed tenant, nested in ManagedTenantItem. */ +export interface ManagedTenantQuotaUsage { + tenantPlan: string; + tenantDailyLimit: number; + tenantMonthlyLimit: number; + tenantDailyUsed: number; + tenantMonthlyUsed: number; +} + /** Response of GET health/mcp: the standalone MCP-server reachability probe. */ export interface McpHealthCheckResponse { timestamp: string; check: HealthCheck; } +/** One account's share of the organization budget, nested in GetMcpOrganizationUsageResponse. */ +export interface McpOrganizationUsageItem { + userId: string; + /** Absent for rows written before the UPN was recorded. */ + userPrincipalName?: string; + /** True when the account is a delegated (MSP) administrator homed in another tenant. */ + delegated: boolean; + /** The delegated administrator's home tenant; absent for the tenant's own members. */ + homeTenantId?: string; + requestsToday: number; + requestsThisMonth: number; + requestsInRange: number; + /** Absent for rows written before the timestamp was recorded. */ + lastRequestAt?: string; +} + /** 429 body written by McpQuotaEnforcementMiddleware when the per-user MCP daily/monthly quota is exhausted (structurally a success shape: first key is quotaExceeded). */ export interface McpQuotaExceededResponse { quotaExceeded: boolean; @@ -2235,9 +2394,11 @@ export interface McpQuotaExceededResponse { /** Reset time of the exceeded window, pre-formatted "yyyy-MM-ddTHH:mm:ssZ". */ resetUtc: string; message: string; + /** The MANAGED tenant whose organization windows blocked a delegated (MSP) read — its plan governs the budget, not the caller's. Absent when the caller's own tenant/plan was exceeded and on the all-managed-tenants-exhausted aggregate block. */ + targetTenantId?: string; } -/** Effective quota state nested in GetMyMcpUsageResponse: the caller's own windows and the organization-wide windows of their tenant (shared by every member; 0 = unlimited). */ +/** Effective quota state nested in GetMyMcpUsageResponse: the caller's own windows and the organization-wide windows of their tenant (shared by every member; 0 = unlimited). For a delegated (MSP) caller the tenant windows are those of their HOME tenant — reads into managed tenants are charged to the managed tenant per request and never appear here. */ export interface McpUsageQuotaNode { dailyLimit: number; monthlyLimit: number; @@ -2602,6 +2763,12 @@ export interface RelatedDoc { url: string; } +/** Response of POST global/delegated-slots/{tenantId}/release-hold: how many holds ended now. */ +export interface ReleaseDelegatedSlotHoldResponse { + homeTenantId: string; + released: number; +} + /** A remediation step with title and sub-steps */ export interface RemediationStep { /** Title of the remediation approach */ @@ -3007,6 +3174,8 @@ export interface SessionListResponse { sessions: SessionSummary[]; /** Absent when there is no further page. */ nextLink?: string; + /** Managed tenants dropped from a delegated (MSP) MCP fleet aggregate because their organization MCP budget is exhausted (each managed tenant's own plan governs it). Absent unless at least one tenant was excluded; only GetAllSessions ever sets it. */ + quotaExcludedTenants?: string[]; } export interface SessionMetrics { @@ -3109,6 +3278,8 @@ export interface SessionStats { export interface SessionStatsResponse { success: boolean; stats: SessionStats; + /** See QuotaExcludedTenants; only GetAllSessionStats ever sets it. */ + quotaExcludedTenants?: string[]; } /** Status of an enrollment session */ @@ -3253,6 +3424,10 @@ export interface SetTenantPlanTierResponse { effectiveEdition: string; /** End of the retention downgrade grace window, or null — the key is omitted when null. */ retentionGraceEndsUtc?: string; + /** Effective delegated (MSP) tenant slot limit after the change (override or plan entitlement). */ + maxDelegatedTenants: number; + /** The Global Admin override; omitted when the plan entitlement applies. */ + maxDelegatedTenantsOverride?: number; } /** Distinct-CVE counts grouped by their highest CVSS severity band. */ @@ -3529,6 +3704,8 @@ export interface TenantConfiguration { trialGrantedBy?: string; /** When the tenant's EFFECTIVE edition last dropped Pro → Community via the plan endpoint (UTC). Anchors the retention downgrade grace period: for RetentionDowngradeGraceDays after losing Pro the retention cap stays at the Pro value so a downgrade (e.g. non-payment) does not immediately hard-delete data older than the Community cap. Trial expiry needs no write — TrialExpiresUtc itself is the anchor there. Cleared whenever the tenant becomes effectively Pro again. Backend-only: not delivered to the agent (no ConfigVersion impact). */ proDowngradedUtc?: string; + /** Global-Admin override of the delegated ("MSP") tenant slot limit — how many distinct customer tenants users homed in this tenant may manage. Null = the plan entitlement applies (Community 0, Pro 2); a value applies regardless of edition (pre-provisioning a package), while USING delegation still requires Pro. Mutable only via the plan endpoint. Backend-only: not delivered to the agent (no ConfigVersion impact). */ + maxDelegatedTenantsOverride?: number; /** Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*") Comma-separated list */ manufacturerWhitelist: string; /** Hardware whitelist: Allowed models (supports wildcards like "Latitude*") Comma-separated list Default: "*" (all models allowed) */ @@ -3703,6 +3880,8 @@ export interface TenantFeatureEntitlements { userRateLimitPerMinute?: number; delegatedAdminAllowed: boolean; mcpUsagePlan: string; + /** Effective delegated (MSP) tenant slot limit (override or plan entitlement); 0 = no delegation. */ + maxDelegatedTenants: number; } /** Response of GET config/{tenantId}/feature-flags: the member-readable subset of the tenant configuration — UI display toggles, feature switches and the read-time edition/entitlement surface. Adding a field here is a deliberate decision that the field is non-sensitive (no webhook URLs, SAS tokens, allowlists, addresses). */ @@ -3745,6 +3924,10 @@ export interface TenantGroup { assigneeCount: number; /** The UPNs assigned to this group (for the management UI). */ assignees: TenantGroupAssignment[]; + /** Operator flag: MCP reads an assignee makes INTO this group's tenants are charged to the assignee's HOME tenant's quota instead of the managed tenant's. For operator-run managed-service groups whose customers must never pay (or be blocked) for the operator's own analysis. Off by default. */ + chargeHomeTenantQuota: boolean; + /** The managing tenant that owns this self-service group (msp-{tenantId}); null for operator-created groups. */ + ownerTenantId?: string; } /** One UPN→group assignment. PK=UPN, RK=groupId in storage. */ @@ -3763,6 +3946,35 @@ export interface TenantGroupListResponse { groups: TenantGroup[]; } +/** One person with access, nested in TenantManagerItem. */ +export interface TenantManagerAssignee { + upn: string; + role: string; + isEnabled: boolean; +} + +/** One party that can read the caller's tenant, nested in TenantManagerListResponse. */ +export interface TenantManagerItem { + /** The Tenant Group conferring the access; absent for direct operator grants. */ + groupId?: string; + /** The managing tenant that owns the group (self-service); absent for operator-created groups and direct grants. */ + ownerTenantId?: string; + ownerDomain?: string; + name: string; + /** self-service | operator */ + source: string; + assignees: TenantManagerAssignee[]; + sinceUtc?: string; + /** True when the caller (the managed tenant's admin) may end this access here. */ + revocable: boolean; +} + +/** Response of GET delegations/managers: who manages the caller's tenant. */ +export interface TenantManagerListResponse { + tenantId: string; + managers: TenantManagerItem[]; +} + export interface TenantMetrics { total: number; active7Days: number; diff --git a/src/McpServer/autopilot-monitor-mcp/src/mcp-server-factory.ts b/src/McpServer/autopilot-monitor-mcp/src/mcp-server-factory.ts index de98bd92..de8d4ff4 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/mcp-server-factory.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/mcp-server-factory.ts @@ -38,10 +38,13 @@ export function buildInstructions(deps: ServerDeps, ga: boolean, strictGa: boole const scopeLine = ga ? 'Scope: omit tenantId for cross-tenant queries (platform scope); pass tenantId to scope to one tenant.' : delegated - ? 'Scope: you are a delegated (MSP) administrator. Every query MUST name a tenant via tenantId — there ' + - `is no cross-tenant aggregate. Your managed tenants: ${managedTenants.join(', ')}.` + + ? 'Scope: you are a delegated (MSP) administrator. Every query MUST name a tenant via tenantId — the one ' + + 'exception is get_fleet_overview, a bounded aggregate across all your managed tenants. ' + + `Your managed tenants: ${managedTenants.join(', ')}.` + (homeTenantId ? ` If you are a member of your own home tenant (${homeTenantId}), you may query it by naming it too.` : '') + - ' Call list_tenants to resolve these IDs to tenant display names (domainName).' + ' Call list_tenants to resolve these IDs to tenant display names (domainName). ' + + 'Quota: a read into a managed tenant draws on THAT tenant\'s own MCP budget (its plan governs it); ' + + 'an exhausted tenant answers 429 naming it (or is skipped by get_fleet_overview) while your other tenants stay available.' : 'Scope: all queries are automatically limited to your tenant.'; // Role-aware headline: everyone below a real Global Admin keeps the exact READ-ONLY // contract (and wording) this server has always advertised. A strict GA additionally diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index 06d353d4..8d4dcaa2 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -5,6 +5,44 @@ import { withToolTelemetry } from '../telemetry.js'; import { getResourceContent, assertKnownEventType, RESOURCE_NAMES } from '../resource-catalog.js'; import { READ_ONLY, READ_ONLY_OPEN, MUTATING, MAX_RESULT_SIZE_CHARS, LEAN_RAW_EVENT_FIELDS, LEAN_RAW_EVENT_OMISSION, toolResultText, SessionIdSchema, TenantGuidSchema, tenantIdDescription } from './shared.js'; import { toolError } from './error-handler.js'; + +/** Default first-page size of get_fleet_overview's session list (a fleet snapshot, not a sweep). */ +const FLEET_OVERVIEW_PAGE_SIZE = 25; + +/** + * Pure: the get_fleet_overview result. The two backend responses each carry their own + * quotaExcludedTenants (the quota layer decides per request); the union is what the model must know is + * missing. Absent keys stay absent (WhenWritingNull on the wire; undefined here) so the payload stays lean. + */ +export function buildFleetOverview( + sessions: SessionListResponse, + stats: SessionStatsResponse | undefined, + days: number, + managedTenants: string[] | undefined, +): Record { + const excluded = new Set([ + ...(sessions.quotaExcludedTenants ?? []), + ...(stats?.quotaExcludedTenants ?? []), + ]); + const quotaExcludedTenants = excluded.size > 0 ? [...excluded] : undefined; + return { + days, + ...(managedTenants ? { managedTenants } : {}), + ...(stats ? { stats: stats.stats } : {}), + count: sessions.count, + sessions: sessions.sessions, + ...(sessions.nextLink ? { nextLink: sessions.nextLink } : {}), + ...(quotaExcludedTenants + ? { + quotaExcludedTenants, + quotaNote: + `${quotaExcludedTenants.length} managed tenant(s) skipped: their organization MCP budget is exhausted, so ` + + 'their sessions and stats are missing from this overview until their window resets or they upgrade their plan. ' + + 'Every other managed tenant is included.', + } + : {}), + }; +} import { shapeVerdictCalibration } from '../verdict-calibration-shape.js'; import type { GeographicLocationSessionsLeanResponse, @@ -21,6 +59,8 @@ import type { RuleStatsRuleAggregate, SoftwareInventoryResponse, VerdictCalibrationResponse, + SessionListResponse, + SessionStatsResponse, } from '../generated/wire-types.generated.js'; // Vocabularies as VALUES, generated from the C# constants (see wire-vocabularies.generated.ts). // Tool enums derive from these — a hand-typed list is what let get_ops_events advertise a @@ -639,6 +679,63 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo }) ); + // Tool: get_fleet_overview — the bounded fleet aggregate. The ONLY delegated tool that takes no tenantId: + // it calls the two GlobalReadOrDelegatedSubset routes (global/stats/sessions + global/sessions) whose + // handlers the backend bounds to the caller's managed set — the same aggregate the delegated web + // dashboard uses. Quota: each managed tenant's OWN organization budget is charged once per call; + // exhausted tenants are dropped by the backend (never the whole request) and echoed as + // quotaExcludedTenants so the model can say what is missing. For a Global Admin / Reader the same routes + // return the unbounded platform-wide view. Not registered for normal (single-tenant) users. + if (ga || delegated) server.registerTool( + 'get_fleet_overview', + { + title: 'Fleet Overview', + description: + (delegated + ? 'Bounded aggregate across ALL the tenants you manage as a delegated (MSP) administrator — the only tool that ' + + 'needs no tenantId. ' + : 'Platform-wide enrollment overview across every tenant (Global Admin / Reader). ') + + 'Returns enrollment stats (counts, success rate, durations, today) plus the most recent sessions, merged ' + + 'server-side newest-first' + (delegated ? ' and bounded to your managed tenants' : '') + '. ' + + (delegated + ? 'Quota: each managed tenant\'s OWN MCP budget is charged once per call; a tenant whose organization budget is ' + + 'exhausted is SKIPPED (not failed) and listed in "quotaExcludedTenants" — its data is missing until its window ' + + 'resets or it upgrades its plan. ' + : '') + + 'For filtered searches or one tenant use search_sessions / get_session_summary with a tenantId. Pagination of the ' + + 'session list: pass the whole nextLink back as "continuation" (stats come with the first page only).', + inputSchema: { + days: z.coerce.number().int().min(1).max(365).optional().default(7) + .describe('Window in days for the stats and the session list (1-365, default 7).'), + pageSize: z.coerce.number().int().min(1).max(200).optional() + .describe('Sessions per page (1-200; default ' + FLEET_OVERVIEW_PAGE_SIZE + ' on the first page). On a follow-up call an explicit value overrides the pageSize embedded in the nextLink; omit it to keep that size.'), + continuation: z.string().optional() + .describe('The full nextLink from a prior response to fetch the next page of sessions (stats are not repeated).'), + }, + annotations: READ_ONLY, + }, + async (args) => withToolTelemetry('get_fleet_overview', args, async () => { + try { + const { days, pageSize: explicitPageSize, continuation } = args; + const pageSize = pageSizeForCall(explicitPageSize, continuation, FLEET_OVERVIEW_PAGE_SIZE); + const sessionsPath = followNextLink('/api/global/sessions', { days, pageSize }, continuation, { pageSize }); + const firstPage = !continuation; + const [sessions, stats] = await Promise.all([ + apiFetch(sessionsPath) as Promise, + firstPage + ? (apiFetch(`/api/global/stats/sessions${buildQuery({ days })}`) as Promise) + : Promise.resolve(undefined), + ]); + return toolResultText( + buildFleetOverview(sessions, stats, days, delegated ? getDelegatedTenantIds() : undefined), + MAX_RESULT_SIZE_CHARS.sessions, + ); + } catch (error: unknown) { + return toolError('get_fleet_overview', args, error); + } + }) + ); + // Tool: list_tenants — reuses the /api/config/all endpoint (GlobalReadOrDelegatedSubset tier). // The endpoint returns FULL configs (incl. secrets) for a GA; extractTenantList strips // them down to TENANT_SAFE_FIELDS before anything reaches the model. diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/error-handler.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/error-handler.ts index 15f18dba..11c1e780 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/error-handler.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/error-handler.ts @@ -41,14 +41,22 @@ export function toolError( // say so, and say WHOSE budget it is, so a member blocked by the tenant window does not go and // create more accounts or ask for a bigger personal plan. const p = error.parsed; - const whose = p.level === 'tenant' ? "your organization's" : 'your'; + // A delegated (MSP) read is charged to the MANAGED tenant ("the budget follows the data"): its plan + // governs the window, so the fix is on that tenant's side — and the caller's other managed tenants + // stay perfectly usable. + const managedTenant = p.level === 'tenant' && typeof p.targetTenantId === 'string' && p.targetTenantId + ? p.targetTenantId + : undefined; + const whose = managedTenant ? `the managed tenant ${managedTenant}'s` : p.level === 'tenant' ? "your organization's" : 'your'; parts.push(`**Quota exceeded in ${toolName}**: ${p.message ?? `${whose} MCP ${p.scope ?? ''} request quota is exhausted.`}`); if (p.limit != null && p.used != null) parts.push(`**Budget**: ${p.used} of ${p.limit} requests used (${p.scope ?? 'window'}, ${p.level ?? 'user'} level).`); if (p.resetUtc) parts.push(`**Resets at**: ${p.resetUtc}`); parts.push( - p.level === 'tenant' - ? '**Suggestion**: do not retry before the reset — the window is shared by every member of the tenant; a tenant admin can review consumption under Configuration → Reporting → MCP Usage.' - : '**Suggestion**: do not retry before the reset; narrow further queries, or ask an administrator about a larger usage plan.', + managedTenant + ? `**Suggestion**: do not retry THIS tenant before the reset — its window is shared by all of that tenant's members and delegated admins and is governed by the managed tenant's own plan (upgrading it to Pro lifts it). Your other managed tenants remain available.` + : p.level === 'tenant' + ? '**Suggestion**: do not retry before the reset — the window is shared by every member of the tenant; a tenant admin can review consumption under Configuration → Reporting → MCP Usage.' + : '**Suggestion**: do not retry before the reset; narrow further queries, or ask an administrator about a larger usage plan.', ); } else if (error instanceof ApiError && error.parsed) { // Structured backend error (4xx) — extract rich details diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/shared.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/shared.ts index ffc7c3da..e96cd1ed 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/shared.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/shared.ts @@ -116,8 +116,9 @@ export function tenantIdDescription(ga: boolean, delegated: boolean, gaText: str // offset-based client-side pagers (geo-offset:/inv-offset:) still need it re-passed every page. The // per-tool `continuation` arg description owns those mechanics; here we only state the invariant. return 'REQUIRED: name the tenant to query — one of YOUR managed tenants (delegated/MSP), or your own ' + - 'home tenant if you are a member of it. There is no cross-tenant aggregate and no implicit default — ' + - 'every query must name a specific tenant. Call list_tenants to see your tenants with display names.'; + 'home tenant if you are a member of it. There is no cross-tenant aggregate here and no implicit default — ' + + 'every query must name a specific tenant. Call list_tenants to see your tenants with display names; ' + + 'for a bounded overview across ALL your managed tenants call get_fleet_overview instead.'; } return ga ? gaText : tenantText; } diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index a99f6040..9feecdb4 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -927,6 +927,54 @@ namespace AutopilotMonitor.Shared public const string CustomerDelegated = "CustomerDelegated"; } + /// Delegated ("MSP") tenant slots — the per-home-tenant limit on distinct managed tenants. + public static class DelegatedSlots + { + /// Machine-readable code on the 409 a slot-limited mutation returns (DelegatedSlotLimitReachedResponse). + public const string LimitReachedCode = "DelegatedSlotLimitReached"; + } + + /// Lifecycle of a DelegationInvitations row. is derived (Pending past ExpiresDate), never stored. + public static class DelegationInvitationStatus + { + public const string Pending = "Pending"; + public const string Accepted = "Accepted"; + public const string Cancelled = "Cancelled"; + /// The managed tenant left again; the slot stays occupied until HoldUntilDate. + public const string Released = "Released"; + public const string Expired = "Expired"; + } + + /// + /// Deterministic ids of the implicit self-service Tenant Group every managing (MSP) tenant owns: + /// msp-{homeTenantId}. Its members are the customers that accepted an invitation; its assignees are + /// the managing tenant's own users. Ordinary group to every consumer (scope resolution, middleware, MCP). + /// + public static class TenantGroupIds + { + public const string SelfServicePrefix = "msp-"; + public static string ForHomeTenant(string homeTenantId) => SelfServicePrefix + homeTenantId.ToLowerInvariant(); + public static bool IsSelfService(string? groupId) => groupId != null && groupId.StartsWith(SelfServicePrefix, System.StringComparison.Ordinal); + } + + /// Machine-readable code values of the self-service delegation 4xx bodies. + public static class DelegationCodes + { + public const string InvalidInvitation = "InvalidInvitation"; + public const string InvitationNotFound = "InvitationNotFound"; + public const string InvitationExpired = "InvitationExpired"; + public const string InvitationAlreadyUsed = "InvitationAlreadyUsed"; + public const string InvitationCancelled = "InvitationCancelled"; + public const string CannotAcceptOwnInvitation = "CannotAcceptOwnInvitation"; + public const string AlreadyManaged = "AlreadyManaged"; + public const string ManagerNotEntitled = "ManagerNotEntitled"; + public const string DelegatedAdminNotAllowed = "DelegatedAdminNotAllowed"; + public const string NotManagedBySelfService = "NotManagedBySelfService"; + public const string NotATenantMember = "NotATenantMember"; + public const string AssigneeNotFound = "AssigneeNotFound"; + public const string IdentityBindingConflict = "IdentityBindingConflict"; + } + /// /// Placeholder substituted for secret-bearing string fields (SAS URLs, webhook URLs, API keys, /// custom webhook headers) when a config object is served to a read-only GlobalReader. Non-empty @@ -1044,6 +1092,13 @@ namespace AutopilotMonitor.Shared // UPN's groups into the effective tenant set. NOT tenant-id-keyed (offboarding never touches it). public const string TenantGroupAssignments = "TenantGroupAssignments"; + // Self-service delegation invitations + slot release holds of a managing (MSP) tenant. + // PK=home (managing) tenant id, RK=invitationId. Lifecycle Pending → Accepted → Released (hold) + // or Pending → Cancelled; "Expired" is derived. TenantId = the managed tenant once accepted, so + // the offboarding property wipe purges rows for an offboarded MANAGED tenant; the home tenant's + // own offboarding wipes the partition (exact-PK bucket). + public const string DelegationInvitations = "DelegationInvitations"; + // Admin identity bindings — the immutable Entra identity (tid + oid) behind every UPN that holds a // cross-tenant role (GlobalAdmins, DelegatedAdmins, TenantGroupAssignments). PK="Bindings", // RK=UPN (lowercase). The API accepts tokens from ANY Entra tenant and upn/preferred_username are @@ -1251,6 +1306,7 @@ namespace AutopilotMonitor.Shared TenantAdmins, McpUsers, DelegatedAdmins, + DelegationInvitations, TenantGroups, TenantGroupAssignments, AdminIdentityBindings, diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs index a5dc7569..2491d083 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs @@ -53,16 +53,25 @@ namespace AutopilotMonitor.Shared.DataAccess Task CreateTenantGroupAsync(string name, string createdBy); /// Renames a group (meta row). Returns false if the group does not exist. Task RenameTenantGroupAsync(string groupId, string name); + /// Sets the group's flag (meta row). Returns false if the group does not exist. + Task SetTenantGroupChargeHomeTenantQuotaAsync(string groupId, bool chargeHomeTenantQuota); /// Deletes a group: all rows in its partition (meta + membership) AND every UPN /// assignment referencing it (cross-partition RowKey scan of the assignments table). Task DeleteTenantGroupAsync(string groupId); /// All groups with their tenant members + assignee counts — for the management UI (not hot path). Task> GetAllTenantGroupsAsync(); Task GetTenantGroupAsync(string groupId); + /// Creates the meta row of a tenant-OWNED (self-service) group if missing — idempotent, never touches an existing group. + Task EnsureOwnedTenantGroupAsync(string groupId, string name, string ownerTenantId); + /// Ids of every group partition holding a membership row for the tenant (cross-partition RowKey scan; existence is re-checked meta-backed by callers). + Task> GetGroupIdsContainingTenantAsync(string tenantId); Task AddTenantToGroupAsync(string groupId, string tenantId); Task RemoveTenantFromGroupAsync(string groupId, string tenantId); /// Tenant IDs (lowercase) in a group, excluding the meta row (HOT PATH — PartitionKey scan). Task> GetGroupTenantsAsync(string groupId); + /// The group's tenant members PLUS its meta-row flags from the same single partition read + /// (HOT PATH — scope resolution). Null when the group does not exist (no meta row). + Task GetGroupMembershipAsync(string groupId); /// Creates or replaces a UPN→group assignment. Task AssignGroupAsync(string upn, string groupId, string role, bool isEnabled, string assignedBy); Task UnassignGroupAsync(string upn, string groupId); @@ -76,6 +85,8 @@ namespace AutopilotMonitor.Shared.DataAccess Task GetIdentityBindingAsync(string upn); /// Every binding — for the operator management UI (small table, not a hot path). Task> GetAllIdentityBindingsAsync(); + /// Every binding homed in one tenant (single-partition property filter) — the delegated slot count's attribution. + Task> GetIdentityBindingsByHomeTenantAsync(string homeTenantId); /// Creates or replaces the binding for a UPN. null/empty ⇒ pinned on first sign-in. Task UpsertIdentityBindingAsync(string upn, string tenantId, string? objectId, string boundBy); /// @@ -142,6 +153,27 @@ namespace AutopilotMonitor.Shared.DataAccess public int AssigneeCount { get; set; } /// The UPNs assigned to this group (for the management UI). public List Assignees { get; set; } = new(); + /// + /// Operator flag: MCP reads an assignee makes INTO this group's tenants are charged to the assignee's + /// HOME tenant's quota instead of the managed tenant's. For operator-run managed-service groups whose + /// customers must never pay (or be blocked) for the operator's own analysis. Off by default. + /// + public bool ChargeHomeTenantQuota { get; set; } + /// The managing tenant that owns this self-service group (msp-{tenantId}); null for operator-created groups. + public string? OwnerTenantId { get; set; } + } + + /// + /// Hot-path projection of one Tenant Group: its tenant members and the meta-row flags that shape the + /// scope they confer, read from the single group partition (see ). + /// + public class TenantGroupMembership + { + public string GroupId { get; set; } = string.Empty; + /// Tenant IDs (lowercase) in this group. + public List TenantIds { get; set; } = new(); + /// See . + public bool ChargeHomeTenantQuota { get; set; } } /// diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IDelegationInvitationRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IDelegationInvitationRepository.cs new file mode 100644 index 00000000..137b329a --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IDelegationInvitationRepository.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Self-service delegation invitations AND slot release holds of a managing (MSP) tenant — one table, + /// PK = home tenant id, RK = invitation id. Lifecycle: Pending → Accepted (the customer tenant joined the + /// home tenant's owned group) → Released (removed again; the slot stays occupied until HoldUntilUtc) — + /// or Pending → Cancelled. "Expired" is derived (Pending past ExpiresAt), never written. A Released row + /// without a preceding invitation (operator added the tenant by hand) is created synthetically so the + /// 24-hour hold applies uniformly. + /// + public interface IDelegationInvitationRepository + { + Task CreateAsync(DelegationInvitation invitation); + + /// One row (with its ETag), or null. + Task GetAsync(string homeTenantId, string invitationId); + + /// Every row of one home tenant (single partition). + Task> GetByHomeTenantAsync(string homeTenantId); + + /// + /// One-shot Pending → Accepted flip guarded by : false when the row was + /// consumed, cancelled or deleted concurrently (412/404) — the caller answers "already used". + /// + Task TryAcceptAsync(string homeTenantId, string invitationId, string etag, string acceptedTenantId, string acceptedBy, DateTime nowUtc); + + /// Cancelled (Pending only) or Released (+hold); GA "release now" rewrites HoldUntilUtc. False when the row is missing. + Task SetStatusAsync(string homeTenantId, string invitationId, string status, DateTime nowUtc, string? actor, DateTime? holdUntilUtc); + + /// Retention: deletes rows created before the cutoff (all terminal by then). Returns the count. + Task DeleteOlderThanAsync(DateTime cutoffUtc); + } + + /// One invitation / hold row. + public class DelegationInvitation + { + public string InvitationId { get; set; } = string.Empty; + /// The inviting (managing / MSP) tenant. + public string HomeTenantId { get; set; } = string.Empty; + /// Constants.DelegationInvitationStatus. + public string Status { get; set; } = string.Empty; + /// Always DelegatedReader for self-service. + public string Role { get; set; } = string.Empty; + /// Constants.DelegatedSource — CustomerDelegated for invitations, OperatorGranted for synthetic holds. + public string Source { get; set; } = string.Empty; + public string CreatedBy { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime ExpiresAt { get; set; } + public DateTime? AcceptedAt { get; set; } + public string? AcceptedBy { get; set; } + /// The managed (accepting) tenant — set on accept (and on synthetic holds). + public string? TenantId { get; set; } + public DateTime? ReleasedAt { get; set; } + public string? ReleasedBy { get; set; } + /// While in the future, the released slot stays occupied. + public DateTime? HoldUntilUtc { get; set; } + /// Storage ETag of the row as read (for the one-shot accept). + public string? ETag { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IUserUsageRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IUserUsageRepository.cs index 19a09d9c..fa41e22e 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IUserUsageRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IUserUsageRepository.cs @@ -39,9 +39,13 @@ namespace AutopilotMonitor.Shared.DataAccess /// /// Increments the tenant-wide MCP counter for (tenant, user, today) — the organization-wide - /// quota's counter, kept per user so the tenant partition has no hot row. + /// quota's counter, kept per user so the tenant partition has no hot row. + /// is the CHARGED tenant: the caller's own tenant, or — for a delegated (MSP) read — the managed + /// tenant whose plan governs the request. is the caller's home tenant + /// when it differs from the charged tenant (empty/null for the tenant's own members), so the charged + /// tenant's admins can tell delegated consumption from their own. /// - Task IncrementTenantUsageAsync(string tenantId, string userId); + Task IncrementTenantUsageAsync(string tenantId, string userId, string? userPrincipalName, string? homeTenantId); /// Tenant-wide MCP counters (one row per user and day) within an optional yyyyMMdd range. Task> GetTenantUsageAsync(string tenantId, string? dateFrom = null, string? dateTo = null); @@ -55,8 +59,14 @@ namespace AutopilotMonitor.Shared.DataAccess { public string TenantId { get; set; } = string.Empty; public string UserId { get; set; } = string.Empty; + /// UPN as last seen; empty on rows written before the column existed. + public string UserPrincipalName { get; set; } = string.Empty; + /// The caller's HOME tenant when the row was charged by a delegated (MSP) read; empty for + /// the tenant's own members (and for rows written before the column existed). + public string HomeTenantId { get; set; } = string.Empty; public string Date { get; set; } = string.Empty; public long RequestCount { get; set; } + public DateTime? LastRequestAt { get; set; } } /// diff --git a/src/Shared/AutopilotMonitor.Shared/Delegation/DelegationInviteTicket.cs b/src/Shared/AutopilotMonitor.Shared/Delegation/DelegationInviteTicket.cs new file mode 100644 index 00000000..4b229fc2 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Delegation/DelegationInviteTicket.cs @@ -0,0 +1,203 @@ +using System; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AutopilotMonitor.Shared.Delegation +{ + /// + /// Stateless codec for the self-service delegation invitation link: a Pro (MSP) tenant admin mints + /// one, hands the link to a customer, and the customer's tenant admin accepts it in the portal. + /// + /// + /// + /// The ticket binds the inviting HOME tenant and the invitation row id into an HMAC-SHA256 signature. + /// The accept endpoint trusts ONLY the values inside the ticket to locate the invitation row; the row + /// (status Pending, not expired, one-shot ETag flip to Accepted) is the authority on whether it is still + /// redeemable, so the ticket alone cannot be replayed, and the ACCEPTING tenant is always the caller's + /// validated JWT tenant — the ticket never names a target. + /// + /// + /// Mirrors : tamper ⇒ HMAC fails; a fixed purpose tag + /// gives domain separation from every other token signed with the shared key; the TTL + /// (, 7 days — the row's ExpiresDate is the authoritative expiry) bounds a + /// leaked link. Reuses the provisioned PaginationTokenSigningKey. + /// + /// + public static class DelegationInviteTicket + { + /// Invitation lifetime — long enough to forward a link to a customer, short enough to bound a leak. + public static readonly TimeSpan DefaultTtl = TimeSpan.FromDays(7); + + private const string Purpose = "msp-invite-v1"; + private const string SigningKeyEnvVar = "PaginationTokenSigningKey"; + + private static readonly JsonSerializerOptions JsonOpts = new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault + }; + + private static byte[]? _testOverrideKey; + + /// Test-only hook to inject a fixed signing key (production reads the env var). + internal static void SetSigningKeyForTesting(byte[]? key) => _testOverrideKey = key; + + private static byte[] GetSigningKey() + { + var test = _testOverrideKey; + if (test != null) return test; + + var raw = Environment.GetEnvironmentVariable(SigningKeyEnvVar); + if (string.IsNullOrEmpty(raw)) + throw new InvalidOperationException( + $"{SigningKeyEnvVar} environment variable is required for DelegationInviteTicket HMAC signing. " + + "Set it to a base64-encoded random 32-byte key."); + try + { + return Convert.FromBase64String(raw); + } + catch (FormatException ex) + { + throw new InvalidOperationException($"{SigningKeyEnvVar} is not valid base64.", ex); + } + } + + /// Encodes a signed ticket binding the inviting home tenant and the invitation row id. + public static string Encode(string homeTenantId, string invitationId, DateTimeOffset? issuedAt = null) + { + if (string.IsNullOrWhiteSpace(homeTenantId)) throw new ArgumentException("homeTenantId required", nameof(homeTenantId)); + if (string.IsNullOrWhiteSpace(invitationId)) throw new ArgumentException("invitationId required", nameof(invitationId)); + + var payload = new TicketPayload + { + P = Purpose, + Hid = homeTenantId.ToLowerInvariant(), + Iid = invitationId, + Iat = (issuedAt ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(), + }; + + var canonicalBytes = JsonSerializer.SerializeToUtf8Bytes(payload, JsonOpts); + payload.Sig = ComputeSignature(canonicalBytes); + return Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(payload, JsonOpts)); + } + + /// + /// Validates and decodes a ticket. True only when the HMAC verifies, the purpose matches and the ticket + /// is within its TTL. is an observability handle, never shown verbatim. + /// + public static bool TryDecode( + string raw, + out string homeTenantId, + out string invitationId, + out string? rejectReason, + DateTimeOffset? now = null, + TimeSpan? ttl = null) + { + homeTenantId = string.Empty; + invitationId = string.Empty; + rejectReason = null; + + if (string.IsNullOrWhiteSpace(raw)) + { + rejectReason = "empty"; + return false; + } + + TicketPayload? payload; + try + { + payload = JsonSerializer.Deserialize(Base64UrlDecode(raw), JsonOpts); + } + catch (FormatException) + { + rejectReason = "malformed_base64"; + return false; + } + catch (JsonException) + { + rejectReason = "malformed_json"; + return false; + } + + if (payload == null) { rejectReason = "null_payload"; return false; } + if (string.IsNullOrEmpty(payload.Hid)) { rejectReason = "missing_home"; return false; } + if (string.IsNullOrEmpty(payload.Iid)) { rejectReason = "missing_invitation"; return false; } + if (string.IsNullOrEmpty(payload.Sig)) { rejectReason = "missing_signature"; return false; } + + var providedSig = payload.Sig!; + payload.Sig = null; + byte[] canonicalBytes; + try + { + canonicalBytes = JsonSerializer.SerializeToUtf8Bytes(payload, JsonOpts); + } + catch (JsonException) + { + rejectReason = "malformed_json"; + return false; + } + if (!FixedTimeEquals(providedSig, ComputeSignature(canonicalBytes))) + { + rejectReason = "bad_signature"; + return false; + } + + if (!FixedTimeEquals(payload.P ?? string.Empty, Purpose)) + { + rejectReason = "wrong_purpose"; + return false; + } + + var nowTs = (now ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(); + var maxAge = (long)(ttl ?? DefaultTtl).TotalSeconds; + if (payload.Iat <= 0 || nowTs - payload.Iat > maxAge) + { + rejectReason = "expired"; + return false; + } + + homeTenantId = payload.Hid; + invitationId = payload.Iid; + return true; + } + + private static bool FixedTimeEquals(string a, string b) + { + if (a.Length != b.Length) return false; + var diff = 0; + for (int i = 0; i < a.Length; i++) diff |= a[i] ^ b[i]; + return diff == 0; + } + + private static string ComputeSignature(byte[] payloadBytes) + { + using var hmac = new HMACSHA256(GetSigningKey()); + return Convert.ToBase64String(hmac.ComputeHash(payloadBytes)); + } + + private static string Base64UrlEncode(byte[] bytes) + => Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); + + private static byte[] Base64UrlDecode(string s) + { + var padded = s.Replace('-', '+').Replace('_', '/'); + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + case 0: break; + default: throw new FormatException("invalid base64url length"); + } + return Convert.FromBase64String(padded); + } + + private sealed class TicketPayload + { + [JsonPropertyName("p")] public string P { get; set; } = string.Empty; + [JsonPropertyName("hid")] public string Hid { get; set; } = string.Empty; + [JsonPropertyName("iid")] public string Iid { get; set; } = string.Empty; + [JsonPropertyName("iat")] public long Iat { get; set; } + [JsonPropertyName("sig")] public string? Sig { get; set; } + } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index c49cd795..912ec762 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -194,6 +194,15 @@ namespace AutopilotMonitor.Shared.Models /// public DateTime? ProDowngradedUtc { get; set; } + /// + /// Global-Admin override of the delegated ("MSP") tenant slot limit — how many distinct customer + /// tenants users homed in this tenant may manage. Null = the plan entitlement applies + /// (Community 0, Pro 2); a value applies regardless of edition (pre-provisioning a package), + /// while USING delegation still requires Pro. Mutable only via the plan endpoint. Backend-only: + /// not delivered to the agent (no ConfigVersion impact). + /// + public int? MaxDelegatedTenantsOverride { get; set; } + /// /// Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*") /// Comma-separated list diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantFeatureFlagsApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantFeatureFlagsApiModels.cs index eb422296..d17bb885 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantFeatureFlagsApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantFeatureFlagsApiModels.cs @@ -46,5 +46,7 @@ namespace AutopilotMonitor.Shared.Models public int? UserRateLimitPerMinute { get; set; } public bool DelegatedAdminAllowed { get; set; } public string McpUsagePlan { get; set; } = string.Empty; + /// Effective delegated (MSP) tenant slot limit (override or plan entitlement); 0 = no delegation. + public int MaxDelegatedTenants { get; set; } } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs index caf6a704..cbb26c0b 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs @@ -198,6 +198,12 @@ namespace AutopilotMonitor.Shared.Models /// End of the retention downgrade grace window, or null — the key is omitted when null. public DateTime? RetentionGraceEndsUtc { get; set; } + + /// Effective delegated (MSP) tenant slot limit after the change (override or plan entitlement). + public int MaxDelegatedTenants { get; set; } + + /// The Global Admin override; omitted when the plan entitlement applies. + public int? MaxDelegatedTenantsOverride { get; set; } } /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/DelegationApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/DelegationApiModels.cs new file mode 100644 index 00000000..9b6a9cc5 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/DelegationApiModels.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Shared.DataAccess; + +namespace AutopilotMonitor.Shared.Models +{ + // Declaration order == wire order. + + /// + /// 409 body when a delegated-admin mutation (grant, group assign, add tenant to group, self-service + /// invitation or accept) would push a managing (MSP) tenant over its delegated tenant slot limit. + /// error comes first so generic error rendering keeps working; code lets the GA UI offer the + /// "raise the limit and retry" flow. + /// + public class DelegatedSlotLimitReachedResponse : IApiResponse + { + public string Error { get; set; } = default!; + public string Code { get; set; } = Constants.DelegatedSlots.LimitReachedCode; + /// The managing (home) tenant whose slots are exhausted. + public string HomeTenantId { get; set; } = default!; + /// Its display name; absent when the config row carries none. + public string? HomeTenantDomain { get; set; } + /// Slots in use (distinct managed tenants + pending invitations + release holds). + public int Used { get; set; } + /// The effective limit (plan entitlement or the Global Admin override). + public int Limit { get; set; } + /// New slots the rejected mutation needed. + public int Required { get; set; } + } + + /// Response of GET global/delegated-slots/{tenantId} and GET delegations/slots: a managing tenant's slot usage. + public class DelegatedSlotUsageResponse : IApiResponse + { + public string HomeTenantId { get; set; } = default!; + /// Effective limit (override when set, else the plan entitlement). + public int Limit { get; set; } + /// The plan entitlement (Community 0, Pro 2). + public int CatalogLimit { get; set; } + /// The Global Admin override; absent when the catalog value applies. + public int? OverrideLimit { get; set; } + public int Used { get; set; } + /// Distinct managed tenant ids (lowercase) reachable by users homed in this tenant. + public IReadOnlyList ManagedTenantIds { get; set; } = default!; + /// Pending self-service invitations (each holds a slot until accepted, cancelled or expired). + public int PendingInvitations { get; set; } + /// Release holds: slots freed by a removal that stay occupied for 24 hours. + public IReadOnlyList Holds { get; set; } = default!; + } + + /// One release hold nested in . + public class DelegatedSlotHold + { + public string InvitationId { get; set; } = default!; + /// The managed tenant that was removed; absent when unknown. + public string? TenantId { get; set; } + public DateTime HoldUntilUtc { get; set; } + public string ReleasedBy { get; set; } = default!; + } + + /// Response of POST global/delegated-slots/{tenantId}/release-hold: how many holds ended now. + public class ReleaseDelegatedSlotHoldResponse : IApiResponse + { + public string HomeTenantId { get; set; } = default!; + public int Released { get; set; } + } + + // ---- Self-service delegation (the managing Pro tenant's own surface) ---------------- + + /// One invitation row as the managing tenant sees it. Never carries the token. + public class DelegationInvitationItem + { + public string InvitationId { get; set; } = default!; + /// Pending | Accepted | Cancelled | Released | Expired (derived: pending past its expiry). + public string Status { get; set; } = default!; + public string CreatedBy { get; set; } = default!; + public DateTime CreatedUtc { get; set; } + public DateTime ExpiresUtc { get; set; } + public DateTime? AcceptedUtc { get; set; } + public string? AcceptedBy { get; set; } + /// The managed tenant (accepted / released rows). + public string? TenantId { get; set; } + public string? TenantDomain { get; set; } + /// Released rows: while in the future the slot is still occupied. + public DateTime? HoldUntilUtc { get; set; } + } + + /// Response of GET delegations/invitations. + public class DelegationInvitationListResponse : IApiResponse + { + public string HomeTenantId { get; set; } = default!; + public IReadOnlyList Invitations { get; set; } = default!; + } + + /// Response of POST delegations/invitations: the token is shown ONCE (the link is copy-only). + public class CreateDelegationInvitationResponse : IApiResponse + { + public string InvitationId { get; set; } = default!; + public string Token { get; set; } = default!; + public DateTime ExpiresUtc { get; set; } + } + + /// Response of GET delegations/accept?token=: what accepting would do — no mutation. + public class DelegationAcceptPreviewResponse : IApiResponse + { + /// The inviting (managing) tenant. + public string HomeTenantId { get; set; } = default!; + public string? HomeTenantDomain { get; set; } + public DateTime ExpiresUtc { get; set; } + /// Pending | Accepted | Cancelled | Released | Expired. + public string Status { get; set; } = default!; + /// The caller's tenant — the one that would be managed. + public string TargetTenantId { get; set; } = default!; + public string? TargetTenantDomain { get; set; } + } + + /// Response of POST delegations/accept. + public class AcceptDelegationInvitationResponse : IApiResponse + { + public string HomeTenantId { get; set; } = default!; + public string? HomeTenantDomain { get; set; } + public string ManagedTenantId { get; set; } = default!; + } + + /// MCP organization budget of a managed tenant, nested in . + public class ManagedTenantQuotaUsage + { + public string TenantPlan { get; set; } = default!; + public int TenantDailyLimit { get; set; } + public int TenantMonthlyLimit { get; set; } + public long TenantDailyUsed { get; set; } + public long TenantMonthlyUsed { get; set; } + } + + /// One managed tenant as the managing tenant sees it. + public class ManagedTenantItem + { + public string TenantId { get; set; } = default!; + public string? Domain { get; set; } + /// self-service (joined by invitation, removable here) | operator (provisioned by platform operators). + public string Source { get; set; } = default!; + public DateTime? SinceUtc { get; set; } + public bool Removable { get; set; } + /// Absent when not resolved (cap reached or read failure). + public ManagedTenantQuotaUsage? Usage { get; set; } + } + + /// Response of GET delegations/managed. + public class ManagedTenantListResponse : IApiResponse + { + public string HomeTenantId { get; set; } = default!; + public DelegatedSlotUsageResponse Slots { get; set; } = default!; + public IReadOnlyList Tenants { get; set; } = default!; + } + + /// Response of GET delegations/assignees: the managing tenant's own users on its self-service group. + public class DelegationAssigneeListResponse : IApiResponse + { + public string HomeTenantId { get; set; } = default!; + public string GroupId { get; set; } = default!; + public IReadOnlyList Assignees { get; set; } = default!; + } + + /// Response of POST delegations/assignees. + public class DelegationAssignResponse : IApiResponse + { + public TenantGroupAssignment Assignment { get; set; } = default!; + } + + /// One party that can read the caller's tenant, nested in . + public class TenantManagerItem + { + /// The Tenant Group conferring the access; absent for direct operator grants. + public string? GroupId { get; set; } + /// The managing tenant that owns the group (self-service); absent for operator-created groups and direct grants. + public string? OwnerTenantId { get; set; } + public string? OwnerDomain { get; set; } + public string Name { get; set; } = default!; + /// self-service | operator + public string Source { get; set; } = default!; + public IReadOnlyList Assignees { get; set; } = default!; + public DateTime? SinceUtc { get; set; } + /// True when the caller (the managed tenant's admin) may end this access here. + public bool Revocable { get; set; } + } + + /// One person with access, nested in . + public class TenantManagerAssignee + { + public string Upn { get; set; } = default!; + public string Role { get; set; } = default!; + public bool IsEnabled { get; set; } + } + + /// Response of GET delegations/managers: who manages the caller's tenant. + public class TenantManagerListResponse : IApiResponse + { + public string TenantId { get; set; } = default!; + public IReadOnlyList Managers { get; set; } = default!; + } + + // ---- Requests ----------------------------------------------------------------------- + + public class AcceptDelegationInvitationRequest + { + public string Token { get; set; } = string.Empty; + } + + public class RemoveManagedTenantRequest + { + public string TenantId { get; set; } = string.Empty; + } + + public class RevokeTenantManagerRequest + { + public string HomeTenantId { get; set; } = string.Empty; + } + + public class DelegationAssignRequest + { + public string Upn { get; set; } = string.Empty; + } + + public class ReleaseDelegatedSlotHoldRequest + { + public string? InvitationId { get; set; } + public bool All { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs index b45f0316..60ea3446 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs @@ -237,5 +237,12 @@ namespace AutopilotMonitor.Shared.Models public string ResetUtc { get; set; } = default!; public string Message { get; set; } = default!; + + /// + /// The MANAGED tenant whose organization windows blocked a delegated (MSP) read — its plan governs + /// the budget, not the caller's. Absent when the caller's own tenant/plan was exceeded and on the + /// all-managed-tenants-exhausted aggregate block. + /// + public string? TargetTenantId { get; set; } } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/MetricsApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/MetricsApiModels.cs index ff47e1c1..0d1cb10d 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/MetricsApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/MetricsApiModels.cs @@ -93,7 +93,9 @@ namespace AutopilotMonitor.Shared.Models /// /// Effective quota state nested in : the caller's own windows and - /// the organization-wide windows of their tenant (shared by every member; 0 = unlimited). + /// the organization-wide windows of their tenant (shared by every member; 0 = unlimited). For a + /// delegated (MSP) caller the tenant windows are those of their HOME tenant — reads into managed + /// tenants are charged to the managed tenant per request and never appear here. /// // Declaration order == wire order. public class McpUsageQuotaNode @@ -123,6 +125,40 @@ namespace AutopilotMonitor.Shared.Models public IReadOnlyList Records { get; set; } = default!; } + /// + /// Organization-wide MCP usage by user for the caller's OWN tenant (GetMcpOrganizationUsage): every + /// account whose requests were charged to this tenant's organization budget — its own members and any + /// delegated (MSP) administrators reading the tenant. Built from the tenant's organization counters. + /// + // Declaration order == wire order. + public class GetMcpOrganizationUsageResponse : IApiResponse + { + public string TenantId { get; set; } = default!; + /// Effective range start (yyyyMMdd). + public string DateFrom { get; set; } = default!; + /// Effective range end (yyyyMMdd, inclusive). + public string DateTo { get; set; } = default!; + public IReadOnlyList Users { get; set; } = default!; + } + + /// One account's share of the organization budget, nested in . + // Declaration order == wire order. + public class McpOrganizationUsageItem + { + public string UserId { get; set; } = default!; + /// Absent for rows written before the UPN was recorded. + public string? UserPrincipalName { get; set; } + /// True when the account is a delegated (MSP) administrator homed in another tenant. + public bool Delegated { get; set; } + /// The delegated administrator's home tenant; absent for the tenant's own members. + public string? HomeTenantId { get; set; } + public long RequestsToday { get; set; } + public long RequestsThisMonth { get; set; } + public long RequestsInRange { get; set; } + /// Absent for rows written before the timestamp was recorded. + public DateTime? LastRequestAt { get; set; } + } + /// /// Global per-tenant MCP/API usage envelope (GetGlobalMcpUsage). The MCP server paginates /// over the records key — its name is wire-critical. diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs index 21dbd3b8..c9551b0a 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs @@ -726,6 +726,12 @@ namespace AutopilotMonitor.Shared.Models public IReadOnlyList Sessions { get; set; } = default!; /// Absent when there is no further page. public string? NextLink { get; set; } + /// + /// Managed tenants dropped from a delegated (MSP) MCP fleet aggregate because their organization MCP + /// budget is exhausted (each managed tenant's own plan governs it). Absent unless at least one tenant + /// was excluded; only GetAllSessions ever sets it. + /// + public string[]? QuotaExcludedTenants { get; set; } } /// @@ -749,6 +755,8 @@ namespace AutopilotMonitor.Shared.Models { public bool Success { get; set; } public SessionStats Stats { get; set; } = default!; + /// See ; only GetAllSessionStats ever sets it. + public string[]? QuotaExcludedTenants { get; set; } } /// Single-session detail envelope (GetSession). diff --git a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx index 426219d8..ed920181 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx @@ -1,6 +1,7 @@ "use client"; -import { Suspense, useState } from "react"; +import { Suspense, useState, useEffect } from "react"; +import type { DelegatedSlotUsageResponse } from "@/utils/wire-types.generated"; import { useSearchParams } from "next/navigation"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; @@ -46,6 +47,8 @@ export interface TenantConfiguration { trialExpiresUtc?: string | null; /** Whether the tenant has used its one self-service trial. */ trialConsumed?: boolean; + /** Delegated (MSP) tenant slot override; null/undefined = plan entitlement (Community 0, Pro 2). Managed via PATCH plan. */ + maxDelegatedTenantsOverride?: number | null; /** * Dual app-reg homing: null/undefined = legacy app. Typed explicitly so a payload refactor * cannot silently drop the field on the generic PUT round-trip (absent ⇒ backend resets to @@ -134,6 +137,51 @@ function TenantManagementSectionInner({ const [editingTenant, setEditingTenant] = useState(null); const [savingTenant, setSavingTenant] = useState(false); const [savingPlan, setSavingPlan] = useState(false); + // Delegated (MSP) slot usage of the tenant being edited (GET global/delegated-slots/{id}); null = not loaded. + const [slotUsage, setSlotUsage] = useState(null); + const [releasingHold, setReleasingHold] = useState(null); + // Support escape hatch: end a removed customer's 24 h slot hold early (GlobalAdminOnly, audited). + const handleReleaseHold = async (tenantId: string, invitationId: string) => { + if (!canMutate) return; + try { + setReleasingHold(invitationId); + setError(null); + const response = await authenticatedFetch(api.delegatedSlots.releaseHold(tenantId), getAccessToken, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ invitationId }), + }); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || `Failed to release the hold: ${response.statusText}`); + } + setSlotUsage((prev) => prev + ? { ...prev, holds: prev.holds.filter((h) => h.invitationId !== invitationId), used: Math.max(0, prev.used - 1) } + : prev); + setSuccessMessage("Slot hold released — the managing tenant can invite again now."); + setTimeout(() => setSuccessMessage(null), 4000); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to release the hold"); + } finally { + setReleasingHold(null); + } + }; + const editingTenantId = editingTenant?.tenantId ?? null; + useEffect(() => { + if (!editingTenantId) return; + let cancelled = false; + authenticatedFetch(api.delegatedSlots.get(editingTenantId), getAccessToken) + .then(async (res) => { + if (cancelled) return; + setSlotUsage(res.ok ? ((await res.json()) as DelegatedSlotUsageResponse) : null); + }) + .catch(() => { + if (!cancelled) setSlotUsage(null); + }); + return () => { + cancelled = true; + }; + }, [editingTenantId, getAccessToken]); const [homingDialogTarget, setHomingDialogTarget] = useState<"primary" | "legacy" | null>(null); const [savingHoming, setSavingHoming] = useState(false); // Failures of the homing/offboard calls go into these instead of the page-level @@ -236,6 +284,7 @@ function TenantManagementSectionInner({ // "enterprise" normalizes to "pro" on save, legacy "free" to "community". planTier: tenant.planTier === "pro" || tenant.planTier === "enterprise" ? "pro" : "community", trialExpiresUtc: tenant.trialExpiresUtc ?? null, + maxDelegatedTenants: tenant.maxDelegatedTenantsOverride ?? null, }), }); @@ -250,7 +299,9 @@ function TenantManagementSectionInner({ planTier: result.planTier, trialExpiresUtc: result.trialExpiresUtc ?? null, trialConsumed: result.trialConsumed ?? t.trialConsumed, + maxDelegatedTenantsOverride: result.maxDelegatedTenantsOverride ?? null, }); + setSlotUsage((prev) => (prev ? { ...prev, limit: result.maxDelegatedTenants ?? prev.limit, overrideLimit: result.maxDelegatedTenantsOverride ?? undefined } : prev)); setTenants(prev => prev.map(t => (t.tenantId === tenant.tenantId ? apply(t) : t))); setEditingTenant(prev => (prev && prev.tenantId === tenant.tenantId ? apply(prev) : prev)); setSuccessMessage(`Plan saved — effective edition: ${result.effectiveEdition}`); @@ -832,6 +883,62 @@ function TenantManagementSectionInner({ Set a date to grant/extend a Pro trial; clear to end it. Saving does not reset trial consumption.

+
+ +
+ setEditingTenant({ + ...editingTenant, + maxDelegatedTenantsOverride: e.target.value === "" ? null : Math.max(0, Math.floor(Number(e.target.value))), + })} + placeholder={slotUsage ? `plan: ${slotUsage.catalogLimit}` : "plan default"} + className="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + /> + {editingTenant.maxDelegatedTenantsOverride != null && ( + + )} + {slotUsage && ( + + Slots: {slotUsage.used} of {slotUsage.limit} in use + {slotUsage.pendingInvitations > 0 && ` (${slotUsage.pendingInvitations} pending)`} + {slotUsage.holds.length > 0 && ` (${slotUsage.holds.length} on hold)`} + + )} +
+ {slotUsage && slotUsage.holds.length > 0 && ( +
    + {slotUsage.holds.map((h) => ( +
  • + {h.tenantId ?? "unknown tenant"} + held until {new Date(h.holdUntilUtc).toLocaleString()} + by {h.releasedBy} + +
  • + ))} +
+ )} +

+ How many distinct customer tenants this (MSP) tenant’s users may manage. Blank = plan entitlement + (Community 0, Pro 2); a value applies regardless of plan, using delegation still requires Pro. +

+
{editingTenant.trialConsumed diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionDelegatedAdmins.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionDelegatedAdmins.tsx index ba4e635e..8d5143e2 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionDelegatedAdmins.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionDelegatedAdmins.tsx @@ -7,6 +7,8 @@ import { useTenantList } from "@/hooks/useTenantList"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { api } from "@/lib/api"; import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding"; +import { parseSlotLimitError, type SlotLimitError } from "@/lib/delegatedSlots"; +import { DelegatedSlotPrompt, raiseDelegatedSlotLimit } from "@/components/DelegatedSlotPrompt"; import { SectionCardHeader } from "@/components/SectionCardHeader"; /** One delegated-admin assignment as returned by /api/global/delegated-admins (camelCase JSON). */ @@ -51,6 +53,10 @@ export function SectionDelegatedAdmins() { const [homeTenantPick, setHomeTenantPick] = useState(""); const [granting, setGranting] = useState(false); const [busyKey, setBusyKey] = useState(null); + // 409 DelegatedSlotLimitReached: the home tenant's slots are full — offer to raise the limit and retry + // the (still filled-in) grant form. + const [slotPrompt, setSlotPrompt] = useState(null); + const [raisingSlots, setRaisingSlots] = useState(false); const domainOf = useMemo(() => { const map = new Map(tenants.map((t) => [t.tenantId.toLowerCase(), t.domainName])); @@ -113,8 +119,15 @@ export function SectionDelegatedAdmins() { if (response.status === 422 && data.code === HOME_TENANT_UNRESOLVED) { setNeedHomeTenant(true); } + const slot = parseSlotLimitError(response.status, data); + if (slot) { + // Keep the form as-is; the prompt's "raise & retry" re-runs this grant. + setSlotPrompt(slot); + return; + } throw new Error(data.error || `Failed to grant: ${response.statusText}`); } + setSlotPrompt(null); const dom = domainOf(newTenantId); flash(`Granted ${upn} ${ROLE_LABELS[newRole] ?? newRole} on ${dom || newTenantId}.`); setNewUpn(""); @@ -131,6 +144,20 @@ export function SectionDelegatedAdmins() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [newUpn, newTenantId, newRole, needHomeTenant, homeTenantPick, getAccessToken, fetchAssignments, domainOf]); + const handleRaiseSlots = useCallback(async (newLimit: number) => { + if (!slotPrompt) return; + setRaisingSlots(true); + setError(null); + const failure = await raiseDelegatedSlotLimit(getAccessToken, slotPrompt.homeTenantId, newLimit); + setRaisingSlots(false); + if (failure) { + setError(failure); + return; + } + setSlotPrompt(null); + await handleGrant(); + }, [slotPrompt, getAccessToken, handleGrant]); + const handleToggle = useCallback(async (a: DelegatedAssignment) => { const key = `${a.upn}|${a.tenantId}`; try { @@ -217,6 +244,14 @@ export function SectionDelegatedAdmins() { {error}
)} + {slotPrompt && ( + setSlotPrompt(null)} + /> + )} {/* Grant form */}
diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionTenantGroups.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionTenantGroups.tsx index 4508c6d3..8fa605bd 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionTenantGroups.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionTenantGroups.tsx @@ -8,23 +8,13 @@ import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch" import { api } from "@/lib/api"; import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding"; import { SectionCardHeader } from "@/components/SectionCardHeader"; +import type { TenantGroup } from "@/utils/wire-types.generated"; +import { parseSlotLimitError, type SlotLimitError } from "@/lib/delegatedSlots"; +import { DelegatedSlotPrompt, raiseDelegatedSlotLimit } from "@/components/DelegatedSlotPrompt"; -/** One UPN assigned to a group (camelCase JSON from the backend). */ -interface GroupAssignee { - upn: string; - role: string; // "DelegatedReader" | "DelegatedAdmin" - isEnabled: boolean; -} - -/** A tenant group as returned by /api/global/tenant-groups. */ -interface TenantGroup { - groupId: string; - name: string; - createdBy: string; - createdAt: string; - tenantIds: string[]; - assigneeCount: number; - assignees: GroupAssignee[]; +/** A mutation held back by the slot limit, with everything needed to replay it after the limit is raised. */ +interface SlotPromptState extends SlotLimitError { + retry: { key: string; url: string; method: string; body: unknown; ok: string }; } const ROLE_LABELS: Record = { @@ -51,6 +41,9 @@ export function SectionTenantGroups() { const [newName, setNewName] = useState(""); const [creating, setCreating] = useState(false); const [busyKey, setBusyKey] = useState(null); + // 409 DelegatedSlotLimitReached on add-tenant / assign: offer to raise the managing tenant's limit and replay. + const [slotPrompt, setSlotPrompt] = useState(null); + const [raisingSlots, setRaisingSlots] = useState(false); // Per-group input state (keyed by groupId). const [tenantToAdd, setTenantToAdd] = useState>({}); @@ -118,8 +111,14 @@ export function SectionTenantGroups() { if (response.status === 422 && data.code === HOME_TENANT_UNRESOLVED && key.startsWith("assign:")) { setNeedHomeTenant((p) => ({ ...p, [key.slice("assign:".length)]: true })); } + const slot = parseSlotLimitError(response.status, data); + if (slot) { + setSlotPrompt({ ...slot, retry: { key, url, method, body, ok } }); + return false; + } throw new Error(data.error || `Request failed: ${response.statusText}`); } + setSlotPrompt(null); flash(ok); await fetchGroups(); return true; @@ -134,6 +133,21 @@ export function SectionTenantGroups() { [getAccessToken, fetchGroups], ); + const handleRaiseSlots = useCallback(async (newLimit: number) => { + if (!slotPrompt) return; + setRaisingSlots(true); + setError(null); + const failure = await raiseDelegatedSlotLimit(getAccessToken, slotPrompt.homeTenantId, newLimit); + setRaisingSlots(false); + if (failure) { + setError(failure); + return; + } + const { key, url, method, body, ok } = slotPrompt.retry; + setSlotPrompt(null); + await mutate(key, url, method, body, ok); + }, [slotPrompt, getAccessToken, mutate]); + const handleCreate = useCallback(async () => { const name = newName.trim(); if (!name) return; @@ -147,7 +161,28 @@ export function SectionTenantGroups() { async (t: TenantGroup) => { const name = prompt("Rename group:", t.name)?.trim(); if (!name || name === t.name) return; - await mutate(`rename:${t.groupId}`, api.tenantGroups.rename(t.groupId), "PATCH", { name }, `Renamed to "${name}".`); + await mutate(`rename:${t.groupId}`, api.tenantGroups.update(t.groupId), "PATCH", { name }, `Renamed to "${name}".`); + }, + [mutate], + ); + + /** + * Operator flag: MCP reads an assignee makes into this group's tenants are charged to the assignee's HOME + * tenant's quota instead of the managed tenant's — for our own managed-service group, whose customers must + * never pay for (or be blocked by) our analysis. Audited under every tenant in the group. + */ + const handleToggleChargeMode = useCallback( + async (t: TenantGroup) => { + const next = !t.chargeHomeTenantQuota; + await mutate( + `charge:${t.groupId}`, + api.tenantGroups.update(t.groupId), + "PATCH", + { chargeHomeTenantQuota: next }, + next + ? `"${t.name}": MCP reads are now charged to the assignee's home tenant.` + : `"${t.name}": MCP reads are now charged to the managed tenant.`, + ); }, [mutate], ); @@ -271,6 +306,14 @@ export function SectionTenantGroups() { {error}
)} + {slotPrompt && ( + setSlotPrompt(null)} + /> + )} {/* Create form */}
@@ -339,23 +382,58 @@ export function SectionTenantGroups() { {t.tenantIds.length} tenant{t.tenantIds.length === 1 ? "" : "s"} · {t.assigneeCount} assignee {t.assigneeCount === 1 ? "" : "s"} + {t.chargeHomeTenantQuota && ( + + Quota: home tenant + + )} + {t.ownerTenantId && ( + + Owned by {domainOf(t.ownerTenantId) || t.ownerTenantId} + + )}
- - + {!t.ownerTenantId && ( + <> + + + + )}
+ {/* Quota attribution (operator-managed groups) */} + +
{/* Tenants column */}
@@ -488,7 +566,9 @@ export function SectionTenantGroups() {

Reader is read-only (secrets redacted). Only onboarded tenants appear in the dropdown. Changes take - effect on the assignee’s next request. + effect on the assignee’s next request. By default an assignee’s MCP reads into a managed tenant + draw on that tenant’s own MCP budget (its plan governs it); the per-group checkbox re-attributes them + to the assignee’s home tenant.

diff --git a/src/Web/autopilot-monitor-web/app/delegations/accept/page.tsx b/src/Web/autopilot-monitor-web/app/delegations/accept/page.tsx new file mode 100644 index 00000000..ff34fc29 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/delegations/accept/page.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { Suspense, useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { useAuth } from "@/contexts/AuthContext"; +import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { api } from "@/lib/api"; +import { describeDelegationError, invitationStatusLabel } from "@/lib/delegations"; +import type { AcceptDelegationInvitationResponse, DelegationAcceptPreviewResponse } from "@/utils/wire-types.generated"; + +export default function AcceptDelegationPage() { + // useSearchParams needs a Suspense boundary for the static prerender (query-string route). + return ( + + + + ); +} + +function AcceptDelegationInner() { + const searchParams = useSearchParams(); + const token = searchParams?.get("token") ?? ""; + const { getAccessToken, user } = useAuth(); + + const [preview, setPreview] = useState(null); + const [loadingState, setLoadingState] = useState(true); + // No token ⇒ nothing to load; derived, so the effect never sets state synchronously. + const loading = loadingState && !!token; + const [error, setError] = useState(null); + const [confirming, setConfirming] = useState(false); + const [accepting, setAccepting] = useState(false); + const [done, setDone] = useState(null); + + const explain = useCallback(async (response: Response, fallback: string) => { + const data = await response.json().catch(() => ({})); + if (response.status === 403) { + return "Only a tenant administrator of your tenant can accept an invitation. Ask an administrator to open this link."; + } + return describeDelegationError(data.code, data.error || fallback); + }, []); + + useEffect(() => { + if (!token) return; + let cancelled = false; + const run = async () => { + try { + const response = await authenticatedFetch(api.delegations.acceptPreview(token), getAccessToken); + if (cancelled) return; + if (!response.ok) { + setError(await explain(response, `Could not read the invitation (${response.status}).`)); + return; + } + setPreview((await response.json()) as DelegationAcceptPreviewResponse); + } catch (err) { + if (cancelled) return; + setError(err instanceof TokenExpiredError ? "Session expired. Please refresh the page." : err instanceof Error ? err.message : "Could not read the invitation."); + } finally { + if (!cancelled) setLoadingState(false); + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [token, getAccessToken, explain]); + + const accept = useCallback(async () => { + setAccepting(true); + setError(null); + try { + const response = await authenticatedFetch(api.delegations.accept(), getAccessToken, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + if (!response.ok) { + setError(await explain(response, `Could not accept the invitation (${response.status}).`)); + return; + } + setDone((await response.json()) as AcceptDelegationInvitationResponse); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not accept the invitation."); + } finally { + setAccepting(false); + setConfirming(false); + } + }, [token, getAccessToken, explain]); + + const homeLabel = preview?.homeTenantDomain || preview?.homeTenantId || "the inviting organization"; + const targetLabel = preview?.targetTenantDomain || preview?.targetTenantId || user?.tenantId || "your tenant"; + const acceptable = preview?.status === "Pending"; + + return ( +
+
+

Delegated access invitation

+ + {loading &&

Checking the invitation…

} + + {!token && ( +
This link carries no invitation.
+ )} + {error && ( +
{error}
+ )} + + {done ? ( +
+
+ {done.homeTenantDomain || done.homeTenantId} now has read-only access to your tenant. + The grant is recorded in your audit log; you can end it at any time. +
+ + Review who can read your tenant + +
+ ) : preview && !error ? ( +
+

+ {homeLabel} asks for read-only access to{" "} + {targetLabel} through Autopilot Monitor’s delegated (MSP) administration. +

+
    +
  • Their assigned users can view your enrollment sessions, events and analytics — no configuration changes, no device actions, configuration secrets redacted.
  • +
  • Their AI (MCP) requests into your tenant count against your organization’s MCP budget and are listed on your MCP Usage page.
  • +
  • Every grant and revoke is written to your audit log. You can revoke the access at any time under Settings → Tenant → Delegated Access.
  • +
+

+ Invitation status: {invitationStatusLabel(preview.status)} + {preview.status === "Pending" && ` · valid until ${new Date(preview.expiresUtc).toLocaleString()}`} +

+ {acceptable && !confirming && ( + + )} + {acceptable && confirming && ( +
+ Confirm the delegation? + + +
+ )} + {!acceptable && ( +

{describeDelegationError(`Invitation${preview.status}`, "This invitation can no longer be accepted. Ask the managing organization for a new link.")}

+ )} +
+ ) : null} + + {!loading && !done && ( + + Back to the portal + + )} +
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/delegations/layout.tsx b/src/Web/autopilot-monitor-web/app/delegations/layout.tsx new file mode 100644 index 00000000..ef98d7ba --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/delegations/layout.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { ProtectedRoute } from "@/components/ProtectedRoute"; + +/** + * Self-service delegation pages (invitation accept). Sign-in only: the accept endpoint itself requires + * the caller to be a tenant administrator of their own tenant (TenantAdminOrGA), and the page explains a + * 403 instead of hiding the link behind a client-side role gate. + */ +export default function DelegationsLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/src/Web/autopilot-monitor-web/app/privacy/page.tsx b/src/Web/autopilot-monitor-web/app/privacy/page.tsx index 70a28c1e..186912ea 100644 --- a/src/Web/autopilot-monitor-web/app/privacy/page.tsx +++ b/src/Web/autopilot-monitor-web/app/privacy/page.tsx @@ -132,7 +132,7 @@ export default function PrivacyPage() {
  • Authenticated users in your own tenant, according to their role — Admin, Operator, Viewer, or a role-less Member limited to the Progress Portal
  • Platform operators — Global Admin for operations and support, and Global Reader for read-only support with configuration secrets redacted
  • -
  • Delegated (MSP) administrators you or your provider have been granted — read-only, limited to exactly the tenants in scope, with configuration secrets redacted, and with every grant and revocation written to your tenant's audit log so you can always see who was given access to your data
  • +
  • Delegated (MSP) administrators you or your provider have been granted — read-only, limited to exactly the tenants in scope, with configuration secrets redacted, with every grant and revocation written to your tenant's audit log so you can always see who was given access to your data, and with their AI (MCP) requests into your tenant counted against your organization's MCP budget and listed, marked as delegated, on your MCP Usage page

Delegated administration is a Pro capability and is off unless explicitly granted. Full detail on the diff --git a/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts b/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts index 4d1eed95..851de22a 100644 --- a/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts +++ b/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts @@ -61,7 +61,7 @@ const SERVER_DENIED_FIELDS = new Set([ "lastUpdated", "updatedBy", "onboardedAt", "onboardedBy", "homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince", "planTier", "trialExpiresUtc", "trialStartedUtc", "trialConsumed", "trialGrantedBy", - "proDowngradedUtc", + "proDowngradedUtc", "maxDelegatedTenantsOverride", ]); describe("SECTION_FIELD_MAP parity", () => { diff --git a/src/Web/autopilot-monitor-web/app/settings/reporting/sections/SectionMcpUsage.tsx b/src/Web/autopilot-monitor-web/app/settings/reporting/sections/SectionMcpUsage.tsx index aecd3a3d..4bd10d87 100644 --- a/src/Web/autopilot-monitor-web/app/settings/reporting/sections/SectionMcpUsage.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/reporting/sections/SectionMcpUsage.tsx @@ -7,6 +7,7 @@ import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch" import { api } from "@/lib/api"; import { DocsLink } from "@/components/DocsLink"; import { DOCS_PATHS } from "@/lib/docsPaths"; +import type { McpOrganizationUsageItem, McpUsageQuotaNode } from "@/utils/wire-types.generated"; interface UsageRecord { userId: string; @@ -26,16 +27,12 @@ interface DailyAggregate { // GetMyMcpUsageResponse.quota — the caller's own windows plus the organization-wide windows every // member shares. 0 = unlimited; -1 used = counters temporarily unavailable. -interface QuotaState { - dailyLimit: number; - monthlyLimit: number; - dailyUsed: number; - monthlyUsed: number; - tenantPlan: string; - tenantDailyLimit: number; - tenantMonthlyLimit: number; - tenantDailyUsed: number; - tenantMonthlyUsed: number; +type QuotaState = McpUsageQuotaNode; + +function formatLastRequest(iso: string | undefined): string { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString(); } function QuotaBar({ label, used, limit }: { label: string; used: number; limit: number }) { @@ -79,8 +76,11 @@ function getDateTo(): string { } export function SectionMcpUsage() { - const { getAccessToken } = useAuth(); + const { getAccessToken, user } = useAuth(); + const canSeeOrganization = !!(user?.isTenantAdmin || user?.isGlobalAdmin); const [records, setRecords] = useState([]); + // Organization budget by account — tenant admins only; null = not loaded / not permitted. + const [orgUsers, setOrgUsers] = useState(null); const [usagePlan, setUsagePlan] = useState(null); const [effectivePlan, setEffectivePlan] = useState(null); const [quota, setQuota] = useState(null); @@ -106,6 +106,18 @@ export function SectionMcpUsage() { setEffectivePlan(data.effectivePlan || null); setQuota(data.quota ?? null); setUpn(data.upn || ""); + + if (canSeeOrganization) { + // Every account charged to this tenant's organization budget — including delegated (MSP) + // administrators reading the tenant. A 403 (role changed mid-session) just hides the card. + const orgRes = await authenticatedFetch(api.mcpUsage.organization(dateFrom, dateTo), getAccessToken); + if (orgRes.ok) { + const org = await orgRes.json(); + setOrgUsers(org.users ?? []); + } else { + setOrgUsers(null); + } + } } catch (err) { if (err instanceof TokenExpiredError) { setError("Session expired. Please refresh the page."); @@ -115,7 +127,7 @@ export function SectionMcpUsage() { } finally { setLoading(false); } - }, [getAccessToken]); + }, [getAccessToken, canSeeOrganization]); useEffect(() => { const run = async () => { @@ -124,6 +136,8 @@ export function SectionMcpUsage() { void run(); }, [fetchUsage, dateRange]); + const delegatedReaders = orgUsers?.filter((u) => u.delegated).length ?? 0; + // Aggregate records by date const dailyAggregates: DailyAggregate[] = (() => { const byDate = new Map }>(); @@ -208,13 +222,73 @@ export function SectionMcpUsage() {

- Shared by every account in your tenant. A personal plan override widens only the account's own - windows, never these. + Shared by every account in your tenant and by delegated (MSP) administrators reading it. A personal + plan override widens only the account's own windows, never these.

+ {user?.isDelegated && ( +

+ Your reads into tenants you manage are charged to that tenant's own plan, not to these windows. +

+ )} )} + {/* Organization usage by account (tenant admins) */} + {orgUsers && ( +
+
+

Organization usage by account

+ + {orgUsers.length} account{orgUsers.length === 1 ? "" : "s"} + {delegatedReaders > 0 && ` · ${delegatedReaders} delegated`} + +
+ {orgUsers.length === 0 ? ( +

No requests have been charged to your organization budget yet.

+ ) : ( +
+ + + + + + + + + + + + {orgUsers.map((u) => ( + + + + + + + + ))} + +
AccountTodayThis monthRange ({dateRange})Last request
+ {u.userPrincipalName || u.userId} + {u.delegated && ( + + Delegated (MSP) + + )} + {u.requestsToday.toLocaleString()}{u.requestsThisMonth.toLocaleString()}{u.requestsInRange.toLocaleString()}{formatLastRequest(u.lastRequestAt)}
+
+ )} +

+ Every request counted against the organization windows above, by the account that made it. Delegated + (MSP) administrators reading your tenant appear here too — their reads draw on your plan. +

+
+ )} + {/* Summary Cards */}
diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/[section]/SectionClient.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/[section]/SectionClient.tsx index 789a790f..370ce5a3 100644 --- a/src/Web/autopilot-monitor-web/app/settings/tenant/[section]/SectionClient.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/[section]/SectionClient.tsx @@ -12,6 +12,7 @@ import { SectionSlaTargets } from "../sections/SectionSlaTargets"; import { SectionSubmitLogs } from "../sections/SectionSubmitLogs"; import { SectionContact } from "../sections/SectionContact"; import { SectionOptionalGraphCapabilities } from "../sections/SectionOptionalGraphCapabilities"; +import { SectionDelegatedAccess } from "../sections/SectionDelegatedAccess"; const SECTION_COMPONENTS: Record = { "plan": SectionPlan, @@ -20,6 +21,7 @@ const SECTION_COMPONENTS: Record = { "notifications": SectionNotifications, "sla-targets": SectionSlaTargets, "access-management": SectionAccessManagement, + "delegations": SectionDelegatedAccess, "bootstrap-sessions": SectionBootstrapSessions, "graph-permissions": SectionOptionalGraphCapabilities, "contact": SectionContact, diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionDelegatedAccess.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionDelegatedAccess.tsx new file mode 100644 index 00000000..ebd26ea5 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionDelegatedAccess.tsx @@ -0,0 +1,474 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useAuth } from "@/contexts/AuthContext"; +import { useTenantConfig } from "../../TenantConfigContext"; +import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { api } from "@/lib/api"; +import { SectionCardHeader } from "@/components/SectionCardHeader"; +import { DOCS_PATHS } from "@/lib/docsPaths"; +import { buildInviteLink, describeDelegationError, holdRemainingLabel, invitationStatusLabel } from "@/lib/delegations"; +import type { + DelegationAssigneeListResponse, + DelegationInvitationListResponse, + ManagedTenantListResponse, + TenantManagerListResponse, +} from "@/utils/wire-types.generated"; + +type Confirm = + | { kind: "revoke"; homeTenantId: string; label: string } + | { kind: "remove"; tenantId: string; label: string } + | { kind: "unassign"; upn: string } + | { kind: "cancel"; invitationId: string } + | null; + +function formatDay(iso: string | undefined | null): string { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString(); +} + +/** + * Settings → Tenant → Delegated Access. Two cards: (1) who can read THIS tenant through a delegation (every + * tenant, with a customer-side revoke for self-service delegations), (2) for a Pro tenant, the tenants it + * manages as an MSP: slots, managed tenants with their MCP budget, invitation links (single-use, copy-only), + * and which of its own users hold the delegated access. Every mutation is audited under both tenants by + * the backend; removing a customer holds its slot for 24 hours. + */ +export function SectionDelegatedAccess() { + const { getAccessToken, user } = useAuth(); + const { canEditConfig, editionInfo, admins } = useTenantConfig(); + const canManage = editionInfo.entitlements.delegatedAdminAllowed; + + const [managers, setManagers] = useState(null); + const [managed, setManaged] = useState(null); + const [invitations, setInvitations] = useState(null); + const [assignees, setAssignees] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [flash, setFlash] = useState(null); + const [busy, setBusy] = useState(null); + const [confirm, setConfirm] = useState(null); + const [assignUpn, setAssignUpn] = useState(""); + // The invitation token is shown ONCE (the backend never returns it again). + const [createdLink, setCreatedLink] = useState(null); + const [copied, setCopied] = useState(false); + + const notify = (msg: string) => { + setFlash(msg); + setTimeout(() => setFlash(null), 4000); + }; + + const handleError = useCallback((err: unknown, fallback: string) => { + setError(err instanceof TokenExpiredError ? "Session expired. Please refresh the page." : err instanceof Error ? err.message : fallback); + }, []); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const mgr = await authenticatedFetch(api.delegations.managers(), getAccessToken); + if (!mgr.ok) throw new Error(`Failed to load delegated access: ${mgr.status}`); + setManagers((await mgr.json()) as TenantManagerListResponse); + + if (canManage) { + const [m, i, a] = await Promise.all([ + authenticatedFetch(api.delegations.managed(), getAccessToken), + authenticatedFetch(api.delegations.invitations(), getAccessToken), + authenticatedFetch(api.delegations.assignees(), getAccessToken), + ]); + setManaged(m.ok ? ((await m.json()) as ManagedTenantListResponse) : null); + setInvitations(i.ok ? ((await i.json()) as DelegationInvitationListResponse) : null); + setAssignees(a.ok ? ((await a.json()) as DelegationAssigneeListResponse) : null); + } + } catch (err) { + handleError(err, "Failed to load delegated access"); + } finally { + setLoading(false); + } + }, [getAccessToken, canManage, handleError]); + + useEffect(() => { + if (!canEditConfig) return; + const run = async () => { + await load(); + }; + void run(); + }, [load, canEditConfig]); + + /** Runs a mutation; a structured error body ({ error, code }) is explained via describeDelegationError. */ + const mutate = useCallback( + async (key: string, url: string, method: string, body: unknown, ok: string): Promise => { + setBusy(key); + setError(null); + try { + 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(describeDelegationError(data.code, data.error || `Request failed: ${response.statusText}`)); + } + notify(ok); + setConfirm(null); + await load(); + return response; + } catch (err) { + handleError(err, "Request failed"); + return null; + } finally { + setBusy(null); + } + }, + [getAccessToken, load, handleError], + ); + + const createInvitation = useCallback(async () => { + setBusy("invite"); + setError(null); + setCreatedLink(null); + setCopied(false); + try { + const response = await authenticatedFetch(api.delegations.invitations(), getAccessToken, { method: "POST" }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(describeDelegationError(data.code, data.error || `Request failed: ${response.statusText}`)); + setCreatedLink(buildInviteLink(window.location.origin, data.token)); + notify("Invitation created — copy the link now; it is shown only once."); + await load(); + } catch (err) { + handleError(err, "Failed to create the invitation"); + } finally { + setBusy(null); + } + }, [getAccessToken, load, handleError]); + + const copyLink = useCallback(async () => { + if (!createdLink) return; + try { + await navigator.clipboard.writeText(createdLink); + setCopied(true); + } catch { + setCopied(false); + } + }, [createdLink]); + + const assignableUpns = useMemo(() => { + const assigned = new Set((assignees?.assignees ?? []).map((a) => a.upn.toLowerCase())); + return admins.filter((a) => !assigned.has(a.upn.toLowerCase())).map((a) => a.upn); + }, [admins, assignees]); + + if (!canEditConfig) { + return ( +
+ This page is available to tenant administrators only. +
+ ); + } + + const slots = managed?.slots; + const now = new Date(); + + return ( +
+ {flash && ( +
{flash}
+ )} + {error && ( +
{error}
+ )} + + {/* Card 1 — who manages my tenant */} +
+ +
+ {loading && !managers ? ( +

Loading…

+ ) : !managers || managers.managers.length === 0 ? ( +

No organization has delegated access to your tenant.

+ ) : ( +
    + {managers.managers.map((m) => { + const key = m.groupId ?? "operators"; + const isRevoke = confirm?.kind === "revoke" && confirm.homeTenantId === m.ownerTenantId; + return ( +
  • + {m.ownerDomain || m.name} + + {m.source === "self-service" ? "Delegated by you" : "Platform operators"} + + + {m.assignees.length} reader{m.assignees.length === 1 ? "" : "s"} + {m.sinceUtc && ` · since ${formatDay(m.sinceUtc)}`} + + + {m.revocable && m.ownerTenantId && !isRevoke && ( + + )} + {m.revocable && isRevoke && ( + <> + End {confirm.label}’s access now? + + + + )} + {!m.revocable && managed by support} + + {m.assignees.length > 0 && ( + {m.assignees.map((a) => a.upn).join(", ")} + )} +
  • + ); + })} +
+ )} +
+
+ + {/* Card 2 — tenants you manage (Pro) */} +
+ + {slots.used} of {slots.limit} slot{slots.limit === 1 ? "" : "s"} in use + + )} + /> +
+ {!canManage ? ( +

+ Managing other tenants is a Pro capability.{" "} + See the Plan page{" "} + to start a trial or upgrade. +

+ ) : loading && !managed ? ( +

Loading…

+ ) : ( + <> + {slots && ( +

+ {slots.managedTenantIds.length} managed + {slots.pendingInvitations > 0 && ` · ${slots.pendingInvitations} pending invitation${slots.pendingInvitations === 1 ? "" : "s"}`} + {slots.holds.length > 0 && ` · ${slots.holds.length} slot${slots.holds.length === 1 ? "" : "s"} held after a removal`} + {slots.used >= slots.limit && " · no free slot — remove a tenant or ask for a larger package"} +

+ )} + + {/* Managed tenants */} +
+

Managed tenants

+ {!managed || managed.tenants.length === 0 ? ( +

No managed tenants yet — create an invitation below.

+ ) : ( +
    + {managed.tenants.map((t) => { + const isRemove = confirm?.kind === "remove" && confirm.tenantId === t.tenantId; + return ( +
  • + {t.domain || t.tenantId} + {t.source === "operator" && ( + provisioned by operators + )} + {t.usage && ( + + MCP {t.usage.tenantDailyUsed}/{t.usage.tenantDailyLimit || "∞"} today · {t.usage.tenantMonthlyUsed}/{t.usage.tenantMonthlyLimit || "∞"} month + + )} + {t.sinceUtc && since {formatDay(t.sinceUtc)}} + + {t.removable && !isRemove && ( + + )} + {t.removable && isRemove && ( + <> + Remove {confirm.label}? Its slot stays held for 24 h. + + + + )} + +
  • + ); + })} +
+ )} +
+ + {/* Invitations */} +
+
+

Invitations

+ +
+ {createdLink && ( +
+

+ Send this link to an administrator of the tenant you want to manage. It works once and expires in 7 days — it is not shown again. +

+
+ e.currentTarget.select()} className="flex-1 min-w-0 px-3 py-1.5 border border-purple-200 rounded-lg text-xs font-mono text-gray-900 bg-white" /> + +
+
+ )} + {invitations && invitations.invitations.length > 0 && ( +
    + {invitations.invitations.map((inv) => { + const isCancel = confirm?.kind === "cancel" && confirm.invitationId === inv.invitationId; + const hold = inv.status === "Released" ? holdRemainingLabel(inv.holdUntilUtc, now) : ""; + return ( +
  • + + {invitationStatusLabel(inv.status)} + + {inv.tenantDomain || inv.tenantId || "not yet accepted"} + + created {formatDay(inv.createdUtc)} by {inv.createdBy} + {inv.status === "Pending" && ` · expires ${formatDay(inv.expiresUtc)}`} + {inv.acceptedUtc && ` · accepted ${formatDay(inv.acceptedUtc)}`} + {hold && ` · ${hold}`} + + {inv.status === "Pending" && ( + + {!isCancel ? ( + + ) : ( + <> + Cancel this invitation? + + + + )} + + )} +
  • + ); + })} +
+ )} +
+ + {/* Assignees */} +
+

Your users with access

+

+ Members of your tenant (Access Management) who may read every managed tenant — read-only, configuration secrets redacted. +

+ {assignees && assignees.assignees.length > 0 && ( +
    + {assignees.assignees.map((a) => { + const isUnassign = confirm?.kind === "unassign" && confirm.upn === a.upn; + return ( +
  • + {a.upn} + {!a.isEnabled && (disabled)} + + {!isUnassign ? ( + + ) : ( + <> + Remove {a.upn}’s access? + + + + )} + +
  • + ); + })} +
+ )} +
+ + +
+ {user?.upn && assignableUpns.length === 0 && (assignees?.assignees.length ?? 0) === 0 && ( +

Add members under Access Management first.

+ )} +
+ + )} +
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/tenantNavSections.ts b/src/Web/autopilot-monitor-web/app/settings/tenant/tenantNavSections.ts index 64337fae..8757abe6 100644 --- a/src/Web/autopilot-monitor-web/app/settings/tenant/tenantNavSections.ts +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/tenantNavSections.ts @@ -1,5 +1,6 @@ export const TENANT_NAV_SECTIONS = [ { id: "access-management", label: "Access Management", description: "Manage tenant admins and operators" }, + { id: "delegations", label: "Delegated Access", description: "Who can read your tenant, and the tenants you manage (MSP)" }, { id: "autopilot", label: "Autopilot Validation", description: "Autopilot device and corporate identifier validation" }, { id: "hardware-whitelist", label: "Hardware Whitelist", description: "Manufacturer and model whitelist configuration" }, { id: "notifications", label: "Notifications", description: "Webhook notification configuration" }, diff --git a/src/Web/autopilot-monitor-web/app/settings/types.ts b/src/Web/autopilot-monitor-web/app/settings/types.ts index cdf79688..f369f6c0 100644 --- a/src/Web/autopilot-monitor-web/app/settings/types.ts +++ b/src/Web/autopilot-monitor-web/app/settings/types.ts @@ -138,6 +138,8 @@ export interface TenantConfiguration { planTier?: string; trialExpiresUtc?: string | null; trialConsumed?: boolean; + /** Delegated (MSP) tenant slot override; null = plan entitlement. GA-only, via PATCH plan. */ + maxDelegatedTenantsOverride?: number | null; } // Wire type is generated from the backend DTO ("role" is absent for legacy pre-role rows). diff --git a/src/Web/autopilot-monitor-web/app/terms/page.tsx b/src/Web/autopilot-monitor-web/app/terms/page.tsx index 341ac89f..0c020f09 100644 --- a/src/Web/autopilot-monitor-web/app/terms/page.tsx +++ b/src/Web/autopilot-monitor-web/app/terms/page.tsx @@ -116,10 +116,10 @@ export default function TermsPage() {

  • Delegated access is read-only and limited to exactly the tenants in scope. Configuration secrets are redacted.
  • -
  • A grant is either assigned by platform operators or delegated by the customer's own tenant admin; customer-initiated delegations require approval before they take effect.
  • +
  • A grant is either assigned by platform operators or accepted by the customer's own tenant administrator through a single-use invitation link from the managing organization; nothing is granted until the customer accepts, and the number of tenants a managing organization may manage is limited by its plan.
  • Every grant, revocation, and disablement is recorded in the managed customer tenant's audit log, so a customer can always determine who holds access to their data.
  • A managing organization is responsible for having the necessary data protection agreements in place with the customers it manages.
  • -
  • Access can be revoked at any time by the customer or by platform operators and takes effect within seconds.
  • +
  • Access can be revoked at any time by the customer, by the managing organization, or by platform operators and takes effect within seconds; a managing organization's slot for a removed customer is released after 24 hours.
diff --git a/src/Web/autopilot-monitor-web/components/DelegatedSlotPrompt.tsx b/src/Web/autopilot-monitor-web/components/DelegatedSlotPrompt.tsx new file mode 100644 index 00000000..d547c769 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/DelegatedSlotPrompt.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { api } from "@/lib/api"; +import { authenticatedFetch } from "@/lib/authenticatedFetch"; +import { nextSlotLimit, slotTenantLabel, type SlotLimitError } from "@/lib/delegatedSlots"; + +/** + * Raises a managing tenant's delegated slot override via PATCH config/{tenantId}/plan (GlobalAdminOnly). + * Returns the error text on failure — a 404 means the home tenant has no config row yet (not onboarded), + * which the bump flow cannot fix. + */ +export async function raiseDelegatedSlotLimit( + getAccessToken: () => Promise, + homeTenantId: string, + newLimit: number, +): Promise { + const response = await authenticatedFetch(api.config.plan(homeTenantId), getAccessToken, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ maxDelegatedTenants: newLimit }), + }); + if (response.ok) return null; + if (response.status === 404) { + return "The managing tenant is not onboarded yet (no tenant configuration) — onboard it first, then raise its slot limit."; + } + const data = await response.json().catch(() => ({})); + return data.error || `Failed to raise the slot limit: ${response.statusText}`; +} + +interface DelegatedSlotPromptProps { + prompt: SlotLimitError; + busy: boolean; + onRaise: (newLimit: number) => void; + onCancel: () => void; +} + +/** + * Inline confirm (house pattern, no modal) shown when a delegated-admin mutation hit the managing tenant's + * slot limit: names the tenant, its usage, and offers to raise the limit to the smallest value that fits, + * then retry the original mutation. + */ +export function DelegatedSlotPrompt({ prompt, busy, onRaise, onCancel }: DelegatedSlotPromptProps) { + const next = nextSlotLimit(prompt); + return ( +
+

+ {slotTenantLabel(prompt)} has {prompt.used} of {prompt.limit} delegated tenant + slot{prompt.limit === 1 ? "" : "s"} in use; this change needs {prompt.required} more. +

+
+ Raise the limit to {next} and retry? + + +
+

+ The override is saved on the managing tenant’s plan (Tenant Management → Plan & Trial) and is audited there. +

+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx b/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx index ce41893d..1fce46b2 100644 --- a/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx @@ -35,7 +35,10 @@ export function AuthGate() { // sign-in; only honor it when the user's tenant is activated. On the re-home hop only // PEEK: the link has to survive the extra sign-in round trip for the AuthGate pass that // follows, and leaving it in place beats re-saving it. - const returnUrl = rehomeNow ? peekPostLoginReturnUrl() : consumePostLoginReturnUrl(); + // While activation is pending the link is only PEEKED as well: an invited customer admin whose + // tenant is still being activated must land on the invitation once activation completes, not on + // /dashboard — the link survives the /activation detour for the AuthGate pass that follows. + const returnUrl = rehomeNow || isActivationPending ? peekPostLoginReturnUrl() : consumePostLoginReturnUrl(); let target: Route; if (isActivationPending) { target = "/activation"; diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/delegatedSlots.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/delegatedSlots.test.ts new file mode 100644 index 00000000..689a73db --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/delegatedSlots.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { DELEGATED_SLOT_LIMIT_REACHED, nextSlotLimit, parseSlotLimitError, slotTenantLabel } from "../delegatedSlots"; + +describe("parseSlotLimitError", () => { + const body = { + error: "Delegated tenant slot limit reached for partner.example: 2 of 2 slot(s) in use, 1 more needed.", + code: DELEGATED_SLOT_LIMIT_REACHED, + homeTenantId: "99999999-9999-9999-9999-999999999999", + homeTenantDomain: "partner.example", + used: 2, + limit: 2, + required: 1, + }; + + it("parses the 409 conflict body", () => { + const e = parseSlotLimitError(409, body); + expect(e).not.toBeNull(); + expect(e!.homeTenantDomain).toBe("partner.example"); + expect(e!.used).toBe(2); + expect(e!.limit).toBe(2); + expect(e!.required).toBe(1); + }); + + it("ignores other statuses, other 409 codes and malformed bodies", () => { + expect(parseSlotLimitError(422, body)).toBeNull(); + expect(parseSlotLimitError(409, { error: "UPN already bound elsewhere" })).toBeNull(); + expect(parseSlotLimitError(409, { code: DELEGATED_SLOT_LIMIT_REACHED })).toBeNull(); + expect(parseSlotLimitError(409, null)).toBeNull(); + expect(parseSlotLimitError(409, "nope")).toBeNull(); + }); + + it("treats an absent domain as null and falls back to the id for the label", () => { + const e = parseSlotLimitError(409, { ...body, homeTenantDomain: undefined })!; + expect(e.homeTenantDomain).toBeNull(); + expect(slotTenantLabel(e)).toBe(body.homeTenantId); + expect(slotTenantLabel(parseSlotLimitError(409, body)!)).toBe("partner.example"); + }); +}); + +describe("nextSlotLimit", () => { + it("is the smallest limit that fits the rejected mutation", () => { + expect(nextSlotLimit({ used: 2, limit: 2, required: 1 })).toBe(3); + expect(nextSlotLimit({ used: 2, limit: 2, required: 3 })).toBe(5); + // A stale limit above used+required is never lowered. + expect(nextSlotLimit({ used: 1, limit: 10, required: 1 })).toBe(10); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/delegations.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/delegations.test.ts new file mode 100644 index 00000000..d39a783e --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/delegations.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + buildInviteLink, + delegationAcceptPath, + describeDelegationError, + holdRemainingLabel, + invitationStatusLabel, +} from "../delegations"; + +describe("invitation links", () => { + it("builds a query-string link (static export: never a path segment) with the token encoded", () => { + expect(delegationAcceptPath("ab+c/d=")).toBe("/delegations/accept?token=ab%2Bc%2Fd%3D"); + expect(buildInviteLink("https://portal.example/", "tok")).toBe("https://portal.example/delegations/accept?token=tok"); + expect(buildInviteLink("https://portal.example", "tok")).toBe("https://portal.example/delegations/accept?token=tok"); + }); +}); + +describe("labels", () => { + it("maps wire statuses to labels and keeps unknown values", () => { + expect(invitationStatusLabel("Released")).toBe("Removed"); + expect(invitationStatusLabel("Expired")).toBe("Expired"); + expect(invitationStatusLabel("Weird")).toBe("Weird"); + }); + + it("counts down a release hold in hours and goes silent once it lapsed", () => { + const now = new Date("2026-09-02T12:00:00Z"); + expect(holdRemainingLabel("2026-09-03T11:30:00Z", now)).toBe("slot held for 24 h"); + expect(holdRemainingLabel("2026-09-02T12:30:00Z", now)).toBe("slot held for less than an hour"); + expect(holdRemainingLabel("2026-09-02T11:00:00Z", now)).toBe(""); + expect(holdRemainingLabel(null, now)).toBe(""); + expect(holdRemainingLabel("not-a-date", now)).toBe(""); + }); +}); + +describe("describeDelegationError", () => { + it("explains every accept-chain code and falls back to the server message", () => { + for (const code of [ + "InvalidInvitation", "InvitationExpired", "InvitationAlreadyUsed", "InvitationCancelled", + "CannotAcceptOwnInvitation", "AlreadyManaged", "ManagerNotEntitled", "DelegatedSlotLimitReached", + ]) { + expect(describeDelegationError(code, "fallback")).not.toBe("fallback"); + } + expect(describeDelegationError("SomethingElse", "server said so")).toBe("server said so"); + expect(describeDelegationError(undefined, "server said so")).toBe("server said so"); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/edition.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/edition.test.ts index c214392e..5b25041b 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/edition.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/edition.test.ts @@ -20,6 +20,7 @@ describe("parseEditionInfo", () => { retentionCapDays: 365, userRateLimitPerMinute: 150, delegatedAdminAllowed: true, + maxDelegatedTenants: 2, mcpUsagePlan: "pro", }, }); @@ -30,6 +31,7 @@ describe("parseEditionInfo", () => { expect(info.entitlements.retentionCapDays).toBe(365); expect(info.entitlements.userRateLimitPerMinute).toBe(150); expect(info.entitlements.delegatedAdminAllowed).toBe(true); + expect(info.entitlements.maxDelegatedTenants).toBe(2); }); it("accepts the pre-rename edition value 'enterprise' as pro (deploy-order safety)", () => { @@ -54,6 +56,7 @@ describe("parseEditionInfo", () => { const info = parseEditionInfo({ edition: "pro" }); expect(info.entitlements.retentionCapDays).toBe(90); expect(info.entitlements.userRateLimitPerMinute).toBeNull(); + expect(info.entitlements.maxDelegatedTenants).toBe(0); }); it("contactEmailSet: only an explicit false means missing (fail-safe against older backends)", () => { diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/navVisibility.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/navVisibility.test.ts index 854fc989..cf3dceaa 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/navVisibility.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/navVisibility.test.ts @@ -196,7 +196,7 @@ describe("item and sub-item filtering", () => { }); it("tenant-admin-only sub-sections hide from Operators and Viewers but not platform scopes", () => { - for (const sub of ["cfg-autopilot", "cfg-access-mgmt", "cfg-offboarding"]) { + for (const sub of ["cfg-autopilot", "cfg-access-mgmt", "cfg-offboarding", "cfg-delegations"]) { expect(subIds(flagsOf("TenantAdmin"))).toContain(sub); expect(subIds(flagsOf("GlobalReader+adminModeOn"))).toContain(sub); expect(subIds(flagsOf("Operator")), `Operator must not see '${sub}'`).not.toContain(sub); diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 77545d0d..d8541338 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -671,11 +671,41 @@ export const api = { remove: (upn: string) => `${API_BASE_URL}/api/global/identity-bindings/${encodeURIComponent(upn)}`, }, + // ── Delegated (MSP) tenant slots — GA read of a managing tenant's slot usage + hold release ── + delegatedSlots: { + get: (tenantId: string) => `${API_BASE_URL}/api/global/delegated-slots/${encodeURIComponent(tenantId)}`, + /** POST { invitationId } | { all: true } — ends a 24 h release hold early (GlobalAdminOnly). */ + releaseHold: (tenantId: string) => `${API_BASE_URL}/api/global/delegated-slots/${encodeURIComponent(tenantId)}/release-hold`, + }, + + // ── Self-service delegation (always the caller's own tenant — no tenantId in these routes) ── + delegations: { + slots: () => `${API_BASE_URL}/api/delegations/slots`, + managed: () => `${API_BASE_URL}/api/delegations/managed`, + /** POST { tenantId } — end a self-service delegation to a managed tenant (24 h slot hold). */ + removeManaged: () => `${API_BASE_URL}/api/delegations/managed/remove`, + /** GET list / POST mint a single-use invitation link. */ + invitations: () => `${API_BASE_URL}/api/delegations/invitations`, + cancelInvitation: (invitationId: string) => `${API_BASE_URL}/api/delegations/invitations/${encodeURIComponent(invitationId)}`, + /** GET list / POST { upn } — the managing tenant's own users on its self-service group. */ + assignees: () => `${API_BASE_URL}/api/delegations/assignees`, + unassign: (upn: string) => `${API_BASE_URL}/api/delegations/assignees/${encodeURIComponent(upn)}`, + /** GET ?token= — what accepting would do (no mutation). */ + acceptPreview: (token: string) => `${API_BASE_URL}/api/delegations/accept${qs({ token })}`, + /** POST { token } — the caller's tenant joins the inviting tenant's managed set. */ + accept: () => `${API_BASE_URL}/api/delegations/accept`, + /** GET — who can read the caller's tenant through a delegation. */ + managers: () => `${API_BASE_URL}/api/delegations/managers`, + /** POST { homeTenantId } — the customer ends a self-service delegation. */ + revokeManager: () => `${API_BASE_URL}/api/delegations/managers/revoke`, + }, + // ── Tenant Groups (MSP mode — named bundles of tenants for delegated admins) ── tenantGroups: { list: () => `${API_BASE_URL}/api/global/tenant-groups`, create: () => `${API_BASE_URL}/api/global/tenant-groups`, - rename: (groupId: string) => + /** PATCH — update group metadata: { name?, chargeHomeTenantQuota? } (at least one). */ + update: (groupId: string) => `${API_BASE_URL}/api/global/tenant-groups/${encodeURIComponent(groupId)}`, remove: (groupId: string) => `${API_BASE_URL}/api/global/tenant-groups/${encodeURIComponent(groupId)}`, @@ -710,6 +740,9 @@ export const api = { `${API_BASE_URL}/api/metrics/mcp-usage/me${qs({ dateFrom, dateTo })}`, user: (userId: string, dateFrom?: string, dateTo?: string) => `${API_BASE_URL}/api/metrics/mcp-usage/user/${encodeURIComponent(userId)}${qs({ dateFrom, dateTo })}`, + /** Own tenant's organization budget by account (members + delegated MSP admins). Tenant Admin / Global Reader. */ + organization: (dateFrom?: string, dateTo?: string) => + `${API_BASE_URL}/api/metrics/mcp-usage/organization${qs({ dateFrom, dateTo })}`, global: (tenantId?: string, dateFrom?: string, dateTo?: string) => `${API_BASE_URL}/api/global/metrics/mcp-usage${qs({ tenantId, dateFrom, dateTo })}`, daily: (tenantId?: string, dateFrom?: string, dateTo?: string) => diff --git a/src/Web/autopilot-monitor-web/lib/delegatedSlots.ts b/src/Web/autopilot-monitor-web/lib/delegatedSlots.ts new file mode 100644 index 00000000..ec9d8fd2 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/delegatedSlots.ts @@ -0,0 +1,41 @@ +/** + * Delegated (MSP) tenant slots — pure helpers for the Global Admin "limit reached → raise it and retry" + * flow. The backend answers 409 with code DelegatedSlotLimitReached (DelegatedSlotLimitReachedResponse) + * when a grant / group assignment / add-tenant would push a managing tenant over its slot limit. + */ + +export const DELEGATED_SLOT_LIMIT_REACHED = "DelegatedSlotLimitReached"; + +export interface SlotLimitError { + homeTenantId: string; + homeTenantDomain: string | null; + used: number; + limit: number; + required: number; + error: string; +} + +/** The parsed 409 body when it is the slot-limit conflict; null for every other status/body. */ +export function parseSlotLimitError(status: number, body: unknown): SlotLimitError | null { + if (status !== 409 || !body || typeof body !== "object") return null; + const b = body as Record; + if (b.code !== DELEGATED_SLOT_LIMIT_REACHED || typeof b.homeTenantId !== "string") return null; + return { + homeTenantId: b.homeTenantId, + homeTenantDomain: typeof b.homeTenantDomain === "string" && b.homeTenantDomain ? b.homeTenantDomain : null, + used: typeof b.used === "number" ? b.used : 0, + limit: typeof b.limit === "number" ? b.limit : 0, + required: typeof b.required === "number" ? b.required : 1, + error: typeof b.error === "string" ? b.error : "Delegated tenant slot limit reached.", + }; +} + +/** The smallest limit that lets the rejected mutation through. */ +export function nextSlotLimit(e: Pick): number { + return Math.max(e.limit, e.used + e.required); +} + +/** Display label of the managing tenant: its domain, else the id. */ +export function slotTenantLabel(e: Pick): string { + return e.homeTenantDomain || e.homeTenantId; +} diff --git a/src/Web/autopilot-monitor-web/lib/delegations.ts b/src/Web/autopilot-monitor-web/lib/delegations.ts new file mode 100644 index 00000000..c50e20b2 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/delegations.ts @@ -0,0 +1,58 @@ +/** + * Self-service delegation (MSP) — pure helpers for the "Delegated Access" section and the invitation + * accept page. No React, unit-tested. + */ + +export const DELEGATION_ACCEPT_ROUTE = "/delegations/accept"; + +/** In-app path of an invitation link (static export ⇒ query string, never a path segment). */ +export function delegationAcceptPath(token: string): string { + return `${DELEGATION_ACCEPT_ROUTE}?token=${encodeURIComponent(token)}`; +} + +/** The link a managing tenant admin copies and hands to the customer. */ +export function buildInviteLink(origin: string, token: string): string { + return `${origin.replace(/\/+$/, "")}${delegationAcceptPath(token)}`; +} + +/** Display label for an invitation's wire status (Pending | Accepted | Cancelled | Released | Expired). */ +export function invitationStatusLabel(status: string): string { + switch (status) { + case "Pending": return "Pending"; + case "Accepted": return "Accepted"; + case "Cancelled": return "Cancelled"; + case "Released": return "Removed"; + case "Expired": return "Expired"; + default: return status; + } +} + +/** "Slot held for 23 h" style countdown for a release hold; empty once it lapsed. */ +export function holdRemainingLabel(holdUntilUtc: string | null | undefined, now: Date = new Date()): string { + if (!holdUntilUtc) return ""; + const until = new Date(holdUntilUtc).getTime(); + if (Number.isNaN(until)) return ""; + const ms = until - now.getTime(); + if (ms <= 0) return ""; + const hours = Math.ceil(ms / 3_600_000); + return hours >= 2 ? `slot held for ${hours} h` : "slot held for less than an hour"; +} + +/** + * Human explanation for the accept flow's error codes (DelegationCodes / DelegatedSlotLimitReached); + * falls back to the backend message. + */ +export function describeDelegationError(code: string | undefined, fallback: string): string { + switch (code) { + case "InvalidInvitation": return "This invitation link is not valid. Ask the managing organization for a new link."; + case "InvitationExpired": return "This invitation has expired (links are valid for 7 days). Ask the managing organization for a new link."; + case "InvitationAlreadyUsed": return "This invitation has already been used. Ask the managing organization for a new link."; + case "InvitationCancelled": return "The managing organization cancelled this invitation."; + case "CannotAcceptOwnInvitation": return "An invitation is accepted by an administrator of the tenant to be managed — not by the inviting tenant."; + case "AlreadyManaged": return "Your tenant is already managed by this organization."; + case "ManagerNotEntitled": return "The inviting organization is no longer on a plan that includes delegated administration."; + case "DelegatedSlotLimitReached": return "The managing organization has no free delegated tenant slot. Ask them to raise their limit, then try the link again."; + case "DelegatedAdminNotAllowed": return "Delegated administration is a Pro capability."; + default: return fallback; + } +} diff --git a/src/Web/autopilot-monitor-web/lib/docsPaths.ts b/src/Web/autopilot-monitor-web/lib/docsPaths.ts index 35ceae1b..ee3ff910 100644 --- a/src/Web/autopilot-monitor-web/lib/docsPaths.ts +++ b/src/Web/autopilot-monitor-web/lib/docsPaths.ts @@ -11,6 +11,7 @@ export const DOCS_PATHS = { hardwareWhitelist: "/reference/settings#hardware-whitelist", notifications: "/integrations/notifications", accessManagement: "/concepts/roles-and-permissions", + delegatedAccess: "/concepts/roles-and-permissions#msp-fleet-access", slaTargets: "/portal-guide/sla-compliance#configuring-targets", plan: "/plans", contact: "/reference/settings#contact", diff --git a/src/Web/autopilot-monitor-web/lib/edition.ts b/src/Web/autopilot-monitor-web/lib/edition.ts index 77a800f0..d703ed22 100644 --- a/src/Web/autopilot-monitor-web/lib/edition.ts +++ b/src/Web/autopilot-monitor-web/lib/edition.ts @@ -25,6 +25,8 @@ export interface EditionInfo { userRateLimitPerMinute: number | null; delegatedAdminAllowed: boolean; mcpUsagePlan: string; + /** Effective delegated (MSP) tenant slot limit (override or plan entitlement); 0 = no delegation. */ + maxDelegatedTenants: number; }; } @@ -43,6 +45,7 @@ export const COMMUNITY_DEFAULT: EditionInfo = { userRateLimitPerMinute: null, delegatedAdminAllowed: false, mcpUsagePlan: "community", + maxDelegatedTenants: 0, }, }; @@ -74,6 +77,7 @@ export function parseEditionInfo(flags: unknown): EditionInfo { typeof ent.userRateLimitPerMinute === "number" ? ent.userRateLimitPerMinute : null, delegatedAdminAllowed: ent.delegatedAdminAllowed === true, mcpUsagePlan: typeof ent.mcpUsagePlan === "string" ? ent.mcpUsagePlan : "community", + maxDelegatedTenants: typeof ent.maxDelegatedTenants === "number" ? ent.maxDelegatedTenants : 0, }, }; } diff --git a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx index 7e2f56d7..882e8047 100644 --- a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx +++ b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx @@ -206,6 +206,7 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [ id: "cfg-tenant", label: "Tenant", icon: , items: [ { id: "cfg-access-mgmt", label: "Access Management", href: route("/settings/tenant/access-management") }, + { id: "cfg-delegations", label: "Delegated Access", href: route("/settings/tenant/delegations") }, { id: "cfg-autopilot", label: "Autopilot Validation", href: route("/settings/tenant/autopilot") }, { id: "cfg-hardware", label: "Hardware Whitelist", href: route("/settings/tenant/hardware-whitelist") }, { id: "cfg-notifications", label: "Notifications", href: route("/settings/tenant/notifications") }, diff --git a/src/Web/autopilot-monitor-web/lib/navVisibility.ts b/src/Web/autopilot-monitor-web/lib/navVisibility.ts index 68e7a567..8de51b81 100644 --- a/src/Web/autopilot-monitor-web/lib/navVisibility.ts +++ b/src/Web/autopilot-monitor-web/lib/navVisibility.ts @@ -131,7 +131,7 @@ export function filterExpandableNavGroups( // Tenant-admin-only sub-sections: Operators and Viewers (read-only settings // viewers) don't see them — matches the in-page "tenant administrators only" gates. A platform // scope (GA / read-only GlobalReader) keeps the full GA-identical sidebar. - if (sub.id === "cfg-autopilot" || sub.id === "cfg-access-mgmt" || sub.id === "cfg-offboarding") { + if (sub.id === "cfg-autopilot" || sub.id === "cfg-access-mgmt" || sub.id === "cfg-offboarding" || sub.id === "cfg-delegations") { return flags.isAdminLike || flags.hasGlobalScope; } return true; diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts index cf84267b..7622bb15 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts @@ -86,6 +86,7 @@ export const SHARED_MANIFEST = { "trialConsumed", "trialGrantedBy", "proDowngradedUtc", + "maxDelegatedTenantsOverride", "manufacturerWhitelist", "modelWhitelist", "validateAutopilotDevice", diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 0b6f181e..52f79473 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -82,6 +82,7 @@ "trialConsumed", "trialGrantedBy", "proDowngradedUtc", + "maxDelegatedTenantsOverride", "manufacturerWhitelist", "modelWhitelist", "validateAutopilotDevice", @@ -547,6 +548,34 @@ "accessRevoked" ], "types": { + "AcceptDelegationInvitationResponse": { + "kind": "object", + "doc": "Response of POST delegations/accept.", + "fields": [ + { + "name": "homeTenantId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "homeTenantDomain", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "managedTenantId", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, "ActiveUserItem": { "kind": "object", "doc": "One currently active web user as listed by GET global/presence.", @@ -4029,6 +4058,33 @@ } ] }, + "CreateDelegationInvitationResponse": { + "kind": "object", + "doc": "Response of POST delegations/invitations: the token is shown ONCE (the link is copy-only).", + "fields": [ + { + "name": "invitationId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "token", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "expiresUtc", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, "CreateTenantGroupResponse": { "kind": "object", "doc": "Response of POST global/tenant-groups: the created group's id and (trimmed) name.", @@ -4583,6 +4639,381 @@ } ] }, + "DelegatedSlotHold": { + "kind": "object", + "doc": "One release hold nested in DelegatedSlotUsageResponse.", + "fields": [ + { + "name": "invitationId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "The managed tenant that was removed; absent when unknown." + }, + { + "name": "holdUntilUtc", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "releasedBy", + "type": { + "kind": "primitive", + "name": "string" + } + } + ] + }, + "DelegatedSlotLimitReachedResponse": { + "kind": "object", + "doc": "409 body when a delegated-admin mutation (grant, group assign, add tenant to group, self-service invitation or accept) would push a managing (MSP) tenant over its delegated tenant slot limit. error comes first so generic error rendering keeps working; code lets the GA UI offer the \"raise the limit and retry\" flow.", + "fields": [ + { + "name": "error", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "code", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "homeTenantId", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "The managing (home) tenant whose slots are exhausted." + }, + { + "name": "homeTenantDomain", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Its display name; absent when the config row carries none." + }, + { + "name": "used", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Slots in use (distinct managed tenants + pending invitations + release holds)." + }, + { + "name": "limit", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "The effective limit (plan entitlement or the Global Admin override)." + }, + { + "name": "required", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "New slots the rejected mutation needed." + } + ] + }, + "DelegatedSlotUsageResponse": { + "kind": "object", + "doc": "Response of GET global/delegated-slots/{tenantId} and GET delegations/slots: a managing tenant's slot usage.", + "fields": [ + { + "name": "homeTenantId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "limit", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Effective limit (override when set, else the plan entitlement)." + }, + { + "name": "catalogLimit", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "The plan entitlement (Community 0, Pro 2)." + }, + { + "name": "overrideLimit", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "The Global Admin override; absent when the catalog value applies." + }, + { + "name": "used", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "managedTenantIds", + "type": { + "kind": "array", + "element": { + "kind": "primitive", + "name": "string" + } + }, + "doc": "Distinct managed tenant ids (lowercase) reachable by users homed in this tenant." + }, + { + "name": "pendingInvitations", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Pending self-service invitations (each holds a slot until accepted, cancelled or expired)." + }, + { + "name": "holds", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "DelegatedSlotHold" + } + }, + "doc": "Release holds: slots freed by a removal that stay occupied for 24 hours." + } + ] + }, + "DelegationAcceptPreviewResponse": { + "kind": "object", + "doc": "Response of GET delegations/accept?token=: what accepting would do — no mutation.", + "fields": [ + { + "name": "homeTenantId", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "The inviting (managing) tenant." + }, + { + "name": "homeTenantDomain", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "expiresUtc", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "status", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Pending | Accepted | Cancelled | Released | Expired." + }, + { + "name": "targetTenantId", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "The caller's tenant — the one that would be managed." + }, + { + "name": "targetTenantDomain", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + } + ] + }, + "DelegationAssignResponse": { + "kind": "object", + "doc": "Response of POST delegations/assignees.", + "fields": [ + { + "name": "assignment", + "type": { + "kind": "ref", + "name": "TenantGroupAssignment" + } + } + ] + }, + "DelegationAssigneeListResponse": { + "kind": "object", + "doc": "Response of GET delegations/assignees: the managing tenant's own users on its self-service group.", + "fields": [ + { + "name": "homeTenantId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "groupId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "assignees", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "TenantGroupAssignment" + } + } + } + ] + }, + "DelegationInvitationItem": { + "kind": "object", + "doc": "One invitation row as the managing tenant sees it. Never carries the token.", + "fields": [ + { + "name": "invitationId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "status", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Pending | Accepted | Cancelled | Released | Expired (derived: pending past its expiry)." + }, + { + "name": "createdBy", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "createdUtc", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "expiresUtc", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "acceptedUtc", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "acceptedBy", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "The managed tenant (accepted / released rows)." + }, + { + "name": "tenantDomain", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "holdUntilUtc", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Released rows: while in the future the slot is still occupied." + } + ] + }, + "DelegationInvitationListResponse": { + "kind": "object", + "doc": "Response of GET delegations/invitations.", + "fields": [ + { + "name": "homeTenantId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "invitations", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "DelegationInvitationItem" + } + } + } + ] + }, "DeletionRowKeySample": { "kind": "object", "doc": "One sampled table row key (delete preview / stored manifest summary).", @@ -7192,6 +7623,45 @@ } ] }, + "GetMcpOrganizationUsageResponse": { + "kind": "object", + "doc": "Organization-wide MCP usage by user for the caller's OWN tenant (GetMcpOrganizationUsage): every account whose requests were charged to this tenant's organization budget — its own members and any delegated (MSP) administrators reading the tenant. Built from the tenant's organization counters.", + "fields": [ + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "dateFrom", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Effective range start (yyyyMMdd)." + }, + { + "name": "dateTo", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Effective range end (yyyyMMdd, inclusive)." + }, + { + "name": "users", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "McpOrganizationUsageItem" + } + } + } + ] + }, "GetMcpUserUsageResponse": { "kind": "object", "doc": "Per-user MCP/API usage envelope (GetMcpUserUsage). Non-global callers only receive the records attributed to their own tenant; a foreign oid and an unknown oid are indistinguishable (both 200 with empty records). The MCP server paginates over the records key — its name is wire-critical.", @@ -10547,6 +11017,130 @@ } ] }, + "ManagedTenantItem": { + "kind": "object", + "doc": "One managed tenant as the managing tenant sees it.", + "fields": [ + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "domain", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "source", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "self-service (joined by invitation, removable here) | operator (provisioned by platform operators)." + }, + { + "name": "sinceUtc", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "removable", + "type": { + "kind": "primitive", + "name": "boolean" + } + }, + { + "name": "usage", + "type": { + "kind": "ref", + "name": "ManagedTenantQuotaUsage" + }, + "optional": true, + "doc": "Absent when not resolved (cap reached or read failure)." + } + ] + }, + "ManagedTenantListResponse": { + "kind": "object", + "doc": "Response of GET delegations/managed.", + "fields": [ + { + "name": "homeTenantId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "slots", + "type": { + "kind": "ref", + "name": "DelegatedSlotUsageResponse" + } + }, + { + "name": "tenants", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "ManagedTenantItem" + } + } + } + ] + }, + "ManagedTenantQuotaUsage": { + "kind": "object", + "doc": "MCP organization budget of a managed tenant, nested in ManagedTenantItem.", + "fields": [ + { + "name": "tenantPlan", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "tenantDailyLimit", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "tenantMonthlyLimit", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "tenantDailyUsed", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "tenantMonthlyUsed", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, "McpHealthCheckResponse": { "kind": "object", "doc": "Response of GET health/mcp: the standalone MCP-server reachability probe.", @@ -10567,6 +11161,75 @@ } ] }, + "McpOrganizationUsageItem": { + "kind": "object", + "doc": "One account's share of the organization budget, nested in GetMcpOrganizationUsageResponse.", + "fields": [ + { + "name": "userId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "userPrincipalName", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Absent for rows written before the UPN was recorded." + }, + { + "name": "delegated", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "True when the account is a delegated (MSP) administrator homed in another tenant." + }, + { + "name": "homeTenantId", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "The delegated administrator's home tenant; absent for the tenant's own members." + }, + { + "name": "requestsToday", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "requestsThisMonth", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "requestsInRange", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "lastRequestAt", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Absent for rows written before the timestamp was recorded." + } + ] + }, "McpQuotaExceededResponse": { "kind": "object", "doc": "429 body written by McpQuotaEnforcementMiddleware when the per-user MCP daily/monthly quota is exhausted (structurally a success shape: first key is quotaExceeded).", @@ -10632,12 +11295,21 @@ "kind": "primitive", "name": "string" } + }, + { + "name": "targetTenantId", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "The MANAGED tenant whose organization windows blocked a delegated (MSP) read — its plan governs the budget, not the caller's. Absent when the caller's own tenant/plan was exceeded and on the all-managed-tenants-exhausted aggregate block." } ] }, "McpUsageQuotaNode": { "kind": "object", - "doc": "Effective quota state nested in GetMyMcpUsageResponse: the caller's own windows and the organization-wide windows of their tenant (shared by every member; 0 = unlimited).", + "doc": "Effective quota state nested in GetMyMcpUsageResponse: the caller's own windows and the organization-wide windows of their tenant (shared by every member; 0 = unlimited). For a delegated (MSP) caller the tenant windows are those of their HOME tenant — reads into managed tenants are charged to the managed tenant per request and never appear here.", "fields": [ { "name": "dailyLimit", @@ -12259,6 +12931,26 @@ } ] }, + "ReleaseDelegatedSlotHoldResponse": { + "kind": "object", + "doc": "Response of POST global/delegated-slots/{tenantId}/release-hold: how many holds ended now.", + "fields": [ + { + "name": "homeTenantId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "released", + "type": { + "kind": "primitive", + "name": "number" + } + } + ] + }, "RemediationStep": { "kind": "object", "doc": "A remediation step with title and sub-steps", @@ -14104,6 +14796,18 @@ }, "optional": true, "doc": "Absent when there is no further page." + }, + { + "name": "quotaExcludedTenants", + "type": { + "kind": "array", + "element": { + "kind": "primitive", + "name": "string" + } + }, + "optional": true, + "doc": "Managed tenants dropped from a delegated (MSP) MCP fleet aggregate because their organization MCP budget is exhausted (each managed tenant's own plan governs it). Absent unless at least one tenant was excluded; only GetAllSessions ever sets it." } ] }, @@ -14564,6 +15268,18 @@ "kind": "ref", "name": "SessionStats" } + }, + { + "name": "quotaExcludedTenants", + "type": { + "kind": "array", + "element": { + "kind": "primitive", + "name": "string" + } + }, + "optional": true, + "doc": "See QuotaExcludedTenants; only GetAllSessionStats ever sets it." } ] }, @@ -15250,6 +15966,23 @@ }, "optional": true, "doc": "End of the retention downgrade grace window, or null — the key is omitted when null." + }, + { + "name": "maxDelegatedTenants", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Effective delegated (MSP) tenant slot limit after the change (override or plan entitlement)." + }, + { + "name": "maxDelegatedTenantsOverride", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "The Global Admin override; omitted when the plan entitlement applies." } ] }, @@ -16415,6 +17148,15 @@ "optional": true, "doc": "When the tenant's EFFECTIVE edition last dropped Pro → Community via the plan endpoint (UTC). Anchors the retention downgrade grace period: for RetentionDowngradeGraceDays after losing Pro the retention cap stays at the Pro value so a downgrade (e.g. non-payment) does not immediately hard-delete data older than the Community cap. Trial expiry needs no write — TrialExpiresUtc itself is the anchor there. Cleared whenever the tenant becomes effectively Pro again. Backend-only: not delivered to the agent (no ConfigVersion impact)." }, + { + "name": "maxDelegatedTenantsOverride", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Global-Admin override of the delegated (\"MSP\") tenant slot limit — how many distinct customer tenants users homed in this tenant may manage. Null = the plan entitlement applies (Community 0, Pro 2); a value applies regardless of edition (pre-provisioning a package), while USING delegation still requires Pro. Mutable only via the plan endpoint. Backend-only: not delivered to the agent (no ConfigVersion impact)." + }, { "name": "manufacturerWhitelist", "type": { @@ -17137,6 +17879,14 @@ "kind": "primitive", "name": "string" } + }, + { + "name": "maxDelegatedTenants", + "type": { + "kind": "primitive", + "name": "number" + }, + "doc": "Effective delegated (MSP) tenant slot limit (override or plan entitlement); 0 = no delegation." } ] }, @@ -17322,6 +18072,23 @@ } }, "doc": "The UPNs assigned to this group (for the management UI)." + }, + { + "name": "chargeHomeTenantQuota", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "Operator flag: MCP reads an assignee makes INTO this group's tenants are charged to the assignee's HOME tenant's quota instead of the managed tenant's. For operator-run managed-service groups whose customers must never pay (or be blocked) for the operator's own analysis. Off by default." + }, + { + "name": "ownerTenantId", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "The managing tenant that owns this self-service group (msp-{tenantId}); null for operator-created groups." } ] }, @@ -17390,6 +18157,129 @@ } ] }, + "TenantManagerAssignee": { + "kind": "object", + "doc": "One person with access, nested in TenantManagerItem.", + "fields": [ + { + "name": "upn", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "role", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "isEnabled", + "type": { + "kind": "primitive", + "name": "boolean" + } + } + ] + }, + "TenantManagerItem": { + "kind": "object", + "doc": "One party that can read the caller's tenant, nested in TenantManagerListResponse.", + "fields": [ + { + "name": "groupId", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "The Tenant Group conferring the access; absent for direct operator grants." + }, + { + "name": "ownerTenantId", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "The managing tenant that owns the group (self-service); absent for operator-created groups and direct grants." + }, + { + "name": "ownerDomain", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "name", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "source", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "self-service | operator" + }, + { + "name": "assignees", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "TenantManagerAssignee" + } + } + }, + { + "name": "sinceUtc", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "revocable", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "True when the caller (the managed tenant's admin) may end this access here." + } + ] + }, + "TenantManagerListResponse": { + "kind": "object", + "doc": "Response of GET delegations/managers: who manages the caller's tenant.", + "fields": [ + { + "name": "tenantId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "managers", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "TenantManagerItem" + } + } + } + ] + }, "TenantMetrics": { "kind": "object", "fields": [ diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts index 000f4dd3..7edee15f 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -7,6 +7,13 @@ // (optional = key absent under WhenWritingNull) and names mirror the C# wire exactly. +/** Response of POST delegations/accept. */ +export interface AcceptDelegationInvitationResponse { + homeTenantId: string; + homeTenantDomain?: string; + managedTenantId: string; +} + /** One currently active web user as listed by GET global/presence. */ export interface ActiveUserItem { tenantId: string; @@ -768,6 +775,13 @@ export interface CreateAnalyzeRuleFromTemplateResponse { message: string; } +/** Response of POST delegations/invitations: the token is shown ONCE (the link is copy-only). */ +export interface CreateDelegationInvitationResponse { + invitationId: string; + token: string; + expiresUtc: string; +} + /** Response of POST global/tenant-groups: the created group's id and (trimmed) name. */ export interface CreateTenantGroupResponse { groupId: string; @@ -899,6 +913,97 @@ export interface DelegatedAdminListResponse { assignments: DelegatedAdminEntry[]; } +/** One release hold nested in DelegatedSlotUsageResponse. */ +export interface DelegatedSlotHold { + invitationId: string; + /** The managed tenant that was removed; absent when unknown. */ + tenantId?: string; + holdUntilUtc: string; + releasedBy: string; +} + +/** 409 body when a delegated-admin mutation (grant, group assign, add tenant to group, self-service invitation or accept) would push a managing (MSP) tenant over its delegated tenant slot limit. error comes first so generic error rendering keeps working; code lets the GA UI offer the "raise the limit and retry" flow. */ +export interface DelegatedSlotLimitReachedResponse { + error: string; + code: string; + /** The managing (home) tenant whose slots are exhausted. */ + homeTenantId: string; + /** Its display name; absent when the config row carries none. */ + homeTenantDomain?: string; + /** Slots in use (distinct managed tenants + pending invitations + release holds). */ + used: number; + /** The effective limit (plan entitlement or the Global Admin override). */ + limit: number; + /** New slots the rejected mutation needed. */ + required: number; +} + +/** Response of GET global/delegated-slots/{tenantId} and GET delegations/slots: a managing tenant's slot usage. */ +export interface DelegatedSlotUsageResponse { + homeTenantId: string; + /** Effective limit (override when set, else the plan entitlement). */ + limit: number; + /** The plan entitlement (Community 0, Pro 2). */ + catalogLimit: number; + /** The Global Admin override; absent when the catalog value applies. */ + overrideLimit?: number; + used: number; + /** Distinct managed tenant ids (lowercase) reachable by users homed in this tenant. */ + managedTenantIds: string[]; + /** Pending self-service invitations (each holds a slot until accepted, cancelled or expired). */ + pendingInvitations: number; + /** Release holds: slots freed by a removal that stay occupied for 24 hours. */ + holds: DelegatedSlotHold[]; +} + +/** Response of GET delegations/accept?token=: what accepting would do — no mutation. */ +export interface DelegationAcceptPreviewResponse { + /** The inviting (managing) tenant. */ + homeTenantId: string; + homeTenantDomain?: string; + expiresUtc: string; + /** Pending | Accepted | Cancelled | Released | Expired. */ + status: string; + /** The caller's tenant — the one that would be managed. */ + targetTenantId: string; + targetTenantDomain?: string; +} + +/** Response of POST delegations/assignees. */ +export interface DelegationAssignResponse { + assignment: TenantGroupAssignment; +} + +/** Response of GET delegations/assignees: the managing tenant's own users on its self-service group. */ +export interface DelegationAssigneeListResponse { + homeTenantId: string; + groupId: string; + assignees: TenantGroupAssignment[]; +} + +/** One invitation row as the managing tenant sees it. Never carries the token. */ +export interface DelegationInvitationItem { + invitationId: string; + /** Pending | Accepted | Cancelled | Released | Expired (derived: pending past its expiry). */ + status: string; + createdBy: string; + createdUtc: string; + expiresUtc: string; + acceptedUtc?: string; + acceptedBy?: string; + /** The managed tenant (accepted / released rows). */ + tenantId?: string; + tenantDomain?: string; + /** Released rows: while in the future the slot is still occupied. */ + holdUntilUtc?: string; +} + +/** Response of GET delegations/invitations. */ +export interface DelegationInvitationListResponse { + homeTenantId: string; + invitations: DelegationInvitationItem[]; +} + /** One sampled table row key (delete preview / stored manifest summary). */ export interface DeletionRowKeySample { pk: string; @@ -1508,6 +1613,16 @@ export interface GetLatestVersionsResponse { source: string; } +/** Organization-wide MCP usage by user for the caller's OWN tenant (GetMcpOrganizationUsage): every account whose requests were charged to this tenant's organization budget — its own members and any delegated (MSP) administrators reading the tenant. Built from the tenant's organization counters. */ +export interface GetMcpOrganizationUsageResponse { + tenantId: string; + /** Effective range start (yyyyMMdd). */ + dateFrom: string; + /** Effective range end (yyyyMMdd, inclusive). */ + dateTo: string; + users: McpOrganizationUsageItem[]; +} + /** Per-user MCP/API usage envelope (GetMcpUserUsage). Non-global callers only receive the records attributed to their own tenant; a foreign oid and an unknown oid are indistinguishable (both 200 with empty records). The MCP server paginates over the records key — its name is wire-critical. */ export interface GetMcpUserUsageResponse { userId: string; @@ -2214,12 +2329,56 @@ export interface MaintenanceResult { contactEmailsBackfilled: number; } +/** One managed tenant as the managing tenant sees it. */ +export interface ManagedTenantItem { + tenantId: string; + domain?: string; + /** self-service (joined by invitation, removable here) | operator (provisioned by platform operators). */ + source: string; + sinceUtc?: string; + removable: boolean; + /** Absent when not resolved (cap reached or read failure). */ + usage?: ManagedTenantQuotaUsage; +} + +/** Response of GET delegations/managed. */ +export interface ManagedTenantListResponse { + homeTenantId: string; + slots: DelegatedSlotUsageResponse; + tenants: ManagedTenantItem[]; +} + +/** MCP organization budget of a managed tenant, nested in ManagedTenantItem. */ +export interface ManagedTenantQuotaUsage { + tenantPlan: string; + tenantDailyLimit: number; + tenantMonthlyLimit: number; + tenantDailyUsed: number; + tenantMonthlyUsed: number; +} + /** Response of GET health/mcp: the standalone MCP-server reachability probe. */ export interface McpHealthCheckResponse { timestamp: string; check: HealthCheck; } +/** One account's share of the organization budget, nested in GetMcpOrganizationUsageResponse. */ +export interface McpOrganizationUsageItem { + userId: string; + /** Absent for rows written before the UPN was recorded. */ + userPrincipalName?: string; + /** True when the account is a delegated (MSP) administrator homed in another tenant. */ + delegated: boolean; + /** The delegated administrator's home tenant; absent for the tenant's own members. */ + homeTenantId?: string; + requestsToday: number; + requestsThisMonth: number; + requestsInRange: number; + /** Absent for rows written before the timestamp was recorded. */ + lastRequestAt?: string; +} + /** 429 body written by McpQuotaEnforcementMiddleware when the per-user MCP daily/monthly quota is exhausted (structurally a success shape: first key is quotaExceeded). */ export interface McpQuotaExceededResponse { quotaExceeded: boolean; @@ -2235,9 +2394,11 @@ export interface McpQuotaExceededResponse { /** Reset time of the exceeded window, pre-formatted "yyyy-MM-ddTHH:mm:ssZ". */ resetUtc: string; message: string; + /** The MANAGED tenant whose organization windows blocked a delegated (MSP) read — its plan governs the budget, not the caller's. Absent when the caller's own tenant/plan was exceeded and on the all-managed-tenants-exhausted aggregate block. */ + targetTenantId?: string; } -/** Effective quota state nested in GetMyMcpUsageResponse: the caller's own windows and the organization-wide windows of their tenant (shared by every member; 0 = unlimited). */ +/** Effective quota state nested in GetMyMcpUsageResponse: the caller's own windows and the organization-wide windows of their tenant (shared by every member; 0 = unlimited). For a delegated (MSP) caller the tenant windows are those of their HOME tenant — reads into managed tenants are charged to the managed tenant per request and never appear here. */ export interface McpUsageQuotaNode { dailyLimit: number; monthlyLimit: number; @@ -2602,6 +2763,12 @@ export interface RelatedDoc { url: string; } +/** Response of POST global/delegated-slots/{tenantId}/release-hold: how many holds ended now. */ +export interface ReleaseDelegatedSlotHoldResponse { + homeTenantId: string; + released: number; +} + /** A remediation step with title and sub-steps */ export interface RemediationStep { /** Title of the remediation approach */ @@ -3007,6 +3174,8 @@ export interface SessionListResponse { sessions: SessionSummary[]; /** Absent when there is no further page. */ nextLink?: string; + /** Managed tenants dropped from a delegated (MSP) MCP fleet aggregate because their organization MCP budget is exhausted (each managed tenant's own plan governs it). Absent unless at least one tenant was excluded; only GetAllSessions ever sets it. */ + quotaExcludedTenants?: string[]; } export interface SessionMetrics { @@ -3109,6 +3278,8 @@ export interface SessionStats { export interface SessionStatsResponse { success: boolean; stats: SessionStats; + /** See QuotaExcludedTenants; only GetAllSessionStats ever sets it. */ + quotaExcludedTenants?: string[]; } /** Status of an enrollment session */ @@ -3253,6 +3424,10 @@ export interface SetTenantPlanTierResponse { effectiveEdition: string; /** End of the retention downgrade grace window, or null — the key is omitted when null. */ retentionGraceEndsUtc?: string; + /** Effective delegated (MSP) tenant slot limit after the change (override or plan entitlement). */ + maxDelegatedTenants: number; + /** The Global Admin override; omitted when the plan entitlement applies. */ + maxDelegatedTenantsOverride?: number; } /** Distinct-CVE counts grouped by their highest CVSS severity band. */ @@ -3529,6 +3704,8 @@ export interface TenantConfiguration { trialGrantedBy?: string; /** When the tenant's EFFECTIVE edition last dropped Pro → Community via the plan endpoint (UTC). Anchors the retention downgrade grace period: for RetentionDowngradeGraceDays after losing Pro the retention cap stays at the Pro value so a downgrade (e.g. non-payment) does not immediately hard-delete data older than the Community cap. Trial expiry needs no write — TrialExpiresUtc itself is the anchor there. Cleared whenever the tenant becomes effectively Pro again. Backend-only: not delivered to the agent (no ConfigVersion impact). */ proDowngradedUtc?: string; + /** Global-Admin override of the delegated ("MSP") tenant slot limit — how many distinct customer tenants users homed in this tenant may manage. Null = the plan entitlement applies (Community 0, Pro 2); a value applies regardless of edition (pre-provisioning a package), while USING delegation still requires Pro. Mutable only via the plan endpoint. Backend-only: not delivered to the agent (no ConfigVersion impact). */ + maxDelegatedTenantsOverride?: number; /** Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*") Comma-separated list */ manufacturerWhitelist: string; /** Hardware whitelist: Allowed models (supports wildcards like "Latitude*") Comma-separated list Default: "*" (all models allowed) */ @@ -3703,6 +3880,8 @@ export interface TenantFeatureEntitlements { userRateLimitPerMinute?: number; delegatedAdminAllowed: boolean; mcpUsagePlan: string; + /** Effective delegated (MSP) tenant slot limit (override or plan entitlement); 0 = no delegation. */ + maxDelegatedTenants: number; } /** Response of GET config/{tenantId}/feature-flags: the member-readable subset of the tenant configuration — UI display toggles, feature switches and the read-time edition/entitlement surface. Adding a field here is a deliberate decision that the field is non-sensitive (no webhook URLs, SAS tokens, allowlists, addresses). */ @@ -3745,6 +3924,10 @@ export interface TenantGroup { assigneeCount: number; /** The UPNs assigned to this group (for the management UI). */ assignees: TenantGroupAssignment[]; + /** Operator flag: MCP reads an assignee makes INTO this group's tenants are charged to the assignee's HOME tenant's quota instead of the managed tenant's. For operator-run managed-service groups whose customers must never pay (or be blocked) for the operator's own analysis. Off by default. */ + chargeHomeTenantQuota: boolean; + /** The managing tenant that owns this self-service group (msp-{tenantId}); null for operator-created groups. */ + ownerTenantId?: string; } /** One UPN→group assignment. PK=UPN, RK=groupId in storage. */ @@ -3763,6 +3946,35 @@ export interface TenantGroupListResponse { groups: TenantGroup[]; } +/** One person with access, nested in TenantManagerItem. */ +export interface TenantManagerAssignee { + upn: string; + role: string; + isEnabled: boolean; +} + +/** One party that can read the caller's tenant, nested in TenantManagerListResponse. */ +export interface TenantManagerItem { + /** The Tenant Group conferring the access; absent for direct operator grants. */ + groupId?: string; + /** The managing tenant that owns the group (self-service); absent for operator-created groups and direct grants. */ + ownerTenantId?: string; + ownerDomain?: string; + name: string; + /** self-service | operator */ + source: string; + assignees: TenantManagerAssignee[]; + sinceUtc?: string; + /** True when the caller (the managed tenant's admin) may end this access here. */ + revocable: boolean; +} + +/** Response of GET delegations/managers: who manages the caller's tenant. */ +export interface TenantManagerListResponse { + tenantId: string; + managers: TenantManagerItem[]; +} + export interface TenantMetrics { total: number; active7Days: number;