MSGraph - Custom User Module - Including B2B Guest Accounts

Extending the Microsoft.Graph/users read-only resource to resolve B2B guest accounts alongside native members, without hardcoding object IDs.

The Back Story

If you’ve spent any time wiring up Microsoft Entra ID resources with the Microsoft Graph Bicep extension, you’ll have run into Microsoft.Graph/users. It’s documented here:

Info

Microsoft.Graph users reference (graph-bicep-1.0)

Microsoft.Graph/users is a read-only resource type - x-bicep-resource-writable-scopes: None. It can only ever be declared with the existing keyword; Bicep will never create or modify a user through this resource type.

That read-only nature makes it perfect for exactly one job: resolving an existing user’s object ID (and a handful of profile properties) by User Principal Name, so you can feed that ID into something else - most commonly the ownerIds/memberIds parameters of a Microsoft.Graph/groups module, without ever hardcoding a GUID in your Bicep files.

It works brilliantly… right up until one of those users is a B2B guest instead of a native member. That’s where I hit a wall, and where this post picks up.

The Problem

Working for a managed service provider (MSP) sometimes means most of your Bicep deployments run against customer tenants rather than your own - typically authenticated as an admin account that’s been invited into each customer tenant as a Microsoft Entra B2B guest. When I first wired up a simple user-lookup module for exactly this scenario, I passed in the account’s normal email address as the userPrincipalName - and the lookup failed every time.

The reason is how Entra ID handles B2B guest sign-in names. When you invite jane.doe@contoso.com into fabrikam.onmicrosoft.com as a guest, Entra ID doesn’t store the UPN as-is. It rewrites it into a mangled form:

1
jane.doe_contoso.com#EXT#@fabrikam.onmicrosoft.com

The pattern is:

  1. Take the invited email address.
  2. Replace the @ with _.
  3. Append #EXT#@<resource tenant's initial/verified domain>.

It’s a completely deterministic string transform - no directory lookup is required to compute it - but Bicep has no way of knowing whether a given address belongs to a guest or a member in the target tenant. That’s a fact about the tenant’s directory, not something you can derive from the string itself. So the caller has to tell it.

Bicep Config Configuration

Before any of the module code below will resolve, Bicep needs to know what microsoftGraphv1 actually refers to. That mapping doesn’t live in the module itself - it’s declared once in a bicepconfig.json file sitting alongside your Bicep files (or in a parent folder).

Per the official quickstart, add an extensions block that pins the alias to the Microsoft Graph extension package published to the Microsoft Artifact Registry:

1
2
3
4
5
{
  "extensions": {
    "microsoftGraphv1": "br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:1.0.0"
  }
}

Important

The key on the left (microsoftGraphv1 here) is just a friendly alias - it has to match, character-for-character, whatever name you use in the extension microsoftGraphv1 statement at the top of your Bicep file. Get the casing out of sync between bicepconfig.json and your .bicep file and Bicep will fail to resolve the extension.

A couple of other things worth checking before you start:

  • Bicep CLI version
    You’ll need at least v0.36.1 for Microsoft Graph extension support.
    Run az bicep version (or bicep --version if you’re using the standalone CLI) and upgrade with az bicep upgrade if needed.

  • VS Code Bicep extension
    Install/update the Bicep extension so IntelliSense picks up the Microsoft Graph types once bicepconfig.json is in place.

  • Permissions
    Per the Microsoft.Graph/users reference, reading an existing user needs at least User.Read for a signed-in (delegated) identity, or User.Read.All for an application/service principal identity. Higher-privileged scopes like User.ReadWrite.All or Directory.Read.All also work, but aren’t required just for lookups.

With bicepconfig.json in place, extension microsoftGraphv1 at the top of the module resolves correctly and the Microsoft.Graph/users@v1.0 resource type becomes available.

The Solution

I extended the plain UPN-lookup pattern into a small reusable module that accepts an isGuest flag per user, and reconstructs the mangled UPN automatically when needed.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
metadata name = 'Microsoft Graph User Lookup'
metadata description = 'Resolves one or more existing Microsoft Entra users by User Principal Name (UPN) via the Microsoft Graph Bicep extension. Microsoft.Graph/users is a read-only resource type in the Bicep extension (writable scopes: None) - it can only be referenced with the `existing` keyword, never created or updated. This module is intended for looking up object IDs (and other profile attributes) of already-existing users, for example to feed the `ownerIds`/`memberIds` parameters of the microsoftGraph/groups module without hardcoding object IDs.'
metadata owner = ''

extension microsoftGraphv1

// ================ //
// Types            //
// ================ //

@description('An identity to resolve to a Microsoft Entra user object.')
type userLookup = {
  @description('Required. The UPN/email of the identity as known outside the target tenant (e.g. the address a B2B guest was invited with, or the native UPN for a member account).')
  userPrincipalName: string

  @description('Optional. Set to true when this identity was invited as a Microsoft Entra B2B guest into the target tenant rather than being a native member account. When true, Entra ID rewrites the sign-in UPN to the mangled `local-part_domain#EXT#@resourceTenantDomain` form, and `guestTenantDomain` is used to reconstruct it for the lookup. Defaults to false (native member).')
  isGuest: bool?
}

// ================ //
// Parameters       //
// ================ //

@description('Optional. The identities to look up in the target tenant. Leave empty if no lookups are needed.')
param users userLookup[] = []

@description('Optional. Required only when at least one entry in `users` has `isGuest: true`. The initial/verified domain of the target Microsoft Entra tenant (e.g. \'contoso.onmicrosoft.com\'), used to reconstruct the mangled UPN Entra ID assigns to B2B guest accounts.')
param guestTenantDomain string = ''

// ================ //
// Variables        //
// ================ //

// Microsoft Entra B2B guest UPN mangling convention: the invited email's '@' is replaced with
// '_', then '#EXT#@<resourceTenantDomain>' is appended - e.g. 'jane.doe@contoso.com' invited into
// 'fabrikam.onmicrosoft.com' signs in as 'jane.doe_contoso.com#EXT#@fabrikam.onmicrosoft.com'.
// This is a deterministic string transform (no directory lookup required), but Bicep cannot infer
// on its own whether a given address is a guest or a member in the target tenant - that must be
// declared by the caller via `isGuest`, since this is a fact about the tenant, not the UPN string.
var resolvedUserPrincipalNames = [
  for user in users: (user.?isGuest ?? false)
    ? '${replace(user.userPrincipalName, '@', '_')}#EXT#@${guestTenantDomain}'
    : user.userPrincipalName
]

// ================ //
// Resources        //
// ================ //

// NOTE: Microsoft.Graph/users only supports `existing` (x-bicep-resource-flags: ReadOnly,
// x-bicep-resource-writable-scopes: None) - every other property on the resource is read-only,
// so `userPrincipalName` is the only value that can be supplied here. This does not create or
// modify any user; it only resolves the UPN to the user's directory object for output.
resource userResources 'Microsoft.Graph/users@v1.0' existing = [
  for resolvedUserPrincipalName in resolvedUserPrincipalNames: {
    userPrincipalName: resolvedUserPrincipalName
  }
]

// ================ //
// Outputs          //
// ================ //

@description('The object IDs of the resolved users, in the same order as users.')
output userIds string[] = [for i in range(0, length(users)): userResources[i].id]

@description('The display names of the resolved users, in the same order as users.')
output displayNames string[] = [for i in range(0, length(users)): userResources[i].displayName]

@description('The SMTP mail addresses of the resolved users, in the same order as users. May be empty for users without a mailbox.')
output mails string[] = [for i in range(0, length(users)): userResources[i].mail]

Walking Through It

A few things worth calling out for anyone adapting this:

  • userLookup type - each entry pairs the invited UPN with an isGuest flag. This keeps the caller-facing interface identical whether you’re resolving a native member or a guest; the mangling is entirely internal to the module.
  • resolvedUserPrincipalNames - a single for expression does the mangling up front, using the null-coalescing user.?isGuest ?? false pattern so isGuest can be omitted entirely for native members.
  • userResources existing loop - this is still the same one-liner pattern as the official docs, just fed the resolved (mangled where needed) UPNs instead of the raw ones.
  • Outputs are index-aligned - userIds, displayNames and mails all come back in the same order as the users array you passed in, so you can safely zip them back together in the calling module.
Important

guestTenantDomain only needs to be supplied once per module call, even if you’re resolving a mix of guest and member accounts in the same array - it’s applied only to entries where isGuest is true. Get the value wrong (e.g. use the home tenant domain instead of the resource tenant’s initial domain) and the lookup will silently fail to match, since Graph will simply not find a user with that mangled UPN.

Using It: Resolving Our Deployment Identity

In an MSP-style deployment model, the automation account authenticates as an admin identity that is always a B2B guest in whichever customer tenant it’s currently deploying into. Resolving its object ID (for example, to seed as the owner of a newly created Entra ID group) now looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
module getEntraIdDeploymentUser '../modules/microsoftGraph/users/main.bicep' = {
  name: 'get-entra-id-deployment-user-${locationShortCode}'
  scope: resourceGroup(resourceGroupNameArray[0])
  params: {
    users: [
      {
        userPrincipalName: deployedBy
        isGuest: true // Required if User is B2B Guest Invited
      }
    ]
    guestTenantDomain: entraGuestTenantDomain
  }
  dependsOn: [
    createResourceGroups
  ]
}

From here, getEntraIdDeploymentUser.outputs.userIds[0] drops straight into an ownerIds/memberIds array on a downstream groups module - no manual object ID lookups, no copy-pasting GUIDs between the Entra admin center and a parameters file, and no drift when the deployment is re-run against a different customer tenant.

Why This Matters

The official Microsoft.Graph/users pattern works great in a single-tenant world where everyone deploying is a native member. The moment you’re working as a managed service provider - where the identity running the deployment is a guest in every customer tenant you touch - the plain UPN lookup breaks silently, and it’s not obvious why unless you already know about Entra’s UPN mangling behaviour.

Wrapping that mangling logic into the lookup module itself means:

  • Callers keep passing in the UPN they actually know (the invited email address), not some Graph-internal representation of it.
  • The isGuest/guestTenantDomain distinction is explicit and self-documenting at the call site.
  • The module composes cleanly with the rest of a Microsoft Graph Bicep module set - resolved user IDs feed straight into groups, app role assignments, or anything else that needs a principalId.
Tip

If your organisation only ever deploys as native members, you can drop isGuest/guestTenantDomain entirely and this collapses back to the plain official pattern - the userLookup type defaults isGuest to false, so existing callers don’t need to change.

Conclusion

The official Microsoft.Graph/users lookup pattern is simple and works well - as long as every account you’re resolving is a native member of the tenant you’re deploying into. That assumption breaks down the moment you’re working cross-tenant with guest accounts, which is the everyday reality for anyone deploying Bicep on behalf of a customer or partners rather than into their own home tenant.

Wrapping the B2B UPN-mangling logic into the module itself means that reality doesn’t leak into every deployment that needs it:

  • You keep referencing people by the UPN you actually know - the address they were invited with - instead of hand-crafting the local-part_domain#EXT#@resourceTenantDomain form yourself, or worse, hardcoding a resolved object ID that will break the moment the deployment targets a different tenant.

  • The isGuest/guestTenantDomain parameters make the guest-vs-member distinction an explicit, self-documenting part of the module’s interface, rather than a tribal-knowledge gotcha that only bites you at deployment time.

  • It composes cleanly with everything else in your Microsoft Graph Bicep estate - resolved object IDs plug straight into ownerIds/memberIds on a groups module, principalId on a role assignment, or anywhere else a directory object reference is needed.

If you only ever deploy as a native member, none of this changes anything for you - isGuest defaults to false and the module behaves exactly like the plain official pattern. But if guest identities are part of your day-to-day deployment story, this small extension turns a silent, confusing lookup failure into something that just works.

Share with your network!

Built with Hugo - Theme Stack designed by Jimmy