From a306cd9e4021cdff4395e3ff3a68448ed8a1cf26 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sat, 29 Aug 2026 19:21:47 +0200 Subject: [PATCH] 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 --- docs/backend/admin-identity-binding.md | 36 ++- docs/log.md | 4 + .../AdminIdentityResolverTests.cs | 104 ++++++++ .../AuthFunctionSideEffectTests.cs | 4 +- .../RevokeEnforcementTests.cs | 27 ++- .../TableStorage/TableMetricsRepository.cs | 3 + .../Admin/DelegatedAdminManagementFunction.cs | 48 ++-- .../IdentityBindingManagementFunction.cs | 34 ++- .../Admin/TenantGroupManagementFunction.cs | 37 +-- .../Functions/Infrastructure/AuthFunction.cs | 24 +- .../AutopilotMonitor.Functions/Program.cs | 1 + .../Services/AdminIdentityResolver.cs | 85 +++++++ .../Services/TableStorageService.Metrics.cs | 32 +++ .../DataAccess/IMetricsRepository.cs | 15 ++ .../components/IdentityBindingsSection.tsx | 225 ++++++++++++++++++ .../components/TenantManagementSection.tsx | 9 + .../sections/SectionDelegatedAdmins.tsx | 82 +++---- .../settings/sections/SectionTenantGroups.tsx | 79 +++--- .../lib/identityBinding.ts | 22 +- 19 files changed, 695 insertions(+), 176 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/AdminIdentityResolverTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityResolver.cs create mode 100644 src/Web/autopilot-monitor-web/app/admin/components/IdentityBindingsSection.tsx 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() { -
-
- 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" - }`} - /> - 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 && ( + + )}

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() {
{grouped.map(([upn, rows]) => (
-
+
{upn} - + {rows.length} tenant{rows.length === 1 ? "" : "s"} - - {bindingLabel(bindingOf.get(upn.toLowerCase()), domainOf)} -
{rows.map((a) => { diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionTenantGroups.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionTenantGroups.tsx index 733a009a..497c07a8 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionTenantGroups.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionTenantGroups.tsx @@ -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([]); - const [bindings, setBindings] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); @@ -56,8 +55,10 @@ export function SectionTenantGroups() { const [tenantToAdd, setTenantToAdd] = useState>({}); const [assignUpn, setAssignUpn] = useState>({}); const [assignRole, setAssignRole] = useState>({}); - const [assignHomeTenantId, setAssignHomeTenantId] = useState>({}); - const [assignObjectId, setAssignObjectId] = useState>({}); + // 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>({}); + const [homeTenantPick, setHomeTenantPick] = useState>({}); 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() { {ROLE_LABELS[a.role] ?? a.role} - - {bindingLabel(bindingOf.get(a.upn.toLowerCase()), domainOf)} -
-
- 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" - /> - 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" - /> -
+ {needHomeTenant[t.groupId] && ( + + )}
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`; -}