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