Setup guide
Everything you must provision before this skill can run โ the exact credentials, permissions, and where to click. Links go to the official consoles and docs, which stay current.
Register a single-tenant Entra application, give it a client secret or certificate, grant it the Microsoft Graph application permissions below with tenant-wide admin consent, and assign its service principal the User Administrator directory role so it may act on other users.
You need to already have
- A Microsoft Entra ID tenant with at least one verified domain (Entra admin center > Identity > Settings > Domain names, Status must read 'Verified')
- Application Administrator or Global Administrator, to create the app registration and its credential
- Global Administrator or Privileged Role Administrator, to grant tenant-wide admin consent for Microsoft Graph application permissions - this is usually the step that blocks people
- Privileged Role Administrator or Global Administrator, to assign the app's service principal the User Administrator (and optionally Groups Administrator / License Administrator) directory role
- Available license seats in the tenant if the skill assigns licenses (Microsoft 365 admin center > Billing > Your products)
- Python 3.9+ with the requests package, or curl, on the machine running the skill
How the pieces connect
Credentials this skill needs
Set these as environment variables. Never paste secrets into a chat or commit them.
| Variable | What it is | Where to get it |
|---|---|---|
AZURE_TENANT_ID | Directory (tenant) ID format: GUID (8-4-4-4-12) or a verified domain such as contoso.onmicrosoft.com | Entra admin center > Identity > Applications > App registrations > your app > Overview > 'Directory (tenant) ID' |
AZURE_CLIENT_ID | Application (client) ID format: GUID (8-4-4-4-12) | Entra admin center > Identity > Applications > App registrations > your app > Overview > 'Application (client) ID' |
AZURE_CLIENT_SECRETsecret | Client secret value format: Opaque ~40-character string, often starting with a short prefix such as 'abc8Q~'; displayed only once at creation | Entra admin center > Identity > Applications > App registrations > your app > Certificates & secrets > Client secrets > New client secret > copy the 'Value' column (NOT the 'Secret ID' column) |
AZURE_CLIENT_CERTIFICATE_PATHsecretoptional | Client certificate (PEM) path format: Filesystem path to a PEM file holding the private key and certificate; use this instead of a secret in production | Generate the key pair yourself, then Entra admin center > App registrations > your app > Certificates & secrets > Certificates > Upload certificate |
GRAPH_BASE_URLoptional | Microsoft Graph base URL format: URL; defaults to https://graph.microsoft.com/v1.0. US Gov: https://graph.microsoft.us/v1.0. China (21Vianet): https://microsoftgraph.chinacloudapi.cn/v1.0 | Not found in a console - set this only when the tenant lives in a national cloud deployment |
ENTRA_MANAGED_GROUP_PREFIXoptional | Managed group name prefix format: Plain string such as 'app-' or 'role-'; only groups whose displayName starts with it are eligible for removal | Your own naming convention - decide it, then confirm matching groups exist at Entra admin center > Identity > Groups > All groups |
Permissions to grant
User.ReadWrite.AllApplicationadmin consent requiredCreate users, update their attributes, set accountEnabled to false, assign a manager, and soft-delete a leaver.
โณ If you never hard-delete, replace it with User.Create + User.ReadUpdate.All + User.EnableDisableAccount.All + User.Read.All. DELETE /users/{id} has no narrower alternative.
User.Read.AllApplicationadmin consent requiredRead user profiles for idempotent lookups, enumerate directReports, and list soft-deleted users before reusing a UPN.
GroupMember.ReadWrite.AllApplicationadmin consent requiredAdd and remove users from security and Microsoft 365 groups during joiner, mover and leaver runs.
โณ Use GroupMember.Read.All alone if the deployment is report-only and never writes memberships.
Group.Read.AllApplicationadmin consent requiredResolve groups by displayName and read groupTypes, mailEnabled, securityEnabled and isAssignableToRole to decide whether a group is safe to write.
โณ GroupMember.Read.All covers membership reads but not the group metadata this skill branches on, so keep Group.Read.All.
LicenseAssignment.ReadWrite.AllApplicationadmin consent requiredCall POST /users/{id}/assignLicense to add or remove SKUs, and read /subscribedSkus for seat availability.
โณ LicenseAssignment.Read.All is enough if the skill only reports license state and never changes it.
User.RevokeSessions.AllApplicationadmin consent requiredInvalidate refresh tokens during offboarding, because disabling the account alone does not promptly end active sessions.
โณ This is already the narrowest permission for the action; do not substitute User.ReadWrite.All.
User.DeleteRestore.AllApplicationadmin consent requiredRestore a soft-deleted user from /directory/deletedItems when rolling back a mistaken offboarding.
โณ Omit entirely if you never restore; merely listing soft-deleted users only needs User.Read.All.
Organization.Read.AllApplicationadmin consent requiredPreflight the tenant's verifiedDomains before creating a UPN, and act as the app-only smoke test in place of /me.
โณ Directory.Read.All also works but is far broader; prefer Organization.Read.All.
User-LifeCycleInfo.ReadWrite.AllApplicationadmin consent requiredWrite employeeHireDate and employeeLeaveDateTime so Entra Lifecycle Workflows can trigger on them.
โณ Optional. Skip it unless you actually stamp lifecycle dates; Microsoft requires it be paired with User.Read.All.
RoleManagement.ReadWrite.DirectoryApplicationadmin consent requiredRequired only to add or remove members of role-assignable groups (isAssignableToRole = true).
โณ Highly privileged - it can grant directory roles. Do not consent it unless you genuinely manage role-assignable groups; otherwise have the skill stop for human approval on those groups.
Step by step
- 1
Register the application
Entra admin center > Identity > Applications > App registrations > 'New registration'. Name it (for example svc-entra-lifecycle), choose 'Accounts in this organizational directory only (Single tenant)', leave 'Redirect URI' empty because the client-credentials flow does not use one, then click 'Register'.
Open in the console / docs โ - 2
Copy the tenant and client IDs
On the app's 'Overview' blade, copy 'Application (client) ID' into AZURE_CLIENT_ID and 'Directory (tenant) ID' into AZURE_TENANT_ID.
Open in the console / docs โ - 3
Create a credential
App registrations > your app > 'Certificates & secrets' > 'Client secrets' tab > 'New client secret' > set Description and Expires (24 months maximum) > 'Add'. Copy the 'Value' column immediately; it is masked once you navigate away. The 'Secret ID' column is NOT the secret. For production prefer the 'Certificates' tab > 'Upload certificate' and use AZURE_CLIENT_CERTIFICATE_PATH.
Open in the console / docs โ - 4
Add the Microsoft Graph application permissions
App registrations > your app > 'API permissions' > 'Add a permission' > 'Microsoft Graph' > 'Application permissions' (not 'Delegated permissions'), then search for and tick each permission listed above and click 'Add permissions'.
Open in the console / docs โ - 5
Grant tenant-wide admin consent
Still on 'API permissions', click 'Grant admin consent for <your tenant>' and confirm 'Yes'. Every row's 'Status' column must then read 'Granted for <your tenant>'. Until this is done, every Graph call fails with Authorization_RequestDenied.
Open in the console / docs โ - 6
Assign the service principal a directory role
Entra admin center > Identity > Roles & admins > 'Roles & admins' > open 'User Administrator' > 'Add assignments' > search for your app's name (service principals are listed here) > 'Add'. Repeat for 'Groups Administrator' if the skill writes group membership and 'License Administrator' if it assigns licenses. Graph application permissions alone will not let the app act on users who hold administrative roles.
Open in the console / docs โ - 7
Confirm the UPN domain is verified
Entra admin center > Identity > Settings > 'Domain names'. The domain you will use as the UPN suffix must show Status 'Verified', otherwise POST /users fails.
Open in the console / docs โ - 8
Export the environment variables and smoke test
Set AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET in the runtime environment and never commit them, then run the verify command below; it must print HTTP 200. Remember that /me does not exist in app-only mode, so test against /users or /organization.
Open in the console / docs โ
Check it worked
TOKEN=$(curl -s -X POST "https://login.microsoftonline.com/$AZURE_TENANT_ID/oauth2/v2.0/token" -d "client_id=$AZURE_CLIENT_ID" -d "client_secret=$AZURE_CLIENT_SECRET" -d "grant_type=client_credentials" -d "scope=https://graph.microsoft.com/.default" | python -c "import sys,json;print(json.load(sys.stdin)['access_token'])") && curl -s -o /dev/null -w "HTTP %{http_code}\n" -H "Authorization: Bearer $TOKEN" "https://graph.microsoft.com/v1.0/users?\$top=1&\$select=id" # expect: HTTP 200If you hit an error
AADSTS7000215: Invalid client secret provided. Ensure the secret being sent in the request is the client secret value, not the client secret ID, for a secret added to app '<client-id>'.Cause: AZURE_CLIENT_SECRET holds the 'Secret ID' GUID from the Client secrets table instead of the 'Value' column, or the secret has expired.
Fix: Create a new client secret and copy the 'Value' column immediately - it is shown only once. If your value looks like a GUID, it is the wrong field.
AADSTS700016: Application with identifier '<client-id>' was not found in the directory '<tenant>'.Cause: AZURE_CLIENT_ID belongs to a different tenant, or the app registration exists but has no service principal in this tenant.
Fix: Re-copy the client ID from the app's Overview blade and confirm AZURE_TENANT_ID names the same directory.
AADSTS900023: Specified tenant identifier '<value>' is neither a valid DNS name, nor a valid external domain.Cause: AZURE_TENANT_ID is malformed - commonly an Azure subscription ID, or a value carrying stray quotes or whitespace from a .env file.
Fix: Use the 'Directory (tenant) ID' GUID or the tenant's onmicrosoft.com domain, unquoted and untrimmed.
AADSTS65001: The user or administrator has not consented to use the application with ID '<client-id>'.Cause: The code requested a delegated scope instead of the app-only scope, or admin consent was never granted.
Fix: Request scope=https://graph.microsoft.com/.default with grant_type=client_credentials, and click 'Grant admin consent' on the API permissions blade.
Authorization_RequestDenied: Insufficient privileges to complete the operation.Cause: Admin consent was never granted, a required permission is missing, or the target user holds a directory role the app is not allowed to act on.
Fix: Confirm every permission row shows 'Granted for <tenant>', then assign the service principal the 'User Administrator' directory role.
Request_BadRequest: Another object with the same value for property userPrincipalName already exists.Cause: The UPN is taken - frequently by a soft-deleted user that still holds the UPN and proxyAddresses for 30 days.
Fix: Query GET /directory/deletedItems/microsoft.graph.user, then either restore that object or permanently delete it before reusing the UPN.
Request_BadRequest: One or more added object references already exist for the following modified properties: 'members'.Cause: POST /groups/{id}/members/$ref was called for a user who is already a member. Graph returns 400 here, not 409.
Fix: Treat this specific 400 as idempotent success instead of retrying or failing the run.
Request_BadRequest: Unable to update the specified properties for on-premises mastered Directory Sync objects or objects currently undergoing migration.Cause: The user has onPremisesSyncEnabled = true, so Active Directory is the source of authority for that attribute.
Fix: Make the change in on-premises AD and let Entra Connect sync it, or exclude synced users from the run.
Request_ResourceNotFound: Resource '<object-id>' does not exist or one of its queried reference-property objects are not present.Cause: Directory replication lag immediately after creating a user or group, or an objectId from the wrong tenant.
Fix: Retry after a few seconds with backoff before treating it as a hard failure; verify the id belongs to this tenant.
ProhibitedInUsageLocationViolation (licenseAssignmentState.error) or a 400 from POST /users/{id}/assignLicenseCause: usageLocation is unset or the SKU is not sold in that country. CountViolation instead means the tenant has no free seats.
Fix: PATCH usageLocation with an ISO 3166-1 alpha-2 code before assigning, and check prepaidUnits.enabled minus consumedUnits on /subscribedSkus.
429 Too Many Requests, with a Retry-After header (activityLimitReached)Cause: Microsoft Graph per-app, per-tenant throttling, typically triggered by bulk joiner or reconcile runs.
Fix: Sleep for exactly the Retry-After value, cap concurrency at 4, and batch with POST /$batch at no more than 20 requests per batch.
Directory_QuotaExceeded: The directory object quota limit for the Principal has been exceeded.Cause: The tenant or the calling principal hit its directory object creation limit.
Fix: Delete unused objects, permanently purge soft-deleted users, or raise a Microsoft support request to increase the tenant quota.
Official documentation: https://learn.microsoft.com/en-us/graph/auth-v2-service โ
API operations this skill uses (20)
Every call the skill makes, linked to its official reference page.
| Operation | Call | Permission | Ref |
|---|---|---|---|
| Acquire app-only access token Every other call needs the resulting bearer token; cache it for its roughly one-hour lifetime and refresh on 401. | POST https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token (grant_type=client_credentials, scope=https://graph.microsoft.com/.default) | n/a - the issued token carries whatever application permissions were consented | docs โ |
| Read tenant verified domains Preflight that the UPN suffix is a verified domain, and serve as the app-only smoke test that replaces /me. | GET /v1.0/organization?$select=id,displayName,verifiedDomains | Organization.Read.All | docs โ |
| Look up a user by UPN or objectId Makes every run idempotent - 200 means converge the existing object, 404 means create it - and surfaces AD-synced users that must be refused. | GET /v1.0/users/{id | userPrincipalName}?$select=id,userPrincipalName,accountEnabled,onPremisesSyncEnabled,usageLocation,assignedLicenses | User.Read.All | docs โ |
| List soft-deleted users Detect a UPN still held by a deleted account before creating a joiner, and find candidates for restore. | GET /v1.0/directory/deletedItems/microsoft.graph.user?$select=id,userPrincipalName,deletedDateTime | User.Read.All | docs โ |
| Create a user The joiner step. Requires accountEnabled, displayName, mailNickname, userPrincipalName and passwordProfile. | POST /v1.0/users | User.Create (or User.ReadWrite.All) | docs โ |
| Update user attributes Sets jobTitle, department, officeLocation, employeeId and usageLocation on joiner and mover runs. | PATCH /v1.0/users/{id} | User.ReadUpdate.All (or User.ReadWrite.All) | docs โ |
| Disable or re-enable an account The first and least destructive offboarding action, and the only one that is trivially reversible. | PATCH /v1.0/users/{id} with body {"accountEnabled": false} | User.EnableDisableAccount.All + User.Read.All (or User.ReadWrite.All) | docs โ |
| Assign a manager Sets the joiner's manager and re-parents a leaver's direct reports so nobody is orphaned. | PUT /v1.0/users/{id}/manager/$ref with body {"@odata.id": "https://graph.microsoft.com/v1.0/users/{managerId}"} | User.ReadWrite.All | docs โ |
| List direct reports Find the people who would be orphaned by an offboarding so they can be reassigned before the account is disabled. | GET /v1.0/users/{id}/directReports?$select=id,userPrincipalName | User.Read.All | docs โ |
| List a user's direct group memberships The current-state side of the mover diff and the rollback snapshot for a leaver. Deliberately not transitiveMemberOf. | GET /v1.0/users/{id}/memberOf?$select=id,displayName,groupTypes,mailEnabled,securityEnabled | User.Read.All (GroupMember.Read.All for group detail) | docs โ |
| List a user's transitive memberships (read-only) Report effective access inherited through nesting or dynamic rules. Never used as a removal target list. | GET /v1.0/users/{id}/transitiveMemberOf?$select=id,displayName | User.Read.All | docs โ |
| Resolve a group by display name Turns human-supplied group names into objectIds and reveals dynamic, mail-enabled or role-assignable groups that must not be written to. | GET /v1.0/groups?$filter=displayName eq '{name}'&$select=id,displayName,groupTypes,mailEnabled,securityEnabled,isAssignableToRole | Group.Read.All | docs โ |
| List members of a group The group-side view used by reconcile runs to diff actual membership against the source of truth. | GET /v1.0/groups/{id}/members?$select=id,userPrincipalName&$top=999 | GroupMember.Read.All | docs โ |
| Add a user to a group Grants access on joiner and mover runs. Returns 204; a 400 saying the reference already exists simply means already a member. | POST /v1.0/groups/{groupId}/members/$ref with body {"@odata.id": "https://graph.microsoft.com/v1.0/directoryObjects/{userId}"} | GroupMember.ReadWrite.All | docs โ |
| Remove a user from a group Revokes access on mover and leaver runs. Returns 204; a 404 means already absent, which is treated as success. | DELETE /v1.0/groups/{groupId}/members/{userId}/$ref | GroupMember.ReadWrite.All | docs โ |
| List tenant license SKUs and seat counts Maps a human SKU part number such as SPE_E3 to its skuId GUID and confirms a free seat exists before assigning. | GET /v1.0/subscribedSkus?$select=skuId,skuPartNumber,prepaidUnits,consumedUnits | LicenseAssignment.Read.All | docs โ |
| Assign or remove licenses Grants entitlements on joiner and reclaims spend on leaver. Works only for directly assigned licenses, never group-inherited ones. | POST /v1.0/users/{id}/assignLicense with body {"addLicenses": [{"skuId": "...", "disabledPlans": []}], "removeLicenses": []} | LicenseAssignment.ReadWrite.All | docs โ |
| Revoke sign-in sessions Invalidates issued refresh tokens right after disabling a leaver, closing the window in which existing sessions still work. | POST /v1.0/users/{id}/revokeSignInSessions | User.RevokeSessions.All | docs โ |
| Soft-delete a user The final offboarding step once the retention window passes. The object stays restorable for 30 days. | DELETE /v1.0/users/{id} | User.ReadWrite.All | docs โ |
| Restore a soft-deleted user Rolls back a mistaken deletion within 30 days, returning the user with its objectId, UPN and group memberships intact. | POST /v1.0/directory/deletedItems/{id}/restore | User.DeleteRestore.All | docs โ |