diff --git a/docs/backend/admin-identity-binding.md b/docs/backend/admin-identity-binding.md
index 679a4243..a3a743ac 100644
--- a/docs/backend/admin-identity-binding.md
+++ b/docs/backend/admin-identity-binding.md
@@ -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`.
diff --git a/docs/log.md b/docs/log.md
index 35d01496..dbff4c35 100644
--- a/docs/log.md
+++ b/docs/log.md
@@ -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.
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AdminIdentityResolverTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AdminIdentityResolverTests.cs
new file mode 100644
index 00000000..18202898
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/AdminIdentityResolverTests.cs
@@ -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;
+
+///
+/// Pins — 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.
+///
+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? signIns = null,
+ params (string TenantId, string Domain)[] tenants)
+ {
+ var metrics = new Mock();
+ metrics.Setup(m => m.GetSignInIdentitiesByUpnAsync(It.IsAny()))
+ .ReturnsAsync((signIns ?? Array.Empty()).ToList());
+
+ var configRepo = new Mock();
+ 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.Instance, new MemoryCache(new MemoryCacheOptions()));
+
+ return new AdminIdentityResolver(metrics.Object, configService, NullLogger.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));
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs
index 30343687..d3dfb172 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs
@@ -96,7 +96,9 @@ public class AuthFunctionSideEffectTests
_autoApproveEnqueuerMock.Object,
new AutopilotMonitor.Functions.Security.EntraAppRegistry(
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
- Mock.Of>()));
+ Mock.Of>()),
+ new Mock(
+ _metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of>()) { CallBase = false }.Object);
// Default: all fire-and-forget calls succeed
_tenantConfigMock
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RevokeEnforcementTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RevokeEnforcementTests.cs
index c7c656d9..ee131084 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/RevokeEnforcementTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/RevokeEnforcementTests.cs
@@ -28,6 +28,14 @@ public class RevokeEnforcementTests
private static readonly StubAdminIdentityBindingService Bindings = new(bound: true);
+ /// Resolver stub: never consulted by the revoke/validation seams under test (loose mock ⇒ null).
+ private static AdminIdentityResolver Resolver() =>
+ new Mock(
+ Mock.Of(),
+ new Mock(Mock.Of(), NullLogger.Instance,
+ new MemoryCache(new MemoryCacheOptions())) { CallBase = false }.Object,
+ NullLogger.Instance) { CallBase = false }.Object;
+
private static DelegatedAdminService BuildService(Mock repo) =>
new(
repo.Object,
@@ -58,7 +66,7 @@ public class RevokeEnforcementTests
var audit = BuildAuditRepo();
var signalR = new FakeSignalRNotificationService();
var fn = new DelegatedAdminManagementFunction(
- NullLogger.Instance, BuildService(repo), Bindings, audit.Object, signalR);
+ NullLogger.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.Instance, BuildService(repo), Bindings, audit.Object, signalR);
+ NullLogger.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()
{
diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs
index dc0a5d9b..f585cc5d 100644
--- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs
+++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs
@@ -86,6 +86,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task DeleteUserActivityOlderThanAsync(DateTime cutoffUtc)
=> _storage.DeleteUserActivityOlderThanAsync(cutoffUtc);
+ public Task> GetSignInIdentitiesByUpnAsync(string upn)
+ => _storage.GetSignInIdentitiesByUpnAsync(upn);
+
public Task RecordUserPresenceAsync(string tenantId, string upn, string userRole)
=> _storage.RecordUserPresenceAsync(tenantId, upn, userRole);
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegatedAdminManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegatedAdminManagementFunction.cs
index d392d318..da3ef22a 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegatedAdminManagementFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegatedAdminManagementFunction.cs
@@ -24,37 +24,33 @@ public class DelegatedAdminManagementFunction
{
private readonly ILogger _logger;
private readonly DelegatedAdminService _delegatedAdminService;
- private readonly AdminIdentityBindingService _bindings;
+ private readonly AdminIdentityResolver _identityResolver;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly ISignalRNotificationService _signalRService;
public DelegatedAdminManagementFunction(
ILogger logger,
DelegatedAdminService delegatedAdminService,
- AdminIdentityBindingService bindings,
+ AdminIdentityResolver identityResolver,
IMaintenanceRepository maintenanceRepo,
ISignalRNotificationService signalRService)
{
_logger = logger;
_delegatedAdminService = delegatedAdminService;
- _bindings = bindings;
+ _identityResolver = identityResolver;
_maintenanceRepo = maintenanceRepo;
_signalRService = signalRService;
}
- ///
- /// 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.
- ///
+ /// GET /api/global/delegated-admins — list every delegated assignment. GlobalReadOrAdmin.
[Function("GetDelegatedAdmins")]
[Authorize]
public async Task 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 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
/// The MANAGED (target) tenant the UPN may read.
public string TenantId { get; set; } = string.Empty;
public string? Role { get; set; }
- /// The grantee's HOME Entra tenant id (required) — the JWT tid their tokens carry; becomes the identity binding.
+ /// The grantee's HOME Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.
public string? HomeTenantId { get; set; }
- /// The grantee's Entra object id (optional) — pinned on their first sign-in when omitted.
+ /// The grantee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.
public string? ObjectId { get; set; }
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/IdentityBindingManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/IdentityBindingManagementFunction.cs
index 9a844c49..56b6c997 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/IdentityBindingManagementFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/IdentityBindingManagementFunction.cs
@@ -143,16 +143,48 @@ public class IdentityBindingManagementFunction
/// Body of PUT global/identity-bindings/{upn}; also the shared validator for the binding fields every grant carries.
public class IdentityBindingRequest
{
+ ///
+ /// 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
+ /// and the UI offers a tenant picker. A supplied object id always wins over a resolved one.
+ ///
+ 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; }
/// Returns the 400 message, or null when the fields are valid (home tenant = GUID required; object id = GUID or absent).
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);
+ }
+
+ ///
+ /// Grant-time variant: the home tenant MAY be absent (the backend resolves it from sign-in history or the
+ /// UPN domain — see ); when supplied it must be a GUID, as must the object id.
+ ///
+ 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;
}
+
+ /// Error code the grant endpoints return (HTTP 422) when the home tenant could not be resolved automatically.
+ 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.";
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantGroupManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantGroupManagementFunction.cs
index a75843cd..566a0c74 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantGroupManagementFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantGroupManagementFunction.cs
@@ -29,7 +29,7 @@ public class TenantGroupManagementFunction
{
private readonly ILogger _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 logger,
DelegatedAdminService delegatedAdminService,
- AdminIdentityBindingService bindings,
+ AdminIdentityResolver identityResolver,
IMaintenanceRepository maintenanceRepo,
ISignalRNotificationService signalRService)
{
_logger = logger;
_delegatedAdminService = delegatedAdminService;
- _bindings = bindings;
+ _identityResolver = identityResolver;
_maintenanceRepo = maintenanceRepo;
_signalRService = signalRService;
}
- ///
- /// 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.
- ///
+ /// GET /api/global/tenant-groups — list every group with tenants + assignee count. GlobalReadOrAdmin.
[Function("GetTenantGroups")]
[Authorize]
public async Task 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
///
/// POST /api/global/tenant-groups/{groupId}/assignees — assign a UPN to the group. GlobalAdminOnly.
/// Body: { "upn": "user@domain.com", "role": "DelegatedReader" | "DelegatedAdmin",
- /// "homeTenantId": "<the assignee's Entra tenant>", "objectId": "<optional>" }.
- /// 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": "<optional override>", "objectId": "<optional>" }.
+ /// 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).
///
[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; }
- /// The assignee's HOME Entra tenant id (required) — becomes the UPN's identity binding.
+ /// The assignee's HOME Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.
public string? HomeTenantId { get; set; }
- /// The assignee's Entra object id (optional) — pinned on their first sign-in when omitted.
+ /// The assignee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.
public string? ObjectId { get; set; }
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs
index 4cedaa84..af242cd7 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs
@@ -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 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;
- /// The grantee's home Entra tenant id (required) — the JWT tid their tokens will carry.
+ /// The grantee's home Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.
public string? HomeTenantId { get; set; }
- /// The grantee's Entra object id (optional) — pinned on their first sign-in when omitted.
+ /// The grantee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.
public string? ObjectId { get; set; }
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs
index 20ac4026..a87e66fd 100644
--- a/src/Backend/AutopilotMonitor.Functions/Program.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Program.cs
@@ -252,6 +252,7 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
// Identity binding (tid + oid behind every cross-tenant-role UPN) — consulted by both role services.
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityResolver.cs b/src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityResolver.cs
new file mode 100644
index 00000000..e08177ad
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityResolver.cs
@@ -0,0 +1,85 @@
+using AutopilotMonitor.Shared.DataAccess;
+using Microsoft.Extensions.Logging;
+
+namespace AutopilotMonitor.Functions.Services;
+
+///
+/// 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:
+///
+/// Sign-in history (): 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.
+/// UPN domain → onboarded tenant: 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.
+///
+/// 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).
+///
+public class AdminIdentityResolver
+{
+ private readonly IMetricsRepository _metricsRepo;
+ private readonly TenantConfigurationService _tenantConfigService;
+ private readonly ILogger _logger;
+
+ public AdminIdentityResolver(
+ IMetricsRepository metricsRepo,
+ TenantConfigurationService tenantConfigService,
+ ILogger logger)
+ {
+ _metricsRepo = metricsRepo;
+ _tenantConfigService = tenantConfigService;
+ _logger = logger;
+ }
+
+ public virtual async Task 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;
+ }
+}
+
+///
+/// Outcome of : 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.
+///
+public sealed record ResolvedAdminIdentity(string TenantId, string? ObjectId, string Source)
+{
+ public const string SourceSignIn = "sign-in";
+ public const string SourceDomain = "domain";
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs
index 05a0b593..e74d2237 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs
@@ -1124,6 +1124,38 @@ namespace AutopilotMonitor.Functions.Services
}
}
+ ///
+ /// 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.
+ ///
+ public async Task> GetSignInIdentitiesByUpnAsync(string upn)
+ {
+ var result = new Dictionary<(string Tid, string Oid), UserSignInIdentity>();
+ if (string.IsNullOrWhiteSpace(upn))
+ return new List();
+
+ 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(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();
+ }
+
///
/// Gets user activity metrics for a specific tenant
///
diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs
index c58173b3..3c42e70b 100644
--- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs
+++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs
@@ -76,6 +76,12 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<(int uniqueUsers, int loginCount)> GetUserActivityForDateAsync(string? tenantId, DateTime date);
/// Retention cleanup: deletes UserActivity login rows whose LoginAt is older than the cutoff. Returns the number deleted.
Task DeleteUserActivityOlderThanAsync(DateTime cutoffUtc);
+ ///
+ /// 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.
+ ///
+ Task> GetSignInIdentitiesByUpnAsync(string upn);
// --- Live Presence (one row per user, upserted) ---
/// Upserts the caller's presence row (PK=tenantId, RK=SHA-256(lowercase UPN) hex) with LastSeen=now.
@@ -148,6 +154,15 @@ namespace AutopilotMonitor.Shared.DataAccess
public string AppName { get; set; } = string.Empty;
}
+ /// One (tenant, object id) a UPN was seen signing in with, plus the most recent such sign-in.
+ 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
{
///
diff --git a/src/Web/autopilot-monitor-web/app/admin/components/IdentityBindingsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/IdentityBindingsSection.tsx
new file mode 100644
index 00000000..01be53c7
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/app/admin/components/IdentityBindingsSection.tsx
@@ -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;
+ 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([]);
+ const [editing, setEditing] = useState(null);
+ const [editTenantId, setEditTenantId] = useState("");
+ const [editObjectId, setEditObjectId] = useState("");
+ const [busy, setBusy] = useState(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 (
+
+
+ {open && (
+
+
+ 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.
+
+ {!loaded ? (
+
Loading…
+ ) : bindings.length === 0 ? (
+
No cross-tenant role holders are homed in this tenant.
+ ) : (
+
+ {bindings.map((b) => (
+
+ {editing === b.upn ? (
+
+
{b.upn}
+ 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"
+ />
+ 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"
+ />
+
+
+
+
+
+ ) : (
+
+ {b.upn}
+
+ {b.isObjectIdPinned ? `oid ${b.objectId}` : "object id pinned on next sign-in"}
+
+ {canMutate && (
+
+
+
+
+ )}
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx
index f4746ac2..87235218 100644
--- a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx
+++ b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx
@@ -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 */}
+
+
{/* Preview Notification Email */}
Preview Notification Email
diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionDelegatedAdmins.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionDelegatedAdmins.tsx
index ba0c878b..4846ee55 100644
--- a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionDelegatedAdmins.tsx
+++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionDelegatedAdmins.tsx
@@ -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([]);
- const [bindings, setBindings] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [successMessage, setSuccessMessage] = useState(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(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() {
-
Reader is read-only (secrets redacted). The tenant must be onboarded to appear in the list above.
- The grant only works from the person’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’s own tenant and account automatically.
@@ -330,17 +318,11 @@ export function SectionDelegatedAdmins() {
diff --git a/src/Web/autopilot-monitor-web/lib/identityBinding.ts b/src/Web/autopilot-monitor-web/lib/identityBinding.ts
index ae8c7eea..e7c7178d 100644
--- a/src/Web/autopilot-monitor-web/lib/identityBinding.ts
+++ b/src/Web/autopilot-monitor-web/lib/identityBinding.ts
@@ -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 {
- 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`;
-}