A self-service assignee reads the managed tenants only while an enabled member of the managing tenant (D-364)

Removing or disabling a member under Access Management left their assignment to the tenant's self-service group in place, and the delegated scope never re-checked the membership, so the person kept reading every managed customer.

- Scope: an assignment to msp-{owner} confers the group's tenants only while the assignee holds an enabled TenantAdmins row in the owner (inside the cached row resolution, fail-closed, Warning when ignored). Operator groups and direct grants are unchanged.
- Access Management: remove and disable end the self-service assignment (DelegatedGroupAccess DELETE under every managed tenant, reason member-removed / member-disabled) before the existing stream cut. Re-enabling restores nothing.
- Assigning requires an enabled member and re-reads the membership after the write; the Global Admin group route follows the same rule for a self-service group (enabled member of the owner, bound to it, read-only).
- The SignalR group join resolves the delegated scope uncached, so the re-join right after a revoke cannot be admitted by another instance's warm cache.
- Web: Delegated Access offers only enabled members and says that removing or disabling a member ends their access; the Global Admin page hides the role select for self-service groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-19 16:52:39 +02:00
parent 1088b36b8b
commit 0c1b83268c
18 changed files with 735 additions and 45 deletions
@@ -598,4 +598,160 @@ public class DelegatedAdminServiceTests
repo.Verify(r => r.UpdateTenantGroupAsync(GroupId1, "Renamed", "Service Desk"), Times.Once);
repo.Verify(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()), Times.Once);
}
// ── Self-service groups: an assignment counts only while its assignee is an enabled member of the owner ──
private static readonly string SelfServiceGroup = Constants.TenantGroupIds.ForHomeTenant(HomeTenant);
/// <summary>The assignee's TenantAdmins row in the owning tenant: enabled, disabled, or none (null).</summary>
private static void OwnerMember(Mock<IAdminRepository> repo, string upn, bool? enabled) =>
repo.Setup(r => r.GetTenantMemberAsync(HomeTenant, upn)).ReturnsAsync(enabled is bool isEnabled
? new TenantMember { TenantId = HomeTenant, Upn = upn, Role = Constants.TenantRoles.Viewer, IsEnabled = isEnabled }
: null);
[Fact]
public async Task GetScope_SelfServiceGroup_EnabledOwnerMember_ConfersTheGroupsTenants()
{
var (svc, repo) = Build();
ReturnsGroups(repo, Assignment(SelfServiceGroup));
GroupTenants(repo, SelfServiceGroup, TenantA, TenantB);
OwnerMember(repo, Upn, enabled: true);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantA));
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantB));
}
[Theory]
[InlineData(false)] // disabled under Access Management
[InlineData(null)] // removed — a failed read reaches the gate the same way (the repository returns null)
public async Task GetScope_SelfServiceGroup_WithoutAnEnabledOwnerMembership_ConfersNothing(bool? enabled)
{
var (svc, repo) = Build();
ReturnsGroups(repo, Assignment(SelfServiceGroup));
GroupTenants(repo, SelfServiceGroup, TenantA);
OwnerMember(repo, Upn, enabled);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.True(scope.IsEmpty);
repo.Verify(r => r.GetGroupMembershipAsync(It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task GetScope_SelfServiceGroup_MembershipInAnotherTenant_DoesNotCount()
{
var (svc, repo) = Build();
ReturnsGroups(repo, Assignment(SelfServiceGroup));
GroupTenants(repo, SelfServiceGroup, TenantA);
repo.Setup(r => r.GetTenantMemberAsync(TenantC, Upn)).ReturnsAsync(new TenantMember { TenantId = TenantC, Upn = Upn });
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.True(scope.IsEmpty);
repo.Verify(r => r.GetTenantMemberAsync(HomeTenant, Upn), Times.Once);
}
[Fact]
public async Task GetScope_SelfServiceGroup_ApplicationPrincipal_IsGatedOnItsAppKeyRow()
{
const string appKey = "app:0a0a0a0a-0000-0000-0000-000000000001";
var (svc, repo) = Build();
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(appKey)).ReturnsAsync(new List<TenantGroupAssignment>
{
new() { Upn = appKey, GroupId = SelfServiceGroup, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true },
});
GroupTenants(repo, SelfServiceGroup, TenantA);
var app = new AdminIdentity(appKey, HomeTenant, Oid);
Assert.True((await svc.GetScopeAsync(app)).IsEmpty);
OwnerMember(repo, appKey, enabled: true);
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, (await svc.GetFreshScopeAsync(app)).RoleFor(TenantA));
}
[Fact]
public async Task GetScope_OperatorGroup_NeverReadsTenantMembership()
{
// Operator groups (GUID ids) are granted by a Global Admin to people who are not members of any owner.
var (svc, repo) = Build();
ReturnsGroups(repo, Assignment(GroupId1));
GroupTenants(repo, GroupId1, TenantA);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantA));
repo.Verify(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task GetScope_DirectGrantAndOperatorGroup_SurviveASuppressedSelfServiceGroup()
{
var (svc, repo) = Build();
Returns(repo, Row(TenantC));
ReturnsGroups(repo, Assignment(SelfServiceGroup), Assignment(GroupId1));
GroupTenants(repo, SelfServiceGroup, TenantA);
GroupTenants(repo, GroupId1, TenantB);
OwnerMember(repo, Upn, enabled: false);
var scope = await svc.GetScopeAsync(Id(Upn));
Assert.Null(scope.RoleFor(TenantA));
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantB));
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantC));
}
[Fact]
public async Task GetScope_OwnerMembershipRead_IsPartOfTheCachedRowScope()
{
var (svc, repo) = Build();
ReturnsGroups(repo, Assignment(SelfServiceGroup));
GroupTenants(repo, SelfServiceGroup, TenantA);
OwnerMember(repo, Upn, enabled: true);
await svc.GetScopeAsync(Id(Upn));
await svc.GetScopeAsync(Id(Upn));
repo.Verify(r => r.GetTenantMemberAsync(HomeTenant, Upn), Times.Once);
}
[Fact]
public async Task GetFreshScope_BypassesAWarmCache_AndRefreshesIt()
{
// A SignalR re-join right after a revoke must not be admitted by a scope cached before it.
var (svc, repo) = Build();
ReturnsGroups(repo, Assignment(SelfServiceGroup));
GroupTenants(repo, SelfServiceGroup, TenantA);
OwnerMember(repo, Upn, enabled: true);
Assert.NotNull((await svc.GetScopeAsync(Id(Upn))).RoleFor(TenantA)); // warms this instance's cache
OwnerMember(repo, Upn, enabled: null); // removed elsewhere — this cache was not invalidated
Assert.NotNull((await svc.GetScopeAsync(Id(Upn))).RoleFor(TenantA)); // the cached answer (≤ 30 s)
Assert.True((await svc.GetFreshScopeAsync(Id(Upn))).IsEmpty);
Assert.True((await svc.GetScopeAsync(Id(Upn))).IsEmpty); // written back: the cache now agrees
repo.Verify(r => r.GetTenantMemberAsync(HomeTenant, Upn), Times.Exactly(2));
}
[Fact]
public async Task GetFreshScope_NullIdentity_IsEmpty_WithoutTouchingStorage()
{
var (svc, repo) = Build();
Assert.True((await svc.GetFreshScopeAsync(null)).IsEmpty);
repo.Verify(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()), Times.Never);
}
[Theory]
[InlineData("msp-99999999-9999-9999-9999-999999999999", "99999999-9999-9999-9999-999999999999")]
[InlineData("tpl-aaaaaaaa", null)]
[InlineData("0f8fad5bd9cb469fa16570867728950e", null)]
[InlineData("msp-", null)]
[InlineData("MSP-99999999-9999-9999-9999-999999999999", null)]
[InlineData(null, null)]
public void TenantGroupIds_OwnerOf_IsTheInverseOfForHomeTenant_ForSelfServiceIdsOnly(string? groupId, string? expectedOwner)
{
Assert.Equal(expectedOwner, Constants.TenantGroupIds.OwnerOf(groupId));
}
}
@@ -500,6 +500,119 @@ public class DelegationSelfServiceTests
Assert.Equal(404, r.Failure!.Status);
}
// ── Assignees are ENABLED members of the managing tenant — at grant time, and when the membership ends ──
private static void AssignedToTheGroup(Harness h) =>
h.Repo.Setup(x => x.GetGroupAssignmentsForUpnAsync(MspUser))
.ReturnsAsync(new List<TenantGroupAssignment> { new() { Upn = MspUser, GroupId = GroupId } });
private static void VerifyNoAuditOf(Harness h, string action) =>
h.Audit.Verify(a => a.LogAuditEntryAsync(It.IsAny<string>(), action, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<Dictionary<string, string>?>()), Times.Never);
[Fact]
public async Task Assign_DisabledMember_400_NothingWritten()
{
var h = Build();
h.GroupTenants.Add(Customer);
h.Repo.Setup(x => x.GetTenantMemberAsync(Home, MspUser))
.ReturnsAsync(new TenantMember { Upn = MspUser, TenantId = Home, Role = "Operator", IsEnabled = false });
var r = await h.Svc.AssignAsync(Home, MspUser, MspAdmin);
Assert.Equal(400, r.Failure!.Status);
Assert.Equal(Constants.DelegationCodes.NotATenantMember, r.Failure.Code);
h.Repo.Verify(x => x.AssignGroupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<string>()), Times.Never);
h.Repo.Verify(x => x.EnsureOwnedTenantGroupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
VerifyNoAuditOf(h, "CREATE");
}
[Fact]
public async Task Assign_MemberRemovedWhileAssigning_IsRolledBack_400_NoCreateAudit()
{
// The removal wrote the member row before this assignment existed, so its cleanup found nothing to end;
// the re-read after the write catches it instead.
var h = Build();
h.GroupTenants.Add(Customer);
h.Repo.SetupSequence(x => x.GetTenantMemberAsync(Home, MspUser))
.ReturnsAsync(new TenantMember { Upn = MspUser, TenantId = Home, Role = "Operator" })
.ReturnsAsync((TenantMember?)null);
AssignedToTheGroup(h);
var r = await h.Svc.AssignAsync(Home, MspUser, MspAdmin);
Assert.Equal(400, r.Failure!.Status);
Assert.Equal(Constants.DelegationCodes.NotATenantMember, r.Failure.Code);
h.Repo.Verify(x => x.AssignGroupAsync(MspUser, GroupId, Constants.DelegatedRoles.DelegatedReader, true, MspAdmin), Times.Once);
h.Repo.Verify(x => x.UnassignGroupAsync(MspUser, GroupId), Times.Once);
VerifyNoAuditOf(h, "CREATE");
}
[Fact]
public async Task AssignMember_ReasonAndActorReachEveryCustomersAudit()
{
var h = Build();
h.GroupTenants.Add(Customer);
h.GroupTenants.Add(Other);
var r = await h.Svc.AssignMemberAsync(Home, MspUser, "GA@Vendor.Example", DelegationSelfService.AssigneeReasons.GroupAssigned);
Assert.True(r.Ok);
foreach (var tenant in new[] { Customer, Other })
{
h.Audit.Verify(a => a.LogAuditEntryAsync(tenant, "CREATE", "DelegatedGroupAccess", MspUser, "ga@vendor.example",
It.Is<Dictionary<string, string>?>(d => d != null && d["Reason"] == "group-assigned" && d["HomeTenantId"] == Home)), Times.Once);
}
}
[Fact]
public async Task EndAssignment_Assigned_UnassignsAndAuditsEveryCustomerWithTheReason_WithoutDisconnecting()
{
var h = Build();
h.GroupTenants.Add(Customer);
h.GroupTenants.Add(Other);
AssignedToTheGroup(h);
var ended = await h.Svc.EndAssignmentAsync(Home, "Analyst@Partner.Example", "Admin@Partner.Example", DelegationSelfService.AssigneeReasons.MemberRemoved);
Assert.True(ended);
h.Repo.Verify(x => x.UnassignGroupAsync(MspUser, GroupId), Times.Once);
foreach (var tenant in new[] { Customer, Other })
{
h.Audit.Verify(a => a.LogAuditEntryAsync(tenant, "DELETE", "DelegatedGroupAccess", MspUser, MspAdmin,
It.Is<Dictionary<string, string>?>(d => d != null && d["Reason"] == "member-removed" && d["GroupId"] == GroupId)), Times.Once);
}
Assert.Empty(h.SignalR.DisconnectedUsers); // the caller cuts the streams after its own writes
}
[Fact]
public async Task EndAssignment_NotAssigned_IsANoOp_WithoutAudit()
{
var h = Build();
h.GroupTenants.Add(Customer);
var ended = await h.Svc.EndAssignmentAsync(Home, MspUser, MspAdmin, DelegationSelfService.AssigneeReasons.MemberDisabled);
Assert.False(ended);
h.Repo.Verify(x => x.UnassignGroupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
VerifyNoAuditOf(h, "DELETE");
}
[Fact]
public async Task Unassign_Assigned_AuditsTheSelfServiceReason_AndDisconnectsOnce()
{
var h = Build();
h.GroupTenants.Add(Customer);
AssignedToTheGroup(h);
var r = await h.Svc.UnassignAsync(Home, MspUser, MspAdmin);
Assert.True(r.Ok);
h.Audit.Verify(a => a.LogAuditEntryAsync(Customer, "DELETE", "DelegatedGroupAccess", MspUser, MspAdmin,
It.Is<Dictionary<string, string>?>(d => d != null && d["Reason"] == "self-service-unassigned")), Times.Once);
Assert.Equal(new[] { MspUser }, h.SignalR.DisconnectedUsers);
}
// ── Managers (customer side) ────────────────────────────────────────────────
[Fact]
@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// In-memory <see cref="ISelfServiceAssignees"/> for the function tests: records every call, and — paired with a
/// <see cref="FakeSignalRNotificationService"/> — how many disconnects had happened when it was called, which is
/// the proof that a handler ends the assignment BEFORE it cuts the live streams.
/// </summary>
public class FakeSelfServiceAssignees : ISelfServiceAssignees
{
private readonly FakeSignalRNotificationService? _signalR;
public FakeSelfServiceAssignees(FakeSignalRNotificationService? signalR = null) => _signalR = signalR;
public List<(string HomeTenantId, string Upn, string Actor, string Reason, int DisconnectsBefore)> Ended { get; } = new();
public List<(string HomeTenantId, string Upn, string Actor, string Reason)> Assigned { get; } = new();
/// <summary>When set, <see cref="EndAssignmentAsync"/> throws it (fail-soft path of the caller).</summary>
public Exception? EndThrows { get; set; }
/// <summary>When set, <see cref="AssignMemberAsync"/> returns it instead of a success.</summary>
public DelegationFailure? AssignFails { get; set; }
public Task<bool> EndAssignmentAsync(string homeTenantId, string upn, string actor, string reason)
{
Ended.Add((homeTenantId, upn, actor, reason, _signalR?.DisconnectedUsers.Count ?? 0));
if (EndThrows != null)
throw EndThrows;
return Task.FromResult(true);
}
public Task<DelegationResult<TenantGroupAssignment>> AssignMemberAsync(string homeTenantId, string upn, string actor, string reason)
{
Assigned.Add((homeTenantId, upn, actor, reason));
return Task.FromResult(AssignFails is { } failure
? DelegationResult<TenantGroupAssignment>.Fail(failure.Status, failure.Code, failure.Message)
: DelegationResult<TenantGroupAssignment>.Success(new TenantGroupAssignment { Upn = upn, GroupId = homeTenantId }));
}
}
@@ -1,5 +1,6 @@
using System.IO;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Services;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
@@ -9,7 +10,8 @@ namespace AutopilotMonitor.Functions.Tests;
/// HTTP route, and the raw table/log proxies rely on the <c>RequestContext</c> it resolves. Nothing
/// at runtime would notice if the registration in <c>Program.cs</c> were dropped or moved in front of
/// <see cref="AuthenticationMiddleware"/> (the policy step reads the principal the auth step stores) —
/// the routes would simply open up. This source scan pins the registration and its order.
/// the routes would simply open up. This source scan pins the registration and its order, plus the one
/// service registration that ending delegated access depends on.
/// </summary>
public class MiddlewarePipelineOrderTests
{
@@ -87,6 +89,17 @@ public class MiddlewarePipelineOrderTests
}
}
[Fact]
public void Self_service_assignee_rule_is_registered_as_the_delegation_service()
{
// Access Management (member remove/disable) and the GA group route take it by constructor; without the
// registration every route of both classes fails to activate, and nothing checks DI before a request.
var src = ProgramSource();
Assert.Equal(1, Count(src,
$"AddSingleton<{nameof(ISelfServiceAssignees)}>(sp => sp.GetRequiredService<{nameof(DelegationSelfService)}>())"));
}
private static int Count(string haystack, string needle)
{
var count = 0;
@@ -646,6 +646,57 @@ public class PolicyEnforcementMiddlewareTests
Assert.True(result.Context!.IsDelegatedReader);
}
/// <summary>The caller is assigned to <paramref name="home"/>'s self-service group, which manages <paramref name="managed"/>.</summary>
private static void AsSelfServiceAssignee(Harness h, string home, string managed)
{
var groupId = Constants.TenantGroupIds.ForHomeTenant(home);
h.Repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()))
.ReturnsAsync(new List<TenantGroupAssignment>
{
new() { GroupId = groupId, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true },
});
h.Repo.Setup(r => r.GetGroupMembershipAsync(groupId))
.ReturnsAsync(new TenantGroupMembership { GroupId = groupId, TenantIds = new List<string> { managed } });
}
[Fact]
public async Task Delegated_SelfServiceAssignee_RemovedFromTheManagingTenant_IsForbiddenOnTheManagedTenant()
{
// A member removed or disabled under Access Management may still hold the msp-{home} assignment row;
// it no longer rescues them into the customers the managing tenant manages.
const string upn = "leaver@partner.example";
var h = BuildHarness();
AsSelfServiceAssignee(h, home: TenantA, managed: TenantB); // harness default: no TenantAdmins row anywhere
var result = await h.Middleware.DecideAsync(
"GET", "/api/sessions/abc-123/events", TenantB, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
}
[Fact]
public async Task Delegated_SelfServiceAssignee_EnabledInTheManagingTenant_ReadsTheManagedTenant()
{
const string upn = "analyst@partner.example";
var h = BuildHarness();
AsSelfServiceAssignee(h, home: TenantA, managed: TenantB);
h.Repo.Setup(r => r.GetTenantMemberAsync(TenantA, upn)).ReturnsAsync(new TenantMember
{
Upn = upn,
TenantId = TenantA,
Role = Constants.TenantRoles.Viewer,
IsEnabled = true,
});
var result = await h.Middleware.DecideAsync(
"GET", "/api/sessions/abc-123/events", TenantB, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
Assert.Equal(TenantB, result.Context!.TargetTenantId);
Assert.True(result.Context!.IsDelegatedReader);
}
[Fact]
public async Task Delegated_SessionMutation_IsForbidden()
{
@@ -77,7 +77,8 @@ public class RevokeEnforcementTests
}
private static (TenantGroupManagementFunction Fn, Mock<IAdminRepository> Repo,
Mock<IMaintenanceRepository> Audit, FakeSignalRNotificationService SignalR) BuildGroupFn(ProConferralService? proConferral = null)
Mock<IMaintenanceRepository> Audit, FakeSignalRNotificationService SignalR) BuildGroupFn(
ProConferralService? proConferral = null, ISelfServiceAssignees? assignees = null)
{
var repo = new Mock<IAdminRepository>();
// Loose-mock defaults return a null Task — every repo read the flows touch needs a concrete setup.
@@ -90,7 +91,7 @@ public class RevokeEnforcementTests
var signalR = new FakeSignalRNotificationService();
var fn = new TenantGroupManagementFunction(
NullLogger<TenantGroupManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, signalR, BuildSlots(repo),
proConferral ?? TestProConferral.Inert());
proConferral ?? TestProConferral.Inert(), assignees ?? new FakeSelfServiceAssignees(signalR));
return (fn, repo, audit, signalR);
}
@@ -475,4 +476,79 @@ public class RevokeEnforcementTests
Assert.Equal(TenantGroupManagementFunction.GroupUpdateOutcome.NotFound,
await fn.UpdateGroupCoreAsync("tpl-missing", new UpdateTenantGroupRequest { Name = "Renamed" }, "ga@vendor.example"));
}
// --- A Global Admin assigning into a tenant's self-service group gets the self-service rule ---
[Fact]
public async Task AssignToOwnedGroupCore_AppliesTheSelfServiceRule_AsTheGlobalAdmin()
{
var assignees = new FakeSelfServiceAssignees();
var (fn, _, audit, _) = BuildGroupFn(assignees: assignees);
var failure = await fn.AssignToOwnedGroupCoreAsync(
OwnerTenant, UpnLower, Constants.DelegatedRoles.DelegatedReader, null, null, "ga@vendor.example");
Assert.Null(failure);
var assigned = Assert.Single(assignees.Assigned);
Assert.Equal((OwnerTenant, UpnLower, "ga@vendor.example", DelegationSelfService.AssigneeReasons.GroupAssigned), assigned);
VerifyNoAudit(audit); // the rule writes the customers' CREATE rows itself
}
[Fact]
public async Task AssignToOwnedGroupCore_DelegatedAdminRole_IsRefused_RuleNotConsulted()
{
var assignees = new FakeSelfServiceAssignees();
var (fn, _, _, _) = BuildGroupFn(assignees: assignees);
var failure = await fn.AssignToOwnedGroupCoreAsync(
OwnerTenant, UpnLower, Constants.DelegatedRoles.DelegatedAdmin, null, null, "ga@vendor.example");
Assert.Equal(400, failure?.Status);
Assert.Empty(assignees.Assigned);
}
[Theory]
[InlineData("44444444-4444-4444-4444-444444444444", null)]
[InlineData(null, "55555555-5555-5555-5555-555555555555")]
[InlineData(OwnerTenant, "55555555-5555-5555-5555-555555555555")]
public async Task AssignToOwnedGroupCore_ForeignHomeOrObjectId_IsRefused_RuleNotConsulted(string? homeTenantId, string? objectId)
{
var assignees = new FakeSelfServiceAssignees();
var (fn, _, _, _) = BuildGroupFn(assignees: assignees);
var failure = await fn.AssignToOwnedGroupCoreAsync(
OwnerTenant, UpnLower, Constants.DelegatedRoles.DelegatedReader, homeTenantId, objectId, "ga@vendor.example");
Assert.Equal(400, failure?.Status);
Assert.Empty(assignees.Assigned);
}
[Fact]
public async Task AssignToOwnedGroupCore_HomeNamingTheOwner_Passes()
{
var assignees = new FakeSelfServiceAssignees();
var (fn, _, _, _) = BuildGroupFn(assignees: assignees);
var failure = await fn.AssignToOwnedGroupCoreAsync(
OwnerTenant, UpnLower, Constants.DelegatedRoles.DelegatedReader, OwnerTenant.ToUpperInvariant(), null, "ga@vendor.example");
Assert.Null(failure);
Assert.Single(assignees.Assigned);
}
[Theory]
[InlineData(400, Constants.DelegationCodes.NotATenantMember)]
[InlineData(403, Constants.DelegationCodes.DelegatedAdminNotAllowed)]
[InlineData(409, Constants.DelegationCodes.IdentityBindingConflict)]
public async Task AssignToOwnedGroupCore_RuleFailure_IsPassedThrough(int status, string code)
{
var assignees = new FakeSelfServiceAssignees { AssignFails = new DelegationFailure(status, code, "refused") };
var (fn, _, _, _) = BuildGroupFn(assignees: assignees);
var failure = await fn.AssignToOwnedGroupCoreAsync(
OwnerTenant, UpnLower, Constants.DelegatedRoles.DelegatedReader, null, null, "ga@vendor.example");
Assert.Equal(status, failure?.Status);
Assert.Equal(code, failure?.Code);
}
}
@@ -184,6 +184,73 @@ public class TenantAdminManagementFunctionTests
Assert.Equal(new[] { "user@contoso.com" }, h.SignalR.DisconnectedUsers);
}
// ── removal and disable end the member's self-service delegation, before the stream cut ──
[Fact]
public async Task Remove_ends_the_self_service_assignment_before_cutting_streams()
{
var h = BuildHarness();
var (req, ctx) = BuildRequest(new { });
var response = await h.Function.RemoveTenantAdmin(req, TenantId, "Leaver@Contoso.com", ctx);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var ended = Assert.Single(h.Assignees.Ended);
Assert.Equal(TenantId, ended.HomeTenantId);
Assert.Equal("leaver@contoso.com", ended.Upn.ToLowerInvariant());
Assert.Equal(CallerUpn, ended.Actor);
Assert.Equal(DelegationSelfService.AssigneeReasons.MemberRemoved, ended.Reason);
Assert.Equal(0, ended.DisconnectsBefore);
Assert.Equal(new[] { "leaver@contoso.com" }, h.SignalR.DisconnectedUsers);
}
[Fact]
public async Task Disable_ends_the_self_service_assignment_before_cutting_streams()
{
var h = BuildHarness();
var (req, ctx) = BuildRequest(new { });
var response = await h.Function.DisableTenantAdmin(req, TenantId, "Leaver@Contoso.com", ctx);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var ended = Assert.Single(h.Assignees.Ended);
Assert.Equal(TenantId, ended.HomeTenantId);
Assert.Equal("leaver@contoso.com", ended.Upn.ToLowerInvariant());
Assert.Equal(DelegationSelfService.AssigneeReasons.MemberDisabled, ended.Reason);
Assert.Equal(0, ended.DisconnectsBefore);
Assert.Equal(new[] { "leaver@contoso.com" }, h.SignalR.DisconnectedUsers);
}
[Fact]
public async Task Enable_add_and_role_change_never_end_an_assignment()
{
var h = BuildHarness();
var (enable, ctx1) = BuildRequest(new { });
await h.Function.EnableTenantAdmin(enable, TenantId, "user@contoso.com", ctx1);
var (add, ctx2) = BuildRequest(new { upn = "new@contoso.com", role = "viewer" });
await h.Function.AddTenantAdmin(add, TenantId, ctx2);
var (demote, ctx3) = BuildRequest(new { role = "viewer", canManageBootstrapTokens = false });
await h.Function.UpdateMemberPermissions(demote, TenantId, "user@contoso.com", ctx3);
Assert.Empty(h.Assignees.Ended);
}
[Fact]
public async Task Remove_still_succeeds_and_cuts_streams_when_ending_the_assignment_fails()
{
// Fail-soft: the scope resolution ignores an assignment without an enabled membership anyway.
var h = BuildHarness();
h.Assignees.EndThrows = new InvalidOperationException("storage unavailable");
var (req, ctx) = BuildRequest(new { });
var response = await h.Function.RemoveTenantAdmin(req, TenantId, "leaver@contoso.com", ctx);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Single(h.Assignees.Ended);
Assert.Equal(new[] { "leaver@contoso.com" }, h.SignalR.DisconnectedUsers);
}
// ── harness ─────────────────────────────────────────────────────────────
private sealed class Harness
@@ -191,6 +258,7 @@ public class TenantAdminManagementFunctionTests
public TenantAdminManagementFunction Function = default!;
public Mock<IAdminRepository> AdminRepo = default!;
public FakeSignalRNotificationService SignalR = default!;
public FakeSelfServiceAssignees Assignees = default!;
}
private static Harness BuildHarness()
@@ -210,13 +278,15 @@ public class TenantAdminManagementFunctionTests
var maintenanceRepo = new Mock<IMaintenanceRepository>(MockBehavior.Loose);
var signalR = new FakeSignalRNotificationService();
var assignees = new FakeSelfServiceAssignees(signalR);
return new Harness
{
Function = new TenantAdminManagementFunction(
NullLogger<TenantAdminManagementFunction>.Instance, service, maintenanceRepo.Object, signalR),
NullLogger<TenantAdminManagementFunction>.Instance, service, maintenanceRepo.Object, signalR, assignees),
AdminRepo = adminRepo,
SignalR = signalR,
Assignees = assignees,
};
}
@@ -20,17 +20,39 @@ public class TenantAdminManagementFunction
private readonly TenantAdminsService _tenantAdminsService;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly ISignalRNotificationService _signalRService;
private readonly ISelfServiceAssignees _selfServiceAssignees;
public TenantAdminManagementFunction(
ILogger<TenantAdminManagementFunction> logger,
TenantAdminsService tenantAdminsService,
IMaintenanceRepository maintenanceRepo,
ISignalRNotificationService signalRService)
ISignalRNotificationService signalRService,
ISelfServiceAssignees selfServiceAssignees)
{
_logger = logger;
_tenantAdminsService = tenantAdminsService;
_maintenanceRepo = maintenanceRepo;
_signalRService = signalRService;
_selfServiceAssignees = selfServiceAssignees;
}
/// <summary>
/// A member who is removed or disabled loses their self-service delegation (read access to every tenant this
/// tenant manages) with it: the assignment ends and each managed customer's trail records the loss. Runs after
/// the member write and before <see cref="CutLiveStreamsAsync"/>, so the re-join sees both. Fail-soft — the scope
/// resolution ignores an assignment without an enabled membership anyway, so a failure here costs only the
/// cleanup and the customer audit row.
/// </summary>
private async Task EndSelfServiceAssignmentAsync(string tenantId, string adminUpn, string actor, string reason)
{
try
{
await _selfServiceAssignees.EndAssignmentAsync(tenantId, adminUpn, actor, reason);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Ending the self-service delegation of {AdminUpn} in tenant {TenantId} failed ({Reason})", adminUpn, tenantId, reason);
}
}
/// <summary>
@@ -187,6 +209,7 @@ public class TenantAdminManagementFunction
upn!
);
await EndSelfServiceAssignmentAsync(requestCtx.TargetTenantId, adminUpn, upn!, DelegationSelfService.AssigneeReasons.MemberRemoved);
await CutLiveStreamsAsync(adminUpn);
_logger.LogInformation($"Tenant Admin removed: {adminUpn} from tenant {requestCtx.TargetTenantId} by {upn}");
@@ -223,6 +246,7 @@ public class TenantAdminManagementFunction
new Dictionary<string, string> { { "Action", "Disable" } }
);
await EndSelfServiceAssignmentAsync(requestCtx.TargetTenantId, adminUpn, upn!, DelegationSelfService.AssigneeReasons.MemberDisabled);
await CutLiveStreamsAsync(adminUpn);
_logger.LogInformation($"Tenant Admin disabled: {adminUpn} for tenant {requestCtx.TargetTenantId} by {upn}");
@@ -26,6 +26,9 @@ namespace AutopilotMonitor.Functions.Functions.Admin;
/// customer sees "who can read my tenant" / "access removed"); the group appears there under its customer-facing
/// name (<see cref="DelegationSelfService.CustomerNameOf"/>), never its internal name. Group create/rename/label
/// carry no access change — they are logged operationally only (no AuditLogs partition).
///
/// A tenant's self-service group (<see cref="Constants.TenantGroupIds"/>) keeps the self-service assignee rule
/// even here: a Global Admin assigns only enabled members of the owning tenant, read-only, bound to it as home.
/// </summary>
public class TenantGroupManagementFunction
{
@@ -36,6 +39,7 @@ public class TenantGroupManagementFunction
private readonly ISignalRNotificationService _signalRService;
private readonly DelegatedSlotService _slots;
private readonly ProConferralService _proConferral;
private readonly ISelfServiceAssignees _selfServiceAssignees;
private const string AuditEntity = "DelegatedGroupAccess";
@@ -46,7 +50,8 @@ public class TenantGroupManagementFunction
IMaintenanceRepository maintenanceRepo,
ISignalRNotificationService signalRService,
DelegatedSlotService slots,
ProConferralService proConferral)
ProConferralService proConferral,
ISelfServiceAssignees selfServiceAssignees)
{
_logger = logger;
_delegatedAdminService = delegatedAdminService;
@@ -55,6 +60,7 @@ public class TenantGroupManagementFunction
_signalRService = signalRService;
_slots = slots;
_proConferral = proConferral;
_selfServiceAssignees = selfServiceAssignees;
}
/// <summary>The managing tenant that owns <paramref name="groupId"/> (self-service group), or null for operator groups / unknown groups.</summary>
@@ -319,7 +325,8 @@ public class TenantGroupManagementFunction
/// "homeTenantId": "&lt;optional override&gt;", "objectId": "&lt;optional&gt;" }.
/// The UPN's identity binding is resolved automatically (sign-in history, then UPN domain → onboarded
/// tenant); 422 HomeTenantUnresolved when neither works, 409 if the UPN is already bound elsewhere
/// (rebind explicitly via global/identity-bindings).
/// (rebind explicitly via global/identity-bindings). A tenant's self-service group takes only enabled members of
/// its owner, read-only and bound to the owner (<see cref="AssignToOwnedGroupCoreAsync"/>).
/// </summary>
[Function("AssignTenantGroup")]
[Authorize]
@@ -350,6 +357,16 @@ public class TenantGroupManagementFunction
return await NotFound(req);
var upn = body.Upn.ToLowerInvariant();
var owner = Constants.TenantGroupIds.OwnerOf(group.GroupId);
if (owner != null)
{
var failure = await AssignToOwnedGroupCoreAsync(owner, upn, role, body.HomeTenantId, body.ObjectId, currentUpn);
return failure == null
? await req.CreatedAsync(new MessageResponse { Message = "Assigned to group" })
: await DelegationSelfServiceFunction.FailAsync(req, failure);
}
var identity = await IdentityBindingRules.ResolveForGrantAsync(_identityResolver, upn, body.HomeTenantId, body.ObjectId);
if (identity == null)
{
@@ -383,7 +400,7 @@ public class TenantGroupManagementFunction
{ "Group", DelegationSelfService.CustomerNameOf(group) },
{ "GroupId", groupId },
{ "Role", role },
{ "Reason", "group-assigned" },
{ "Reason", DelegationSelfService.AssigneeReasons.GroupAssigned },
});
_logger.LogInformation("Group {GroupId} assigned to {Upn} ({Role}) by {By}", groupId, upn, role, currentUpn);
@@ -391,6 +408,30 @@ public class TenantGroupManagementFunction
return await req.CreatedAsync(new MessageResponse { Message = "Assigned to group" });
}
/// <summary>
/// Testable core of a Global Admin assignment into a tenant's self-service group. The self-service assignee rule
/// applies (<see cref="ISelfServiceAssignees.AssignMemberAsync"/>): an enabled member of the owning tenant, bound to
/// it as home, read-only. The group's tenants already occupy the owner's slots, so no slot check. Null on success.
/// </summary>
internal async Task<DelegationFailure?> AssignToOwnedGroupCoreAsync(
string ownerTenantId, string upn, string role, string? homeTenantId, string? objectId, string? currentUpn)
{
if (role != Constants.DelegatedRoles.DelegatedReader)
return new DelegationFailure(400, Constants.ApiErrorCodes.BadRequest,
$"A self-service group grants read-only access; role must be '{Constants.DelegatedRoles.DelegatedReader}'.");
if (!string.IsNullOrWhiteSpace(objectId)
|| (!string.IsNullOrWhiteSpace(homeTenantId) && !string.Equals(homeTenantId, ownerTenantId, StringComparison.OrdinalIgnoreCase)))
return new DelegationFailure(400, Constants.ApiErrorCodes.BadRequest,
"Assignees of a self-service group are members of the owning tenant and are bound to it; omit homeTenantId and objectId.");
var result = await _selfServiceAssignees.AssignMemberAsync(ownerTenantId, upn, currentUpn ?? "", DelegationSelfService.AssigneeReasons.GroupAssigned);
if (!result.Ok)
return result.Failure;
_logger.LogInformation("Self-service group of {OwnerTenantId} assigned to {Upn} by {By}", ownerTenantId, upn, currentUpn);
return null;
}
/// <summary>DELETE /api/global/tenant-groups/{groupId}/assignees/{upn} — unassign a UPN. GlobalAdminOnly.</summary>
[Function("UnassignTenantGroup")]
[Authorize]
@@ -431,7 +472,7 @@ public class TenantGroupManagementFunction
{
{ "Group", DelegationSelfService.CustomerNameOf(group) },
{ "GroupId", groupId },
{ "Reason", "group-unassigned" },
{ "Reason", DelegationSelfService.AssigneeReasons.GroupUnassigned },
});
}
@@ -92,8 +92,10 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
{
// realtime/groups/join is not a tenant-scoped route, so the middleware did not
// resolve the delegated scope onto RequestContext — resolve it here, bounded to
// the requested tenant only. Home tid gates the MSP seat (Pro-only).
var scope = await _delegatedAdminService.GetScopeAsync(AdminIdentity.FromRequestContext(requestCtx));
// the requested tenant only. Home tid gates the MSP seat (Pro-only). FRESH, not
// cached: a join streams until the connection drops, and after a revoke the client
// re-joins at once — possibly on an instance whose cached scope predates the revoke.
var scope = await _delegatedAdminService.GetFreshScopeAsync(AdminIdentity.FromRequestContext(requestCtx));
allowedCrossTenant = scope.RoleFor(requestedTenantId) != null;
if (allowedCrossTenant)
_logger.LogInformation($"Delegated user {userEmail} joining managed cross-tenant group: {request.GroupName}");
@@ -271,6 +271,8 @@ builder.Services.AddSingleton<GlobalAdminService>();
builder.Services.AddSingleton<DelegatedAdminService>();
builder.Services.AddSingleton<DelegatedSlotService>();
builder.Services.AddSingleton<DelegationSelfService>();
// The self-service assignee rule for Access Management (member remove/disable) and the GA group route.
builder.Services.AddSingleton<ISelfServiceAssignees>(sp => sp.GetRequiredService<DelegationSelfService>());
builder.Services.AddSingleton<McpUserService>();
builder.Services.AddSingleton<McpClientRegistrationService>();
builder.Services.AddSingleton<PreviewWhitelistService>();
@@ -20,6 +20,10 @@ namespace AutopilotMonitor.Functions.Services;
/// GATED on the caller's home tenant (JWT tid) being Pro — the MSP seat is a Pro capability of the tenant
/// the admin is homed in, while the managed targets may be any edition. Row resolution is cached briefly
/// (UPN-keyed; binding + gate applied per call); every mutation invalidates the cache.
///
/// An assignment to a tenant's self-service group (<see cref="Constants.TenantGroupIds"/>) confers scope only
/// while its assignee is an ENABLED member of the owning tenant — re-checked at every row resolution, so a
/// person removed or disabled under Access Management stops reading the managed customers on every path.
/// </summary>
public class DelegatedAdminService
{
@@ -66,12 +70,24 @@ public class DelegatedAdminService
/// and a rebind / home-tenant edition change converges within the respective cache TTLs.
/// </para>
/// </summary>
public virtual async Task<DelegatedScope> GetScopeAsync(AdminIdentity? identity)
public virtual Task<DelegatedScope> GetScopeAsync(AdminIdentity? identity)
=> ResolveScopeAsync(identity, bypassRowCache: false);
/// <summary>
/// <see cref="GetScopeAsync"/> with the assignment rows and the self-service membership read fresh, never
/// from this instance's cache (the fresh result is written back). For decisions that outlive the request —
/// a SignalR group join keeps streaming until the connection drops, and the client re-joins right after a
/// revoke, possibly on an instance whose cached scope predates it. Binding and entitlement keep their caches.
/// </summary>
public virtual Task<DelegatedScope> GetFreshScopeAsync(AdminIdentity? identity)
=> ResolveScopeAsync(identity, bypassRowCache: true);
private async Task<DelegatedScope> ResolveScopeAsync(AdminIdentity? identity, bool bypassRowCache)
{
if (identity == null)
return DelegatedScope.Empty;
var scope = await GetRowScopeAsync(identity.Upn);
var scope = await GetRowScopeAsync(identity.Upn, bypassRowCache);
if (scope.IsEmpty)
return scope;
@@ -89,11 +105,11 @@ public class DelegatedAdminService
}
/// <summary>The scope the assignment ROWS (direct + group-derived) confer on a UPN, ignoring identity binding
/// and entitlement. Cached briefly (UPN-keyed).</summary>
private async Task<DelegatedScope> GetRowScopeAsync(string upn)
/// and entitlement. Cached briefly (UPN-keyed); <paramref name="bypassCache"/> skips the read, never the write.</summary>
private async Task<DelegatedScope> GetRowScopeAsync(string upn, bool bypassCache)
{
var cacheKey = $"delegated-scope:{upn}";
if (_cache.TryGetValue<DelegatedScope>(cacheKey, out var cached) && cached != null)
if (!bypassCache && _cache.TryGetValue<DelegatedScope>(cacheKey, out var cached) && cached != null)
return cached;
var rows = await _adminRepo.GetDelegatedTenantsAsync(upn);
@@ -129,6 +145,18 @@ public class DelegatedAdminService
if (role == null)
continue;
// A self-service group's assignees are its owner's own members, so the precondition of that grant is
// re-checked here, not trusted from grant time: no enabled TenantAdmins row in the owning tenant (removed,
// disabled, or the read failed) ⇒ the assignment confers nothing.
var owner = Constants.TenantGroupIds.OwnerOf(assignment.GroupId);
if (owner != null && (await _adminRepo.GetTenantMemberAsync(owner, upn))?.IsEnabled != true)
{
_logger.LogWarning(
"[DelegatedAdmin] Ignoring the assignment of {Upn} to self-service group {GroupId}: not an enabled member of the owning tenant {OwnerTenantId}",
upn, assignment.GroupId, owner);
continue;
}
// Members from ONE partition read (meta-backed: a missing meta row ⇒ no group, no scope).
var membership = await _adminRepo.GetGroupMembershipAsync(assignment.GroupId);
if (membership == null)
@@ -529,8 +557,9 @@ public class TenantGroupEntity : ITableEntity
/// <summary>
/// A row in the <see cref="Constants.TableNames.TenantGroupAssignments"/> table: delegated-admin UPN X
/// is assigned to group T at role <see cref="Role"/>. PK = UPN (lowercase), RK = groupId. Resolving
/// a UPN's scope point-scans this by PK, then expands each group into its tenant members. Operator-managed
/// only (no PendingApproval flow); fail-closed — only enabled + recognized-role assignments confer scope.
/// a UPN's scope point-scans this by PK, then expands each group into its tenant members. Written by operators
/// and by self-service delegation (no PendingApproval flow); fail-closed — only enabled + recognized-role
/// assignments confer scope, and a self-service assignment only while its assignee is an enabled member of the owner.
/// </summary>
public class TenantGroupAssignmentEntity : ITableEntity
{
@@ -26,13 +26,32 @@ public sealed record ManagedTenantsView(DelegatedSlotUsage Slots, IReadOnlyList<
public sealed record TenantManagerView(string? GroupId, string? OwnerTenantId, string? OwnerDomain, string Name, string Source,
IReadOnlyList<TenantGroupAssignment> Assignees, DateTime? SinceUtc, bool Revocable);
/// <summary>
/// The self-service assignee rule for the routes outside the self-service surface: Access Management ends the
/// assignment of a member it removes or disables, and a Global Admin assigns into a tenant's self-service group
/// under the same rule. Implementations may throw; each caller decides how a failure surfaces.
/// </summary>
public interface ISelfServiceAssignees
{
/// <summary>
/// Ends <paramref name="upn"/>'s assignment to <paramref name="homeTenantId"/>'s self-service group and audits the
/// loss under every managed tenant with <paramref name="reason"/>. False, with nothing written, when the person
/// is not assigned. Never cuts live streams — the caller does that after its own writes.
/// </summary>
Task<bool> EndAssignmentAsync(string homeTenantId, string upn, string actor, string reason);
/// <summary>Assigns an enabled member of <paramref name="homeTenantId"/> to its self-service group (read-only), audited with <paramref name="reason"/>.</summary>
Task<DelegationResult<TenantGroupAssignment>> AssignMemberAsync(string homeTenantId, string upn, string actor, string reason);
}
/// <summary>
/// The customer-facing half of delegated ("MSP") administration — no Global Admin involved:
/// <list type="bullet">
/// <item>A Pro (managing) tenant owns exactly ONE implicit Tenant Group, <c>msp-{tenantId}</c>
/// (<see cref="Constants.TenantGroupIds"/>). Its members are the customer tenants that ACCEPTED an invitation;
/// its assignees are the managing tenant's own users. <c>DelegatedAdminService.GetScopeAsync</c>, the policy
/// middleware and the MCP server need no change — the group is an ordinary group to them.</item>
/// its assignees are the managing tenant's own ENABLED members — <c>DelegatedAdminService</c> re-checks that
/// membership at every scope resolution, and removing or disabling the member under Access Management ends the
/// assignment (<see cref="ISelfServiceAssignees"/>).</item>
/// <item>Invitations are signed single-use links (<see cref="DelegationInviteTicket"/>); 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.</item>
@@ -43,10 +62,22 @@ public sealed record TenantManagerView(string? GroupId, string? OwnerTenantId, s
/// </list>
/// Self-service grants are always <see cref="Constants.DelegatedRoles.DelegatedReader"/> (read-only).
/// </summary>
public class DelegationSelfService
public class DelegationSelfService : ISelfServiceAssignees
{
public const string SourceSelfService = "self-service";
public const string SourceOperator = "operator";
/// <summary>The <c>Reason</c> an assignee's <c>DelegatedGroupAccess</c> audit row carries under every managed tenant.</summary>
public static class AssigneeReasons
{
public const string SelfServiceAssigned = "self-service-assigned";
public const string SelfServiceUnassigned = "self-service-unassigned";
public const string GroupAssigned = "group-assigned";
public const string GroupUnassigned = "group-unassigned";
public const string MemberRemoved = "member-removed";
public const string MemberDisabled = "member-disabled";
}
/// <summary>
/// The label a managed tenant sees for operator-provisioned access: direct grants, and operator-created groups
/// without a <see cref="TenantGroup.CustomerLabel"/>. Operator groups are internal organisation — their names
@@ -315,14 +346,17 @@ public class DelegationSelfService
public Task<List<TenantGroupAssignment>> ListAssigneesAsync(string homeTenantId)
=> _delegatedAdmins.GetGroupAssigneesAsync(Constants.TenantGroupIds.ForHomeTenant(homeTenantId.ToLowerInvariant()));
public async Task<DelegationResult<TenantGroupAssignment>> AssignAsync(string homeTenantId, string upn, string actor)
public Task<DelegationResult<TenantGroupAssignment>> AssignAsync(string homeTenantId, string upn, string actor)
=> AssignMemberAsync(homeTenantId, upn, actor, AssigneeReasons.SelfServiceAssigned);
public async Task<DelegationResult<TenantGroupAssignment>> AssignMemberAsync(string homeTenantId, string upn, string actor, string reason)
{
var home = homeTenantId.ToLowerInvariant();
var normalizedUpn = upn.ToLowerInvariant();
if (!(await _entitlements.GetEntitlementsAsync(home)).DelegatedAdminAllowed)
return DelegationResult<TenantGroupAssignment>.Fail(403, Constants.DelegationCodes.DelegatedAdminNotAllowed, "Delegated administration is a Pro capability.");
if (await _adminRepo.GetTenantMemberAsync(home, normalizedUpn) == null)
return DelegationResult<TenantGroupAssignment>.Fail(400, Constants.DelegationCodes.NotATenantMember, "Only members of your own tenant (Access Management) can be assigned.");
if (!await IsEnabledMemberAsync(home, normalizedUpn))
return NotAnEnabledMember();
var groupId = await EnsureOwnedGroupAsync(home);
try
@@ -336,11 +370,20 @@ public class DelegationSelfService
return DelegationResult<TenantGroupAssignment>.Fail(409, Constants.DelegationCodes.IdentityBindingConflict, ex.Message);
}
// Removing or disabling a member writes the member row first and then ends the assignment; this side writes
// the assignment first and then reads the member again. Whichever runs second sees the other's write, so an
// assignment racing the member's removal never outlives it.
if (!await IsEnabledMemberAsync(home, normalizedUpn))
{
await _delegatedAdmins.UnassignGroupAsync(normalizedUpn, groupId);
return NotAnEnabledMember();
}
var tenants = await _adminRepo.GetGroupTenantsAsync(groupId);
foreach (var tenant in tenants)
{
await _audit.LogAuditEntryAsync(tenant, "CREATE", AuditGroupAccess, normalizedUpn, actor.ToLowerInvariant(),
new Dictionary<string, string> { { "GroupId", groupId }, { "Role", Constants.DelegatedRoles.DelegatedReader }, { "Reason", "self-service-assigned" }, { "HomeTenantId", home } });
new Dictionary<string, string> { { "GroupId", groupId }, { "Role", Constants.DelegatedRoles.DelegatedReader }, { "Reason", reason }, { "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() };
@@ -348,22 +391,36 @@ public class DelegationSelfService
}
public async Task<DelegationResult<bool>> UnassignAsync(string homeTenantId, string upn, string actor)
{
if (!await EndAssignmentAsync(homeTenantId, upn, actor, AssigneeReasons.SelfServiceUnassigned))
return DelegationResult<bool>.Fail(404, Constants.DelegationCodes.AssigneeNotFound, "That person is not assigned.");
await _signalR.DisconnectUserAsync(upn.ToLowerInvariant());
return DelegationResult<bool>.Success(true);
}
public async Task<bool> EndAssignmentAsync(string homeTenantId, string upn, string actor, string reason)
{
var home = homeTenantId.ToLowerInvariant();
var normalizedUpn = upn.ToLowerInvariant();
var groupId = Constants.TenantGroupIds.ForHomeTenant(home);
if (!await _delegatedAdmins.UnassignGroupAsync(normalizedUpn, groupId))
return DelegationResult<bool>.Fail(404, Constants.DelegationCodes.AssigneeNotFound, "That person is not assigned.");
return false;
await _signalR.DisconnectUserAsync(normalizedUpn);
foreach (var tenant in await _adminRepo.GetGroupTenantsAsync(groupId))
{
await _audit.LogAuditEntryAsync(tenant, "DELETE", AuditGroupAccess, normalizedUpn, actor.ToLowerInvariant(),
new Dictionary<string, string> { { "GroupId", groupId }, { "Reason", "self-service-unassigned" }, { "HomeTenantId", home } });
new Dictionary<string, string> { { "GroupId", groupId }, { "Reason", reason }, { "HomeTenantId", home } });
}
return DelegationResult<bool>.Success(true);
return true;
}
private async Task<bool> IsEnabledMemberAsync(string tenantId, string upn)
=> (await _adminRepo.GetTenantMemberAsync(tenantId, upn))?.IsEnabled == true;
private static DelegationResult<TenantGroupAssignment> NotAnEnabledMember()
=> DelegationResult<TenantGroupAssignment>.Fail(400, Constants.DelegationCodes.NotATenantMember, "Only enabled members of the managing tenant (Access Management) can be assigned.");
// ── Managers (managed tenant's side) ─────────────────────────────────────────
public async Task<List<TenantManagerView>> ListManagersAsync(string managedTenantId)
@@ -176,7 +176,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
/// <summary>
/// Variant D for the tenant's own self-service Tenant Group: rows whose
/// <c>RowKey == "msp-{tenantId}"</c> (TenantGroupAssignments: PK=managed tenant). Takes
/// <c>RowKey == "msp-{tenantId}"</c> (TenantGroupAssignments: PK=assignee UPN). Takes
/// the tenant GUID and derives the group id itself.
/// </summary>
public Task<int> WipeByOwnedGroupRowKeyAsync(
@@ -302,7 +302,7 @@ namespace AutopilotMonitor.Functions.Services
/// Management SDK (1.33) exposes no user-level connection close: (1) strip the user from all
/// groups server-side, and (2) push "accessRevoked" to the user's connections — the web
/// client restarts its connection on receipt, and every re-join then re-runs authorization
/// against the fresh (≤30s-cached) scope. Residual: a tampered client that ignores (2) and
/// (a delegated cross-tenant join against an uncached scope). Residual: a tampered client that ignores (2) and
/// whose connection-scoped memberships survive (1) keeps its streams until the connection
/// drops. Warning (not Debug) on failure so it reaches App Insights.
/// </summary>
@@ -1378,13 +1378,21 @@ namespace AutopilotMonitor.Shared
/// <summary>
/// Deterministic ids of the implicit self-service Tenant Group every managing (MSP) tenant owns:
/// <c>msp-{homeTenantId}</c>. 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).
/// the managing tenant's own users, and an assignment confers scope only while its assignee is an enabled
/// member of that tenant (checked at every scope resolution). Operator-created groups carry GUID ids and
/// never match the prefix.
/// </summary>
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);
/// <summary>The managing tenant that owns a self-service group (the inverse of <see cref="ForHomeTenant"/>), or null for any other group id.</summary>
public static string? OwnerOf(string? groupId)
=> IsSelfService(groupId) && groupId!.Length > SelfServicePrefix.Length
? groupId.Substring(SelfServicePrefix.Length)
: null;
}
/// <summary>Machine-readable <c>code</c> values of the self-service delegation 4xx bodies.</summary>
@@ -1519,7 +1527,8 @@ namespace AutopilotMonitor.Shared
// Tenant Group assignments — which delegated-admin UPN is assigned to which group.
// PK=UPN (lowercase), RK=groupId. Hot path: GetScopeAsync point-scans by PK to expand the
// UPN's groups into the effective tenant set. NOT tenant-id-keyed (offboarding never touches it).
// UPN's groups into the effective tenant set. NOT tenant-id-keyed: offboarding removes only the
// assignments to the tenant's own self-service group (RowKey == "msp-{tenantId}").
public const string TenantGroupAssignments = "TenantGroupAssignments";
// Self-service delegation invitations + slot release holds of a managing (MSP) tenant.
@@ -231,7 +231,8 @@ export function SectionTenantGroups() {
async (t: TenantGroup) => {
const upn = (assignUpn[t.groupId] || "").trim();
if (!canAssign(t.groupId)) return;
const role = assignRole[t.groupId] || "DelegatedReader";
// A self-service group is read-only for every assignee; the backend refuses any other role there.
const role = t.ownerTenantId ? "DelegatedReader" : assignRole[t.groupId] || "DelegatedReader";
const ok = await mutate(
`assign:${t.groupId}`,
api.tenantGroups.assign(t.groupId),
@@ -507,14 +508,16 @@ export function SectionTenantGroups() {
autoComplete="off"
className="w-full sm:w-auto sm:flex-1 min-w-0 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
/>
<select
value={assignRole[t.groupId] || "DelegatedReader"}
onChange={(e) => setAssignRole((p) => ({ ...p, [t.groupId]: e.target.value }))}
className="flex-1 sm:flex-none min-w-0 px-2 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
>
<option value="DelegatedReader">Reader</option>
<option value="DelegatedAdmin">Admin</option>
</select>
{!t.ownerTenantId && (
<select
value={assignRole[t.groupId] || "DelegatedReader"}
onChange={(e) => setAssignRole((p) => ({ ...p, [t.groupId]: e.target.value }))}
className="flex-1 sm:flex-none min-w-0 px-2 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
>
<option value="DelegatedReader">Reader</option>
<option value="DelegatedAdmin">Admin</option>
</select>
)}
<button
onClick={() => handleAssign(t)}
disabled={!canAssign(t.groupId) || busyKey === `assign:${t.groupId}`}
@@ -161,7 +161,7 @@ export function SectionDelegatedAccess() {
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);
return admins.filter((a) => a.isEnabled && !assigned.has(a.upn.toLowerCase())).map((a) => a.upn);
}, [admins, assignees]);
if (!canEditConfig) {
@@ -408,7 +408,7 @@ export function SectionDelegatedAccess() {
<div className="space-y-2">
<p className="text-xs font-semibold text-gray-700 uppercase tracking-wide">Your users with access</p>
<p className="text-xs text-gray-500">
Members of your tenant (Access Management) who may read every managed tenant — read-only, configuration secrets redacted.
Members of your tenant (Access Management) who may read every managed tenant — read-only, configuration secrets redacted. Removing or disabling a member under Access Management ends their access here; after re-enabling, assign them again.
</p>
{assignees && assignees.assignees.length > 0 && (
<ul className="divide-y divide-gray-100">
@@ -465,7 +465,7 @@ export function SectionDelegatedAccess() {
</button>
</div>
{user?.upn && assignableUpns.length === 0 && (assignees?.assignees.length ?? 0) === 0 && (
<p className="text-xs text-gray-400">Add members under Access Management first.</p>
<p className="text-xs text-gray-400">Add or enable members under Access Management first.</p>
)}
</div>
</>