Lesson W4.1.1 had Kladde.GraphLab request two permissions, User.Read and Calendars.Read, without explaining why those two. This lesson makes up for that. With Microsoft Graph, permissions are no side issue: they decide whether a call ends with 403, and they decide what happens when an app's key falls into the wrong hands. At the end of the lesson the Kladde has a permission list with a rationale for each row, and you can read a token, classify a consent and trace an error message about permissions back to its cause.

Status of the review, please read first. Every technical statement in this lesson comes from pages on Microsoft Learn that were read while the lesson was written. No command, no menu path and no code of this lesson was run against a Microsoft 365 tenant, and the code in the form shown here has not been built. Where output would appear, the text describes it instead of faking one. Where we did not read something, it says "open". Menu labels in the Entra admin center and in Exchange change; if your screen differs from the text, the text follows the state of the Learn pages that were read.

You know the basics of OAuth and OpenID Connect, signing in with Entra ID and the Kladde's first app registration from lesson 2.6.2 "OpenID Connect with Microsoft Entra ID". How the Kladde reaches Azure without a secret is in lesson 3.3.1 "No keys: managed identity", and the basic terms of roles and assignments are in lesson 3.1.4 "Who may do what: Entra ID and Azure RBAC". This lesson does not repeat that. It builds on it.

Two kinds of access

An app can get to protected data in two ways, and Learn keeps them strictly apart:

  • Delegated access: A person has signed in, and the app works on their behalf. It needs delegated permissions, also called scopes.
  • App-only access: Nobody is signed in, and the app works with its own identity. It needs application permissions, also called app roles.

Learn names automation and backup, background services and daemons as the cases for the second way: It fits when no particular person is supposed to be signed in or when the data cannot be narrowed down to one person. The Kladde has both cases: An office manager who signs in to the Kladde and wants to see her calendar is delegated. The background service that synchronizes appointments with the office calendar at night is application.

More important than the names is how far the access reaches:

Delegated Application
Context on behalf of a signed-in person without a person
What is in effect the intersection: what the app may do and what the person may do themselves all data the permission targets
Example Files.Read.All reads only files the person may read themselves reads every file in the tenant
Who may consent people for their data, administrators for everyone's only administrators (or owners of the API)
How consent comes about static (list in the registration) or dynamic (asked for at sign-in) static only (list in the registration)
Other names scopes, OAuth2 permission scopes app roles, app-only permissions
Result of consent (in Graph) oAuth2PermissionGrant appRoleAssignment

With delegated access, two things must hold: The app must have the scope, and the person must have the right themselves. If either is missing, the call fails. The source of the data checks the second right: for directory data it is Entra roles, for mailboxes and calendars it is Exchange Online. As examples, Learn names a role in Microsoft Entra RBAC for directory data and Exchange Online RBAC for mail and calendar.

With application permissions there is no second hurdle. Whoever has Mail.Read as an application reads every mailbox in the tenant unless something else restricts it. That is exactly why Learn, from the point of view of least privilege, advises the delegated model wherever it is enough, and that is exactly why further down in this lesson there is a section that cuts application permissions back to individual mailboxes.

A figure from Learn fits here: An app can request up to 400 permissions for tenant apps and up to 30 for apps for personal accounts. Anyone who needs hundreds has a different problem.

What permissions are called

The names follow the pattern {resource}.{operation}.{constraint}: User.Read, Calendars.ReadWrite, Mail.Send, Files.Read.All, Sites.Selected. The parts mean what the name says: .Read is read, .ReadWrite is read and write, and the last part limits: .All means "all" (all files, all mailboxes), .Shared includes shared items, .Selected means "only what someone has explicitly assigned to you". The complete list, with a description and whether admin consent is required, is in Learn's Permissions reference. There you can see, for example, Mail.Send as an application with "Send mail as any user without a signed-in user" and "AdminConsentRequired: Yes", and as a delegated permission with "No".

One sentence that is often forgotten in practice: A permission is not a role. Mail.Send is a permission of the Microsoft Graph API. "Cloud Application Administrator" is a role in the directory, and "Application Mail.Send" further down is a role in Exchange. The words are similar, and the systems are separate.

Consent is the process in which people or administrators allow an app to access protected data. Learn distinguishes:

  • Consent by the person. It happens when someone signs in to an app. First the sign-in credentials are checked, then whether there is already a consent for the requested permissions. If there is none, Entra shows a consent prompt with the list of permissions and the details about the publisher. If the person agrees, that is stored, and at later sign-ins the prompt as a rule does not come back.
  • Consent by administrators. Some permissions can only be given by an administrator: all application permissions and many delegated permissions with high privileges. An administrator may consent for themselves or for the whole organization. After consent for the organization, Entra no longer asks people (unless the app later requests something the administration has not yet consented to).

Whether people may consent themselves is decided by the tenant. An administrator with the appropriate role sets it, and Learn names three built-in options:

  1. User consent is turned off. Nobody may consent to new permissions; existing consents remain. Only people with a directory role that allows it may consent for new apps.
  2. Users may consent to apps from verified publishers or from their own tenant, but only for permissions the tenant has selected (classified as "low impact").
  3. Users may consent to all apps, as long as the permissions do not require admin consent.

That has a practical consequence for the Kladde: What works in your test may not work at the customer's, because their tenant has turned user consent off. With every customer tenant, expect consent by the administration, and write in your onboarding which permissions the app needs and why. An app that asks for rights it cannot explain gets rejected, and Learn writes the sentence for it: If you are not sure who controls the app and why it requests these permissions, do not consent.

Who may give admin consent

For consent on behalf of the whole organization, Learn names three groups:

  • Privileged Role Administrator: for apps that request any permissions for any APIs.
  • Cloud Application Administrator, Application Administrator (and others): for any permissions of any APIs, except the app roles of Microsoft Graph (application permissions).
  • A custom directory role that includes the right to grant permissions to applications.

The sentence with the exception belongs in your onboarding: Application permissions for Graph are given only by a Privileged Role Administrator (or a role equivalent to it). Someone who, as a Cloud Application Administrator, is supposed to consent to an application permission for Graph cannot do it, according to Learn.

Two ways in the admin center lead to the goal, both described by Learn:

  • In the enterprise applications: Entra ID, Enterprise apps, All applications, choose the app, open Permissions under Security, carefully review the requested permissions and choose Grant admin consent. This works if the app already exists in the tenant, for example because a person has already signed in.
  • In the app registrations: Entra ID, App registrations, All applications, choose the app, open API permissions under Manage, review and choose Grant admin consent. This is the way for apps that your organization registered itself.

Learn also names the URL for consent of the organization, with which you can send an administrator on their way without wasting a word on secrets:

https://login.microsoftonline.com/{organization}/adminconsent?client_id={client-id}

{client-id} is the app's application ID, {organization} is the tenant ID or a verified domain of the tenant (the value organizations lets the consent happen in the home tenant of the person signing in). The URL contains no secrets, only the ID of the app. Send an administration this URL and a list of the permissions with a rationale, never a client secret, not in any chat and not in any mail. What the page "Admin consent on the Microsoft identity platform" says about further parameters we did not read (open).

Three warnings from Learn that you can take over into your onboarding:

  1. Consent for the organization is a sensitive process: It can give the app's publisher access to large parts of the data, for example to all mailboxes or all sites.
  2. By default it allows all people in the tenant to use the app. Whoever wants to restrict that sets the app to assignment required and assigns people or groups.
  3. It can revoke permissions already granted tenant-wide to the app. Permissions that people gave for themselves remain.

Learn also shows how to grant consent by script, with Microsoft Graph PowerShell or with the Graph API itself (POST /oauth2PermissionGrants with consentType: AllPrincipals for delegated permissions and POST /servicePrincipals/{id}/appRoleAssignedTo for application permissions). With it comes the explicit warning: What is granted by script is subject to no review or confirmation and takes effect immediately. Such a script belongs in a repository, with a dry run (-WhatIf), and lesson W4.3.2 comes back to it. The rule here: First read the permissions the script grants, then let it run.

Requests to the administration

If people may not consent themselves, Entra offers the admin consent workflow: The person sees "Approval required", writes a justification and requests consent, the named reviewers get a notification, and the person learns the result. Several requests from the same person go to the administration only once. Whether your customer's tenant has turned this workflow on is its own business. Your part is the justification that you give the person.

Incremental consent

With delegated permissions, an app can request little at first and more later: Learn calls it incremental and dynamic consent. It is the right choice for optional features. The Kladde asks at sign-in only for what is necessary. Only whoever turns on the calendar feature sees the consent prompt for Calendars.Read. In the ASP.NET Core integration of Microsoft.Identity.Web you give the additional scopes for that on an individual call; Learn shows it like this:

C#
var messages = await _graphClient.Me.Messages
    .GetAsync(r => r.Options.WithScopes("Mail.Read"));

This requires the package Microsoft.Identity.Web.GraphServiceClient (services.AddMicrosoftGraph()), which you know from lesson W4.1.1 as the way for ASP.NET Core. Application permissions do not have this: They are always static, that is, entered in the registration. Whoever works as an application requests the scope https://graph.microsoft.com/.default and thereby gets what is in the registration and what was consented to. Learn says explicitly: The client credentials flow requires /.default, the permissions must be in the registration beforehand, and an administration must have consented beforehand.

A small warning about .default: It means "everything the app has". It is therefore convenient, but it does not show the code what it needs. The permission list below holds the knowledge, not the code.

The app registration

For an app to use Entra, it must be registered. The registration establishes a trust relationship between the app and the Microsoft identity platform, and a one-sided one: The app trusts the platform, not the other way around. An application object, once created, cannot be moved to another tenant. Creating it requires at least the role Cloud Application Administrator.

When you create it, you decide who may use the app ("Supported account types"): only accounts in your own directory (a line-of-business application for your own company, "single-tenant"), accounts in any directory (multitenant app, for example for software as a service) or additionally personal Microsoft accounts. The Kladde at Nordlicht Reisen GmbH is single-tenant. After that the overview shows two values: the application (client) ID, which identifies the app in all tenants, and the object ID, which identifies it in your tenant. Neither is a secret; do not mix them up, because Exchange (see below) wants both, in different places.

How the app identifies itself

An app that works without a person (a confidential client) needs a credential. Learn names three:

Credential Learn says
Key pair with a public part (certificate) the recommended kind, more secure than a secret; what you upload is the file with the public part (.cer, .pem or .crt)
Client secret less secure; common for local development, not intended for production; lifetime two years at most (24 months), Microsoft recommends under twelve months; the value is shown only once
Federated credential a workload proof, for example from GitHub Actions or a Kubernetes service account, entirely without a secret

For the Kladde that means, in this order: K2 takes the client secret in the user secrets (lesson 2.6.2, lesson 2.8.5 "Sending mail, confirmed"), because it is simplest in development. From K3 on, a managed identity carries the token, without a secret lying anywhere (lesson 3.3.1). That a managed identity is assigned Graph application permissions is material for lesson W4.3.2. The key, the secret, never belongs in the repository, never in the project file and never in a chat message.

On the question of whether the Kladde's background service should share its sign-in, Learn gives no answer. We give one as a design recommendation (not from Learn): One registration per purpose.

Registration Purpose Type Credential
Kladde (Development) from lesson 2.6.2 sign-in of the people in the Kladde delegated (sign-in only) secret in the user secrets, none later
Kladde Office (Development), new background service: send mail, synchronize the calendar application secret in the user secrets, managed identity from K3
kladde-graphlab-test, new, only in the test tenant tool Kladde.GraphLab delegated none (public client, device code)

The reasoning: An application permission on "all mailboxes" does not belong in the registration that every person signs in with; a secret that opens the office service does not belong in the same object as the sign-in; and Exchange (see below) assigns rights to one identity. Whoever mixes two purposes cannot restrict them separately and cannot revoke them separately.

Choosing the least privileged permission

Every reference page of a Graph call has a section Permissions with a table, split into delegated (work or school account, personal account) and application, with two columns: Least privileged permission and Higher privileged permissions. Learn says: Choose the permission marked as least privileged; take a higher one only if your app truly needs it. Excerpts from the pages we read (each v1.0):

Call Delegated (work or school account) Application
GET /me, GET /users/{id} (user-get) least User.Read; higher among others User.Read.All least User.Read.All
calendarView (user-list-calendarview) least Calendars.ReadBasic; higher Calendars.Read, Calendars.ReadWrite least Calendars.ReadBasic; higher Calendars.Read, Calendars.ReadWrite
sendMail (user-sendmail) Mail.Send; higher: not available Mail.Send; higher: not available
Create an event (calendar-post-events) Calendars.ReadWrite Calendars.ReadWrite

Two observations from it:

  1. Reading and writing are different permissions. Whoever creates appointments needs Calendars.ReadWrite. Learn gives an example that hits the Kladde exactly: An app that reads calendar data from Outlook and writes appointments needs Calendars.ReadWrite, even if fetching the changes (delta) alone requires only Calendars.Read. So the app requests one permission, matching the highest need.
  2. "Least" applies per call, not per app. Your app requests the union of the least privileged permissions of all its calls, no more.

A trap for calendarView: According to the reference, Calendars.ReadBasic is the least privileged. What is open is which properties of an appointment arrive with it: We did not read the text of the permission. Whoever needs the subject and does not get it with the least privileged permission takes the next higher one. You settle that in the test tenant; the permission list below writes it down as a verification task.

A permission limited to a single place: .Selected

For files there is a family of its own: Because the permission model of SharePoint and OneDrive does not fit into the grid of scopes, there are Selected scopes: Sites.Selected for a specific site collection, and also Lists.SelectedOperations.Selected, ListItems.SelectedOperations.Selected and Files.SelectedOperations.Selected for a list, list items, or files and folders. All support delegated and application. Important: Consent to Sites.Selected alone gives no access to any site. After that, an administration must explicitly allow the app for each site (according to the pages read, through POST /sites/{siteId}/permissions with a role such as read, write, manage or fullcontrol and the client ID of the app). That is the way to give an app access to one site instead of all. Lesson W4.3.1 does this with the Kladde. For Teams there is a way to bind the permission to individual teams, chats and messages, resource-specific consent (RSC). What Learn says in detail for Teams channel messages we will read only in lesson W4.3.1 (open).

The Kladde's permission list

From all this comes the list that every customer tenant gets. One row per task, with type, permission and rationale. The state after the course, as far as it has been read:

Task Type Permission Rationale Restriction
Sign-in of the people delegated sign-in only (K2) the Kladde needs no Graph data of the person none needed
Send confirmation mails (K2 2.8.5) application Mail.Send the service sends without a person; there is nothing smaller for sendMail Exchange RBAC for Applications, on one mailbox (buero@…, the alias is German for "office")
Read and write the office's appointments (W4.2.2, W4.2.3) application Calendars.ReadWrite we write appointments; reading and writing need the same higher permission (Learn's example) Exchange RBAC for Applications, on the office mailboxes
Display "my appointments today" in the tool delegated User.Read, Calendars.Read (verification task: is Calendars.ReadBasic enough?) the person sees only what they see themselves through the delegated model
Store files (W4.3.1) application Sites.Selected exactly one site assignment to the site through sites/{id}/permissions
Post in the Teams channel (W4.3.1) open open (not read) will be read in W4.3.1 open

Every row contains a sentence "why nothing less". Whoever cannot write that down has not yet understood the permission. And the column Restriction is no decoration: For application permissions on mailboxes it is the boundary, as we will see in a moment.

Reading a token

Whether an app is traveling delegated or as an application, you see in the token. Learn describes two claims:

  • scp: a space-separated list of the delegated scopes for which the app has a consent. It appears only in user tokens.
  • roles: a list of permissions. In application tokens these are the app's app roles. In user tokens, roles instead contains the person's roles in the target API: So the claim can also appear in a delegated token, but means something different there.

A rule for reading follows: Check scp first. If scp is in the token, it is a delegated token, and roles names the person's roles. If scp is missing, it is an application token, and roles are the permissions. Learn's examples for this:

JSON
"scp": "Directory.Read.All User.Read",
JSON
"roles": [
    "User.Read.All"
],

In addition: The optional claim idtyp has the value app in application tokens and is the simplest way to tell a token without a person from one with a person (Learn names it in connection with the combination of scp and roles). And: Application tokens arise only in the client credentials flow; delegated tokens arise from flows with a person (authorization code, device code, on-behalf-of and some others). If you have granted application permissions and work with a token from the authorization code flow, you see none of them, and the call ends with 403. Learn names exactly this case among the causes: The kind of permission and the kind of token must match.

You may read tokens to understand. You may not decide based on them: Learn has the API check the claims (the called page validates signature, audience and scopes), not the caller. And a token is a secret. Whoever pastes it into a web tool to decode it hands it out of their control; Learn names jwt.ms as a tool for it, we consider it acceptable for tokens from test tenants and unnecessary for all others, because reading the claims in .NET is a few lines. The snippet below never prints the token, only its permissions:

C#
// src/Kladde.GraphLab/TokenPermissions.cs
using System.Buffers.Text;
using System.Text.Json;

namespace Kladde.GraphLab;

public enum TokenKind
{
    Unknown,
    Delegated,
    Application,
}

/// <summary>
/// What the claims of an access token say, for a human. It reads the payload and checks nothing: no signature, no audience, no expiry.
/// Nothing may be authorized with it; the API that receives the token does that.
/// </summary>
public sealed record TokenPermissions(TokenKind Kind, IReadOnlyList<string> Permissions)
{
    private static readonly TokenPermissions Nothing = new(TokenKind.Unknown, []);

    public static TokenPermissions Read(string jwt)
    {
        string[] parts = jwt.Split('.');
        if (parts.Length != 3)
        {
            return Nothing;
        }

        try
        {
            using JsonDocument payload = JsonDocument.Parse(Base64Url.DecodeFromChars(parts[1]));
            JsonElement root = payload.RootElement;
            if (root.ValueKind != JsonValueKind.Object)
            {
                return Nothing;
            }

            // scp first: a user token may carry roles too, but those are the person's roles in the API.
            if (root.TryGetProperty("scp", out JsonElement scope) && scope.ValueKind == JsonValueKind.String)
            {
                string[] scopes = (scope.GetString() ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries);
                return new TokenPermissions(TokenKind.Delegated, scopes);
            }

            bool isAppToken = root.TryGetProperty("idtyp", out JsonElement type)
                && type.ValueKind == JsonValueKind.String
                && type.GetString() == "app";
            if (root.TryGetProperty("roles", out JsonElement roles) && roles.ValueKind == JsonValueKind.Array)
            {
                string[] names = roles.EnumerateArray().Select(role => role.GetString() ?? "").Where(name => name.Length > 0).ToArray();
                return new TokenPermissions(TokenKind.Application, names);
            }

            return isAppToken ? new TokenPermissions(TokenKind.Application, []) : Nothing;
        }
        catch (Exception exception) when (exception is FormatException or ArgumentException or JsonException)
        {
            return Nothing;
        }
    }
}

Base64Url is a type from .NET, not from Graph; the format of the tokens (three parts, separated by dots, the middle one Base64 URL-encoded) is that of JSON Web Tokens, which Learn describes in "Tokens and claims overview". An application token without roles, but with idtyp: app, is a special case that the code explicitly shows as "application without permission": Learn speaks of "role-less app-only access tokens", which an API is supposed to do nothing with.

Limiting application permissions to mailboxes

Back to the sentence from further up: Application permissions on mail, calendar, contacts and mailbox settings apply to all mailboxes of the organization. Mail.Send as an application means: send as any mailbox. That is far too much for the Kladde's service, which is supposed to send from buero@…. Learn offers two ways to restrict it, and one of them is outdated.

RBAC for Applications (the current way)

Role Based Access Control for Applications in Exchange Online cuts the permission not in Entra ID but in Exchange and couples it to a scope: Which mailboxes may the app reach? Learn sums up the difference like this: You grant organization-wide permissions in Entra ID, and permissions restricted to resources with RBAC for Applications. It supports up to 10,000 apps per organization and replaces the Application Access Policies.

The setup, in Learn's words: five steps.

  1. Create a scope (optional): a management scope, a filter expression over properties of the mailboxes, or an administrative unit from Entra ID.
  2. Create a pointer to the service principal in Exchange. The service principal itself is managed in Entra; Exchange creates only a reference and notices deletions in Entra by itself.
  3. Choose the suitable application role, a special management role in Exchange that can be assigned only to an application. Examples from Learn's list: Application Mail.Send, Application Mail.Read, Application Calendars.Read, Application Calendars.ReadWrite, Application Mail Full Access.
  4. Create the role assignment: service principal, role and scope together.
  5. Verify with a test command.

The commands, from Learn, with our placeholders:

PowerShell
New-ServicePrincipal -AppId <client-id> -ObjectId <service-principal-object-id> -DisplayName "Kladde Büro"

New-ManagementScope -Name "Kladde Buero" -RecipientRestrictionFilter "Alias -eq 'buero'"

New-ManagementRoleAssignment -Name "Kladde Mail.Send Buero" -Role "Application Mail.Send" -App <service-principal-object-id> -CustomResourceScope "Kladde Buero"

Test-ServicePrincipalAuthorization -Identity <service-principal-object-id> -Resource buero

Notes on that, each from Learn:

  • Do not mix up the IDs. New-ServicePrincipal requires the ID from the enterprise applications, not the one from the app registrations: "Don't use the IDs from the App Registrations page, as it shows different values." The object ID you need is that of the service principal; the application ID is the client ID.
  • Prerequisite: member of the role group Organization Management in Exchange and, in Entra, the role Exchange Administrator.
  • The test: Test-ServicePrincipalAuthorization simulates what the role assignments allow, without the caching, and names InScope with True or False for each requested mailbox. Without -Resource, it says "Not Run". The result does not take into account what Entra ID gave the app separately.
  • Time: Changes take effect with a delay: The cache of an app without calls is cleared after 30 minutes, that of an active app stays for up to 2 hours. The test command is exempt from this. Whoever tests with the real app right after a change is up to two hours too early.
  • Limits: Application roles can be assigned only to service principals; exclusive scopes do not restrict the app's access; the Autodiscover service does not currently work with application roles. Supported protocols are Microsoft Graph and EWS.

The most important rule is in the "FAQ" section of Learn: The rights from Entra ID and from Exchange are added. Whoever has consented to Mail.Send in Entra ID and additionally assigns Application Mail.Send on a mailbox in Exchange has no restriction: The union of "all mailboxes" and "one mailbox" is "all mailboxes". For the Kladde that means: For the office service, Mail.Send is not consented to in Entra ID but only assigned in Exchange. In Entra ID the list of application permissions for Kladde Office (Development) stays empty for these rights. Learn names the two sources independently of each other; open is what a token of such an app contains in roles, we did not read that. Do not be surprised if you find nothing in Entra ID: A combined view of the rights from both sources in the Entra admin center is marked "upcoming" at Learn.

Application Access Policies (the old way)

An Application Access Policy restricts only the permissions that were consented to in Entra ID, and only to the members of a group. Learn marks it as outdated (legacy) and says RBAC for Applications replaces it. For new setups, do not use it. Whoever finds it in a customer tenant can migrate it: management scope with a filter on the group (MemberOfGroup, difference: only direct members count, nested ones do not), service principal pointer, role assignment with scope, then remove the consent in Entra ID and then the policy. Running both at the same time Learn calls an edge case "that we do not expect", and shows two examples in which the mixture leads to denial or to more access than intended.

The script for it

The Kladde's service gets its restriction once, in every tenant, before the first call. Every command that changes a tenant belongs in a script that lies in the repository, knows a dry run (-WhatIf) and touches the tenant only after confirmation:

PowerShell
# scripts/runbooks/graph/kladde-mailbox-scope.ps1
[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)][string]$ServicePrincipalAppId,       # <client-id> of the app registration
    [Parameter(Mandatory)][string]$ServicePrincipalObjectId,    # object ID of the ENTERPRISE application, not of the registration
    [Parameter(Mandatory)][string]$MailboxAlias,
    [ValidateSet('Application Mail.Send', 'Application Calendars.Read', 'Application Calendars.ReadWrite')]
    [string]$Role = 'Application Mail.Send',
    [switch]$Remove
)

$ErrorActionPreference = 'Stop'
$scopeName = "Kladde $MailboxAlias"
$assignmentName = "Kladde $Role $MailboxAlias"

if ($PSCmdlet.ShouldProcess('Exchange Online', 'Connect-ExchangeOnline')) {
    Connect-ExchangeOnline -ShowBanner:$false
}

if ($Remove) {
    if ($PSCmdlet.ShouldProcess($assignmentName, 'Remove-ManagementRoleAssignment')) {
        Remove-ManagementRoleAssignment -Identity $assignmentName -Confirm:$false
    }
    if ($PSCmdlet.ShouldProcess($scopeName, 'Remove-ManagementScope')) {
        Remove-ManagementScope -Identity $scopeName -Confirm:$false
    }
    return
}

if ($PSCmdlet.ShouldProcess($ServicePrincipalObjectId, 'New-ServicePrincipal')) {
    New-ServicePrincipal -AppId $ServicePrincipalAppId -ObjectId $ServicePrincipalObjectId -DisplayName 'Kladde Buero'
}
if ($PSCmdlet.ShouldProcess($scopeName, "New-ManagementScope (Alias -eq '$MailboxAlias')")) {
    New-ManagementScope -Name $scopeName -RecipientRestrictionFilter "Alias -eq '$MailboxAlias'"
}
if ($PSCmdlet.ShouldProcess($assignmentName, 'New-ManagementRoleAssignment')) {
    New-ManagementRoleAssignment -Name $assignmentName -Role $Role -App $ServicePrincipalObjectId -CustomResourceScope $scopeName
}
if ($PSCmdlet.ShouldProcess($MailboxAlias, 'Test-ServicePrincipalAuthorization')) {
    Test-ServicePrincipalAuthorization -Identity $ServicePrincipalObjectId -Resource $MailboxAlias | Format-Table
}

-WhatIf calls none of this, not even the sign-in: It only writes what it would do. That is the part you can run without a tenant. The cleanup version with -Remove removes the assignment and the scope; the pointer to the service principal is deleted by Remove-ServicePrincipal (named by Learn, not built in here because a service principal in Exchange may also be needed by other assignments). For a multitenant scenario, quite a bit is still missing from the script; it is a start, and lesson W4.3.2 extends it.

A consent is not over with the agreement. It is a state that someone has to maintain:

  • Looking it up: In the enterprise applications under Permissions, an administration sees what an app is allowed to do. That is the place where you show a customer that the list matches your permission list.
  • Changes: If the app changes its permissions, the person sees the consent prompt anew. Learn names further reasons for unexpected prompts: an app with a duty of user assignment (then only the consent for the organization counts), a different user than the one who consented, a consent that applied only to the consenting person, and a revoked consent.
  • Limiting access works independently of consent: set the app to assignment required and assign people or groups.
  • Scripts (Graph PowerShell or Graph API) change consents immediately and without asking back. Read the permissions the script grants first.
  • How consents are revoked we did not read (open). Learn covers it on pages of its own.

And for communication with an administration, as above: It gets a list with a rationale and the URL for consent, nothing else. A value that you copy from the registration you send to a stranger only if it is in your permission list: application ID and tenant ID. Never the client secret.

Typical mistakes

Learn lists the reasons a 403 can have on a page about resolving authorization errors. The selection that counts for the Kladde:

Message Meaning What you check
401 Unauthorized The token is missing, invalid or expired; also a delegated token of a personal account for an API that knows only work or school accounts token, expiry, account type
AADSTS65001: "The user or administrator has not consented to use the application …" The consent is missing. The error message names the app and demands an interactive request whether User.Read is in the registration; whether the tenant has turned user consent off; whether user assignment is required; whether the app's service principal exists in the tenant
AADSTS90094 and AADSTS90093 The app requests permissions that a person may not grant, or a policy forbids it have the administration consent
AADSTS700016 An app with this ID does not exist in the tenant compare client_id and tenant
403 with Authorization_RequestDenied ("Insufficient privileges to complete the operation") The token lacks a permission, or the caller lacks a required role permissions in the reference, in Entra and in the token; for directory data the Entra role
403, token of the wrong kind Application permissions granted, but a delegated token used (or the other way around) scp and roles in the token
403, person without the right or without a license with delegated, the intersection counts; if the person lacks the right or the license for the data, the call fails check the person in the mailbox
403, wrong audience (aud) The token was issued for a different API (for example for the older Azure AD Graph or the Outlook APIs) the scope for which the token was fetched
403 with insufficient_claims, or 400 with interaction_required A conditional access policy demands more (multifactor sign-in, devices) Learn: Handling conditional access challenges
403 with ErrorAccessDenied and "Access to OData is disabled." Exchange Online: an EWS access policy of the organization blocks Get-OrganizationConfig \| fl EwsApplicationAccessPolicy,EWS*List, and Get-CASMailbox <upn> \| fl …; with EnforceAllowList the app's User-Agent must be in EwsAllowList, with EnforceBlockList it must be removed from EwsBlockList; the change takes effect with a delay

One thing in common: Graph's error object (error.code, error.message, innerError) is what you evaluate. Learn says: Program against the error codes in code, never against the text in message; that changes and is not localized. And: With nested innerError, take the most specific code you know. The request-id in the error object belongs in your log; it leads support to the request (lesson W4.3.2).

Error messages often contain names and IDs, but never secrets; even so, an error message does not belong unchecked on a web page for strangers.

Tests without a tenant

This lesson has two things that can be checked without a tenant: reading a token and the reaction to a permission error. The first test builds the tokens itself. It needs no signature, because TokenPermissions checks none.

C#
// tests/Kladde.GraphLab.Tests/TokenPermissionsTests.cs
using System.Buffers.Text;
using System.Text;
using Shouldly;
using Xunit;

namespace Kladde.GraphLab.Tests;

public sealed class TokenPermissionsTests
{
    [Fact]
    public void AScopeClaimMakesItADelegatedToken()
    {
        TokenPermissions token = TokenPermissions.Read(Jwt("""{ "scp": "Directory.Read.All User.Read", "sub": "<sub>" }"""));

        token.Kind.ShouldBe(TokenKind.Delegated);
        token.Permissions.ShouldBe(["Directory.Read.All", "User.Read"]);
    }

    [Fact]
    public void RolesWithoutAScopeMakeItAnApplicationToken()
    {
        TokenPermissions token = TokenPermissions.Read(Jwt("""{ "oid": "<oid>", "roles": [ "User.Read.All" ], "sub": "<sub>" }"""));

        token.Kind.ShouldBe(TokenKind.Application);
        token.Permissions.ShouldBe(["User.Read.All"]);
    }

    [Fact]
    public void RolesInAUserTokenAreNotThePermissions()
    {
        // A user token may list the person's roles in the API next to the scopes; the scopes are what the app may do.
        TokenPermissions token = TokenPermissions.Read(Jwt("""{ "scp": "User.Read", "roles": [ "Approver" ] }"""));

        token.Kind.ShouldBe(TokenKind.Delegated);
        token.Permissions.ShouldBe(["User.Read"]);
    }

    [Fact]
    public void AnAppTokenWithoutRolesIsAnApplicationWithoutPermissions()
    {
        TokenPermissions token = TokenPermissions.Read(Jwt("""{ "idtyp": "app" }"""));

        token.Kind.ShouldBe(TokenKind.Application);
        token.Permissions.ShouldBeEmpty();
    }

    [Theory]
    [InlineData("")]
    [InlineData("not-a-token")]
    [InlineData("a.b.c")]
    public void SomethingThatIsNoTokenIsUnknown(string text)
    {
        TokenPermissions.Read(text).Kind.ShouldBe(TokenKind.Unknown);
    }

    // header.payload.signature, the first two parts Base64Url of JSON, the signature a placeholder nobody checks.
    private static string Jwt(string payloadJson)
    {
        string header = Base64Url.EncodeToString(Encoding.UTF8.GetBytes("""{ "alg": "none" }"""));
        string payload = Base64Url.EncodeToString(Encoding.UTF8.GetBytes(payloadJson));
        return $"{header}.{payload}.signature";
    }
}

The token excerpts in the tests are Learn's (scp: "Directory.Read.All User.Read", roles: ["User.Read.All"]), with placeholders for the IDs. The test SomethingThatIsNoTokenIsUnknown with "a.b.c" checks the case that b is not valid Base64 URL or not JSON. The expectation is Unknown, not a crash.

The second test shows what the SDK makes of a 403, and needs the FakeGraphHandler from lesson W4.1.1:

C#
// tests/Kladde.GraphLab.Tests/WhoAmIForbiddenTests.cs
using System.Net;
using Microsoft.Graph.Models.ODataErrors;
using Shouldly;
using Xunit;

namespace Kladde.GraphLab.Tests;

public sealed class WhoAmIForbiddenTests
{
    [Fact]
    public async Task AForbiddenAnswerSurfacesAsAnODataError()
    {
        FakeGraphHandler fake = FakeGraphHandler.Json("""
            {
              "error": {
                "code": "Authorization_RequestDenied",
                "message": "Insufficient privileges to complete the operation.",
                "innerError": { "request-id": "<request-id>", "date": "2025-11-05T08:00:00" }
              }
            }
            """, HttpStatusCode.Forbidden);
        WhoAmI who = new(GraphTestClient.With(fake));

        await Should.ThrowAsync<ODataError>(() => who.DescribeAsync());
    }
}

The error object is the one from Learn's page on error responses, with a placeholder for the request-id. Open: That the SDK throws an ODataError on 403 is stated by a Learn page on Microsoft.Identity.Web ("Catch ODataError"); the namespace Microsoft.Graph.Models.ODataErrors and the properties of the error (for example the code in Error) are not on the pages read. Look them up in your IDE before you program against them. And whether a test that checks only the type is enough: It does not prove that the app deals with the error wisely (an understandable message, a retry only where it is allowed). That is the job of lesson W4.3.2.

Last, a small, pure function that turns an error code into a hint. It has no dependency on the SDK and is therefore easy to test:

C#
// src/Kladde.GraphLab/AuthorizationHints.cs
namespace Kladde.GraphLab;

/// <summary>An English hint for the codes of the table "Typical mistakes" (Microsoft Learn: resolve Graph authorization errors).</summary>
public static class AuthorizationHints
{
    public static string? For(string? code) => code switch
    {
        "AADSTS65001" => "Consent missing: A person or the administration must consent to the app's permissions.",
        "AADSTS90093" or "AADSTS90094" => "A person may not grant these permissions: The administration must consent.",
        "AADSTS700016" => "The app does not exist in the tenant: check client_id and tenant.",
        "Authorization_RequestDenied" => "403: Permission or role missing. Check the app's permissions, the token (scp, roles) and the person's roles.",
        "ErrorAccessDenied" => "403 from Exchange Online: check the token (roles), the mailbox restriction and the EWS access policy.",
        _ => null,
    };
}

Exercise 1: Write the permission list (without a tenant)

The Kladde's permission list, with a rationale for each row. This exercise needs no tenant. You need Learn's reference pages.

  1. Open the reference pages of calendarView, sendMail, "Create event" and "Get a user" (URLs in "Further reading"). Read the table Permissions on each.
  2. Write a table with the columns Task, Type, Permission, why nothing less, Restriction and four rows: send confirmation mail; read and write the office's appointments; "my appointments today" in a tool; show the profile of the signed-in person.
  3. For each row, decide whether delegated or application. Write one sentence for it.
  4. For the rows with application permissions, decide where the permission is granted, in Entra ID or in Exchange, and why.
  5. Below it, write what a customer tenant has to do for the Kladde to run, in three lines: who consents, which role is needed for it and which URL you send them.

How to check: No row names a permission with .All where a smaller one is enough; every row with application permissions names a restriction; the row for the tool is delegated; and for consent to Graph application permissions you name the role Privileged Role Administrator. Your answer does not have to match the solution word for word, but every deviation needs a sentence of justification.

Clean-up: nothing, it is a writing exercise.

Solution 1

Task Type Permission Why nothing less Restriction
Send confirmation mail application Mail.Send the service runs without a person; for sendMail Learn names nothing smaller RBAC for Applications, role Application Mail.Send, scope on the mailbox buero
Read and write the office's appointments application Calendars.ReadWrite we create appointments; Learn's example says: reading and writing together need the higher permission RBAC for Applications, role Application Calendars.ReadWrite, scope on the office mailboxes
"my appointments today" in the tool delegated User.Read, Calendars.Read (verification task: Calendars.ReadBasic) the person sees only their appointments, the permission is the one for calendarView without writing through the delegated model
Profile of the signed-in person delegated User.Read least privileged for GET /me through the delegated model
  • Where the permission is granted: The application permissions on mail and calendar in Exchange (RBAC for Applications), not in Entra ID, because both sources are added; a consent in Entra ID would cancel the restriction.
  • Customer tenant: (1) The administration consents: for Graph application permissions Learn requires a Privileged Role Administrator, for delegated permissions a Cloud Application Administrator, among others, is enough. (2) An Exchange administrator sets up the restriction (Organization Management in Exchange, Exchange Administrator in Entra). (3) You send them the list with rationale and the URL https://login.microsoftonline.com/<tenant-id>/adminconsent?client_id=<client-id>, never a secret.

Common deviations:

  • "Calendars.ReadBasic is enough for everything": The role Application Calendars.ReadBasic is not in the list of Exchange application roles (only Calendars.Read and Calendars.ReadWrite). So whoever wants to restrict calendars in Exchange has no choice.
  • "One registration for everything": That works, but then you cannot restrict or revoke the rights separately (see the section on the registration).
  • "Mail.ReadWrite for sending": According to Learn's Permissions reference, Mail.ReadWrite explicitly contains no permission to send.

Exercise 2: Read a token (without a tenant)

Build TokenPermissions with tests. This exercise needs no tenant.

  1. Write TokenPermissions as in the lesson in Kladde.GraphLab (the file TokenPermissions.cs) and the tests from the lesson in TokenPermissionsTests.
  2. Add a test TheTokenItselfIsNeverPartOfTheResult that checks that neither the token's text nor the signature appears in ToString() of the result. To do that, build a token with the signature secret-do-not-print and check that the text is not in result.ToString().
  3. Run the tests and read the exit code.
  4. Counter-check: In Read, change the order so that roles is checked before scp, and start the tests. The test RolesInAUserTokenAreNotThePermissions must turn red. Undo the change.

How to check: All tests are green, $LASTEXITCODE is 0; after the counter-check exactly the named test is red.

Clean-up: take back the counter-check; no file outside the project was created.

Solution 2

C#
    [Fact]
    public void TheTokenItselfIsNeverPartOfTheResult()
    {
        string jwt = Jwt("""{ "scp": "User.Read" }""").Replace(".signature", ".secret-do-not-print", StringComparison.Ordinal);

        string text = TokenPermissions.Read(jwt).ToString();

        text.ShouldNotContain("secret-do-not-print");
        text.ShouldNotContain(jwt);
    }

The test is an example of "the test proves what the class does not do". Because TokenPermissions is a record, ToString() contains everything that is in the record (Kind and Permissions); as long as only the permissions are in the record, the test is green. It turns red as soon as someone adds a field Raw, and that is its point.

Common mistakes:

  • ToString() shows Permissions = System.String[]: That is the default representation of a list in the record. It is enough for the test; for readable output, write string.Join(", ", token.Permissions) in the console.
  • The test for a.b.c throws instead of returning Unknown: Catching FormatException and JsonException is missing; check the catch.
  • Base64Url is unknown: The namespace System.Buffers.Text is missing, or the project does not target .NET 9 or newer.

Exercise 3: A 403 and a hint (without a tenant)

Check the behavior on a permission error. This exercise needs no tenant.

  1. Write the test AForbiddenAnswerSurfacesAsAnODataError from the lesson. It needs the classes FakeGraphHandler and GraphTestClient from lesson W4.1.1.
  2. Write AuthorizationHints and a test that expects a hint for each of the six codes and none for "NoSuchThing" and null.
  3. Run the tests, read the exit code.
  4. Write one more test with the answer 401 and the error object { "error": { "code": "InvalidAuthenticationToken", "message": "<text>" } } that checks that an ODataError comes here too.

How to check: All tests green, exit code 0. Counter-check: Let the fake answer with 200 and {}; Should.ThrowAsync must then turn red, because nothing is thrown.

Clean-up: take back the counter-check.

Solution 3

C#
public sealed class AuthorizationHintsTests
{
    [Theory]
    [InlineData("AADSTS65001")]
    [InlineData("AADSTS90093")]
    [InlineData("AADSTS90094")]
    [InlineData("AADSTS700016")]
    [InlineData("Authorization_RequestDenied")]
    [InlineData("ErrorAccessDenied")]
    public void EveryCodeOfTheTableHasAHint(string code)
    {
        AuthorizationHints.For(code).ShouldNotBeNullOrWhiteSpace();
    }

    [Theory]
    [InlineData("NoSuchThing")]
    [InlineData(null)]
    public void AnUnknownCodeHasNoHint(string? code)
    {
        AuthorizationHints.For(code).ShouldBeNull();
    }
}

The theory has six rows, one for each code of the table "Typical mistakes" for which the function knows a hint (AADSTS90093 and AADSTS90094 are in one row there). The test for 401 is the same as the one for 403, with HttpStatusCode.Unauthorized and the other error object.

Mistakes: [InlineData(null)] reports a nullability warning: The parameter must be string?. And the test with 200 and {} does not throw because GetAsync then returns a User with empty properties: That too is the expected counter-check.

Use the same app once as a person and once with admin consent. This exercise needs a test tenant, never a production tenant: You change consents. There you need an account with the role Cloud Application Administrator or higher and a second test person without an administrative role. Whoever does not have that does exercises 1 to 3, which cover the same material without a tenant.

  1. Use the registration kladde-graphlab-test from exercise 4 of lesson W4.1.1 (or create it again following the instructions there). In the registration under API permissions, check that exactly User.Read and Calendars.Read (delegated) are listed and that no consent for the organization has been granted (column "Status": without a check mark).
  2. Start Kladde.GraphLab -- me as the second test person (without an administrative role). Note: Did a consent prompt come, did an error come (which AADSTS code?), or did it run through? The result depends on the settings of your test tenant; that is the observation of the exercise.
  3. If an error came: Check it against the table "Typical mistakes". If a prompt came: Read the list of permissions and the publisher, and consent.
  4. As the administration: Entra ID, App registrations, All applications, choose the app, API permissions, Grant admin consent. Read the permissions before you confirm.
  5. Under Enterprise apps, All applications, the app, Permissions, check that the two permissions are granted for the organization.
  6. Start Kladde.GraphLab -- me and -- day again as the second test person.
  7. Write two sentences: What changed on the second run, and which setting of your tenant caused it?

How to check: After step 4, the second person's sign-in runs without a consent prompt and without AADSTS65001; in step 5, User.Read and Calendars.Read are listed as granted. We did not see this, only derived it from Learn.

Clean-up: Delete the app registration kladde-graphlab-test in the list of app registrations and then check that the enterprise application of the same name has disappeared from the list of Enterprise apps; otherwise delete it there (what Entra does on deleting a registration with the enterprise application is not on the pages read). Remove the environment variables GRAPHLAB_CLIENT_ID and GRAPHLAB_TENANT_ID. If you created the second test person only for this, delete them.

Solution 4

Typical observations and their causes:

  • Consent prompt on the first run: User consent is allowed and the permissions do not require admin consent. That is the normal case with developer tenants, but not guaranteed.
  • AADSTS65001 or AADSTS90094: User consent is turned off or limited to certain permissions. The administration must consent (step 4). That is the case you must expect at the customer's.
  • Still a prompt after step 4: The app has requested a permission the administration has not consented to (for example because one was added to the registration later), or the registration lists a different one than your list.
  • "Grant admin consent" cannot be clicked: Your account does not have the role. For delegated permissions, Learn names at least Cloud Application Administrator.
  • The second person cannot use the app although consent was given: The app is set to assignment required, and the person is not assigned.

Exercise 5: Limit a mailbox (test tenant with Exchange Online)

Restrict an application role to a mailbox and verify it with the test command. This exercise needs a test tenant with Exchange Online, never a production tenant, and there two mailboxes (buero and fremd, "office" and "foreign"). Whether and on what terms you get such a tenant is up to Microsoft. You need the role Exchange Administrator in Entra and membership in Organization Management in Exchange, and the object ID of the enterprise application (not that of the app registration) of a test app. The part without a tenant is the dry run of the script with -WhatIf in step 1 and exercises 1 to 3.

  1. Create the script scripts/runbooks/graph/kladde-mailbox-scope.ps1 from the lesson. Start it first with -WhatIf and the placeholders: pwsh -File scripts/runbooks/graph/kladde-mailbox-scope.ps1 -ServicePrincipalAppId <client-id> -ServicePrincipalObjectId <service-principal-object-id> -MailboxAlias buero -WhatIf. It must trigger no sign-in and writes a line "What if: …" for each step. If the lines are not in your console, capture the run with Start-Transcript.
  2. If needed, install the module ExchangeOnlineManagement (by the way Learn describes for Exchange Online PowerShell; we did not read it).
  3. Start the script without -WhatIf with the role Application Calendars.Read and alias buero. Sign in as the Exchange administrator.
  4. Check with Test-ServicePrincipalAuthorization -Identity <service-principal-object-id> -Resource fremd (the script already checks buero).
  5. Note what is in InScope, for both mailboxes.

How to check: For buero, InScope is True, for fremd it is False; in step 4 the test also shows the role Application Calendars.Read with the scope Kladde buero. We did not see either of these. And: The test command does not replace a call by the app: It checks the assignments, not whether Entra gave the app something additionally. The assignments in Exchange take effect for the real app only after up to two hours (cache).

Clean-up: pwsh -File scripts/runbooks/graph/kladde-mailbox-scope.ps1 ... -MailboxAlias buero -Role 'Application Calendars.Read' -Remove. Then Remove-ServicePrincipal -Identity <service-principal-object-id> if no other test needs the pointer, and disconnect the session (Disconnect-ExchangeOnline, to our knowledge, not read at Learn). You delete the test app as in exercise 4.

Solution 5

Common mistakes:

  • New-ServicePrincipal reports that the ID is wrong: You took the object ID of the app registration or the application ID as -ObjectId. What is needed is the object ID of the service principal from the enterprise applications (Learn shows both places in a screenshot); -AppId is the client ID.
  • InScope is False for both mailboxes: The alias in the filter does not match the mailbox's alias, or the mailbox is not the one you mean. Check the filter Alias -eq 'buero'.
  • The test command says "Not Run": You left out -Resource.
  • The real app can read the mailbox fremd despite InScope: False: In Entra ID the app was additionally consented to the same permission. The rights add up. Remove the consent in Entra ID (enterprise applications, Permissions).
  • A change shows no effect for the real app: The cache: up to two hours for an app with calls.
  • On the second run the script reports that the objects already exist: It does not check whether they exist. Clean up with -Remove and start again.

What you can do now

  • You explain the difference between delegated permissions and application permissions, name for both how far the access reaches, and know why Learn recommends the delegated model where it is enough.
  • You say who may consent, which role is needed for Graph application permissions, and what a tenant's settings change. You send an administration the URL for consent and a list with a rationale, never a secret.
  • You register an app, classify secret, key pair and federated credential, and justify why the Kladde gets one registration per purpose.
  • You choose the least privileged permission for each call with the reference pages and write the Kladde's permission list with a rationale for each row.
  • You read scp and roles in a token and recognize whether it is delegated or belongs to the application; in doing so you never print the token itself.
  • You restrict application permissions on mailboxes with RBAC for Applications, know that Entra ID and Exchange add up, and run a script with -WhatIf first.
  • You trace AADSTS65001, Authorization_RequestDenied and ErrorAccessDenied back to their causes.

Further reading

The Kladde: It now has a permission list with a rationale for each row, three registrations with one purpose each, a script with a dry run for restricting the office mailbox, a tool that reads a token without showing it, and tests that run without a tenant. In lesson W4.2.1 the Kladde sends its first mail with the SDK.