Metadata-Version: 2.4
Name: microsoft_graph_helpers
Version: 0.4.0
Summary: Helper functions for interacting with Microsoft Graph API
Author: Lucas Krupinski
License: MIT
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: System Administrators
Classifier: Intended Audience :: Developers
Classifier: Topic :: System :: Systems Administration
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Operating System :: OS Independent
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: requests>=2.32.5
Dynamic: requires-python

# Microsoft Graph Helpers

A Python module for calling various Microsoft Graph endpoints. Not nearly as comprehensive as Microsoft's official modules, but far lighter weight and with a minimal amount of dependencies.

Not comprehensive, only contains calls that I've needed to user in my other applets.
## Return conventions

Every function returns one of three things, and which one depends on what the
function is *for*.

**Fetching a value returns the value.** `get_bearer_token` gives a token,
`get_group_guid` a guid, `get_upn_from_email` a UPN, `get_user_location` a
country code. `False` means it could not answer.

**Fetching an object or a collection returns the parsed JSON.**  `get_user`,
`list_users`, `create_user`, `restore_user`, `list_deleted_users`. `False`
means the request failed.

**Doing something returns `True` or `False`.** `update_user`, `delete_user`,
`disable_user`, `revoke_ms_sessions`, `set_user_location`. There is no useful
answer beyond whether Graph accepted it.

Nothing raises on a failed Graph call. `False` comes back and the reason goes
to the logging module, at `warning` for a rejected request and `error` for one
that never completed. These are helpers for other applications to call, so a
failure is a value to branch on, not an exception to catch. If a call returns
`False` and you want to know why, read the log.

### Testing the result

```python
result = list_users(token, filter="accountEnabled eq false")
if not result:
    return                        # the request failed
for user in result["value"]:      # legitimately empty if nothing matched
    ...
```

**A zero-result search is a success.** `{"value": []}` is a non-empty dict, so
`if not result` never mistakes "nothing matched" for "the request failed".
`result` tells you whether Graph answered; `result["value"]` tells you what it
found.

**Never test with `== True` or `is True`.** A function that returns a value
returns a string, and `"US" == True` is `False`, so a check written that way
treats every success as a failure, silently, with nothing in the log because
nothing went wrong.

```python
loc = get_user_location(token, upn)   # returns "US", None, or False
if loc == True:    # never fires. "US" is not True
if loc is not False:   # lets None through
if not loc:        # correct first cut, catches None and False
```

### The one function with three outcomes

`get_user_location` is the only helper where `False` is not the only
non-answer:

| return | meaning | what to do |
|---|---|---|
| `"US"` | location set | proceed |
| `None` | account exists, no location set | call `set_user_location` |
| `False` | could not answer | stop |

`None` never means the user was not found; that is `False` like everywhere
else. Test with `is None` and `is False`, because a bare `if not loc` collapses
the two cases that call for opposite responses.

## Permissions

Every function's docstring names the least privileged application permission
that works, verified against the Graph reference rather than assumed. To see
them all at once:

```bash
grep -A1 "Needs " microsoft_graph_helpers/*.py
```

Three are worth knowing before you grant anything, because they are the ones
people guess wrong:

- **`revoke_ms_sessions` needs `User.RevokeSessions.All`** and nothing else
  works app-only. `User.ReadWrite.All` covers almost every other user call in
  this module but not that one.
- **`get_user_direct_group_memberships` needs `Directory.Read.All`** app-only.
  `User.Read.All` is not enough, unlike every other read here.
- **`reset_ms_password` needs a directory role**, not just a scope. The app
  must hold at least User Administrator. Without it you get
  `403 Authorization_RequestDenied`, which reads like a missing permission.

`core.py` and `get_bearer_token` need no Graph permission. They are transport
and token plumbing; the permission that matters is whichever one the calling
function documents.

## Users

`get_upn_from_email` turns whatever address you were handed into the account's
UPN. Alerts and ticket queues often carry the address a person sends mail from
rather than the name they sign in with, and the rest of the user calls
(`revoke_ms_sessions`, `reset_ms_password`, `get_user_direct_group_memberships`)
only take a UPN.

```python
upn = get_upn_from_email(token, "lucas@example.edu")
if upn:
    revoke_ms_sessions(token, upn)
```

It runs two lookups on the one token you pass in. First `userPrincipalName`,
then `mail`, stopping at the first hit. An address that is already a UPN
answers on the first query; a mail address costs both. Needs `User.Read.All`.

**UPN is checked first on purpose.** Entra enforces uniqueness on UPN but not
on mail, so one person's UPN can also be sitting in another person's mail
field. Entra accepts that without complaint. Checking mail first meant an
address that was a real sign-in name resolved to the other account, which for a
caller that revokes sessions or resets passwords means acting on the wrong
person. An exact UPN match now always wins.

**A duplicate `mail` value makes the lookup fail rather than guess.** Entra does
not enforce uniqueness on `mail` the way it does on UPN, so two accounts can
carry the same value: most often accounts synced from on-prem AD, where nothing
blocks it, and accounts with no mailbox, where Exchange is not there to object.
When a lookup matches more than one user, it logs an error naming every match
and returns `False` instead of acting on whichever came back first. Callers use
this to reset passwords and kill sessions, so an ambiguous answer is treated as
no answer. A `mail` collision still falls through to the UPN lookup, and a hit
there is definitive.

**Only `mail` and `userPrincipalName` are searched.** Aliases in
`proxyAddresses` and recovery addresses in `otherMails` are not. An address that
exists only as an alias comes back `False`.

Addresses are escaped before they go into the `$filter`, so an apostrophe in
`o'brien@example.edu` does not break the query.

### Creating, changing and removing accounts

`create_user` takes the five properties Graph insists on as named arguments
(`userPrincipalName`, `displayName`, `mailNickname`, a password, and
`accountEnabled`) rather than a dict, because omitting one returns a 400 that
does not tell you which one is missing. Anything else writable goes in
`additional_properties`. It returns the created object so you get the new `id`
without a second lookup.

```python
user = create_user(
    token,
    user_principal_name="avance@example.edu",
    display_name="Adele Vance",
    mail_nickname="avance",
    password=generated_password,
    usage_location="US",
    additional_properties={"givenName": "Adele", "surname": "Vance"},
)
if user:
    print(user["id"])
```

Two things that bite: the domain in the UPN has to be a verified domain on the
tenant, and `usage_location` is not required to create the account but license
assignment fails without it. Needs `User.Create` or `User.ReadWrite.All`.

`update_user` patches writable properties and takes a plain dict, since the
user resource has far more fields than are worth spelling out as arguments.
Property names are not validated here; a name Graph does not recognize comes
back as a 400 and gets logged. Needs `User.ReadWrite.All`.

`disable_user` and `enable_user` flip `accountEnabled`. They are thin wrappers
over `update_user`, named because that property has its own least-privilege
scope, `User.EnableDisableAccount.All` plus `User.Read.All`. An app that only
switches accounts off during offboarding does not need `User.ReadWrite.All`.

**Disabling does not end sessions that are already running.** It blocks new
sign-ins, but an access token issued before the change keeps working until it
expires, usually up to an hour, so a disabled account can still be reading mail
after the call returns `True`. Pair it with `revoke_ms_sessions`, which
invalidates the refresh tokens so nothing can be renewed:

```python
disable_user(token, upn)        # block new sign-ins first
revoke_ms_sessions(token, upn)  # then kill what is already running
```

That order matters. Revoking first leaves a gap where the account can sign
straight back in. Disabling also does not free the license: the account keeps
its seat until the license is removed or the account is deleted.

**`delete_user` is a soft delete.** The account, its mailbox and its license
assignments move to a holding area for 30 days, and `restore_user` puts all of
it back, group memberships included. After 30 days Entra purges the account on
its own and the licenses are freed. Needs `User.ReadWrite.All`, and note that
an app token with that permission still cannot delete a user holding a
privileged admin role unless the app holds an equal or higher role.

`restore_user` takes an **object id, not a UPN**, because a deleted user is out
of `/users` and the UPN no longer resolves. `list_deleted_users` is there to
find that id. Pass `new_user_principal_name` when the old UPN has since been
handed to someone else, and `auto_reconcile_proxy_conflict=True` when an active
account has picked up one of the deleted user's proxy addresses, which
otherwise fails the whole restore. Both need `User.DeleteRestore.All`.

```python
import re

for u in list_deleted_users(token).get("value", []):
    # Entra prefixes the object id, dashes stripped, onto the UPN when it soft
    # deletes, so a startswith test on the original address never matches.
    upn = re.sub(r"^[0-9a-f]{32}", "", u["userPrincipalName"])
    if upn.startswith("avance@"):
        restore_user(token, u["id"])
```

`permanently_delete_user` empties the recycle bin for one account. **There is
no undo.** The mailbox is gone, the object id never comes back, and
`restore_user` has nothing left to work with. Every other call here can be
walked back, including `delete_user`.

It takes an object id and nothing else, never a UPN. Two safeguards fall out of
that, both intentional: a mistyped name cannot resolve to a live account
because names are not accepted, and the endpoint only reaches objects already
in the recycle bin, so an id belonging to a live user comes back not-found
rather than destroyed. Delete first, then purge. Needs
`User.DeleteRestore.All`.

`list_users` lists users, with `filter`, `select`, `order_by` and `top`.

```python
# everyone who will fail a license assignment
list_users(token, filter="usageLocation eq null", select="userPrincipalName")
```

**That query fails if you send it yourself.** `eq null`, `ne`, `not`, `endsWith`
and `$search` all need Graph's advanced query capabilities: a
`ConsistencyLevel: eventual` header and `$count=true`. `list_users` runs the
query plainly, and retries once with those headers if Graph answers
`Request_UnsupportedQuery`, so callers never have to know the rule.

Worth absorbing because Graph's errors point the wrong way. A failed
`usageLocation eq null` reports the filter clause is unsupported *for that
property*, which reads as "you cannot filter this", and a failed `ne` reports
the operator is unsupported. Both work with the headers.

`top` is a **cap on results, not a page size.** Graph's `$top` is a page size,
and since requests here follow `@odata.nextLink` automatically, passing it
through would page the whole directory a few at a time and return everything.

Build `filter` yourself and run interpolated values through
`escape_odata_value`. Needs `User.Read.All`.

`get_user` reads a user and returns the response. `select` limits which
properties come back, as a comma separated string or a list.

```python
user = get_user(token, upn, select=["displayName", "accountEnabled", "usageLocation"])
```

**Leave `select` off and you get Graph's default property set, which is small.**
It does not include `usageLocation`, `accountEnabled`, `assignedLicenses`,
`licenseAssignmentStates`, `employeeId`, or most of what you probably came for.
A property missing from the response usually means it was not asked for, not
that it is empty on the account. That distinction is worth remembering before
you go debugging a value that looks unset. Needs `User.Read.All`.

`set_user_location` and `get_user_location` handle `usageLocation`, the
property whose absence fails a *later* call rather than the one you just made.

Setting it takes a two letter ISO 3166-1 alpha-2 code, normalized to
uppercase. There is no list of valid codes here. Graph keeps its own list and
enforces it, so a local copy would need maintaining every time ISO 3166 changes
and would eventually reject a code Graph accepts.

Only the shape is checked locally, because anything that is not two letters
cannot be a country code and is not worth a round trip. Everything else goes to
Graph, and its rejection is translated on the way back. Graph's own message for
a bad code is:

    Property usageLocation is invalid.

That is the whole error. It does not say what a valid value looks like, and it
does not say what to use instead, so the log says it for you:

    Graph rejected 'UK' as a usageLocation for adele@example.edu. It must be
    a two letter ISO 3166-1 alpha-2 code such as US, GB or JP. The United
    Kingdom is GB, not UK. Graph said: Property usageLocation is invalid.

The `GB` hint appears only for `UK`; every other rejected code gets the general
message. Graph's own words are kept on the end in case the 400 was really about
something else. Nothing is ever silently substituted, because quietly changing
a country code on a compliance field is worse than an error.

Reading it needs its own function because `usageLocation` does not come back
from a plain user `GET`. It has to be requested with `$select`, and without
that an account with a location set looks identical to one without.

`get_user_location` returns three things, and the difference matters:

| return | meaning | what to do |
|---|---|---|
| `"US"` | account has a location | proceed |
| `None` | account exists, **no location set** | call `set_user_location` |
| `False` | could not answer: not found, denied, network | stop |

**`None` never means the user was not found.** A missing user, a denied request
and a dead connection all come back `False`. `None` only happens when Graph
answered, the account is there, and the field is empty.

It returns three rather than two because `create_user` does not set
`usageLocation`, so every account starts in the `None` state. That is the
normal path, not an edge case. Folding it into `False` would make "this account
needs a location" look identical to "Graph is unreachable", and those want
opposite responses.

`None` logs at **warning**, not error. Nothing failed and the caller can carry
on, but a license assignment will fail later until a location is set, so it is
worth seeing. Test with `is None` and `is False`; a bare `if not location`
collapses the two cases you most need to tell apart.
