feat(security): resolve admin identity bindings automatically at grant time; per-tenant bindings panel

Granting a cross-tenant role is "type the UPN" again. AdminIdentityResolver derives the home
tenant + object id from sign-in history (UserActivity: both values came from validated tokens,
most recent sign-in wins), falls back to the UPN domain of an onboarded tenant (tid only, oid
pinned on first sign-in), and never guesses when a UPN was seen under two tenants. Only a
never-seen domain yields 422 HomeTenantUnresolved, on which the portal shows a home-tenant picker.

- IMetricsRepository.GetSignInIdentitiesByUpnAsync (filtered UserActivity scan, grant-time only)
- Grant endpoints: homeTenantId/objectId optional overrides; bindings dropped from the list bodies
- Web: identity fields and binding pills removed from the delegated / tenant-group forms;
  new collapsed "Identity bindings" panel in Tenant Management -> Edit Tenant Configuration
  (bindings homed in that tenant; inline edit / remove via global/identity-bindings)
- Tests: AdminIdentityResolverTests; grant validation now accepts an absent home tenant

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-29 19:21:47 +02:00
parent 8ec8524252
commit a306cd9e40
19 changed files with 695 additions and 176 deletions
+25 -11
View File
@@ -83,19 +83,30 @@ winner). Two accounts racing the first sign-in inside the home tenant therefore
Row lookups stay cached by UPN; the binding has its own 30 s cache keyed by UPN and is invalidated on
pin, rebind and removal. A rebind on one scaled-out instance converges on the others within seconds.
## Grants bind first
## Grants bind first — and resolve the identity themselves
`AddGlobalAdminAsync`, `DelegatedAdminService.UpsertAsync` and `AssignGroupAsync` take
`homeTenantId` (GUID, required) and `objectId` (GUID, optional) and call `EnsureBoundAsync` **before**
writing the role row. `EnsureBoundAsync` creates the binding, keeps a compatible one (same tenant; the
Granting is "type the UPN". The three grant endpoints (`POST auth/global-admins`,
`POST global/delegated-admins`, `POST global/tenant-groups/{id}/assignees`) accept `homeTenantId` /
`objectId` only as optional overrides; normally `AdminIdentityResolver` supplies them:
1. **Sign-in history** (`UserActivity`, filtered on the UPN): the person has signed in before ⇒ tid + oid
are known, both from validated tokens. The most recent sign-in wins for the oid. A UPN seen under
**more than one tenant** is never auto-resolved — that is the attack shape the binding exists for.
2. **UPN domain → onboarded tenant** (`TenantConfiguration.DomainName`, exactly one match): tid only; the
oid is pinned on the first sign-in.
3. Neither ⇒ **422 `HomeTenantUnresolved`**; the portal then shows a home-tenant picker (onboarded
tenants) and retries with `homeTenantId`. This is the only manual step, and only for a UPN whose
domain the platform has never seen.
`AddGlobalAdminAsync`, `DelegatedAdminService.UpsertAsync` and `AssignGroupAsync` then take the resolved
`homeTenantId` / `objectId` and call `EnsureBoundAsync` **before** writing the role row. `EnsureBoundAsync` creates the binding, keeps a compatible one (same tenant; the
supplied object id equals the pin or upgrades an unpinned one), and throws
`IdentityBindingConflictException` for a different tenant or a different pinned object id — the
functions map that to **409**. A grant can therefore never silently re-home a UPN.
Why the object id may be omitted: the operator granting an external MSP admin has no directory access
to the partner tenant, so the person's `oid` is frequently unknown at grant time; the home tenant is
always known (it is who the grant is for). The first sign-in from that tenant pins it; from then on a
recycled UPN in the same tenant is refused.
Why the object id may be unknown: a person who has never signed in has no history to resolve from, and
the operator granting an external MSP admin has no directory access to the partner tenant. The first
sign-in from the bound tenant pins it; from then on a recycled UPN in the same tenant is refused.
## Explicit rebind surface
@@ -106,9 +117,10 @@ recycled UPN in the same tenant is refused.
| `DELETE global/identity-bindings/{upn}` | GlobalAdminOnly | Make every role row of the UPN inert without touching the rows. Self-removal is refused. |
Both mutations are audited under the binding's home tenant (`AdminIdentityBinding` entity, previous
values in the details) and logged at Warning. The list endpoints of delegated admins and tenant groups
return `bindings` alongside their rows so the management UI can show a per-UPN context pill (home tenant,
pin state) and require the home tenant in its grant / assign forms.
values in the details) and logged at Warning. The portal surfaces this in **Tenant Management → Edit
Tenant Configuration → Identity bindings** (collapsed): the bindings homed in that tenant, with inline
edit (re-home / re-pin; empty object id ⇒ re-pinned on the next sign-in) and remove. The grant forms
themselves carry no identity fields — the binding is a background property of the grant.
## Lifecycle and secondary effects
@@ -144,6 +156,8 @@ Same row, binding without an object id: the first `tid=1111…` sign-in pins its
* `src/Backend/AutopilotMonitor.Functions/Security/AdminIdentity.cs` — the identity record and its factories.
* `src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityBindingService.cs` — verdict, pin, grant-time binding, rebind; the table entity.
* `src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityResolver.cs` — sign-in-history / UPN-domain resolution at grant time (`AdminIdentityResolverTests`).
* `src/Web/autopilot-monitor-web/app/admin/components/IdentityBindingsSection.tsx` — the per-tenant inspect/correct panel.
* `src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs`, `DelegatedAdminService.cs` — resolution order.
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs` — `TryPinIdentityObjectIdAsync` and the binding CRUD.
* `src/Backend/AutopilotMonitor.Functions/Functions/Admin/IdentityBindingManagementFunction.cs` — the rebind surface and the shared `IdentityBindingRequest.Validate`.
+4
View File
@@ -1,5 +1,9 @@
# Log
## 2026-08-29 (6)
* **Update**: `backend/admin-identity-binding.md` — grants are self-maintaining: `AdminIdentityResolver` derives the home tenant + object id from sign-in history (`UserActivity`), falls back to the UPN domain of an onboarded tenant, refuses to guess when a UPN was seen under two tenants; 422 `HomeTenantUnresolved` + portal tenant picker only for a never-seen domain. Identity fields and pills removed from the delegated / tenant-group forms; inspect/correct lives in Tenant Management → Edit Tenant Configuration → Identity bindings (collapsed).
## 2026-08-29 (5)
* **Creation**: Added `backend/admin-identity-binding.md` — cross-tenant roles (GlobalAdmin / GlobalReader, delegated MSP) were resolved from the UPN string alone while the API accepts tokens from every Entra tenant. New `AdminIdentityBindings` table (RK = UPN → home `TenantId` + `ObjectId`), `AdminIdentity` (upn + tid + oid) as the key both role services resolve on, binding checked after the row and before the Pro gate, object id pinned on the first sign-in from the bound tenant (ETag-guarded), grants require `homeTenantId` and refuse to re-home (409), `global/identity-bindings` GET/PUT/DELETE as the audited rebind path, `RequestContext.ObjectId`, throttle bucket keyed on oid. Legacy rows are inert until bound — seed bindings before the backend deploy.
@@ -0,0 +1,104 @@
using AutopilotMonitor.Functions.Services;
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;
/// <summary>
/// Pins <see cref="AdminIdentityResolver"/> — what makes granting a cross-tenant role "type the UPN":
/// sign-in history first (tid + oid, both from validated tokens), then UPN domain → onboarded tenant
/// (tid only, oid pinned on first sign-in), and NO answer when the history is ambiguous (same UPN under
/// two tenants — exactly the situation the binding exists for) or the domain is unknown.
/// </summary>
public class AdminIdentityResolverTests
{
private const string Upn = "msp@partner.example";
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private const string OidOld = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa";
private const string OidNew = "bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb";
private static AdminIdentityResolver Build(
IEnumerable<UserSignInIdentity>? signIns = null,
params (string TenantId, string Domain)[] tenants)
{
var metrics = new Mock<IMetricsRepository>();
metrics.Setup(m => m.GetSignInIdentitiesByUpnAsync(It.IsAny<string>()))
.ReturnsAsync((signIns ?? Array.Empty<UserSignInIdentity>()).ToList());
var configRepo = new Mock<IConfigRepository>();
configRepo.Setup(r => r.GetAllTenantConfigurationsAsync())
.ReturnsAsync(tenants.Select(t => new TenantConfiguration { TenantId = t.TenantId, DomainName = t.Domain }).ToList());
var configService = new TenantConfigurationService(
configRepo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
return new AdminIdentityResolver(metrics.Object, configService, NullLogger<AdminIdentityResolver>.Instance);
}
private static UserSignInIdentity SignIn(string tid, string oid, int daysAgo) => new()
{
TenantId = tid, ObjectId = oid, LastLoginAt = DateTime.UtcNow.AddDays(-daysAgo), LoginCount = 1,
};
[Fact]
public async Task SignInHistory_SingleTenant_ResolvesTidAndOid()
{
var r = await Build(new[] { SignIn(TenantA, OidOld, 3) }).ResolveAsync("MSP@Partner.Example");
Assert.NotNull(r);
Assert.Equal(TenantA, r!.TenantId);
Assert.Equal(OidOld, r.ObjectId);
Assert.Equal(ResolvedAdminIdentity.SourceSignIn, r.Source);
}
[Fact]
public async Task SignInHistory_SameTenantTwoOids_TakesMostRecent()
{
// Account re-created in the same tenant: the newer oid is the one that will sign in next.
var r = await Build(new[] { SignIn(TenantA, OidOld, 30), SignIn(TenantA, OidNew, 1) }).ResolveAsync(Upn);
Assert.Equal(OidNew, r!.ObjectId);
}
[Fact]
public async Task SignInHistory_TwoTenants_IsAmbiguous_NotResolved_EvenIfDomainMatches()
{
// The same UPN string seen under two tenants is the attack shape; never auto-pick, even when the
// domain fallback would have an answer.
var r = await Build(new[] { SignIn(TenantA, OidOld, 3), SignIn(TenantB, OidNew, 1) }, (TenantA, "partner.example"))
.ResolveAsync(Upn);
Assert.Null(r);
}
[Fact]
public async Task NoSignIns_DomainOfOnboardedTenant_ResolvesTidOnly()
{
var r = await Build(null, (TenantA, "Partner.Example"), (TenantB, "other.example")).ResolveAsync(Upn);
Assert.NotNull(r);
Assert.Equal(TenantA, r!.TenantId);
Assert.Null(r.ObjectId);
Assert.Equal(ResolvedAdminIdentity.SourceDomain, r.Source);
}
[Fact]
public async Task NoSignIns_TwoTenantsClaimTheDomain_NotResolved()
{
var r = await Build(null, (TenantA, "partner.example"), (TenantB, "partner.example")).ResolveAsync(Upn);
Assert.Null(r);
}
[Fact]
public async Task NoSignIns_UnknownDomain_NotResolved()
{
var r = await Build(null, (TenantA, "other.example")).ResolveAsync(Upn);
Assert.Null(r);
}
[Theory]
[InlineData("nodomain")]
[InlineData("trailing@")]
public async Task MalformedUpn_NotResolved(string upn)
=> Assert.Null(await Build(null, (TenantA, "partner.example")).ResolveAsync(upn));
}
@@ -96,7 +96,9 @@ public class AuthFunctionSideEffectTests
_autoApproveEnqueuerMock.Object,
new AutopilotMonitor.Functions.Security.EntraAppRegistry(
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()));
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()),
new Mock<AdminIdentityResolver>(
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object);
// Default: all fire-and-forget calls succeed
_tenantConfigMock
@@ -28,6 +28,14 @@ public class RevokeEnforcementTests
private static readonly StubAdminIdentityBindingService Bindings = new(bound: true);
/// <summary>Resolver stub: never consulted by the revoke/validation seams under test (loose mock ⇒ null).</summary>
private static AdminIdentityResolver Resolver() =>
new Mock<AdminIdentityResolver>(
Mock.Of<IMetricsRepository>(),
new Mock<TenantConfigurationService>(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false }.Object,
NullLogger<AdminIdentityResolver>.Instance) { CallBase = false }.Object;
private static DelegatedAdminService BuildService(Mock<IAdminRepository> repo) =>
new(
repo.Object,
@@ -58,7 +66,7 @@ public class RevokeEnforcementTests
var audit = BuildAuditRepo();
var signalR = new FakeSignalRNotificationService();
var fn = new DelegatedAdminManagementFunction(
NullLogger<DelegatedAdminManagementFunction>.Instance, BuildService(repo), Bindings, audit.Object, signalR);
NullLogger<DelegatedAdminManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, signalR);
return (fn, repo, audit, signalR);
}
@@ -75,7 +83,7 @@ public class RevokeEnforcementTests
var audit = BuildAuditRepo();
var signalR = new FakeSignalRNotificationService();
var fn = new TenantGroupManagementFunction(
NullLogger<TenantGroupManagementFunction>.Instance, BuildService(repo), Bindings, audit.Object, signalR);
NullLogger<TenantGroupManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, signalR);
return (fn, repo, audit, signalR);
}
@@ -105,20 +113,25 @@ public class RevokeEnforcementTests
new GrantDelegatedAdminRequest { Upn = Upn, TenantId = tenantId }, out _));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("partner.example")]
[InlineData("11111111-1111-1111-1111")]
public void ValidateGrant_MissingOrNonGuidHomeTenant_Rejected(string? homeTenantId)
public void ValidateGrant_NonGuidHomeTenant_Rejected(string homeTenantId)
{
// The grantee's HOME tenant is what the identity binding is keyed on — without it the grant would be
// inert at best and UPN-only (the original cross-tenant hole) at worst. Required, GUID, fail-closed.
// The home tenant is OPTIONAL at the API (resolved from sign-in history / UPN domain when absent),
// but a supplied value must be a GUID — a typo must not become a binding.
var error = DelegatedAdminManagementFunction.ValidateGrantRequest(
new GrantDelegatedAdminRequest { Upn = Upn, TenantId = TenantA, HomeTenantId = homeTenantId }, out _);
Assert.NotNull(error);
Assert.Contains("homeTenantId", error);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void ValidateGrant_AbsentHomeTenant_IsValid_ResolvedLater(string? homeTenantId)
=> Assert.Null(DelegatedAdminManagementFunction.ValidateGrantRequest(
new GrantDelegatedAdminRequest { Upn = Upn, TenantId = TenantA, HomeTenantId = homeTenantId }, out _));
[Fact]
public void ValidateGrant_NonGuidObjectId_Rejected()
{
@@ -86,6 +86,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<int> DeleteUserActivityOlderThanAsync(DateTime cutoffUtc)
=> _storage.DeleteUserActivityOlderThanAsync(cutoffUtc);
public Task<List<UserSignInIdentity>> GetSignInIdentitiesByUpnAsync(string upn)
=> _storage.GetSignInIdentitiesByUpnAsync(upn);
public Task RecordUserPresenceAsync(string tenantId, string upn, string userRole)
=> _storage.RecordUserPresenceAsync(tenantId, upn, userRole);
@@ -24,37 +24,33 @@ public class DelegatedAdminManagementFunction
{
private readonly ILogger<DelegatedAdminManagementFunction> _logger;
private readonly DelegatedAdminService _delegatedAdminService;
private readonly AdminIdentityBindingService _bindings;
private readonly AdminIdentityResolver _identityResolver;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly ISignalRNotificationService _signalRService;
public DelegatedAdminManagementFunction(
ILogger<DelegatedAdminManagementFunction> logger,
DelegatedAdminService delegatedAdminService,
AdminIdentityBindingService bindings,
AdminIdentityResolver identityResolver,
IMaintenanceRepository maintenanceRepo,
ISignalRNotificationService signalRService)
{
_logger = logger;
_delegatedAdminService = delegatedAdminService;
_bindings = bindings;
_identityResolver = identityResolver;
_maintenanceRepo = maintenanceRepo;
_signalRService = signalRService;
}
/// <summary>
/// GET /api/global/delegated-admins — list every delegated assignment plus the identity bindings of the
/// assigned UPNs (so the UI can show which tenant/object id each grant is actually usable from). GlobalReadOrAdmin.
/// </summary>
/// <summary>GET /api/global/delegated-admins — list every delegated assignment. GlobalReadOrAdmin.</summary>
[Function("GetDelegatedAdmins")]
[Authorize]
public async Task<HttpResponseData> GetDelegatedAdmins(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/delegated-admins")] HttpRequestData req)
{
var assignments = await _delegatedAdminService.GetAllAsync();
var bindings = await _bindings.GetAllAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { assignments, bindings });
await response.WriteAsJsonAsync(new { assignments });
return response;
}
@@ -75,18 +71,23 @@ public class DelegatedAdminManagementFunction
if (validationError != null)
return await Bad(req, validationError);
// Edition note: TARGET tenants may be any edition — an MSP on Pro may manage Community
// customers. The Pro requirement applies to the delegated admin's HOME tenant (homeTenantId,
// which also becomes the UPN's identity binding) and is enforced at resolve time
// (DelegatedAdminService.GetScopeAsync gates on the JWT tid). Grants to non-Pro-homed admins are
// simply inert. A binding conflict (UPN already homed in another tenant) is a 409, never a silent rebind.
// The UPN's identity binding (home tenant + object id) is resolved automatically — sign-in history,
// then UPN domain → onboarded tenant; the body may override it. Unresolvable ⇒ 422 and the UI asks
// the operator to pick the tenant. Edition note: TARGET tenants may be any edition — an MSP on Pro
// may manage Community customers; the Pro requirement applies to the HOME tenant and is enforced at
// resolve time (DelegatedAdminService.GetScopeAsync gates on the JWT tid), so grants to non-Pro-homed
// admins are simply inert. A binding conflict (UPN already homed elsewhere) is a 409, never a silent rebind.
var identity = await IdentityBindingRequest.ResolveForGrantAsync(_identityResolver, body!.Upn, body.HomeTenantId, body.ObjectId);
if (identity == null)
return await Unresolved(req);
DelegatedAdminEntry entry;
try
{
entry = await _delegatedAdminService.UpsertAsync(
body!.Upn, body.TenantId, role,
body.Upn, body.TenantId, role,
Constants.DelegatedStatus.Active, Constants.DelegatedSource.OperatorGranted, currentUpn ?? "",
body.HomeTenantId!, body.ObjectId);
identity.Value.TenantId, identity.Value.ObjectId);
}
catch (IdentityBindingConflictException ex)
{
@@ -100,7 +101,7 @@ public class DelegatedAdminManagementFunction
{ "Role", entry.Role },
{ "Status", entry.Status },
{ "Source", entry.Source },
{ "HomeTenantId", body.HomeTenantId!.ToLowerInvariant() },
{ "HomeTenantId", identity.Value.TenantId.ToLowerInvariant() },
});
_logger.LogInformation("Delegated grant: {Upn} -> {TenantId} ({Role}) by {By}",
@@ -228,7 +229,7 @@ public class DelegatedAdminManagementFunction
if (!Guid.TryParse(body.TenantId, out _))
return "a valid tenantId (GUID) is required";
var bindingError = IdentityBindingRequest.Validate(body.HomeTenantId, body.ObjectId);
var bindingError = IdentityBindingRequest.ValidateOptional(body.HomeTenantId, body.ObjectId);
if (bindingError != null)
return bindingError;
@@ -252,6 +253,13 @@ public class DelegatedAdminManagementFunction
await conflict.WriteAsJsonAsync(new { error });
return conflict;
}
private static async Task<HttpResponseData> Unresolved(HttpRequestData req)
{
var unresolved = req.CreateResponse(HttpStatusCode.UnprocessableEntity);
await unresolved.WriteAsJsonAsync(new { error = IdentityBindingRequest.HomeTenantUnresolvedMessage, code = IdentityBindingRequest.HomeTenantUnresolvedCode });
return unresolved;
}
}
public class GrantDelegatedAdminRequest
@@ -260,8 +268,8 @@ public class GrantDelegatedAdminRequest
/// <summary>The MANAGED (target) tenant the UPN may read.</summary>
public string TenantId { get; set; } = string.Empty;
public string? Role { get; set; }
/// <summary>The grantee's HOME Entra tenant id (required) — the JWT tid their tokens carry; becomes the identity binding.</summary>
/// <summary>The grantee's HOME Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.</summary>
public string? HomeTenantId { get; set; }
/// <summary>The grantee's Entra object id (optional) — pinned on their first sign-in when omitted.</summary>
/// <summary>The grantee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.</summary>
public string? ObjectId { get; set; }
}
@@ -143,16 +143,48 @@ public class IdentityBindingManagementFunction
/// <summary>Body of PUT global/identity-bindings/{upn}; also the shared validator for the binding fields every grant carries.</summary>
public class IdentityBindingRequest
{
/// <summary>
/// The identity a grant binds: the operator-supplied values when present, otherwise the resolver's answer.
/// Returns null when neither yields a home tenant — the endpoint answers 422 <see cref="HomeTenantUnresolvedCode"/>
/// and the UI offers a tenant picker. A supplied object id always wins over a resolved one.
/// </summary>
public static async Task<(string TenantId, string? ObjectId)?> ResolveForGrantAsync(
AdminIdentityResolver resolver, string upn, string? homeTenantId, string? objectId)
{
objectId = string.IsNullOrWhiteSpace(objectId) ? null : objectId;
if (!string.IsNullOrWhiteSpace(homeTenantId))
return (homeTenantId, objectId);
var resolved = await resolver.ResolveAsync(upn);
return resolved == null ? null : (resolved.TenantId, objectId ?? resolved.ObjectId);
}
public string? HomeTenantId { get; set; }
public string? ObjectId { get; set; }
/// <summary>Returns the 400 message, or null when the fields are valid (home tenant = GUID required; object id = GUID or absent).</summary>
public static string? Validate(string? homeTenantId, string? objectId)
{
if (string.IsNullOrWhiteSpace(homeTenantId) || !Guid.TryParse(homeTenantId, out _))
if (string.IsNullOrWhiteSpace(homeTenantId))
return "a valid homeTenantId (GUID — the Entra tenant the person signs in from) is required";
return ValidateOptional(homeTenantId, objectId);
}
/// <summary>
/// Grant-time variant: the home tenant MAY be absent (the backend resolves it from sign-in history or the
/// UPN domain — see <see cref="AdminIdentityResolver"/>); when supplied it must be a GUID, as must the object id.
/// </summary>
public static string? ValidateOptional(string? homeTenantId, string? objectId)
{
if (!string.IsNullOrWhiteSpace(homeTenantId) && !Guid.TryParse(homeTenantId, out _))
return "homeTenantId must be a GUID (the Entra tenant the person signs in from) when supplied";
if (!string.IsNullOrWhiteSpace(objectId) && !Guid.TryParse(objectId, out _))
return "objectId must be a GUID (the person's Entra object id) when supplied";
return null;
}
/// <summary>Error code the grant endpoints return (HTTP 422) when the home tenant could not be resolved automatically.</summary>
public const string HomeTenantUnresolvedCode = "HomeTenantUnresolved";
public const string HomeTenantUnresolvedMessage =
"The person's home tenant could not be resolved automatically (no previous sign-in, and the UPN domain does not belong to an onboarded tenant). Select the tenant they sign in from.";
}
@@ -29,7 +29,7 @@ public class TenantGroupManagementFunction
{
private readonly ILogger<TenantGroupManagementFunction> _logger;
private readonly DelegatedAdminService _delegatedAdminService;
private readonly AdminIdentityBindingService _bindings;
private readonly AdminIdentityResolver _identityResolver;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly ISignalRNotificationService _signalRService;
@@ -38,30 +38,26 @@ public class TenantGroupManagementFunction
public TenantGroupManagementFunction(
ILogger<TenantGroupManagementFunction> logger,
DelegatedAdminService delegatedAdminService,
AdminIdentityBindingService bindings,
AdminIdentityResolver identityResolver,
IMaintenanceRepository maintenanceRepo,
ISignalRNotificationService signalRService)
{
_logger = logger;
_delegatedAdminService = delegatedAdminService;
_bindings = bindings;
_identityResolver = identityResolver;
_maintenanceRepo = maintenanceRepo;
_signalRService = signalRService;
}
/// <summary>
/// GET /api/global/tenant-groups — list every group with tenants + assignee count, plus the identity
/// bindings of the assigned UPNs (which tenant/object id each assignment is usable from). GlobalReadOrAdmin.
/// </summary>
/// <summary>GET /api/global/tenant-groups — list every group with tenants + assignee count. GlobalReadOrAdmin.</summary>
[Function("GetTenantGroups")]
[Authorize]
public async Task<HttpResponseData> GetTenantGroups(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/tenant-groups")] HttpRequestData req)
{
var groups = await _delegatedAdminService.GetAllGroupsAsync();
var bindings = await _bindings.GetAllAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { groups, bindings });
await response.WriteAsJsonAsync(new { groups });
return response;
}
@@ -231,9 +227,10 @@ public class TenantGroupManagementFunction
/// <summary>
/// POST /api/global/tenant-groups/{groupId}/assignees — assign a UPN to the group. GlobalAdminOnly.
/// Body: { "upn": "user@domain.com", "role": "DelegatedReader" | "DelegatedAdmin",
/// "homeTenantId": "&lt;the assignee's Entra tenant&gt;", "objectId": "&lt;optional&gt;" }.
/// homeTenantId binds the UPN to the identity that may use the assignment (409 if the UPN is already
/// bound elsewhere — rebind explicitly via global/identity-bindings).
/// "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).
/// </summary>
[Function("AssignTenantGroup")]
[Authorize]
@@ -252,7 +249,7 @@ public class TenantGroupManagementFunction
if (role != Constants.DelegatedRoles.DelegatedReader && role != Constants.DelegatedRoles.DelegatedAdmin)
return await Bad(req, $"role must be '{Constants.DelegatedRoles.DelegatedReader}' or '{Constants.DelegatedRoles.DelegatedAdmin}'");
var bindingError = IdentityBindingRequest.Validate(body.HomeTenantId, body.ObjectId);
var bindingError = IdentityBindingRequest.ValidateOptional(body.HomeTenantId, body.ObjectId);
if (bindingError != null)
return await Bad(req, bindingError);
@@ -262,12 +259,20 @@ public class TenantGroupManagementFunction
return await NotFound(req);
var upn = body.Upn.ToLowerInvariant();
var identity = await IdentityBindingRequest.ResolveForGrantAsync(_identityResolver, upn, body.HomeTenantId, body.ObjectId);
if (identity == null)
{
var unresolved = req.CreateResponse(HttpStatusCode.UnprocessableEntity);
await unresolved.WriteAsJsonAsync(new { error = IdentityBindingRequest.HomeTenantUnresolvedMessage, code = IdentityBindingRequest.HomeTenantUnresolvedCode });
return unresolved;
}
// The service re-checks existence (covers a delete racing this assign) — skip the audit if it no-ops.
bool assigned;
try
{
assigned = await _delegatedAdminService.AssignGroupAsync(
upn, groupId, role, true, currentUpn ?? "", body.HomeTenantId!, body.ObjectId);
upn, groupId, role, true, currentUpn ?? "", identity.Value.TenantId, identity.Value.ObjectId);
}
catch (IdentityBindingConflictException ex)
{
@@ -390,8 +395,8 @@ public class AssignGroupRequest
{
public string Upn { get; set; } = string.Empty;
public string? Role { get; set; }
/// <summary>The assignee's HOME Entra tenant id (required) — becomes the UPN's identity binding.</summary>
/// <summary>The assignee's HOME Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.</summary>
public string? HomeTenantId { get; set; }
/// <summary>The assignee's Entra object id (optional) — pinned on their first sign-in when omitted.</summary>
/// <summary>The assignee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.</summary>
public string? ObjectId { get; set; }
}
@@ -32,6 +32,7 @@ public class AuthFunction
private readonly McpUserService _mcpUserService;
private readonly Services.Activation.ITenantAutoApproveEnqueuer _tenantAutoApproveEnqueuer;
private readonly EntraAppRegistry _appRegistry;
private readonly AdminIdentityResolver _identityResolver;
public AuthFunction(
ILogger<AuthFunction> logger,
@@ -45,9 +46,11 @@ public class AuthFunction
GlobalNotificationService globalNotificationService,
McpUserService mcpUserService,
Services.Activation.ITenantAutoApproveEnqueuer tenantAutoApproveEnqueuer,
EntraAppRegistry appRegistry)
EntraAppRegistry appRegistry,
AdminIdentityResolver identityResolver)
{
_logger = logger;
_identityResolver = identityResolver;
_globalAdminService = globalAdminService;
_delegatedAdminService = delegatedAdminService;
_tenantConfigService = tenantConfigService;
@@ -251,20 +254,27 @@ public class AuthFunction
await badRequestResponse.WriteAsJsonAsync(new { error = "UPN is required" });
return badRequestResponse;
}
// The grantee's home tenant is mandatory: the row is inert until the UPN is bound to the identity
// that may use it, and the UPN string alone cannot tell which tenant that is.
var bindingError = IdentityBindingRequest.Validate(body.HomeTenantId, body.ObjectId);
// The row is inert until the UPN is bound to the identity that may use it. The home tenant is resolved
// automatically (sign-in history, then UPN domain → onboarded tenant); the body may override it.
var bindingError = IdentityBindingRequest.ValidateOptional(body.HomeTenantId, body.ObjectId);
if (bindingError != null)
{
var badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequestResponse.WriteAsJsonAsync(new { error = bindingError });
return badRequestResponse;
}
var identity = await IdentityBindingRequest.ResolveForGrantAsync(_identityResolver, body.Upn, body.HomeTenantId, body.ObjectId);
if (identity == null)
{
var unresolved = req.CreateResponse(HttpStatusCode.UnprocessableEntity);
await unresolved.WriteAsJsonAsync(new { error = IdentityBindingRequest.HomeTenantUnresolvedMessage, code = IdentityBindingRequest.HomeTenantUnresolvedCode });
return unresolved;
}
GlobalAdminEntity newAdmin;
try
{
newAdmin = await _globalAdminService.AddGlobalAdminAsync(body.Upn, currentUpn!, body.HomeTenantId!, body.ObjectId);
newAdmin = await _globalAdminService.AddGlobalAdminAsync(body.Upn, currentUpn!, identity.Value.TenantId, identity.Value.ObjectId);
}
catch (IdentityBindingConflictException ex)
{
@@ -636,8 +646,8 @@ internal class AuthDecisionResult
public class AddGlobalAdminRequest
{
public string Upn { get; set; } = string.Empty;
/// <summary>The grantee's home Entra tenant id (required) — the JWT tid their tokens will carry.</summary>
/// <summary>The grantee's home Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.</summary>
public string? HomeTenantId { get; set; }
/// <summary>The grantee's Entra object id (optional) — pinned on their first sign-in when omitted.</summary>
/// <summary>The grantee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.</summary>
public string? ObjectId { get; set; }
}
@@ -252,6 +252,7 @@ builder.Services.AddSingleton<SlaMetricsService>();
builder.Services.AddSingleton<SlaBreachEvaluationService>();
// Identity binding (tid + oid behind every cross-tenant-role UPN) — consulted by both role services.
builder.Services.AddSingleton<AdminIdentityBindingService>();
builder.Services.AddSingleton<AdminIdentityResolver>();
builder.Services.AddSingleton<GlobalAdminService>();
builder.Services.AddSingleton<DelegatedAdminService>();
builder.Services.AddSingleton<McpUserService>();
@@ -0,0 +1,85 @@
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Resolves the Entra identity (home tenant id + object id) behind a UPN at grant time WITHOUT operator
/// input, so granting a cross-tenant role is "type the UPN" and the identity binding is self-maintaining:
/// <list type="number">
/// <item><b>Sign-in history</b> (<see cref="Constants.TableNames.UserActivity"/>): the person has signed in
/// before ⇒ tid + oid are known and verified (both came from validated tokens). Ambiguous history (the same
/// UPN string seen under more than one tenant) is NOT auto-resolved — that is the very situation the binding
/// exists for, and only the operator can say which one is meant.</item>
/// <item><b>UPN domain → onboarded tenant</b>: the person has never signed in, but their UPN domain is the
/// domain of an onboarded tenant ⇒ tid is known; the oid is pinned on their first sign-in from that tenant.</item>
/// </list>
/// Neither ⇒ null; the caller asks the operator to pick the home tenant (the only manual step left, and only
/// for a UPN whose domain the platform has never seen).
/// </summary>
public class AdminIdentityResolver
{
private readonly IMetricsRepository _metricsRepo;
private readonly TenantConfigurationService _tenantConfigService;
private readonly ILogger<AdminIdentityResolver> _logger;
public AdminIdentityResolver(
IMetricsRepository metricsRepo,
TenantConfigurationService tenantConfigService,
ILogger<AdminIdentityResolver> logger)
{
_metricsRepo = metricsRepo;
_tenantConfigService = tenantConfigService;
_logger = logger;
}
public virtual async Task<ResolvedAdminIdentity?> ResolveAsync(string upn)
{
upn = upn.ToLowerInvariant();
var signIns = await _metricsRepo.GetSignInIdentitiesByUpnAsync(upn);
var tenants = signIns.Select(s => s.TenantId.ToLowerInvariant()).Distinct().ToList();
if (tenants.Count == 1)
{
// Most recent sign-in wins for the oid (a re-created account in the same tenant shows up as a
// newer oid; the older one would be refused at sign-in anyway).
var latest = signIns.OrderByDescending(s => s.LastLoginAt).First();
return new ResolvedAdminIdentity(tenants[0],
string.IsNullOrWhiteSpace(latest.ObjectId) ? null : latest.ObjectId.ToLowerInvariant(),
ResolvedAdminIdentity.SourceSignIn);
}
if (tenants.Count > 1)
{
_logger.LogWarning("[IdentityBinding] {Upn} has sign-ins from {Count} different tenants — not auto-resolving the home tenant",
upn, tenants.Count);
return null;
}
var at = upn.LastIndexOf('@');
if (at <= 0 || at == upn.Length - 1)
return null;
var domain = upn[(at + 1)..];
var configs = await _tenantConfigService.GetAllConfigurationsAsync();
var byDomain = configs
.Where(c => !string.IsNullOrWhiteSpace(c.DomainName)
&& string.Equals(c.DomainName.Trim(), domain, StringComparison.OrdinalIgnoreCase))
.Select(c => c.TenantId.ToLowerInvariant())
.Distinct()
.ToList();
if (byDomain.Count == 1)
return new ResolvedAdminIdentity(byDomain[0], null, ResolvedAdminIdentity.SourceDomain);
return null;
}
}
/// <summary>
/// Outcome of <see cref="AdminIdentityResolver.ResolveAsync"/>: the home tenant, the object id when the
/// person has signed in before (null ⇒ pinned on first sign-in), and where the answer came from.
/// </summary>
public sealed record ResolvedAdminIdentity(string TenantId, string? ObjectId, string Source)
{
public const string SourceSignIn = "sign-in";
public const string SourceDomain = "domain";
}
@@ -1124,6 +1124,38 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Every distinct (tenant, object id) pair a UPN has signed in with — see IMetricsRepository. The Upn
/// column is not a key, so this is a filtered table scan; acceptable because it runs once per grant.
/// </summary>
public async Task<List<UserSignInIdentity>> GetSignInIdentitiesByUpnAsync(string upn)
{
var result = new Dictionary<(string Tid, string Oid), UserSignInIdentity>();
if (string.IsNullOrWhiteSpace(upn))
return new List<UserSignInIdentity>();
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity);
// Rows store the UPN as the token carried it; compare lowercase in memory and pre-filter on the
// exact lowercase string (the common case) OR the raw input so a mixed-case row is not missed.
var normalized = upn.ToLowerInvariant().Replace("'", "''");
var filter = $"Upn eq '{normalized}'";
await foreach (var entity in tableClient.QueryAsync<TableEntity>(filter: filter, select: new[] { "PartitionKey", "Upn", "ObjectId", "LoginAt" }))
{
var tid = entity.PartitionKey.ToLowerInvariant();
var oid = (entity.GetString("ObjectId") ?? string.Empty).ToLowerInvariant();
var loginAt = entity.GetDateTime("LoginAt") ?? DateTime.MinValue;
if (!result.TryGetValue((tid, oid), out var entry))
{
entry = new UserSignInIdentity { TenantId = tid, ObjectId = oid };
result[(tid, oid)] = entry;
}
entry.LoginCount++;
if (loginAt > entry.LastLoginAt)
entry.LastLoginAt = loginAt;
}
return result.Values.ToList();
}
/// <summary>
/// Gets user activity metrics for a specific tenant
/// </summary>
@@ -76,6 +76,12 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<(int uniqueUsers, int loginCount)> GetUserActivityForDateAsync(string? tenantId, DateTime date);
/// <summary>Retention cleanup: deletes UserActivity login rows whose LoginAt is older than the cutoff. Returns the number deleted.</summary>
Task<int> DeleteUserActivityOlderThanAsync(DateTime cutoffUtc);
/// <summary>
/// Every distinct (tenant id, object id) a UPN has signed in with, from the UserActivity login rows
/// (cross-partition scan filtered on Upn — grant-time only, never a hot path). Both values came from
/// validated tokens, which is what makes them usable as an identity-binding source.
/// </summary>
Task<List<UserSignInIdentity>> GetSignInIdentitiesByUpnAsync(string upn);
// --- Live Presence (one row per user, upserted) ---
/// <summary>Upserts the caller's presence row (PK=tenantId, RK=SHA-256(lowercase UPN) hex) with LastSeen=now.</summary>
@@ -148,6 +154,15 @@ namespace AutopilotMonitor.Shared.DataAccess
public string AppName { get; set; } = string.Empty;
}
/// <summary>One (tenant, object id) a UPN was seen signing in with, plus the most recent such sign-in.</summary>
public class UserSignInIdentity
{
public string TenantId { get; set; } = string.Empty;
public string ObjectId { get; set; } = string.Empty;
public DateTime LastLoginAt { get; set; }
public int LoginCount { get; set; }
}
public class UserActivityMetrics
{
/// <summary>
@@ -0,0 +1,225 @@
"use client";
import { useCallback, useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import { isGuid, type IdentityBinding } from "@/lib/identityBinding";
interface IdentityBindingsSectionProps {
tenantId: string;
getAccessToken: () => Promise<string | null>;
setError: (error: string | null) => void;
setSuccessMessage: (message: string | null) => void;
}
/**
* Collapsed-by-default view of the identity bindings HOMED in this tenant: every UPN that holds a
* cross-tenant role (platform GlobalAdmin/GlobalReader, delegated MSP grants) and signs in from here,
* with the Entra tenant + object id the role is bound to. Maintained automatically (resolved from sign-in
* history at grant time, object id pinned on the first sign-in); this is the inspection / correction
* surface so a re-created account or a re-homed UPN never needs a table edit.
*/
export function IdentityBindingsSection({
tenantId,
getAccessToken,
setError,
setSuccessMessage,
}: IdentityBindingsSectionProps) {
const canMutate = useCanMutatePlatform();
const [open, setOpen] = useState(false);
const [loaded, setLoaded] = useState(false);
const [bindings, setBindings] = useState<IdentityBinding[]>([]);
const [editing, setEditing] = useState<string | null>(null);
const [editTenantId, setEditTenantId] = useState("");
const [editObjectId, setEditObjectId] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const fail = useCallback(
(err: unknown, fallback: string) => {
if (err instanceof TokenExpiredError) setError(err.message);
else setError(err instanceof Error ? err.message : fallback);
},
[setError],
);
const load = useCallback(async () => {
try {
const response = await authenticatedFetch(api.identityBindings.list(), getAccessToken);
if (!response.ok) throw new Error(`Failed to load identity bindings: ${response.statusText}`);
const data = await response.json();
const all: IdentityBinding[] = data.bindings ?? [];
setBindings(
all
.filter((b) => b.tenantId.toLowerCase() === tenantId.toLowerCase())
.sort((a, b) => a.upn.localeCompare(b.upn)),
);
setLoaded(true);
} catch (err) {
fail(err, "Failed to load identity bindings");
}
}, [tenantId, getAccessToken, fail]);
// Lazy: the list is fetched the first time the panel is opened (event-driven, no effect).
const toggle = () => {
const next = !open;
setOpen(next);
if (next && !loaded) void load();
};
const startEdit = (b: IdentityBinding) => {
setEditing(b.upn);
setEditTenantId(b.tenantId);
setEditObjectId(b.objectId);
};
const save = async (upn: string) => {
const tid = editTenantId.trim();
const oid = editObjectId.trim();
if (!isGuid(tid) || (oid !== "" && !isGuid(oid))) return;
try {
setBusy(upn);
setError(null);
const response = await authenticatedFetch(api.identityBindings.put(upn), getAccessToken, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ homeTenantId: tid, objectId: oid || undefined }),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || `Failed to update binding: ${response.statusText}`);
}
setSuccessMessage(`Identity binding for ${upn} updated.`);
setEditing(null);
await load();
} catch (err) {
fail(err, "Failed to update binding");
} finally {
setBusy(null);
}
};
const remove = async (upn: string) => {
if (!confirm(`Remove the identity binding for ${upn}? Every platform/delegated role of this UPN stops working until it is granted again.`)) return;
try {
setBusy(upn);
setError(null);
const response = await authenticatedFetch(api.identityBindings.remove(upn), getAccessToken, { method: "DELETE" });
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || `Failed to remove binding: ${response.statusText}`);
}
setSuccessMessage(`Identity binding for ${upn} removed.`);
await load();
} catch (err) {
fail(err, "Failed to remove binding");
} finally {
setBusy(null);
}
};
const editValid = isGuid(editTenantId) && (editObjectId.trim() === "" || isGuid(editObjectId));
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg">
<button
type="button"
onClick={toggle}
className="w-full flex items-center justify-between px-4 py-3 text-left"
aria-expanded={open}
>
<span className="font-semibold text-gray-900">
Identity bindings
{loaded && <span className="ml-2 text-xs font-normal text-gray-500">{bindings.length}</span>}
</span>
<svg
className={`w-4 h-4 text-gray-500 transition-transform ${open ? "rotate-180" : ""}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="px-4 pb-4 space-y-2">
<p className="text-xs text-gray-500">
Cross-tenant roles (platform admins, delegated MSP admins) are bound to the Entra tenant and account they
were granted for. Bindings are maintained automatically; correct one here only if an account was re-created.
</p>
{!loaded ? (
<p className="text-sm text-gray-400">Loading…</p>
) : bindings.length === 0 ? (
<p className="text-sm text-gray-400">No cross-tenant role holders are homed in this tenant.</p>
) : (
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg bg-white">
{bindings.map((b) => (
<li key={b.upn} className="px-3 py-2 text-sm">
{editing === b.upn ? (
<div className="space-y-2">
<div className="font-medium text-gray-900 break-all">{b.upn}</div>
<input
type="text"
value={editTenantId}
onChange={(e) => setEditTenantId(e.target.value)}
placeholder="Home tenant ID (GUID)"
spellCheck={false}
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm font-mono text-gray-900 focus:outline-none focus:ring-2 focus:ring-sky-500"
/>
<input
type="text"
value={editObjectId}
onChange={(e) => setEditObjectId(e.target.value)}
placeholder="Object ID (GUID) — leave empty to re-pin on the next sign-in"
spellCheck={false}
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm font-mono text-gray-900 focus:outline-none focus:ring-2 focus:ring-sky-500"
/>
<div className="flex gap-2">
<button
onClick={() => save(b.upn)}
disabled={!editValid || busy === b.upn}
className="px-3 py-1 text-sm bg-sky-600 text-white rounded-lg hover:bg-sky-700 disabled:opacity-50 transition-colors"
>
{busy === b.upn ? "Saving…" : "Save"}
</button>
<button
onClick={() => setEditing(null)}
className="px-3 py-1 text-sm border border-gray-300 rounded-lg hover:bg-gray-100 transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="font-medium text-gray-900 break-all">{b.upn}</span>
<span className="text-xs font-mono text-gray-500">
{b.isObjectIdPinned ? `oid ${b.objectId}` : "object id pinned on next sign-in"}
</span>
{canMutate && (
<span className="ml-auto flex gap-2 shrink-0">
<button
onClick={() => startEdit(b)}
disabled={busy === b.upn}
className="text-xs text-sky-700 hover:text-sky-900 disabled:opacity-50"
>
Edit
</button>
<button
onClick={() => remove(b.upn)}
disabled={busy === b.upn}
className="text-xs text-red-600 hover:text-red-800 disabled:opacity-50"
>
Remove
</button>
</span>
)}
</div>
)}
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
@@ -8,6 +8,7 @@ import { classifyClientId, legacyConfigured } from "@/lib/authApp";
import { appHomingErrorMessage } from "@/lib/appHoming";
import { trackEvent } from "@/lib/appInsights";
import { TenantAdminSection } from "./TenantAdminSection";
import { IdentityBindingsSection } from "./IdentityBindingsSection";
import { AppHomingConfirmDialog } from "./AppHomingConfirmDialog";
import { OffboardTenantConfirmDialog } from "./OffboardTenantConfirmDialog";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
@@ -914,6 +915,14 @@ function TenantManagementSectionInner({
setSuccessMessage={setSuccessMessage}
/>
{/* Identity bindings homed in this tenant (cross-tenant role holders) — collapsed, inspect/correct only */}
<IdentityBindingsSection
tenantId={editingTenant.tenantId}
getAccessToken={getAccessToken}
setError={setError}
setSuccessMessage={setSuccessMessage}
/>
{/* Preview Notification Email */}
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
<h3 className="font-semibold text-indigo-900 mb-3">Preview Notification Email</h3>
@@ -6,7 +6,7 @@ import { useNotifications } from "@/contexts/NotificationContext";
import { useTenantList } from "@/hooks/useTenantList";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { api } from "@/lib/api";
import { bindingLabel, bindingsByUpn, isGuid, type IdentityBinding } from "@/lib/identityBinding";
import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding";
/** One delegated-admin assignment as returned by /api/global/delegated-admins (camelCase JSON). */
interface DelegatedAssignment {
@@ -37,7 +37,6 @@ export function SectionDelegatedAdmins() {
const tenants = useTenantList(true);
const [assignments, setAssignments] = useState<DelegatedAssignment[]>([]);
const [bindings, setBindings] = useState<IdentityBinding[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
@@ -45,8 +44,10 @@ export function SectionDelegatedAdmins() {
const [newUpn, setNewUpn] = useState("");
const [newTenantId, setNewTenantId] = useState("");
const [newRole, setNewRole] = useState("DelegatedReader");
const [newHomeTenantId, setNewHomeTenantId] = useState("");
const [newObjectId, setNewObjectId] = useState("");
// Only shown after the backend answered 422 HomeTenantUnresolved (UPN never signed in AND its domain
// belongs to no onboarded tenant) — the one case the identity cannot be resolved automatically.
const [needHomeTenant, setNeedHomeTenant] = useState(false);
const [homeTenantPick, setHomeTenantPick] = useState("");
const [granting, setGranting] = useState(false);
const [busyKey, setBusyKey] = useState<string | null>(null);
@@ -75,7 +76,6 @@ export function SectionDelegatedAdmins() {
if (!response.ok) throw new Error(`Failed to load delegated admins: ${response.statusText}`);
const data = await response.json();
setAssignments(data.assignments ?? []);
setBindings(data.bindings ?? []);
} catch (err) {
handleError(err, "Failed to load delegated admins");
} finally {
@@ -91,13 +91,9 @@ export function SectionDelegatedAdmins() {
void run();
}, [fetchAssignments]);
const bindingOf = useMemo(() => bindingsByUpn(bindings), [bindings]);
const homeTenantValid = isGuid(newHomeTenantId);
const objectIdValid = newObjectId.trim() === "" || isGuid(newObjectId);
const handleGrant = useCallback(async () => {
const upn = newUpn.trim();
if (!upn || !newTenantId || !homeTenantValid || !objectIdValid) return;
if (!upn || !newTenantId || (needHomeTenant && !homeTenantPick)) return;
try {
setGranting(true);
setError(null);
@@ -108,12 +104,14 @@ export function SectionDelegatedAdmins() {
upn,
tenantId: newTenantId,
role: newRole,
homeTenantId: newHomeTenantId.trim(),
objectId: newObjectId.trim() || undefined,
homeTenantId: needHomeTenant ? homeTenantPick : undefined,
}),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
if (response.status === 422 && data.code === HOME_TENANT_UNRESOLVED) {
setNeedHomeTenant(true);
}
throw new Error(data.error || `Failed to grant: ${response.statusText}`);
}
const dom = domainOf(newTenantId);
@@ -121,8 +119,8 @@ export function SectionDelegatedAdmins() {
setNewUpn("");
setNewTenantId("");
setNewRole("DelegatedReader");
setNewHomeTenantId("");
setNewObjectId("");
setNeedHomeTenant(false);
setHomeTenantPick("");
await fetchAssignments();
} catch (err) {
handleError(err, "Failed to grant");
@@ -130,7 +128,7 @@ export function SectionDelegatedAdmins() {
setGranting(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [newUpn, newTenantId, newRole, newHomeTenantId, newObjectId, homeTenantValid, objectIdValid, getAccessToken, fetchAssignments, domainOf]);
}, [newUpn, newTenantId, newRole, needHomeTenant, homeTenantPick, getAccessToken, fetchAssignments, domainOf]);
const handleToggle = useCallback(async (a: DelegatedAssignment) => {
const key = `${a.upn}|${a.tenantId}`;
@@ -264,33 +262,24 @@ export function SectionDelegatedAdmins() {
<option value="DelegatedReader">{ROLE_LABELS.DelegatedReader}</option>
<option value="DelegatedAdmin">{ROLE_LABELS.DelegatedAdmin}</option>
</select>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<input
type="text"
value={newHomeTenantId}
onChange={(e) => setNewHomeTenantId(e.target.value)}
placeholder="Home tenant ID (GUID) — the Entra tenant they sign in from"
autoComplete="off"
spellCheck={false}
className={`flex-1 min-w-0 px-4 py-2 border rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors ${
newHomeTenantId && !homeTenantValid ? "border-red-300" : "border-gray-300"
}`}
/>
<input
type="text"
value={newObjectId}
onChange={(e) => setNewObjectId(e.target.value)}
placeholder="Object ID (GUID, optional — pinned on first sign-in)"
autoComplete="off"
spellCheck={false}
className={`flex-1 min-w-0 px-4 py-2 border rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors ${
newObjectId && !objectIdValid ? "border-red-300" : "border-gray-300"
}`}
/>
{needHomeTenant && (
<select
value={homeTenantPick}
onChange={(e) => setHomeTenantPick(e.target.value)}
className="flex-1 min-w-0 px-3 py-2 border border-amber-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
aria-label="Home tenant of the person"
>
<option value="">Home tenant they sign in from…</option>
{tenants.map((t) => (
<option key={t.tenantId} value={t.tenantId}>
{t.domainName || t.tenantId}
</option>
))}
</select>
)}
<button
onClick={handleGrant}
disabled={granting || !newUpn.trim() || !newTenantId || !homeTenantValid || !objectIdValid}
disabled={granting || !newUpn.trim() || !newTenantId || (needHomeTenant && !homeTenantPick)}
className="px-6 py-2 bg-sky-600 text-white rounded-lg hover:bg-sky-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center space-x-2"
>
{granting ? (
@@ -305,8 +294,7 @@ export function SectionDelegatedAdmins() {
</div>
<p className="text-xs text-sky-700">
Reader is read-only (secrets redacted). The tenant must be onboarded to appear in the list above.
The grant only works from the person&rsquo;s home tenant and account: a sign-in with the same UPN from
another tenant or another account is refused. Leave the object ID empty to pin it on their first sign-in.
The grant is bound to the person&rsquo;s own tenant and account automatically.
</p>
</div>
@@ -330,17 +318,11 @@ export function SectionDelegatedAdmins() {
<div className="space-y-4">
{grouped.map(([upn, rows]) => (
<div key={upn} className="border border-gray-200 rounded-lg">
<div className="px-4 py-2 border-b border-gray-100 bg-gray-50 rounded-t-lg flex flex-wrap items-center gap-x-2 gap-y-1">
<div className="px-4 py-2 border-b border-gray-100 bg-gray-50 rounded-t-lg">
<span className="font-medium text-gray-900">{upn}</span>
<span className="text-xs text-gray-500">
<span className="ml-2 text-xs text-gray-500">
{rows.length} tenant{rows.length === 1 ? "" : "s"}
</span>
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs border border-gray-300 text-gray-600"
title={bindingOf.get(upn.toLowerCase())?.tenantId ?? "No identity binding"}
>
{bindingLabel(bindingOf.get(upn.toLowerCase()), domainOf)}
</span>
</div>
<div className="divide-y divide-gray-100">
{rows.map((a) => {
@@ -6,7 +6,7 @@ import { useNotifications } from "@/contexts/NotificationContext";
import { useTenantList } from "@/hooks/useTenantList";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { api } from "@/lib/api";
import { bindingLabel, bindingsByUpn, isGuid, type IdentityBinding } from "@/lib/identityBinding";
import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding";
/** One UPN assigned to a group (camelCase JSON from the backend). */
interface GroupAssignee {
@@ -43,7 +43,6 @@ export function SectionTenantGroups() {
const tenants = useTenantList(true);
const [groups, setGroups] = useState<TenantGroup[]>([]);
const [bindings, setBindings] = useState<IdentityBinding[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
@@ -56,8 +55,10 @@ export function SectionTenantGroups() {
const [tenantToAdd, setTenantToAdd] = useState<Record<string, string>>({});
const [assignUpn, setAssignUpn] = useState<Record<string, string>>({});
const [assignRole, setAssignRole] = useState<Record<string, string>>({});
const [assignHomeTenantId, setAssignHomeTenantId] = useState<Record<string, string>>({});
const [assignObjectId, setAssignObjectId] = useState<Record<string, string>>({});
// Per-group: shown only after a 422 HomeTenantUnresolved (UPN never signed in AND its domain belongs to
// no onboarded tenant) — the one case the identity binding cannot be resolved automatically.
const [needHomeTenant, setNeedHomeTenant] = useState<Record<string, boolean>>({});
const [homeTenantPick, setHomeTenantPick] = useState<Record<string, string>>({});
const domainOf = useMemo(() => {
const map = new Map(tenants.map((t) => [t.tenantId.toLowerCase(), t.domainName]));
@@ -84,7 +85,6 @@ export function SectionTenantGroups() {
if (!response.ok) throw new Error(`Failed to load groups: ${response.statusText}`);
const data = await response.json();
setGroups(data.groups ?? []);
setBindings(data.bindings ?? []);
} catch (err) {
handleError(err, "Failed to load groups");
} finally {
@@ -114,6 +114,9 @@ export function SectionTenantGroups() {
const response = await authenticatedFetch(url, getAccessToken, init);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
if (response.status === 422 && data.code === HOME_TENANT_UNRESOLVED && key.startsWith("assign:")) {
setNeedHomeTenant((p) => ({ ...p, [key.slice("assign:".length)]: true }));
}
throw new Error(data.error || `Request failed: ${response.statusText}`);
}
flash(ok);
@@ -188,19 +191,11 @@ export function SectionTenantGroups() {
[mutate, domainOf],
);
const bindingOf = useMemo(() => bindingsByUpn(bindings), [bindings]);
/** True when the per-group assign form is complete: UPN, GUID home tenant, and an empty-or-GUID object id. */
/** True when the per-group assign form is complete (UPN, plus the home tenant if the backend asked for it). */
const canAssign = useCallback(
(groupId: string) => {
const objectId = (assignObjectId[groupId] || "").trim();
return (
!!(assignUpn[groupId] || "").trim() &&
isGuid(assignHomeTenantId[groupId] || "") &&
(objectId === "" || isGuid(objectId))
);
},
[assignUpn, assignHomeTenantId, assignObjectId],
(groupId: string) =>
!!(assignUpn[groupId] || "").trim() && (!needHomeTenant[groupId] || !!homeTenantPick[groupId]),
[assignUpn, needHomeTenant, homeTenantPick],
);
const handleAssign = useCallback(
@@ -215,18 +210,17 @@ export function SectionTenantGroups() {
{
upn,
role,
homeTenantId: (assignHomeTenantId[t.groupId] || "").trim(),
objectId: (assignObjectId[t.groupId] || "").trim() || undefined,
homeTenantId: needHomeTenant[t.groupId] ? homeTenantPick[t.groupId] : undefined,
},
`Assigned ${upn} to "${t.name}".`,
);
if (ok) {
setAssignUpn((prev) => ({ ...prev, [t.groupId]: "" }));
setAssignHomeTenantId((prev) => ({ ...prev, [t.groupId]: "" }));
setAssignObjectId((prev) => ({ ...prev, [t.groupId]: "" }));
setNeedHomeTenant((prev) => ({ ...prev, [t.groupId]: false }));
setHomeTenantPick((prev) => ({ ...prev, [t.groupId]: "" }));
}
},
[assignUpn, assignRole, assignHomeTenantId, assignObjectId, canAssign, mutate],
[assignUpn, assignRole, needHomeTenant, homeTenantPick, canAssign, mutate],
);
const handleUnassign = useCallback(
@@ -432,12 +426,6 @@ export function SectionTenantGroups() {
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-sky-100 text-sky-800">
{ROLE_LABELS[a.role] ?? a.role}
</span>
<span
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs border border-gray-300 text-gray-600"
title={bindingOf.get(a.upn.toLowerCase())?.tenantId ?? "No identity binding"}
>
{bindingLabel(bindingOf.get(a.upn.toLowerCase()), domainOf)}
</span>
<button
onClick={() => handleUnassign(t, a.upn)}
disabled={busyKey === `unassign:${t.groupId}:${a.upn}`}
@@ -481,26 +469,21 @@ export function SectionTenantGroups() {
Assign
</button>
</div>
<div className="flex flex-wrap gap-2">
<input
type="text"
value={assignHomeTenantId[t.groupId] || ""}
onChange={(e) => setAssignHomeTenantId((p) => ({ ...p, [t.groupId]: e.target.value }))}
placeholder="Home tenant ID (GUID) — the Entra tenant they sign in from"
autoComplete="off"
spellCheck={false}
className="w-full 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 font-mono focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
/>
<input
type="text"
value={assignObjectId[t.groupId] || ""}
onChange={(e) => setAssignObjectId((p) => ({ ...p, [t.groupId]: e.target.value }))}
placeholder="Object ID (optional — pinned on first sign-in)"
autoComplete="off"
spellCheck={false}
className="w-full 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 font-mono focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
/>
</div>
{needHomeTenant[t.groupId] && (
<select
value={homeTenantPick[t.groupId] || ""}
onChange={(e) => setHomeTenantPick((p) => ({ ...p, [t.groupId]: e.target.value }))}
className="w-full px-2 py-1.5 border border-amber-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
aria-label="Home tenant of the person"
>
<option value="">Home tenant they sign in from…</option>
{tenants.map((tn) => (
<option key={tn.tenantId} value={tn.tenantId}>
{tn.domainName || tn.tenantId}
</option>
))}
</select>
)}
</div>
</div>
</div>
@@ -1,33 +1,25 @@
/**
* The Entra identity (home tenant + object id) a cross-tenant-role UPN is bound to, as returned by
* /api/global/delegated-admins, /api/global/tenant-groups and /api/global/identity-bindings (camelCase JSON).
* A grant is only usable from this identity: the backend refuses tokens whose tid/oid do not match.
* /api/global/identity-bindings (camelCase JSON). A grant is only usable from this identity: the backend
* refuses tokens whose tid/oid do not match. Maintained automatically — resolved from sign-in history at
* grant time, object id pinned on the first sign-in.
*/
export interface IdentityBinding {
upn: string;
tenantId: string;
objectId: string; // "" until pinned (grant without objectId ⇒ pinned on the person's first sign-in)
objectId: string; // "" until pinned
boundBy: string;
boundAt: string;
objectIdPinnedAt: string | null;
isObjectIdPinned: boolean;
}
/** Error code the grant endpoints return (HTTP 422) when the person's home tenant could not be resolved. */
export const HOME_TENANT_UNRESOLVED = "HomeTenantUnresolved";
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** True for a canonical GUID string (what Entra tenant and object ids look like). */
export function isGuid(value: string): boolean {
return GUID_RE.test(value.trim());
}
/** Indexes a bindings list by lowercase UPN. */
export function bindingsByUpn(bindings: IdentityBinding[] | undefined): Map<string, IdentityBinding> {
return new Map((bindings ?? []).map((b) => [b.upn.toLowerCase(), b]));
}
/** Short human label for a binding's state, for the per-UPN context pill. */
export function bindingLabel(b: IdentityBinding | undefined, domainOf: (tenantId: string) => string): string {
if (!b) return "No identity binding — grant is inert";
const home = domainOf(b.tenantId) || `tenant …${b.tenantId.slice(-4)}`;
return b.isObjectIdPinned ? `${home} · object id pinned` : `${home} · object id pinned on first sign-in`;
}