Files
AutopilotMonitor/docs/backend/admin-identity-binding.md
T

13 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, DelegatedAdmins or McpUsers 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 the role services and the MCP whitelist, the first-sign-in object-id pin, and the explicit rebind surface. src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityBindingService.cs
backend
security
authorization
multi-tenant
global-admin
delegated-admin
mcp
2026-08-30T00: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 tid differs.
  • UPN recycling: a User Administrator in the home tenant can re-assign the UPN to another account; same tid, different oid.
  • 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:

  1. null identity → no role, no storage read.
  2. Resolve the row by UPN (cached 30 s, unchanged from before).
  3. 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.
  4. AdminIdentityBindingService.IsBoundAsync(identity) → false ⇒ no role / empty scope.
  5. 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.

The MCP whitelist is a third UPN-keyed grant

McpUsers (PK McpUsers, RK = lowercase UPN) is the WhitelistOnly allow-list consulted by GET auth/mcp, which runs under the AuthenticatedUser policy — a token from any Entra tenant reaches it. Before 2026-08-30 the row was honoured by UPN alone, so a same-UPN identity from another tenant received allowed=true / accessGrant=McpUser, inherited the row's UsagePlan (a paid quota tier provisioned for another organization's user), and was also hit by the row's Disabled kill-switch. The data reach stayed tenant-bound (the MCP server routes a non-global caller on its own JWT tid), but the GA-curated whitelist and the per-user entitlement were bypassable.

McpUserService now resolves the whitelist exactly like the role services: row by UPN (cached 30 s) → no row ⇒ NotPresent without touching the binding → IsBoundAsync(identity) false ⇒ NotPresent. Grant, usage-plan override (GetBoundMcpUserAsync, used by McpQuotaService.ResolvePlanAsync and the self-service usage view) and the Disabled kill-switch all belong to the bound identity only; an unbound legacy row is inert. POST global/mcp-users binds first via the same resolver / 422 / 409 contract as the other grant endpoints, and the MCP users settings section shows the same home-tenant picker on 422. The AllMembers path is unaffected — it is tenant-bound by the auth middleware itself.

Grants bind first — and resolve the identity themselves

Granting is "type the UPN". The four grant endpoints (POST auth/global-admins, POST global/delegated-admins, POST global/tenant-groups/{id}/assignees, POST global/mcp-users) 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, AssignGroupAsync and AddMcpUserAsync 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

  • AdminIdentityBindings is in TableNames.All, in CriticalBackupTables.All and in AuthTablesFullRestoreForbidden (single-row restore only, like the other auth tables).
  • Tenant offboarding wipes bindings whose TenantId is the offboarded tenant (property-only bucket): once a home tenant is gone, its admins' grants become inert.
  • RequestContext.ObjectId carries the JWT oid; AdminIdentity.FromRequestContext serves the handlers that resolve roles outside the middleware (SignalR group join/leave, health checks).
  • The throttle identity (GetCallerId) now prefers oid over 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 tid and oid are recorded per login in UserActivity) 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, McpUserService.cs — resolution order (McpUserServiceIdentityBindingTests for the whitelist matrix).
  • 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.
  • 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.