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>
11 KiB
type, title, description, resource, tags, timestamp
| type | title | description | resource | tags | timestamp | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| Concept | Admin Identity Binding — tid + oid behind every cross-tenant-role UPN | Why a GlobalAdmins or DelegatedAdmins row keyed on a UPN string must never confer access by itself in an API that accepts tokens from every Entra tenant, the AdminIdentityBindings table that pins each such UPN to its immutable home tenant id and object id, the fail-closed resolution order in both role services, the first-sign-in object-id pin, and the explicit rebind surface. | src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityBindingService.cs |
|
2026-08-29T00:00:00+02:00 |
Problem
The API is a multi-tenant Entra application on purpose: AuthenticationMiddleware accepts a token from
any tenant (issuer prefix login.microsoftonline.com/ or sts.windows.net/), and signature, audience,
lifetime and algorithm are validated strictly. Tenant-member roles (TenantAdmins) are keyed on
(tid, upn) and therefore bound to the verified tenant.
The two cross-tenant tiers were not. GlobalAdmins (GlobalAdmin / GlobalReader) and the delegated MSP
tier (DelegatedAdmins + TenantGroupAssignments) resolved their role from the UPN string alone
(upn, falling back to preferred_username). Both claims are mutable and reusable across tenants:
- Domain re-registration: if the domain behind a platform admin's UPN lapses or moves, whoever
verifies it in a fresh tenant can mint the identical UPN; the token is genuine, only
tiddiffers. - UPN recycling: a User Administrator in the home tenant can re-assign the UPN to another account;
same
tid, differentoid. - MSP variant: the delegated tier was gated only on the caller's home tenant being Pro — which any tenant can self-provision via the trial endpoint.
Any of these yielded full platform scope: raw table dumps including TenantConfiguration secrets,
tenant offboarding, GA management. Microsoft's own guidance (post-nOAuth) is explicit: authorization
data must be keyed on the immutable tid + oid pair, never on upn / preferred_username.
Mechanism
One binding per UPN
AdminIdentityBindings (PK Bindings, RK = lowercase UPN) holds, for every UPN that carries a
cross-tenant role, the identity that may use it:
| Column | Meaning |
|---|---|
TenantId |
The admin's home Entra tenant — must equal the JWT tid. Mandatory at grant time. |
ObjectId |
The admin's Entra object id — must equal the JWT oid. Empty until pinned. |
BoundBy, BoundDate |
Operator provenance. |
ObjectIdPinnedDate |
When the object id was fixed (grant time, or the first matching sign-in). |
A single table rather than columns on three role tables: a UPN string belongs to exactly one identity at a time, a person may hold a GlobalAdmins row and delegated assignments, and the role rows stay what they are — grants. The binding says whose grants they are.
Resolution order (both role services)
AdminIdentity is the record (Upn, TenantId, ObjectId) built from a validated principal; it is
null when any of the three claims is missing (app-only tokens, foreign IdP shapes). Both
GlobalAdminService.GetGlobalRoleAsync(AdminIdentity?) and
DelegatedAdminService.GetScopeAsync(AdminIdentity?) follow the same order:
nullidentity → no role, no storage read.- Resolve the row by UPN (cached 30 s, unchanged from before).
- No row / empty scope → return; the binding is not even read. Ordinary tenant users cost one cached read and never produce a binding log line.
AdminIdentityBindingService.IsBoundAsync(identity)→ false ⇒ no role / empty scope.- Delegated only: the Pro home-tenant entitlement gate, after the binding check — an unbound caller never triggers an edition lookup.
IsBoundAsync is the single choke-point: no binding ⇒ false; TenantId mismatch ⇒ false (checked
before anything else, so a foreign-tenant token can never claim the pin below); pinned ObjectId
mismatch ⇒ false; unpinned binding + matching tenant ⇒ pin the caller's oid and verify against
what was actually stored afterwards. Every false outcome is logged at Warning with its reason
([IdentityBinding] TENANT MISMATCH … / OBJECT-ID MISMATCH … / holds no identity binding), the
one level the worker forwards to Application Insights; matches are silent. Pins are logged at Warning
once — they are one-off, operator-relevant events.
The pin is a conditional update in the repository (TryPinIdentityObjectIdAsync: only onto an unpinned
row homed in the caller's tenant, ETag-guarded; a 412 re-reads and the caller compares against the
winner). Two accounts racing the first sign-in inside the home tenant therefore leave exactly one bound.
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 — and resolve the identity themselves
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:
- 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. - UPN domain → onboarded tenant (
TenantConfiguration.DomainName, exactly one match): tid only; the oid is pinned on the first sign-in. - Neither ⇒ 422
HomeTenantUnresolved; the portal then shows a home-tenant picker (onboarded tenants) and retries withhomeTenantId. 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 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
| Route | Policy | Purpose |
|---|---|---|
GET global/identity-bindings |
GlobalReadOrAdmin | Audit which tenant / object id each grant is usable from. |
PUT global/identity-bindings/{upn} |
GlobalAdminOnly | Replace the binding: move a UPN to another tenant, or re-pin after a legitimate account re-creation (omit objectId to clear the pin). |
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 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
AdminIdentityBindingsis inTableNames.All, inCriticalBackupTables.Alland inAuthTablesFullRestoreForbidden(single-row restore only, like the other auth tables).- Tenant offboarding wipes bindings whose
TenantIdis the offboarded tenant (property-only bucket): once a home tenant is gone, its admins' grants become inert. RequestContext.ObjectIdcarries the JWToid;AdminIdentity.FromRequestContextserves the handlers that resolve roles outside the middleware (SignalR group join/leave, health checks).- The throttle identity (
GetCallerId) now prefersoidover the UPN, so two accounts in different tenants that share a UPN string no longer share a rate-limit bucket. Audit and presence rows keep the UPN as their display key; they already carry the tenant id. - Legacy role rows without a binding are inert, not grandfathered. Rolling this out therefore means
seeding the binding rows for the existing admin UPNs (their
tidandoidare recorded per login inUserActivity) before the backend deploy — an unseeded platform admin locks themselves out of every GlobalAdminOnly route, including the rebind surface.
Examples
A GlobalAdmin row for admin@vendor.example bound to tenant 1111… with object id aaaa…:
| Token | Result |
|---|---|
tid=1111… oid=aaaa… upn=admin@vendor.example |
GlobalAdmin |
tid=2222… oid=aaaa… upn=admin@vendor.example |
no role — TENANT MISMATCH warning |
tid=1111… oid=bbbb… upn=admin@vendor.example |
no role — OBJECT-ID MISMATCH warning |
tid=1111… upn=admin@vendor.example (no oid) |
no role — identity incomplete |
Same row, binding without an object id: the first tid=1111… sign-in pins its oid and is admitted; a
tid=2222… sign-in before that is refused and does not pin.
Citations
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—TryPinIdentityObjectIdAsyncand the binding CRUD.src/Backend/AutopilotMonitor.Functions/Functions/Admin/IdentityBindingManagementFunction.cs— the rebind surface and the sharedIdentityBindingRequest.Validate.src/Backend/AutopilotMonitor.Functions.Tests/AdminIdentityBindingServiceTests.cs,AdminIdentityBindingAuthorizationTests.cs— verdict matrix, race semantics, and the middleware-level denials for foreign-tenant and recycled-UPN tokens.- Client-Certificate Tenant Binding — the same "chain-valid is not tenant-bound" argument on the device side.