DocsReference
Microsoft permissions
From docs/PERMISSIONS.md in the repository.
On this page
- How to read this file
- Principles
- Summary
- Sign-in: Entra OIDC
- Sign-in: SAML (enterprise)
- Discovery source: entra
- Discovery source: entra_licenses
- Discovery source: entra_shadow_it
- Discovery source: entra_coverage
- Discovery source: entra_licence_usage
- Discovery source: azure
- Discovery source: powerplatform
- Discovery source: halo
- Ticket connections: Jira, ServiceNow, Halo
- Discovery source: okta
- Discovery source: google_workspace
- Email via Microsoft Graph
- SMTP fallback
- Teams notifications
- Generic webhooks (Phase 5 minimal; Phase 7 formalizes)
- SCIM and SAML (enterprise)
- Provisioning: SCIM (enterprise), what Entra calls on us
- Connection test (Phase 6, built)
- Access checks and guided setup
- AI assistant: Anthropic (enterprise, off by default)
- Phase 11 roadmap: built and candidates
- Change history
Every Microsoft Graph, Azure Resource Manager, Dataverse and Exchange permission Softwarden requests, which feature needs it, which phase introduces it, and whether the current code already uses it. Also lists the non-Microsoft services that receive data or credentials (HaloITSM, Okta, Google Workspace, Anthropic), because an administrator reviewing access needs them in the same place.
Adapted from IT1’s docs/PERMISSIONS.md (IT1: the earlier Django monolith, stackbox) and reorganized by this repository’s discovery sources (DEVELOPMENT_PLAN.md §2.5), sign-in and email. Keep it current: any change to a Microsoft endpoint or permission updates this file in the same commit (CLAUDE.md working rule 5).
How to read this file
Status of each permission:
| Status | Meaning |
|---|---|
| Used today (IT2) | The code in apps/api requests it now. IT2 has never contacted a real Microsoft tenant: every run uses the simulated tenant (discovery/entra_simulator.py, core/oidc_simulator.py). “Used” means “requested by the code”, not “proven against a tenant”. |
| Planned (Phase N) | Not requested by the current code. Introduced by the named phase of DEVELOPMENT_PLAN.md §3. |
| Candidate | A roadmap feature that would need a new permission. Nothing is decided; the permission named is a starting point for research, not a choice. |
Verification. Every endpoint and permission must be verified on Microsoft Learn before the phase that implements it (CLAUDE.md working rule 5; IT1 working rule 8). The Learn check column records what has actually been checked:
- “IT1, date” means IT1 recorded a Microsoft Learn check on that date. It is carried over as a citation, not re-verified in this repository. The implementing phase re-checks it and replaces the entry with its own date.
- “Not verified” means no Learn check is on record for this repository or its donors.
No permission in this file has been verified on Microsoft Learn from this repository yet (Phase 0, 3 November 2026).
Principles
- Read-only. Every discovery permission reads. No Softwarden feature writes to Entra, Azure, Power Platform or Halo. The one send permission (
Mail.Send) is scoped to a single shared mailbox through Exchange RBAC for Applications. - Softwarden never grants permissions or consent itself. Settings pages and the guided walkthrough explain what to grant; an administrator grants it.
- Separate app registrations. Use one registration for sign-in (delegated only, which holds only when it is not shared with discovery) and a separate one per background purpose (mail, discovery), so each holds only its own permissions. IT2 today reuses the sign-in registration for the Entra connector (
core/oidc.pydocstring: “they name the same app registration as the discovery connector”); from Phase 6 the walkthrough recommends separate registrations, following IT1. - Secrets are write-only. Client secrets entered in the UI are Fernet-encrypted under the credential keyring (
core/credentials.py:SOFTWARDEN_CREDENTIAL_KEYS, or the key generated on first start; the pre-Phase-1SOFTWARDEN_SECRETS_KEYonly decrypts) with the id of the key stored beside each value, and never appear in responses, audit snapshots, logs, exports or job arguments. - Checks need no extra permission. Connection tests and access checks make the same reads as a run, limited to one item. A token alone doesn’t prove every subscription, environment or mailbox works; results say which parts were verified.
Summary
| Area | API | Permission or grant | Type | Feature | Phase | Status | Learn check |
|---|---|---|---|---|---|---|---|
| Sign-in (Entra OIDC) | Microsoft identity platform (listed under Microsoft Graph in the portal) | openid |
Delegated | OIDC sign-in, per organization since Phase 8 | 8 | Built, simulated only | Scope not verified; discovery, issuer and claims 2026-11-07 |
| Sign-in (Entra OIDC) | Microsoft identity platform (listed under Microsoft Graph in the portal) | profile |
Delegated | Name claims in the ID token | 8 | Built, simulated only | Not verified |
| Sign-in (Entra OIDC) | Microsoft identity platform (listed under Microsoft Graph in the portal) | email |
Delegated | Email claim (first-sign-in pinning) | 8 | Built, simulated only | Not verified |
| Sign-in (Entra OIDC) | Microsoft Graph | User.Read |
Delegated | Default on new registrations; not requested in the code, no Graph call | exists | Present by default, not requested | Not verified |
Discovery: entra |
Microsoft Graph | Application.Read.All |
Application | Enterprise apps, app registrations, owners (ids), credential expiry (DiscoveredCredential) |
6 | Built, simulated tenant only | 2026-11-06 (endpoints, paging, permission, role ID) |
Discovery: entra (optional) |
Microsoft Graph | User.ReadBasic.All |
Application | Owners’ names and emails; without it, “owner not resolved” | 6 | Built, optional | Role ID from the dev tenant; least privilege for GET /users not verified |
Discovery: entra (optional) |
Microsoft Graph | AgentIdentity.Read.All |
Application | Entra Agent ID agent identities as AI agent candidates (scope include_agent_identities) |
6 | Built, optional, off by default | 2026-11-06 (endpoint and permission); role ID not looked up |
Discovery: entra_licenses |
Microsoft Graph | LicenseAssignment.Read.All |
Application | SKU seat counts for license pools | 6 | Built, simulated tenant only | 2026-11-06 (endpoint, least privilege); app role ID e2f98668-2877-4f38-a2f4-8202e0717aa1 (Learn permissions reference, 2026-11-09) |
Discovery: entra_shadow_it |
Microsoft Graph | AuditLog.Read.All |
Application | Sign-in logs: users and sign-ins per third-party app (needs Entra ID P1 or P2; off with the scope’s sign_ins) |
11 | Built, simulated tenant only | Phase 11 research, 2026-11-09 (least privileged, license, retention, throttling); app role ID b0afded3-3588-46d8-8b3d-9842eff778da (Learn permissions reference, 2026-11-09) |
Discovery: entra_shadow_it |
Microsoft Graph | Directory.Read.All |
Application | OAuth consent grants, the service principals they name, the tenant’s own ID | 11 | Built, simulated tenant only | Phase 11 research, 2026-11-09 (least privileged for oauth2PermissionGrants); role ID not looked up |
Discovery: entra_coverage |
Microsoft Graph | Application.Read.All |
Application | Each linked enterprise app’s single sign-on mode (preferredSingleSignOnMode) |
11 | Built, simulated tenant only | Phase 11 research, 2026-11-09 (property, null meaning); role ID as for entra |
Discovery: entra_coverage |
Microsoft Graph | Synchronization.Read.All |
Application | Provisioning (SCIM) jobs of each linked enterprise app (off with the scope’s provisioning) |
11 | Built, simulated tenant only | Phase 11 research, 2026-11-09 (least privileged; never Application.ReadWrite.OwnedBy); app role ID 5ba43d2f-fa88-4db2-bd1c-a67c5f0fb1ce (Learn permissions reference, 2026-11-09) |
Discovery: entra_coverage |
Microsoft Graph | Policy.Read.All |
Application | Conditional access policies: which apps require MFA, for whom (needs Entra ID P1; off with the scope’s conditional_access) |
11 | Built, simulated tenant only | Phase 11 research, 2026-11-09 (permission, P1, throttling 1 request/s without Retry-After); app role ID 246dd0d5-5bd0-4def-940b-0421030a5b68 (Learn permissions reference, 2026-11-09) |
Discovery: entra_licence_usage |
Microsoft Graph | User.Read.All |
Application | Each licensed user’s assignedLicenses, accountEnabled, userType, createdDateTime for license waste (mail and user principal name are read only to link people and tell service accounts apart, never stored). Tenant-wide admin consent the owner grants before live use |
11 | Built, simulated tenant only | Phase 11 research, 2026-11-09 (assignedLicenses needs $select and User.Read.All; licenseDetails has no application permission); app role ID df021288-bdef-4463-88db-98f22de89214 (Learn permissions reference, 2026-11-09) |
Discovery: entra_licence_usage (optional) |
Microsoft Graph | AuditLog.Read.All |
Application | signInActivity.lastSuccessfulSignInDateTime of each licensed user (needs Entra ID P1; off with the scope’s sign_in_activity; without it the last sign-in is unknown). Tenant-wide admin consent the owner grants before live use |
11 | Built, optional, simulated tenant only | Phase 11 research, 2026-11-09 (P1, pages of 500, 10 requests a minute per tenant); app role ID b0afded3-3588-46d8-8b3d-9842eff778da |
Discovery: azure |
Azure Resource Manager | Azure RBAC Reader (acdd72a7-3385-48ef-bd42-f606fba81ae7) per subscription (or management group) |
Azure role assignment | Logic Apps, Function Apps, App Services (optional), Automation runbooks | 6 | Built (Phase 6 Sources B), simulated tenant only; enterprise discovery.azure |
2026-11-06 (Reader role ID, Subscriptions, Logic workflows, runbooks); Web Apps list and Automation accounts list api-versions not verified |
Discovery: powerplatform |
Dataverse Web API | Application user with a read-only role (prvReadbot, optional prvReadWorkflow); possibly Service Reader |
Dataverse security role | Copilot Studio agents, optional cloud flows | 6 | Built (Phase 6 Sources B), simulated tenant only; enterprise discovery.powerplatform |
2026-11-06 (bots and workflows tables, category 5, error statuses); privileges not verified |
Discovery: powerplatform (optional) |
Power Platform admin API (BAP) | Power Platform management app registration | Admin-equivalent registration | “All environments” (scope all_environments) |
6 | Built (Phase 6 Sources B), opt-in, off by default | 2026-11-06 (BAP 2020-10-01 generally available; registration page is pre-release) |
Discovery: halo |
HaloITSM API | Halo API application, scope read:assets (the default) |
Halo client credentials | Assets | 6 | Built (Phase 6 Sources B), synthetic Halo only; enterprise discovery.halo |
Not Microsoft (IT1 conventions; confirm on the instance’s /apidoc) |
Discovery: okta |
Okta Management API | OAuth 2.0 service app, scope okta.apps.read (read only), with the admin role Read-only Administrator |
Okta OAuth client credentials, private-key client assertion | Applications with sign-on mode and assigned-user counts | 11 | Built, synthetic Okta only; enterprise discovery.okta |
Not Microsoft: Okta’s developer documentation and Management API OpenAPI 2026.44.0, 2026-11-10 |
Discovery: google_workspace |
Google Admin SDK Reports API | Service account with domain-wide delegation (a tenant-wide grant by a super administrator), scope https://www.googleapis.com/auth/admin.reports.audit.readonly, acting as an administrator with the Reports privilege |
Google service account JWT bearer | Third-party apps people authorized (OAuth token audit log), as aggregates | 11 | Built, synthetic Workspace only; enterprise discovery.google |
Not Microsoft: developers.google.com, 2026-11-10 |
| Email via Graph | Microsoft Graph | Mail.Send via Exchange RBAC for Applications, scoped to one mailbox |
Application (Exchange RBAC) | Reminder, digest, subscription and test emails | 5 | Built, simulated tenant only | 2026-11-05 |
| Teams notifications | Teams Workflows webhook | None | n/a | Adaptive Cards to a channel | 5 | Built, mocked only | 2026-11-05 (host pattern and success status not documented; to verify live) |
| Sign-in (generic OIDC) | Entra, Okta, Google Workspace, any OIDC provider | openid, profile, email |
Delegated | Per-organization OIDC with presets | 8 | Built, simulated only | Provider rules from the 2026-11-07 research digest |
| SCIM (enterprise) | Entra’s provisioning service calls Softwarden (/scim/v2/<slug>/) |
None in Graph; a Softwarden SCIM bearer token (Secret Token) held by Entra | n/a (inbound) | Users and groups to accounts and memberships, roles from groups when the organization opts in (ee/scim) |
8 | Built, pytest replay of the validator only | Learn pages per the 2026-11-07 research digest; not run against Entra or the hosted validator (owner step, runbook) |
| SAML (enterprise) | Any SAML 2.0 IdP, Entra enterprise apps included | None | n/a | SAML sign-in (ee/saml); Softwarden calls no Microsoft endpoint |
8 | Built, simulated IdP only | Nothing to verify: no endpoint is called and no permission requested |
End-of-life data (Phase 11, capability eol) |
endoflife.date API v1 (https://endoflife.date/api/v1: GET /products, GET /products/{product}; /products/full for the offline import) |
None (public, unauthenticated, MIT-licensed data) | Not Microsoft | Release cycles and end-of-life dates of the products organizations track; the worker sends only product names in the URL and a Softwarden/<version> User-Agent, never organization data |
11 | Built, recorded fixtures only; SOFTWARDEN_EOL_ENABLED=0 turns it off |
2026-11-09 against https://endoflife.date/docs/api/v1/ (OpenAPI 1.2.1): ETag and 304 confirmed, no Last-Modified, 429 with Retry-After documented, no published rate limit |
| Assistant (Anthropic API, enterprise) | Anthropic Messages API (api.anthropic.com: /v1/messages, /v1/messages/count_tokens) |
Anthropic API key (the organization’s, else the installation’s ANTHROPIC_API_KEY, only with SOFTWARDEN_ASSISTANT_INSTALLATION_CAP_USD set) |
Not Microsoft | Link / create / dismiss suggestions for discovery candidates; data sent below | 6 | Built, mocked SDK only; enterprise assistant, off by default |
Not Microsoft (official anthropic SDK) |
The current code requests from Microsoft the delegated openid profile email sign-in scopes and, for the discovery connectors, client-credentials tokens: one for https://graph.microsoft.com/.default, and since Phase 6 Sources B one for https://management.azure.com/.default (azure), one per Dataverse environment https://<env>.crm[N].dynamics.com/.default and, with All environments, one for https://service.powerapps.com/.default (powerplatform). Each token is sent only to its own resource’s host (discovery/graph_source.Audience). The Graph token relies on the consented application permissions above: Application.Read.All (entra), LicenseAssignment.Read.All (entra_licenses), AuditLog.Read.All and Directory.Read.All (entra_shadow_it), Synchronization.Read.All and Policy.Read.All (entra_coverage, Phase 11, with Application.Read.All), and optionally User.ReadBasic.All and AgentIdentity.Read.All. IT2 sent email over SMTP only (SOFTWARDEN_SMTP_*); Phase 5 adds per-organization Graph sendMail (client credentials, Mail.Send through Exchange RBAC for Applications) with SMTP as the fallback, on the same token endpoint and Graph host.
Sign-in: Entra OIDC
Status: built, simulated providers only. Per-organization OpenID Connect (core/sso.py, core/oidc.py, core/oidc_discovery.py): authorization code flow with PKCE (S256), state and nonce; the provider’s discovery document and signing keys (RS256 or ES256 only, and only those the document lists; one refetch of the key set on an unknown key id); ID token checks for issuer, audience (azp too when present), expiry, nonce and the preset’s own rules. The Entra preset takes the tenant GUID and builds https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration (never common, organizations or consumers); the document’s and the token’s issuer must be https://login.microsoftonline.com/{tenant}/v2.0, the token’s tid must be the tenant, and an identity is pinned on tid + oid (sub is pairwise per app). The secret is sent in the form (client_secret_post), as before. The redirect URI is {SOFTWARDEN_BASE_URL}/api/v1/auth/oidc/callback. The installation-wide SOFTWARDEN_OIDC_TENANT, SOFTWARDEN_OIDC_CLIENT_ID, SOFTWARDEN_OIDC_CLIENT_SECRET remain as a legacy Entra configuration for a single-organization installation.
Learn check, 2026-11-07 (researcher, Phase 8 research digest): the discovery URL for a tenant GUID, the issuer https://login.microsoftonline.com/{tid}/v2.0, using the tenant GUID rather than common, pinning on (tid, oid), sub being pairwise per application and email / preferred_username being mutable. Not verified in this repository: the scope names below and the token endpoint’s client_secret_post (carried over from IT2’s working code); nothing has contacted a real tenant.
| API | Permission | Type | Why | Admin consent | Status |
|---|---|---|---|---|---|
| Microsoft identity platform (listed under Microsoft Graph in the portal; not verified) | openid |
Delegated | OIDC sign-in | Recommended (grant for the tenant to skip user prompts) | Used |
| Microsoft identity platform (listed under Microsoft Graph in the portal; not verified) | profile |
Delegated | Name claims in the ID token | Recommended | Used |
| Microsoft identity platform (listed under Microsoft Graph in the portal; not verified) | email |
Delegated | Email claim, used once to pin the identity to an existing member | Recommended | Used |
| Microsoft Graph | User.Read |
Delegated | Added by default to new registrations. Softwarden reads claims from the ID token and doesn’t call Graph. Can be removed. | Recommended | Not requested by the code |
- No
offline_access; no tokens are stored. No application permissions on the sign-in registration, provided it is a separate registration (the legacy environment settings were documented as the discovery connector’s registration; per-organization settings should name a sign-in-only registration). No group claims: roles are assigned in-app (D6). - Sign-in matches an existing member of the organization only; nobody is created by sign-in (a later Phase 8 package adds optional auto-provision with no role).
- The test sign-in (draft / test / activate) is a real delegated sign-in with the same scopes and needs no extra permission.
- Okta and Google (not Microsoft, no Microsoft permission). Okta: discovery
https://{domain}/.well-known/openid-configuration(org authorization server, issuerhttps://{domain}) orhttps://{domain}/oauth2/{id}/.well-known/openid-configuration(custom server, issuerhttps://{domain}/oauth2/{id},defaultfor the default one); pinned oniss+sub;email_verifiedrequired. Google Workspace: discoveryhttps://accounts.google.com/.well-known/openid-configuration;issishttps://accounts.google.comoraccounts.google.com(both accepted, pinned as the first); pinned onsub;hdmust be a configured Workspace domain (checked on the server);email_verifiedrequired, also as the string “true”. Scopesopenid profile email; the secret goes by HTTP Basic (client_secret_basic) when the document lists it, else in the form. From the same research digest, 2026-11-07; not checked against a real Okta or Google tenant.
Sign-in: SAML (enterprise)
Status: built (Phase 8, ee/saml, capability sso.saml), simulated identity provider only. No permission is requested and Softwarden calls no Microsoft (or other) endpoint: the browser carries the AuthnRequest (HTTP-Redirect) to the IdP and the signed Response (HTTP-POST) back, and the IdP’s metadata is pasted into the settings, never fetched. To use Entra ID as the IdP, an Entra admin creates an enterprise application with SAML single sign-on and gives it the organization’s SP values from Admin › Sign-in (saml in GET /sso/settings): Identifier (Entity ID) {SOFTWARDEN_BASE_URL}/api/v1/auth/saml/<slug>/metadata and Reply URL (ACS) {SOFTWARDEN_BASE_URL}/api/v1/auth/saml/<slug>/acs, then pastes the app’s Federation Metadata XML into Softwarden. Assigning users to that enterprise application is Entra’s own setting and needs no Graph permission. Not checked against a real Entra tenant.
Discovery source: entra
Status: built (Phase 6 Sources A), run against the simulated tenant only; live acceptance on the owner-approved dev tenant is pending. discovery/entra.py on discovery/graph_source.py (client, throttling, error wording) and core/graph.py (transport). Client credentials against https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token, scope https://graph.microsoft.com/.default, from the connector’s tested MicrosoftConnection. Learn check 2026-11-06: docs/research/phase6-discovery-microsoft-learn.md §0 and §1.
GET https://graph.microsoft.com/v1.0/servicePrincipals?$expand=owners($select=id,displayName,mail,userPrincipalName)&$top=100
GET https://graph.microsoft.com/v1.0/applications?$expand=owners($select=id,displayName,mail,userPrincipalName)&$top=100
GET https://graph.microsoft.com/v1.0/servicePrincipals/microsoft.graph.agentIdentity?$top=100 only with scope include_agent_identities
GET https://graph.microsoft.com/v1.0/{applications|servicePrincipals}/{id}/owners?$select=id,displayName,mail,userPrincipalName&$top=100
only for an object whose expanded owners hit the 20-item $expand limit
(every list follows @odata.nextLink, only on graph.microsoft.com; a full listing every run, no delta query)
| API | Permission | Type | Why | Admin consent | Required | Status | Learn check |
|---|---|---|---|---|---|---|---|
| Microsoft Graph | Application.Read.All (app role ID 9a5d68dd-52b0-4cc2-bd40-abcf44ac3a30) |
Application | List enterprise applications and app registrations with owners (ids) and passwordCredentials / keyCredentials end dates |
Yes | Yes | Built | 2026-11-06: least privileged for both lists and the owners pages; role ID on the permissions reference |
| Microsoft Graph | User.ReadBasic.All (app role ID 97235f07-e226-4f63-ace3-39588e11d3a1) |
Application | Fill owners’ names and emails in the expanded owners | Yes | No | Built, optional | Role ID read from the development tenant’s Microsoft Graph service principal (appRoles) in Phase 6; consented and exercised in the Phase 6 live acceptance on that tenant. That it is the least-privileged application permission for GET /users is not verified on Learn. |
| Microsoft Graph | AgentIdentity.Read.All (app role ID b2b8f011-2898-4234-9092-5059f6c1ebfa) |
Application | GET /servicePrincipals/microsoft.graph.agentIdentity (agent identities) |
Yes | No | Built, optional, off by default | 2026-11-06 (least privileged; higher AgentIdentity.ReadWrite.All); role ID read from the development tenant’s Microsoft Graph service principal (appRoles) in Phase 6 |
- Merged by application (client) ID. A registration and its enterprise app are one observation, keyed by
appId(an agent identity by its object ID). Since discovery 0010 the IT2-era observations are re-keyed in place (they were keyed by the service principal’s object ID), so decisions stay. - Credentials.
passwordCredentialsandkeyCredentialscome with the default properties of both lists; the lists use no$select, because$select=keyCredentialsis throttled to 150 requests a minute per tenant (Learn, verified). Key material is null on lists; the secret’shint(its first three characters) is never stored. One credential perkeyId(registration before enterprise app, certificate before secret: a certificate-backed credential appears in both lists). Stored inDiscoveredCredentialwith the end date’s UTC day. - Owners. Expanded on each list. With
Application.Read.Allalone, a user owner comes back with its id and@odata.typeand every other property null (Learn, verified); the run notes how many owners came back unnamed and the API shows each asresolved: false, the named gap “owner not resolved”. Owners are matched to People by Entra object ID (a Person whose user signed in with Entra under that ID,core.UserIdentity), else by email.$expand=ownerson the two list calls is what IT2 already used on service principals; that it behaves the same on/applicationsis to confirm live.$expandreturns at most 20 related objects and no next link, so an object whose expansion comes back with 20 owners has its owners read again from/{applications|servicePrincipals}/{id}/owners, paged (same permission); only those objects cost an extra call. - Microsoft first-party apps are skipped by default and counted under “published by Microsoft”:
appOwnerOrganizationIdin the scope’sfirst_party_tenants, by defaultf8cdef31-a31e-4b4a-93e4-5f571e91255a(Microsoft Services: verified on Learn) and72f988bf-86f1-41af-91ab-2d7cd011db47(Microsoft’s corporate tenant: kept from IT1, not on Learn). The list is editable per connector. Managed identities, legacy and social-IdP principals are skipped as “not an application”; disabled apps by default. - AI agents (DECISIONS 2026-11-06 Phase 6 (a)): tags
AgentCreatedBy:CopilotStudio,AgenticApp,AIAgentBuilder,AgenticInstanceorpower-virtual-agents-*(verified on Learn, Copilot Studio to Agent ID migration page, 2026-06-15), orservicePrincipalTypeServiceIdentity/#microsoft.graph.agentIdentity, make the observation kindai_agent. Learn has no tag namedAIAgent. WhetherApplication.Read.Allalone returns agent identities inGET /servicePrincipalsis to confirm live; both paths are handled (same object ID, one observation), andinclude_agent_identitiesdecides for both: off, an agent identity from either list is skipped and counted as “agent identity (not included)”. Agent identity blueprints (#microsoft.graph.agentIdentityBlueprintin/applications,…BlueprintPrincipalin/servicePrincipals) are kept as applications flaggedagent_blueprint, never as agents, and the run notes how many it saw; how they list on a real tenant is to confirm live. - Throttling and time limits.
429(and503) are retried afterRetry-Afterseconds, else with exponential backoff (2, 4, 8 … s, capped at 120 s), at most 8 retries per request, within 15 minutes of waiting per run (core/graph.Budget). ARetry-Afterabove 120 s stops the run instead (retrying early only counts against the limit). Every run also has a 40-minute wall-clock limit, requests and waits together, checked before each request (discovery/graph_source.RUN_DEADLINE), so a slow or throttled tenant can’t hold the worker past the one-hour stuck-run reaper. Either way the run fails readably and changes nothing. - 401 is the token not being accepted (authentication, not a permission): the run signs in again once and retries; a second 401 fails with “didn’t accept the app’s token”.
- Errors. A refused list names the permission:
403 Authorization_RequestDeniedreads “Microsoft Graph refused to list app registrations (403 Authorization_RequestDenied): the app needs the application permission Application.Read.All with admin consent”. Messages carry the status and Graph’serror.code, never Microsoft’s message text, a token or a secret. - Empty-listing guard. If a list comes back empty (
200with an emptyvalue: revoked consent, a filtered or faulty page) while live observations or discovered credentials came from it, the run fails with “Nothing was changed”, naming the list: no observation goes “no longer seen” or loses its enterprise app or registration, and no discovered credential is removed. That holds for both lists together and for each on its own (the enterprise app list alone would make apps without a registration go; the app registration list alone would remove the registrations’ credentials). The same guard coversentra_licensesandhalo(their one list),azure(each resource list per subscription and Automation account) andpowerplatform(each environment’s agent and flow lists). Observations already gone, in a scope the run couldn’t read, or out of the connector’s scope don’t count, and a new connector has nothing to lose. When they really were deleted, the refusal says what to do: an Admin runs the connector once with “accept empty listing” (accept_empty_listing: trueonPOST /connectors/{id}/run): that one run skips the guard, marks those observations “no longer seen” and removes their credentials, and is recorded on the run (counts.accept_empty_listing) and theconnector.run_requestedaudit event; scheduled runs never set it and later runs are guarded again. Deleting the connector and adding it again also works but loses its candidates’ links to records and its dismissals. - The simulated tenant (
discovery/entra_simulator.py, tenantsimulated) answers these calls as Learn documents them, including403 Authorization_RequestDeniedper missing permission, id-only owners withoutUser.ReadBasic.All, and429with and withoutRetry-After.
Discovery source: entra_licenses
Status: built (Phase 6 Sources A), simulated tenant only. discovery/entra_licenses.py, its own connector (source entra_licenses, capability discovery.entra), usually on the same Microsoft connection as entra. Learn check 2026-11-06 (List subscribedSkus).
GET https://graph.microsoft.com/v1.0/subscribedSkus?$select=skuId,skuPartNumber,appliesTo,capabilityStatus,consumedUnits,prepaidUnits
| API | Permission | Type | Why | Admin consent | Required | Status | Learn check |
|---|---|---|---|---|---|---|---|
| Microsoft Graph | LicenseAssignment.Read.All |
Application | One license observation per SKU with seat counts |
Yes | Yes | Built | 2026-11-06: least privileged (higher: Directory.Read.All, Organization.Read.All and their write forms, not needed). App role ID e2f98668-2877-4f38-a2f4-8202e0717aa1, first read from the development tenant’s Microsoft Graph service principal (appRoles) in Phase 6 and confirmed on Learn’s permissions reference on 2026-11-09 |
- The list supports
$selectonly (no$filter); no paging is documented, but a next link would be followed (on graph.microsoft.com only). - Seats (DECISIONS 2026-11-06 Phase 6 (e)): purchased is
prepaidUnits.enabled, assigned isconsumedUnits;warning,suspendedandlockedOutare stored beside them (seats_warning,seats_suspended,seats_locked_out) and never count as purchased. A suspended SKU therefore shows 0 purchased. - Counts are assignment, not usage. Discovery updates seat counts only on a pool explicitly bound to the SKU with seat sync on (D8 exception, audited as a system edit; the reconciliation package); SKUs no pool has are listed as unbound proposals (
money/licences.unbound).
Discovery source: entra_shadow_it
Status: built (Phase 11, item 1), simulated tenant only; nothing has contacted a real tenant. discovery/entra_shadow_it.py, its own connector (source entra_shadow_it, capability discovery.entra), usually on the same Microsoft connection as entra. It finds third-party apps people sign in to or consented to that nobody registered, and shows them as discovery candidates with usage counts. Separate from entra so an organization without Entra ID P1 or P2, or without these consents, leaves it off. Learn check: the Phase 11 research package (researcher, with Learn citations, 2026-11-09), summarized below.
GET https://graph.microsoft.com/v1.0/organization?$select=id the tenant's own ID
GET https://graph.microsoft.com/v1.0/servicePrincipals?$select=id,appId,displayName,appOwnerOrganizationId,publisherName,verifiedPublisher,servicePrincipalType,homepage,accountEnabled,description&$top=999
GET https://graph.microsoft.com/v1.0/oauth2PermissionGrants
GET https://graph.microsoft.com/v1.0/auditLogs/signIns?$filter=createdDateTime ge {start} and createdDateTime le {end}&$top=1000
(every list follows @odata.nextLink, only on graph.microsoft.com; the sign-in read only with the scope's sign_ins on)
| Endpoint | Permission | Type | License | Retention | Throttling and paging | Required | Learn check |
|---|---|---|---|---|---|---|---|
GET /v1.0/auditLogs/signIns |
AuditLog.Read.All (least privileged) |
Application | Microsoft Entra ID P1 or P2 in the tenant; without it Graph answers 403 Authentication_RequestFromNonPremiumTenantOrB2CTenant |
30 days with P1/P2 (7 days on Free, which can’t read the API anyway) | 5 requests per 10 s per app per tenant; $top at most 1000, newest first; always filtered by a createdDateTime window (supported: createdDateTime ge/le, appId eq, appDisplayName eq/startsWith, userId eq), or the read times out; $select undocumented (not used); no server-side aggregation |
Yes, unless the scope’s Read sign-in logs (sign_ins) is off |
Phase 11 research, 2026-11-09 |
GET /v1.0/oauth2PermissionGrants |
Directory.Read.All (least privileged) |
Application | None | Current state, not a log | $filter with eq only; follow the next link |
Yes | Phase 11 research, 2026-11-09 |
GET /v1.0/servicePrincipals ($select) |
Directory.Read.All (Application.Read.All also allows it) |
Application | None | Current state | $top 999; follow the next link |
Yes (through Directory.Read.All) |
List service principals: Application.Read.All least privileged, Directory.Read.All higher; that Directory.Read.All covers it is from the Phase 6 research, to confirm on Learn |
GET /v1.0/organization?$select=id |
Directory.Read.All |
Application | None | n/a | One call per run | Yes (through Directory.Read.All) |
That Directory.Read.All covers it (Organization.Read.All is the least privileged) is to confirm on Learn; the access check proves it either way |
App role ID of AuditLog.Read.All: b0afded3-3588-46d8-8b3d-9842eff778da (Learn permissions reference, 2026-11-09). Directory.Read.All’s is left out until confirmed on Learn (DECISIONS 2026-11-06 Phase 6 (h)).
- What a run reads. From the connector’s cursor (
ShadowItState.read_until: every sign-in created before it was counted) to 15 minutes ago, never further back than the rolling window: exactly 30 UTC days (today and the 29 days before, from midnight UTC), which is within Entra’s 30-day retention; a first run reads all of it. Windows start at the size the last run ended with (ShadowItState.window_minutes, 3 days at first). A throttled first page (429,503) is waited out (Retry-After, else exponential backoff, within the run’s throttling budget) and the same window read again. A window is halved (down to an hour) and read again when any page answers504or when it has more than 2,000 pages of 1,000; when a window can’t finish within the run’s time (30 minutes of reading, checked before every page against the time a page has taken, or the run’s 40-minute deadline), the run stops there and the halved size is stored for the next run. After a complete window the size doubles back towards 3 days, but only when a window twice as long (about twice its pages, at the time a page has taken so far) fits in the run’s time left. Later pages are retried as every Graph read is (core/graph.Budget). Reads are spaced 2.2 s apart on a real tenant (5 per 10 s per app, with a margin for theentraconnector on the same registration). A window counts only when read completely; the cursor moves to the end of the last complete one, so a later run never counts a sign-in twice. A run that stops after at least one complete window (its time, a throttle it can’t wait out, a500or502) keeps what it counted, says so in its notes, and the next run continues; a run that ran out of time before any window completed succeeds with a note and a smaller window for the next run, so a large tenant advances run by run; a run whose first window fails otherwise fails and stores nothing. v1.0 returns interactive and federated sign-ins only, which is what is counted; failed sign-ins (status.errorCodenot 0) are counted apart and aren’t use. The 15-minute lag behind now is an assumption about log latency, not from Learn. - What counts as third-party. The app’s service principal
appOwnerOrganizationIdisn’t the tenant’s own ID and isn’t in the scope’sfirst_party_tenants(theentrasource’s list and defaults). An app with no service principal in the tenant (typically a Microsoft app) is skipped as “no enterprise app”, the tenant’s own apps as “published by this tenant”, managed identities and legacy principals as “not an application”. Consent:consentTypeAllPrincipalsis admin consent (principalIdnull),Principalone user’s;clientIdis the client’s service principal object ID, mapped to its application ID through the service principal list;scopeis space-separated. - High-risk scopes (
risky_scopes), compared case-insensitively:offline_access,Mail.Read,Mail.ReadWrite,Mail.Sendand their.Sharedforms,MailboxSettings.ReadWrite,Files.Read.All,Files.ReadWrite.All,Sites.Read.All,Sites.ReadWrite.All,Sites.Manage.All,Sites.FullControl.All,Notes.ReadWrite.All,Calendars.ReadWrite,Contacts.ReadWrite,User.ReadWrite.All,Group.ReadWrite.All,Application.ReadWrite.All,AppRoleAssignment.ReadWrite.All,full_access_as_user,EWS.AccessAsUser.All, and anything startingDirectory.orRoleManagement.. - Privacy: what is stored. Per connector, the cursor and a random 32-byte key (
ShadowItState.sketch_key; never in a response, log, audit event or organization export). Per third-party app per UTC day: the successful sign-in count, the distinct users that day, and a HyperLogLog sketch (4,096 one-byte registers, zlib-packed) of HMAC-SHA256(key, user object ID), from which distinct users over several days are counted (about 1.6% standard error, exact in practice for small numbers); days older than the rolling 30 are deleted by the next run. Per app, the overview summary: users and sign-ins over 30 days, last sign-in time, consent type, how many people consented, the scopes. Never stored: sign-in rows or their IDs, user IDs, names, addresses, IP addresses, locations, devices, correlation IDs, or who consented. User IDs are hashed into the sketch in memory and dropped. A sketch can’t list its users; with the key, someone could test whether one known user is likely among an app’s users on a day, which is why the key never leaves the database. - Apps the catalog has (a live application record with the app’s Entra application ID, or an observation of an
entraconnector) are not candidates: skipped as “already in the catalog”, and kept in the overview with their usage. Nothing writes to Entra or to a curated record. - Inactive apps aren’t gone. An app this connector observed whose enterprise app is still in the tenant but which has had no sign-in in the rolling window and has no consent grant is skipped as “inactive” (with
sign_insoff, “not read”): its candidate is out of scope, never “no longer seen”, so a record linked to it isn’t flagged missing. An app whose enterprise app was deleted from the tenant becomes “no longer seen” at the next run, whatever its recent usage and with or without the sign-in logs, so a record linked to it shows as missing. A dismissed candidate comes back only when its risk rises: a consent type more permissive, or a high-risk scope, than it ever had; usage, a lost scope or a reduced consent never bring it back, and usage changes never count as a change of the candidate. - Errors.
403 Authorization_RequestDeniednames the permission (AuditLog.Read.Allfor the sign-in logs,Directory.Read.Allfor the rest). The license refusal reads “Microsoft Graph refused to read the sign-in logs (403 Authentication_RequestFromNonPremiumTenantOrB2CTenant): reading them needs a Microsoft Entra ID P1 or P2 license in the tenant, and this tenant has none. Add one, or turn off Read sign-in logs in this connector’s scope to find shadow IT from consent grants only.” Either way the run fails and stores nothing. - The simulated tenant (
discovery/entra_simulator.py) answers these reads as Learn documents them: third-party apps nobody registered, a fixed sign-in schedule with failures and fractional timestamps, the window filter (an unfiltered read is refused), paging,429withRetry-Afterthroughsimulated_throttle, the 30-day retention, and a tenant without P1 (simulated_no_premium).
Discovery source: entra_coverage
Status: built (Phase 11, item 3), simulated tenant only; nothing has contacted a real tenant. discovery/entra_coverage.py, its own connector (source entra_coverage, capability discovery.entra), usually on the same Microsoft connection as entra. For every catalog application linked to an Entra app (its entra_app_id, or a linked entra or entra_shadow_it candidate) it records whether it is behind single sign-on, whether SCIM provisioning is on, and whether conditional access requires MFA, for all users or only some (discovery_appcoverage, docs/DATA_MODEL.md). Separate from entra so an organization without Entra ID P1, or without these consents, leaves it off. It writes nothing to Entra and never writes a catalog record. Learn check: the Phase 11 research package (researcher, with Learn citations, 2026-11-09), summarized below.
GET https://graph.microsoft.com/v1.0/servicePrincipals?$select=id,appId,displayName,appOwnerOrganizationId,servicePrincipalType,preferredSingleSignOnMode,preferredTokenSigningKeyThumbprint,samlSingleSignOnSettings&$top=999
GET https://graph.microsoft.com/v1.0/servicePrincipals/{id}/synchronization/jobs per linked app, at most 100 a run
GET https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies once a run
(every list follows @odata.nextLink, only on graph.microsoft.com)
| Endpoint | Permission | Type | License | Throttling and paging | Required | Learn check |
|---|---|---|---|---|---|---|
GET /v1.0/servicePrincipals ($select with preferredSingleSignOnMode) |
Application.Read.All (held by entra) |
Application | None | $top 999; follow the next link |
Yes | Phase 11 research, 2026-11-09: preferredSingleSignOnMode is password, saml, notSupported or oidc, and null for older SAML and OIDC apps; samlSingleSignOnSettings and preferredTokenSigningKeyThumbprint are SAML signals |
GET /v1.0/servicePrincipals/{id}/synchronization/jobs |
Synchronization.Read.All |
Application | None | One call per service principal, so capped at 100 a run (least recently read first; the others keep their last answer); Graph defaults (Retry-After honored) | Yes, unless the scope’s Read provisioning (SCIM) jobs (provisioning) is off |
Phase 11 research, 2026-11-09. Never Application.ReadWrite.OwnedBy (it writes) |
GET /v1.0/identity/conditionalAccess/policies |
Policy.Read.All |
Application | Microsoft Entra ID P1 (P2 for risk conditions) | 1 request per second per tenant, and a 429 carries no Retry-After: read once a run, pages 1.1 s apart, a throttled read backs off on its own (2, 4, 8, 16 s; a Retry-After is honored if one ever comes) and then gives up for that run | Yes, unless the scope’s Read conditional access policies (conditional_access) is off |
Phase 11 research, 2026-11-09 |
App role IDs (Learn permissions reference, 2026-11-09): Synchronization.Read.All 5ba43d2f-fa88-4db2-bd1c-a67c5f0fb1ce, Policy.Read.All 246dd0d5-5bd0-4def-940b-0421030a5b68.
- Single sign-on (
sso):saml,oidc,password(Entra’s password-based SSO: the app is behind My Apps but not federated; it counts as SSO for the record’ssso_enabled, as Entra counts it),none(notSupported) orunknown. A null mode is unknown, never “no SSO”, unless the app has a SAML token signing certificate or SAML settings, which make itsaml. - Provisioning (
scim): on when a job’sschedule.stateis Active and itsstatus.codeis neither Paused nor Quarantine; off with no job (an empty list, or a 404), a paused or quarantined job, or a schedule that isn’t active. - Conditional access (
mfa), per app (entra_coverage.evaluate_policy): onlyenabledpolicies enforce (report-only ones are listed asreport_only, disabled ones ignored). The app must be inconditions.applications.includeApplications(its app ID orAll) and not inexcludeApplications;Office365andMicrosoftAdminPortalsmay cover a Microsoft-published app and never a third-party one, so including or excluding either makes a Microsoft app unknown and leaves a third-party app as the rest of the policy says; anapplicationFiltercan’t be evaluated, so a policy whose coverage of the app depends on one is unknown.conditions.users.includeUsersAllis all users (exclusions such as break-glass accounts are counted and shown, not judged); roles (an admin-only policy), groups, guests or listed people are some users, as is a policy limited to some locations, device platforms, client app types, devices (a device filter, or the legacyincludeDevices/excludeDevices), sign-in, user, service principal or insider risk levels, authentication flows (device code flow, authentication transfer), workload identities, or any condition Softwarden doesn’t know (an unknown shape can never give “all users”); usersNonecovers nobody. An enabled policy whose grant isblockis effectblocked; one that blocks all users with no exclusion and no narrowing makes the appblocked, not a missing MFA policy; a block with exclusions (“everyone except Sales”, or break-glass accounts) decides nothing, and the other policies say what the excluded people get. MFA is required whengrantControls.builtInControlscontainsmfaorauthenticationStrength.requirementsSatisfiedismfa, unless the operator isORwith another control beside it (MFA optional; Graph writesOReven for a single control, which still requires MFA). The app isblockedif one policy blocks everyone, elseall_usersif one requires MFA of everyone, elsesome_users, elseunknownif a filter or group decides it, elsenone. - License and errors. A tenant without P1 (the code isn’t on Learn for this endpoint; Softwarden expects
403 Authentication_RequestFromNonPremiumTenantOrB2CTenantas for the sign-in logs) or withoutPolicy.Read.All(any other 403) doesn’t fail the run: SSO and provisioning are reported, MFA is “unknown” with the reason (“needs Microsoft Entra ID P1”), and the run’s notes say so. The access check marks the license as a warning (missing_licence), not a failure. A refused provisioning read (Synchronization.Read.Allmissing) stops the provisioning reads for that run (provisioning unknown, with a note); a 404 is “no provisioning job”. Transient failures keep the last answers: a conditional access read throttled past its retries, answering a 5xx, or unreachable keeps every app’s lastmfa, detail and policies; a provisioning read answering a 5xx keeps that app’s last answer, and throttling past the run’s budget or an unreachable Graph stops the provisioning reads and keeps the rest’s last answers. An answer is kept only for the same service principal (a tenant change or a re-created app is another one) and for at most 7 days after it was read (mfa_checked_at,scim_checked_at); older, it becomes unknown saying since when. Apps waiting their turn past the provisioning cap keep their answer whatever its age. The run notes what was carried over; only a 401 or 403, or the scope turned off, resets them to unknown. The access check names both causes (the permission or the P1 license) for any 403 on conditional access other than the license code. Security defaults (MFA without conditional access, in tenants without P1) aren’t read (GET /policies/identitySecurityDefaultsEnforcementPolicynot researched yet). - Empty-listing guard (this source only). If the service principal listing comes back empty while the connector has coverage from earlier runs, the run fails with “Nothing was changed” rather than deleting every row.
- The simulated tenant (
discovery/entra_simulator.py) has a single sign-on mode per app (null included), provisioning jobs in every state, conditional access policies of every shape above, a tenant without P1 (simulated_no_premium) and a 429 without Retry-After (simulated_throttlewithretry_afternull).
Discovery source: entra_licence_usage
Status: built (Phase 11, item 10, license waste), simulated tenant only; nothing has contacted a real tenant. discovery/entra_licence_usage.py, its own connector (source entra_licence_usage, capability discovery.entra), usually on the same Microsoft connection as entra and entra_licenses. It stores which licenses each licensed user holds and the date of their last successful sign-in, for the license waste report (discovery/licence_waste.py, GET /api/v1/reports/licence-waste). It is the first source that reads per-user data, so it is separate from entra_licenses (seat counts, LicenseAssignment.Read.All): an organization that only wants seat counts never grants these consents. Learn check: the Phase 11 research package (researcher, with Learn citations, 2026-11-09).
Both permissions are tenant-wide admin consents. User.Read.All lets the app read every user’s full profile in the tenant, and AuditLog.Read.All every audit and sign-in log. A Privileged Role Administrator (or Global Administrator) must grant them in the app registration before a live run; Softwarden never asks for them by itself, and this repository hasn’t granted them on any tenant. Grant only User.Read.All to count licenses without sign-in activity.
GET https://graph.microsoft.com/v1.0/users?$select=id,accountEnabled,assignedLicenses,licenseAssignmentStates,userType,createdDateTime,mail,userPrincipalName,signInActivity&$top=500
GET https://graph.microsoft.com/v1.0/users?$select=id,accountEnabled,assignedLicenses,licenseAssignmentStates,userType,createdDateTime,mail,userPrincipalName&$top=999
(the second only when the first is refused with 403, runs past 25 minutes (it is paced at ten pages a minute),
or the scope's sign_in_activity is off; both follow @odata.nextLink, only on graph.microsoft.com)
| Endpoint | Permission | Type | License | Throttling and paging | Required | Learn check |
|---|---|---|---|---|---|---|
GET /v1.0/users with $select=...assignedLicenses... |
User.Read.All (least privileged that returns assignedLicenses; User.ReadBasic.All doesn’t, and LicenseAssignment.Read.All isn’t listed for it) |
Application | None | $top 999; follow the next link; Graph defaults (Retry-After honored within the run’s budget) |
Yes | Phase 11 research, 2026-11-09: assignedLicenses is returned only with $select; users/{id}/licenseDetails has no application permission, so it is never read |
GET /v1.0/users with signInActivity in $select |
AuditLog.Read.All as well as User.Read.All |
Application | Microsoft Entra ID P1 (or P2); without it Graph answers 403 Authentication_RequestFromNonPremiumTenantOrB2CTenant |
At most 500 users a page and 10 requests a minute per tenant: pages are read 6.5 s apart on a real tenant, and a 429 waits for Retry-After within the run’s throttling budget. Graph may return fewer users per page than $top with signInActivity (older Learn text said 120), so the read is bounded by users and time, never by pages: at most 100,000 users a run, and this read stops before a page that would start past 25 minutes (about 230 pages: 115,000 users at 500 a page, 27,000 at 120). The run then reads the users again without signInActivity (unpaced, $top 999) within the rest of its 40-minute deadline, and notes that the last sign-in is unknown this run |
No: optional, off with the scope’s Read last sign-in (sign_in_activity) |
Phase 11 research, 2026-11-09: use lastSuccessfulSignInDateTime (interactive or non-interactive, successful only; lastSignInDateTime includes failures); not backfilled before December 2023 |
App role IDs (Learn permissions reference, 2026-11-09): User.Read.All df021288-bdef-4463-88db-98f22de89214, AuditLog.Read.All b0afded3-3588-46d8-8b3d-9842eff778da (discovery/graph_source.USER_READ_ALL, AUDIT_LOG_SIGN_IN_ACTIVITY).
-
Without P1 or the AuditLog consent the run degrades instead of failing. A 403 on the read with
signInActivity(the license code, orAuthorization_RequestDenied) makes the run read the users again without it. Licenses are counted, every last sign-in is “unknown (needs Entra ID P1 / AuditLog.Read.All)”, nothing is counted as inactive, and the run’s note names the cause. The access check shows the license as a warning (missing_licence) and a missingAuditLog.Read.Allas optional. WithoutUser.Read.Allthe run fails and names it. A Graph listing of users withoutassignedLicensesis refused rather than read as “nobody is licensed”. -
Privacy: what is stored (
discovery_licenseduser, docs/DATA_MODEL.md). Only licensed users are stored, one row each, holding:- an HMAC-SHA256 of the object ID keyed by the installation’s secret key and the connector (never the object ID itself);
- the SKU IDs;
- whether the account is enabled, whether it is a guest (
userTypeGuest, or#EXT#in the user principal name), and whether it is a service account (the scope’s user principal name prefixes or object IDs); - the date (not the time) of the last successful sign-in and of creation, and whether any sign-in was recorded;
- the Person here it is linked to.
Never stored: a name, an email address, a user principal name, the object ID, an IP address, a location or a device. Mail and user principal name are held in memory during the run only to link a member to a Person (by email, the user principal name before mail; guests are linked by identity only, and an ambiguous match is left unlinked) and to match service-account prefixes. The object ID is used only to link through a Person’s Entra sign-in identity of this tenant and to hash.
Every run rewrites the connector’s rows, so the next run deletes any user who is no longer licensed or no longer in the tenant, and deleting the connector deletes them all. The rows are left out of organization exports and tenant backups (personal data the next run reads again), and the organization purge removes them.
-
Who sees what. The report, its CSV, and the pool and contract blocks are counts and money, open to any role. The per-SKU drill-down of users is Admin-only. It shows a linked Person’s name, otherwise “Unlinked user” and the first 8 characters of the keyed hash. Its CSV is audited, and both are refused while platform staff impersonate. The offboarding footprint lists the SKUs a Person holds, without sign-in dates.
-
Group-based licenses.
assignedLicensesincludes licenses inherited from groups (Learn, user resource). A seat held only through a group can only be reclaimed by changing the user’s group membership, not by removing the license from the user. The run therefore also selectslicenseAssignmentStates(sameUser.Read.All) and keeps which SKUs a user holds only through groups (assignedByGroupon every state of the SKU). The drill-down flags them asgroup_assigned. -
One connector per tenant. A second
entra_licence_usageconnector on the same connection or tenant ID is refused, since it would count every licensed user twice. Each run also reads thetidclaim of its access token (the token is read, not verified; Softwarden reads nothing else from it) and stores a keyed hash of the tenant in its counts (tenant_key). A run is refused, changing nothing, when another enabled license usage connector’s last successful run read the same tenant. That catches a tenant named by domain on one connection and by GUID on another, or changed later in Connections. -
Empty-listing guard. If the user listing comes back empty, or with no licensed user, while the connector has rows from earlier runs, the run fails with “Nothing was changed” rather than deleting them.
-
The simulated tenant (
discovery/entra_simulator.py) answersGET /usersas Learn documents it:- only the selected properties, and the
$toplimits (999, or 500 withsignInActivity); - every combination of enabled or disabled, member or guest, no, one or several licenses, and a sign-in that is recent, old, never or failed only;
AuditLog.Read.Alland P1 required forsignInActivity(simulated_no_premium);- paging (
simulated_user_page) and429withRetry-After(simulated_throttle).
- only the selected properties, and the
Discovery source: azure
Status: built (Phase 6 Sources B, enterprise capability discovery.azure), run against the simulated tenant only; live acceptance needs an Azure role assignment the owner hasn’t approved yet. discovery/azure.py on discovery/graph_source.py. Client credentials from the connector’s tested MicrosoftConnection, scope https://management.azure.com/.default; the token is sent only to management.azure.com (every request and every nextLink is checked before the token is attached). No Graph permission. Learn check 2026-11-06: docs/research/phase6-discovery-microsoft-learn.md §3.
GET https://management.azure.com/subscriptions?api-version=2022-12-01 no subscription chosen: every one the app has a role on
GET https://management.azure.com/subscriptions/{id}?api-version=2022-12-01 each chosen subscription
GET https://management.azure.com/subscriptions/{id}/providers/Microsoft.Logic/workflows?api-version=2019-05-01
GET https://management.azure.com/subscriptions/{id}/providers/Microsoft.Web/sites?api-version=2024-04-01
GET https://management.azure.com/subscriptions/{id}/providers/Microsoft.Automation/automationAccounts?api-version=2024-10-23
GET https://management.azure.com{automation account id}/runbooks?api-version=2024-10-23
(each list follows nextLink, only on management.azure.com; a full listing every run)
| API | Grant | Type | Scope | Why | Status | Learn check |
|---|---|---|---|---|---|---|
| Azure Resource Manager | Reader (role definition ID acdd72a7-3385-48ef-bd42-f606fba81ae7, action */read) |
Azure RBAC role assignment | Each subscription to read, or a management group above them (inherited) | List subscriptions, Logic Apps, web apps, Automation accounts and runbooks | Built | 2026-11-06: role ID and */read; Subscriptions List/Get 2022-12-01, Logic workflows 2019-05-01 and runbooks 2024-10-23 verified. Not verified: Web Apps - List 2024-04-01 and Automation accounts list 2024-10-23 (assumed from the runbooks page) |
az role assignment create --assignee <app client id> --role Reader --scope /subscriptions/<subscription id>
# or once for a management group: --scope /providers/Microsoft.Management/managementGroups/<group id>
- Per-type lists, not the generic Resources list (DECISIONS 2026-11-06 Phase 6 (f)): the generic list returns no
properties(IT1’s descriptions came out empty). Resource Graph isn’t used. - Subscriptions. With none chosen, every subscription Subscriptions - List returns is read, so Reader on a management group covers all the subscriptions under it (no management-group API call is made). Only Enabled, Warned and PastDue subscriptions are read; others are noted and their candidates keep their state. A chosen subscription the app can’t read is noted (its candidates keep their state); Subscriptions - Get answers
404 SubscriptionNotFoundfor a subscription the app has no role on (research §3), reported as “no Reader on this subscription, or the ID is wrong”. Each resource type is its own scope: one list refused leaves that type’s candidates as they were while the others update. A resource provider the subscription never registered (409 MissingSubscriptionRegistration) is an empty list, noted (andokin the access check). The run fails only when nothing could be read. - Web apps by
kind.workflowappis read as a Standard Logic App (Learn shows only theAPP_KINDsettingworkflowApp: to confirm live),functionappas a Function App (Learn:functionapp,functionapp,linux),app,apior no kind as an App Service (kept only with scopeinclude_app_services). Any other kind (Flex Consumption is to confirm live) is skipped as “not recognized” and the run notes the kind strings. - Errors. A missing role is
403 AuthorizationFailed; the run and the access check name the action and scope ARM gave (read with a strict pattern; nothing else of Microsoft’s message is kept) and the fix: “Assign the Azure role Reader to the connection’s app on subscription X”. Role changes can take minutes to reach tokens.429/503are retried afterRetry-Afteras for Graph. - Kept: name, resource ID, type, subscription, resource group, location, tags (50 at most), state, kind, runbook type and description, managed identity type. Never kept: a Logic App’s definition or a site’s configuration (either can hold secrets).
Discovery source: powerplatform
Status: built (Phase 6 Sources B, enterprise capability discovery.powerplatform), simulated tenant only; live acceptance needs Dataverse application users the owner hasn’t approved yet. discovery/powerplatform.py. Power Platform doesn’t use Entra API permissions for service principals. Learn check 2026-11-06: research §4.
Least privilege (default): environments selected by URL (scope environment_urls, https://<name>.crm[N].dynamics.com; anything else is refused when saved). Each environment gets its own token, scope https://<env host>/.default, sent only to that host.
GET {env}/api/data/v9.2/bots?$select=botid,name,schemaname,statecode,statuscode,authenticationmode,accesscontrolpolicy,language,publishedon,componentstate,_ownerid_value
Prefer: odata.include-annotations="OData.Community.Display.V1.FormattedValue",odata.maxpagesize=500
GET {env}/api/data/v9.2/workflows?$select=workflowid,name,statecode,description,_ownerid_value&$filter=category eq 5 only with include_cloud_flows
(each follows @odata.nextLink, only on that environment's host)
| API | Grant | Type | Why | Status | Learn check |
|---|---|---|---|---|---|
| Dataverse Web API | Application user in each environment with a read-only role: read on Agent (bot, prvReadbot), organization level |
Dataverse security role | Copilot Studio agents as AI agent candidates | Built | Table, entity set and columns verified 2026-11-06. prvReadbot as the privilege needed is not verified (no Learn page; IT1 needed Service Reader on a fresh install) |
| Dataverse Web API | Same role: read on Process (workflow, prvReadWorkflow) |
Dataverse security role | Cloud flows (optional; scope include_cloud_flows) |
Built, optional | Category 5 = Modern Flow verified. prvReadWorkflow and workflow statecode values (0 Draft, 1 Activated, 2 Suspended) not verified |
| Dataverse Web API | Predefined Service Reader role, only if the read-only role proves insufficient | Dataverse security role | IT1 observed (2026-10-29) the custom role alone wasn’t enough on a fresh install | Fallback | Not verified (IT1 observation only) |
- Set-up: register the app in Entra, then Power Platform admin center > Environment > Settings > Users + permissions > Application users > New app user, with the business unit and the security role above (Learn, verified). Avoid System Administrator.
- Errors. Dataverse answers a caller that isn’t a user of the environment with
401and a missing privilege with403(typesPrivilegeDenied,AccessDenied, …; codes are often empty or hex): both are “needs an application user with read on Agent (bot) rows”, naming the privilege when Dataverse’s message names one (prv..., read with a strict pattern; the message wording is not verified). A Dataverse host the tenant doesn’t know fails sign-in withAADSTS500011(not verified), shown as “not found: check the address”. - An environment that can’t be read (refused, no token, unreachable, throttled out) is noted and its candidates keep their state; only the run’s 40-minute limit stops the whole run. Flows that can’t be read are a note and the agents are still read. Rows without an ID are skipped and counted (“no ID”). Agents in component state Deleted (2, 3) are skipped. A flow’s
clientdata(its definition) is never asked for. RetrieveCurrentOrganization(IT1) isn’t called: an environment chosen by URL is named by its host, and its environment ID is known only through All environments.
All environments (opt-in, scope all_environments): the Power Platform admin API (BAP).
GET https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments?api-version=2020-10-01 token for https://service.powerapps.com/
| API | Grant | Type | Why | Status | Learn check |
|---|---|---|---|---|---|
| Power Platform admin API (BAP) | Register the app for the admin API: PUT https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/adminApplications/{client id}?api-version=2020-10-01 by an administrator in user context, or pac admin create-service-principal |
Admin-equivalent registration | List the tenant’s environments | Built, opt-in, off by default | 2026-11-06: 2020-10-01 generally available with no retirement date; the registration page (Power Platform service principal) is pre-release. New-PowerAppManagementApp (IT1) not verified |
- Microsoft treats such an app like a user with the Power Platform Administrator role, and granular roles can’t limit it. The SPA shows this warning; selected-by-URL remains the default.
- The newer Power Platform API (
api.powerplatform.com,EnvironmentManagement.Environments.Read) wasn’t used: its service-principal roles couldn’t be verified (DECISIONS 2026-11-06 Phase 6 (g)). Open. - An environment’s
instanceUrlis used only when it is a Dataverse host; any other is noted and never sent a token. Environments without a Dataverse database are noted; if no environment is left to read, the run fails with those notes. If the list is refused (401after a fresh token, or403: not registered) the run fails (an environment it would have read could otherwise look gone); the access check names “Power Platform management app”.
Discovery source: halo
Status: built (Phase 6 Sources B, enterprise capability discovery.halo), against the synthetic Halo only. Not Microsoft; listed so every outbound credential is in one place. discovery/halo.py with the connector’s HaloConnection (encrypted secret; core/halo.py). The API conventions are IT1’s from Halo’s integration guides: confirm against the instance’s own {base}/apidoc during live acceptance.
POST {base}/auth/token client credentials, scope default "read:assets"
GET {base}/api/Asset?paginate=true&page_size=100&page_no=N {"record_count", "assets": [...]}, until a short page or record_count
| API | Grant | Why | Phase | Status |
|---|---|---|---|---|
| HaloITSM API | API application (client credentials) with read access to assets; scope read:assets (the default since Phase 6 SPA-A; connections saved earlier keep read:assets read:tickets until changed, and read:tickets waits for the recent-tickets panel) |
Assets as application candidates, with their Halo asset ID | 6 | Built (Phase 6 Sources B) |
base_urlmust be https, with no user name, password, query or fragment (core/outbound.check_service_url). Private networks are allowed (an on-premises Halo) unless the installation setsSOFTWARDEN_ALLOW_PRIVATE_SERVICE_HOSTS=false(a hosted, multi-organization install), which refuses RFC 1918, ULA and carrier-grade NAT too; loopback, link-local (169.254.169.254 included), cloud metadata (fd00:ec2::254,100.100.100.200), unspecified, multicast and reserved addresses are always refused, for a literal on save and for every address the name resolves to when connecting (the token POST and every asset GET), which goes to the checked address with no redirects and a deadline (core/outbound.request(..., private_ok=True)). IT1urlsafety.pyhad the same rule. The token is sent only to the connection’s own host and port.- A refused read (
403) is “missing permission: read:assets”;401is the token not being accepted.429is retried afterRetry-After. Assets without an ID are skipped and counted; paging stops (with a note) if Halo answers a page with the same first asset as the page before. - Kept: the asset ID, key field (else inventory number), asset type, inventory number, client, site, inactive. Inactive assets are skipped by default; scope
asset_typeslimits the types. - The host
halo.simulated.invalidselects the synthetic Halo (discovery/halo_simulator.py, tokens fromcore/halo.SimulatedTransport): nothing leaves the machine.
Ticket connections: Jira, ServiceNow, Halo
ServiceNow and Halo are preview features in 1.0.0: no request is made to either unless the installation sets SOFTWARDEN_PREVIEW_FEATURES (docs/CAPABILITIES.md). The same applies to Okta and Google Workspace below.
Status: built (Phase 11 ticket links, capability ticket_links), against the synthetic systems only. Not Microsoft; no Microsoft permission is involved. Listed so every outbound credential is in one place. tickets/clients.py with the organization’s TicketConnection (Jira and ServiceNow keep an encrypted secret; Halo uses the Phase 6 HaloConnection).
Jira Cloud (REST v3, basic auth: account email + API token)
GET {base}/rest/api/3/issue/{key}?fields=status,summary status name, statusCategory.key, summary
GET {base}/rest/api/3/myself the connection test
POST {base}/rest/api/3/issue create (opt-in): project key, issue type, summary, ADF description
ServiceNow (Table API, basic auth)
GET {base}/api/now/table/{table}?sysparm_query=number={key}&sysparm_fields=number,state,short_description&sysparm_display_value=all&sysparm_limit=1
GET {base}/api/now/table/task?sysparm_limit=1&sysparm_fields=number the connection test
POST {base}/api/now/table/{table} create (opt-in): short_description, description
HaloITSM (client credentials through the Halo connection)
POST {base}/auth/token scope from the Halo connection (must include read:tickets)
GET {base}/api/Tickets/{id} summary, status_id (status_name when sent)
POST {base}/api/Tickets create (opt-in): [{summary, details, tickettype_id}]
| System | Grant | Why | Status |
|---|---|---|---|
| Jira Cloud | An Atlassian account’s API token (basic auth); the account needs Browse projects on the projects whose issues are linked, and Create issues on the create project if creating is on | Read linked issues’ status; optionally create an issue | Built; simulator only |
| ServiceNow | A user with read access to the task tables (incident, change_request, sc_req_item, sc_task, problem, task), and create on the create table if creating is on (basic auth) |
Read linked tickets’ state; optionally create one | Built; simulator only |
| HaloITSM | The Halo connection’s API application with read:tickets (and edit:tickets to create) |
Read linked tickets’ status; optionally create one | Built; simulator only |
- Where checked (2026-11-09). Jira:
https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/(Get issueGET /rest/api/3/issue/{issueIdOrKey}withfieldsa comma-separated list; Create issuePOST /rest/api/3/issue) andhttps://developer.atlassian.com/cloud/jira/platform/basic-auth-for-rest-apis/(basic auth needs an API token; passwords are deprecated), fetched and read. The status category keysnew,indeterminateanddoneare Jira’s long-standing keys (/rest/api/3/statuscategory); the reference page’s examples use placeholder keys, so they weren’t confirmable there. ServiceNow:https://www.servicenow.com/docs/r/api-reference/rest-apis/c_TableAPI.html(Table API) is rendered by script and couldn’t be read from here;sysparm_query,sysparm_fields,sysparm_display_value(true,false,all) andsysparm_limitare the Table API’s documented parameters, to confirm during live acceptance, as are the state labels. Halo: no public reference could be fetched (the instance’s own{base}/apidocis the reference, as for the Phase 6 source);GET /api/Tickets/{id},POST /api/Ticketsand the status IDs (1 New, 9 Closed) are to confirm during live acceptance. - Every request goes through
core/outboundunder the service rule, like the Halo source: https only; no user name or password in the URL; loopback, link-local (169.254.169.254 included), cloud metadata, unspecified, multicast and reserved addresses always refused, and private networks too whenSOFTWARDEN_ALLOW_PRIVATE_SERVICE_HOSTS=false, for a literal on save and for every address the name resolves to when connecting, which goes to the checked address with no redirects, a 10 s socket timeout and a 20 s deadline. The address is always the connection’s own base URL: a link’s stored URL is never fetched, and a link on another site isn’t read with the connection’s credentials. - Errors are stored in Softwarden’s words only (
status_error, the test’sdetail): never the server’s text, the Authorization header, a token or the secret. The credential object’s repr leaves the secret out, and job payloads carry no secret (the job reads the connection row when it runs). - The hourly refresh runs in the worker’s long lane with a 180 s budget per run, one Halo token per run, and stops reading a system at its first timeout, missing answer, busy answer, refused access, refused credentials or refused address, so a slow system can’t hold up the worker or another organization’s reads.
- Creating tickets is off by default per connection (
create_enabled), needs the connection tested at its current revision, and every attempt is audited (ticket_link.ticket_created,ticket_link.create_failed). Only the title and description are sent. - The hosts
jira.simulated.invalid,servicenow.simulated.invalidandhalo.simulated.invalidselect the synthetic systems (tickets/simulator.py): nothing leaves the machine.
Discovery source: okta
Status: built (Phase 11, enterprise capability discovery.okta), against the synthetic Okta only. No real Okta org has been contacted; live acceptance is the owner’s. Not Microsoft; listed so every outbound credential is in one place. discovery/okta.py with the connector’s OktaConnection (core/okta.py; the private key is Fernet-encrypted and write-only). Checked on 2026-11-10 against:
- Implement OAuth for Okta with a service app: client credentials with
private_key_jwt(the only client authentication for service apps that get Okta-scoped tokens), assertioniss=sub= client ID,aud=https://{okta domain}/oauth2/v1/token, at most an hour; token lifetime one hour; each service app needs an admin role, and a scope works only within it. - The Okta Management API OpenAPI document (okta/okta-management-openapi-spec,
dist/current/management-minimal.yaml, version 2026.44.0):GET /api/v1/apps(listApplications;limitat most 200,includeNonDeleted,aftercursor; securityokta.apps.read) andGET /api/v1/apps/{appId}/users(listApplicationUsers;limitat most 500; securityokta.apps.read). Errors{"errorCode": "E0000006", ...}for 403,E0000047for 429. - Pagination: the
linkheader’s rel=“next” URL, followed as an opaque value. - Rate limit best practices:
X-Rate-Limit-Limit,X-Rate-Limit-Remaining,X-Rate-Limit-Reset(UTC epoch seconds); 429 when exceeded. - DPoP: opt-in per app (“Require Demonstrating Proof of Possession (DPoP) header in token requests”). Softwarden sends no DPoP proof; the test tells the Admin to leave it off.
POST https://{okta domain}/oauth2/v1/token grant_type=client_credentials, scope=okta.apps.read,
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer,
client_assertion=<JWT signed with the service app's private key>
GET https://{okta domain}/api/v1/apps?limit=200&includeNonDeleted=true then the link header's rel="next"
GET https://{okta domain}/api/v1/apps/{appId}/users?limit=500 per kept app, while assignment_counts is on
| API | Grant | Why | Phase | Status |
|---|---|---|---|---|
| Okta Management API | OAuth 2.0 scope okta.apps.read, granted to the API service app (Okta API Scopes) |
List applications and count their assigned users. Read only; okta.users.read is accepted on the connection but not needed; any non-read scope is refused |
11 | Built, synthetic only |
| Okta admin roles | Read-only Administrator (READ_ONLY_ADMIN) assigned to the service app (Admin roles) |
A scope works only within the app’s admin role; Read-only Administrator is the least privileged standard role that reads applications and their users (Okta help: standard administrator roles). Confirm on the org during live acceptance | 11 | Built, synthetic only |
- Why a service app, not an SSWS API token. An SSWS token carries every permission of the administrator who created it, isn’t scoped, and is a bearer secret valid until revoked. The service app is scoped to
okta.apps.read, bounded by a read-only admin role, and its private key never leaves Softwarden: only a five-minute signed assertion is sent. The key is a PEM private key (PKCS#8 or PKCS#1) or a private JWK, RSA of 2048 bits or more (RS256) or EC P-256 (ES256);key_idis itskidwhen the app has several keys (a JWK’s ownkidis used otherwise). - The address.
https://{org}.okta.com,.oktapreview.com,.okta-emea.com(one label, never the-adminconsole host or the bare suffix) or a custom domain; https on 443, no path, query, user name or IP address. Every call goes throughcore/outbound’s public rule (outbound.PublicTransport): the name is resolved once, refused unless every address is public (a custom domain resolving to loopback, RFC 1918, link-local or a metadata address is refused before anything is sent), and the request goes to the checked address with no redirects and a deadline. The token goes only to that host on 443, also for next-page links. - Rate limits. The limit is the org’s, shared with every other integration, so once
X-Rate-Limit-Remainingfalls below 10 % ofX-Rate-Limit-Limit(or reaches 0 without a limit header) the next request waits untilX-Rate-Limit-Reset, leaving the rest to the others. A 429 waitsRetry-Afterif given, else until the reset, within the run’s waiting budget (core/graph.RUN_BUDGET); a wait over two minutes stops the run (the next run tries again). - Errors. Token:
invalid_client/unauthorized_client= wrong client ID or key;invalid_scope= the scope isn’t granted;invalid_dpop_proof= DPoP required. Reads: 403E0000006= the admin role can’t read (names Read-only Administrator); 401 re-signs in once. A refused user count is a note, never a failed run. - Kept: the app’s ID, label, key (
name), sign-on mode, status, whether it has provisioning features, created and last-updated times, and the assigned-user count (a number only, never a user). Okta apps have no owners in the API. Filters: inactive apps and Okta’s own apps (saasure,okta_enduser,okta_browser_plugin) skipped by default, bookmark apps optional, name exclusions. - Empty-listing guard. An empty application list while the connector has live observations fails the run with “Nothing was changed”. If every application really was deleted, an Admin runs the connector once with “accept empty listing” (see the
entrasection). - The host
okta.simulated.invalidselects the synthetic Okta (discovery/okta_simulator.py, tokens fromcore/okta.SimulatedTokens): nothing leaves the machine.
Discovery source: google_workspace
Status: built (Phase 11, enterprise capability discovery.google), against the synthetic Workspace only. No real Google Workspace has been contacted; live acceptance is the owner’s. Not Microsoft. discovery/google_workspace.py with the connector’s GoogleWorkspaceConnection (core/google_workspace.py). Checked on 2026-11-10 against:
- Using OAuth 2.0 for server to server applications:
POST https://oauth2.googleapis.com/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:jwt-bearerand an RS256 assertion (kidthe key ID; claimsissthe service account,subthe user to act as,scope,audthe token URL,expat most an hour afteriat); errorsunauthorized_client(client not authorized under domain-wide delegation, or authorized by email instead of the numeric client ID),access_denied(a scope not authorized),invalid_grant(unknownsub, deleted or wrong key, clock skew). - Reports API: activities.list:
GET https://admin.googleapis.com/admin/reports/v1/activity/users/all/applications/token,eventName,startTime,endTime,maxResults(default and maximum 1000),pageToken/nextPageToken; data goes back 180 days; scopehttps://www.googleapis.com/auth/admin.reports.audit.readonly. - OAuth Token Audit Activity Events: the
authorizeevent (“A user authorized access to an application for their data”) withapp_name,client_id,client_typeandscope. - Reports API limits and quotas: 2,400 queries per minute per user per project; activities.list also 250 filter queries per minute (
eventNamemakes it one); quota errors are 503, retried with exponential backoff (Google’s example starts at 5 seconds). - Reports API scopes and Control API access with domain-wide delegation (super admin privileges required; authorize the service account’s client ID with the scopes).
POST https://oauth2.googleapis.com/token grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, assertion=<JWT>
GET https://admin.googleapis.com/admin/reports/v1/activity/users/all/applications/token
?eventName=authorize&startTime={now - days}&endTime={now}&maxResults=1000[&pageToken=...]
| API | Grant | Why | Phase | Status |
|---|---|---|---|---|
| Admin SDK Reports API | https://www.googleapis.com/auth/admin.reports.audit.readonly through domain-wide delegation of the service account’s client ID |
Read the OAuth token audit log: which third-party apps people authorized, with which scopes. Read only | 11 | Built, synthetic only |
| Google Admin console | The administrator the service account acts as has the Reports privilege (a super administrator has it) | The Reports API answers only to administrators allowed to read audit reports. Confirm the exact privilege on the tenant during live acceptance (not verified on the pages above) | 11 | Built, synthetic only |
- Domain-wide delegation is a tenant-wide grant and an owner action. It lets the service account act as any user in the Workspace for the scopes listed, so only a super administrator can make it, and Softwarden never makes it. Authorize exactly one scope,
https://www.googleapis.com/auth/admin.reports.audit.readonly, for the service account’s numeric client ID (shown on the connection), under Security, Access and data control, API controls, Domain-wide delegation. It can take from minutes to a day to apply. Use a service account made for Softwarden only. - Why the token audit log. It is the least-privileged way to see third-party apps: one read-only scope and one paged list for the whole domain. The Directory API’s per-user
tokens.listwould needadmin.directory.user.security(which can also revoke tokens) and a call per user.admin.directory.user.readonlyisn’t needed. - The key. The service account key file (JSON) is pasted once; Softwarden keeps only its private key and key ID (Fernet, write-only, never returned, logged or exported) and shows its
client_emailandclient_id. The file’stoken_uriis ignored: tokens are only asked fromoauth2.googleapis.com. Calls go throughcore/outbound’s public rule; the token goes only toadmin.googleapis.com. - Privacy. Only app-level aggregates are stored: the app’s name, client ID and type, how many distinct people authorized it in the window, how many authorizations, when the last one was, the scopes granted and the high-risk ones. No event, email, profile ID or IP address is stored, logged or kept past the run: distinct people are counted in memory with HMAC-SHA256 under a key made for the run and discarded with it, and the window is re-read every run, so no sketch or per-person state is kept.
- Window and scope.
days(30 by default, 1 to 180),min_users(1 by default), name exclusions. An app nobody authorized in the window is out of scope (“no authorization in the reporting window”), never “no longer seen”: its grants may still be live. High-risk scopes (Softwarden’s list): full mail (https://mail.google.com/,gmail.*exceptgmail.labels), full Drive (drive,drive.readonly),admin.*andcloud-platform; a dismissed candidate comes back on a new one. - Errors. Token errors as above, with the advice naming the client ID and the scope. Reads: 403 = the administrator lacks the Reports privilege; 401 re-signs in once; 503 and 429 back off.
- No empty-listing guard.
authorizefires only when someone consents, so a window with no authorization at all is normal in a small tenant. Such a run succeeds with the note “No authorization in the last N days: nothing was changed” and every observation keeps its state (nothing moves out of scope or goes). - An administrator address in
google.simulated.invalidselects the synthetic Workspace (discovery/google_workspace_simulator.py, tokens fromcore/google_workspace.SimulatedTokens): nothing leaves the machine.
Email via Microsoft Graph
Status: built, not yet run against a real tenant. Learn check: 2026-11-05 (this repository). Sending uses Graph sendMail as the application, from one mailbox, with SMTP as the fallback; both are configured per organization in EmailSettings (Administration › Email settings, PATCH /api/v1/notification-settings/email; the Graph client secret and the SMTP password are Fernet-encrypted, write-only). An organization without settings, or with transport installation_default, uses the server’s SOFTWARDEN_SMTP_*. Code: core/graph.py (the client discovery also uses), notify/channels.py, notify/deliveries.py; tests use core/graph_simulator.py (tenant ID simulated).
POST https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token client credentials, scope https://graph.microsoft.com/.default
POST https://graph.microsoft.com/v1.0/users/{sender mailbox}/sendMail
{"message": {"subject", "body": {"contentType": "HTML", "content"},
"toRecipients": [{"emailAddress": {"address"}}],
"internetMessageHeaders": [{"name": "x-softwarden-notification-id", "value": <delivery id>}]},
"saveToSentItems": false}
headers: client-request-id: <a GUID per attempt, stored on the delivery>, return-client-request-id: true
| API | Permission | Type | How it’s granted | Phase | Status | Learn check |
|---|---|---|---|---|---|---|
| Microsoft Graph | Mail.Send |
Application, through Exchange RBAC for Applications (role Application Mail.Send) |
An Exchange management scope on the sender mailbox. Don’t consent Mail.Send in Entra API permissions. |
5 | Built; live check pending | 2026-11-05 (user: sendMail, RBAC for Applications) |
- Why not Entra: Entra and Exchange grants are a union, so an Entra
Mail.Sendconsent makes the app able to send as anyone in the tenant, whatever the Exchange scope says. Remove any EntraMail.Sendapplication permission and its consent. Application Access Policies are replaced by this feature. - Setup (an Exchange admin with Organization Management, or an Entra Exchange Administrator, in Exchange Online PowerShell):
Connect-ExchangeOnline
# IDs from Entra > Enterprise applications (not App registrations)
New-ServicePrincipal -AppId <app client id> -ObjectId <enterprise app object id> -DisplayName "softwarden-mail"
New-ManagementScope -Name "softwarden sender" -RecipientRestrictionFilter "PrimarySmtpAddress -eq 'softwarden@contoso.com'"
New-ManagementRoleAssignment -App <enterprise app object id> -Role "Application Mail.Send" -CustomResourceScope "softwarden sender"
Test-ServicePrincipalAuthorization -Identity <enterprise app object id> -Resource softwarden@contoso.com # InScope = True
Test-ServicePrincipalAuthorization -Identity <enterprise app object id> -Resource someone-else@contoso.com # InScope = False
- Exchange caches app permissions for 30 minutes to 2 hours, so a fresh grant can fail a test email for a while. A license-free shared mailbox can be the sender (≤ 50 GB; block sign-in); to verify in a test tenant.
- What Softwarden checks. Test connection only gets a token: Microsoft issues one even when no permission is granted, so it proves the tenant, client ID and secret, not the mailbox scope. Send test email sends a real message to the signed-in Admin’s own address and is what proves the scope (a
403 ErrorAccessDeniednames the RBAC role and scope to check). - Answers. Success is
202 Acceptedwith an empty body: accepted, not delivered. Errors are{"error": {"code", "message", "innerError" | "innererror"}}, read case-insensitively and matched onerror.code.429and503carryRetry-After. - Limits and retries. 10,000 requests per 10 minutes and 4 concurrent requests per app and mailbox: Softwarden sends at most 4 at a time per sender mailbox across all workers (PostgreSQL advisory locks) and honors
Retry-After(capped at an hour), else backs off 1, 4, 16 minutes, 4 attempts in all. There is no idempotency header. Softwarden dedupes itself: each delivery has a unique key written before the call, and it never retries after an ambiguous failure (a timeout or a dropped connection after the request was written, a 5xx other than 503): the delivery is marked failed with the reason and an Admin can check and resend. Only429and503(“not accepted”) are retried. - To verify live on the owner-approved dev tenant: sendMail from a license-free shared mailbox; the
Application Mail.Sendrole id; thePrimarySmtpAddressscope filter.
SMTP fallback
For generic SMTP servers (a relay, a hosted mail service). An organization’s host may be a private relay, but loopback, link-local (cloud metadata) and unspecified addresses are refused, also after DNS resolution and inside IPv6 forms; the server’s own SOFTWARDEN_SMTP_HOST is not restricted. Host, port (default 587 for STARTTLS, 465 for implicit TLS, 25 for none), security, optional user name and password, from address. No Microsoft permission. SMTP AUTH with a password against Exchange Online is on a retirement path (off by default for existing tenants from the end of December 2026; final removal in the second half of 2027: Exchange team blog); for Microsoft 365 use Graph. Development uses Mailpit (compose.dev.yaml, the dev stack’s installation default).
Teams notifications
Status: built, not yet run against a real workflow. Learn check: 2026-11-05 (incoming webhooks, connectors). No Graph permission and no Entra registration.
- Office 365 Connectors were retired in May 2026: Softwarden refuses their addresses (
*.webhook.office.com,outlook.office.com). Admin setup: in the channel,...› Workflows › “Send webhook alerts to a channel” › Save › copy the link, and add a co-owner to the workflow (a workflow belongs to the person who made it). Paste the link into Administration › Teams destinations (POST /api/v1/notification-settings/teams-destinations). - The link is a bearer secret: stored Fernet-encrypted in
TeamsDestination(write-only; only its host is shown), never logged, audited or put in a job argument. - Payload (
notify/render.py):
{"type": "message", "attachments": [{"contentType": "application/vnd.microsoft.card.adaptive", "contentUrl": null,
"content": {"$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.2",
"body": [...], "actions": [{"type": "Action.OpenUrl", ...}]}}]}
- Cards: any element except
Action.Submit; Markdown, not HTML; the request body stays under 28 KB (long lists end with “and N more”). More than 4 requests per second is throttled: Softwarden spaces posts to one destination and honors429withRetry-After. - Posts go to the address checked at resolution (no second lookup), under a 30 s wall-clock deadline; IPv6 forms that embed or translate IPv4 (NAT64, IPv4-compatible, SIIT, 6to4, Teredo) are refused.
- Not documented, so assumed until checked against a real workflow: the URL host pattern (any
httpshost is accepted; private, loopback and link-local addresses are refused, and checked again at send time after DNS resolution) and the success status (any 2xx counts as accepted).
Generic webhooks (Phase 5 minimal; Phase 7 formalizes)
Not Microsoft; listed so every outbound destination is in one place. WebhookDestination: an https URL (encrypted, write-only; the same SSRF checks as Teams) and a signing secret (encrypted, write-only, 16 characters or more). Each POST carries X-Softwarden-Delivery (the delivery id), X-Softwarden-Event and X-Softwarden-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>." + body under the signing secret>; a receiver should compare in constant time and refuse old timestamps. Body: {"id", "event", "created_at", "sent_at", "data"}.
SCIM and SAML (enterprise)
- SCIM: Entra’s provisioning service calls Softwarden’s SCIM endpoint (
ee/scim) with a bearer token Softwarden issues. No Graph permission is requested by Softwarden. Roles map from groups only when the organization opts in. Details: “Provisioning: SCIM (enterprise), what Entra calls on us” below. - SAML: an Entra enterprise application configured for SAML sign-in (
ee/saml, python3-saml). No Graph permission.
Provisioning: SCIM (enterprise), what Entra calls on us
Status: built (Phase 8, ee/scim, capability scim), tested by a pytest replay of the Microsoft SCIM Validator’s sequences and Learn’s documented Entra requests (tests/test_scim.py); not yet run against a real Entra tenant or the hosted validator. Here the direction is reversed: Softwarden requests no permission from Microsoft and calls no Microsoft endpoint. Entra’s provisioning service (an enterprise application with Provisioning Mode Automatic, “non-gallery” or custom app) calls Softwarden.
Sources (Microsoft Learn: the 2026-11-07 research digest, then the researcher’s Learn check of these assumptions the same day, DECISIONS_NEEDED Phase 8 SCIM (g)): Tutorial: develop and plan provisioning for a SCIM endpoint (entra/identity/app-provisioning/use-scim-to-provision-users-and-groups), Known issues and resolutions with SCIM 2.0 protocol compliance (application-provisioning-config-problem-scim-compatibility, the aadOptscim062020 flag) and Tutorial: validate a SCIM endpoint (scim-validator-tutorial).
What the Entra admin enters (Admin > Provisioning in Softwarden gives both, from POST /api/v1/scim/tokens):
- Tenant URL:
{SOFTWARDEN_BASE_URL}/scim/v2/<organization slug>. - Secret Token: the
scim_...value shown once when the token is created. Long-lived (up to two years), hashed at rest, revocable; create a second token and revoke the first to rotate.
Auth. Authorization: Bearer scim_... on every request, the discovery documents included. A missing, unknown, revoked or expired token, or a token of another organization than the path’s slug, all get the same 401 (WWW-Authenticate: Bearer). The token is compared in constant time, throttled per token (SOFTWARDEN_SCIM_RATE, default 600 a minute; 429 with Retry-After), and can’t call /api/v1. No cookies or CSRF token are involved. A community organization gets 403.
What Entra calls (all application/scim+json; errors are SCIM errors with status and scimType):
| Request | Used by Entra for | Softwarden’s answer |
|---|---|---|
GET /Users?filter=userName eq "<random GUID>" |
Test Connection | 200 with an empty ListResponse (totalResults: 0, Resources: [], startIndex: 1) |
GET /Users?filter=userName eq "..." or externalId eq "..." (also emails[type eq "work"].value, displayName, id, joined by and) |
matching before create | 200 ListResponse; startIndex / count paging (at most 100) |
POST /Users |
create | 201 with the user, Location, ETag: W/"<version>"; 409 uniqueness for a userName already used here (case-insensitive) |
GET /Users/{id} |
read back | 200, inactive users included (soft delete); 404 |
PATCH /Users/{id} |
update, disable (active false, as a boolean or "False", with a path or in a pathless value object) |
200 with the user in the body, as Learn’s examples answer; both of Entra’s shapes (any op case, pathless value objects with dotted keys, emails[type eq "work"].value); attributes Softwarden doesn’t keep (title, phone numbers, addresses, the enterprise extension and any other urn: extension) are accepted and ignored; the last active Admin can’t be disabled (409 mutability) |
PUT /Users/{id} |
replace (some apps) | 200 |
DELETE /Users/{id} |
hard delete (when the app is set to delete) | 204; the account is deactivated or removed from the organization, never deleted |
POST /Groups, GET /Groups?excludedAttributes=members&filter=displayName eq "...", GET /Groups/{id}?excludedAttributes=members |
groups | 201 / 200; 409 uniqueness for a displayName already used here |
PATCH /Groups/{id} |
rename (replace displayName), add members (add members [{value}]), remove members (remove members[value eq "<id>"], or the older remove members [{value}]) |
204 (confirmed by Learn: “Update to the group PATCH request should yield an HTTP 204 No Content in the response”) |
DELETE /Groups/{id} |
delete | 204 |
GET /ServiceProviderConfig, /Schemas, /ResourceTypes |
schema discovery (the validator) | patch and filter supported (maxResults 100), bulk, sort and change-password not, etag supported, oauthbearertoken |
Request bodies over SOFTWARDEN_SCIM_MAX_BODY (1 MiB) get 413. What the provisioned users and groups do to accounts, memberships and roles: API.md “SCIM provisioning”.
Confirmed by Learn: responses are application/scim+json; a group PATCH answers 204 No Content. Learn is silent on, so assumed: the Content-Type Entra sends (any JSON body is accepted); whether Entra sends If-Match (supported, harmless); whether the discovery documents are called with the bearer token (Softwarden requires it); Entra’s request rate (hence SOFTWARDEN_SCIM_RATE); and the default attribute set of a custom app’s mapping (anything unknown is ignored rather than refused, so a mapping Softwarden doesn’t keep can’t break a sync, but Entra may re-send it each cycle; remove the mappings you don’t need). aadOptscim062020 is still not the default, so both PATCH shapes stay supported.
Connection test (Phase 6, built)
POST /api/v1/connections/microsoft/{id}/test and POST /api/v1/connections/halo/{id}/test (Admin) ask for a token with the stored values and nothing else. The test needs no permission beyond the token, and proves none: Microsoft issues a client-credentials token even when no API permission has admin consent, so a passing test proves the tenant, the application (client) ID and the client secret only. Each source’s access check (below) proves its permissions.
POST https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
grant_type=client_credentials, client_id, client_secret, scope=https://graph.microsoft.com/.default
POST {halo base_url}/auth/token
grant_type=client_credentials, client_id, client_secret, scope (default "read:assets")
The token endpoint and the client-credentials request were checked on Microsoft Learn on 2026-11-05 (core/graph.py, Email via Microsoft Graph). Global Azure only: national clouds and certificate credentials are not offered (IT1 has neither).
A refused Microsoft token request is classified by the AADSTS code at the start of error_description (core/connections.MICROSOFT_CODES, after IT1 discovery/access.py). The stored result is Softwarden’s own wording plus the code; Microsoft’s message is never kept, shown or logged.
| Code | Problem | Meaning | Learn check |
|---|---|---|---|
| AADSTS7000215 | wrong credentials | Invalid client secret (often the Secret ID pasted instead of its Value) | IT1 mapping; not re-verified (research 2026-11-06 §2) |
| AADSTS7000222 | wrong credentials | The client secret has expired | IT1 mapping; not re-verified |
| AADSTS700016 | wrong credentials | No application with this client ID in the tenant | IT1 mapping; not re-verified |
| AADSTS90002 | wrong credentials | Tenant not found | IT1 mapping; not re-verified |
| AADSTS900023 | wrong credentials | The tenant identifier isn’t valid | IT1 mapping; not re-verified |
| AADSTS7000112 | disabled | The application is disabled in the tenant | IT1 mapping; not re-verified |
| AADSTS65001 | missing permission | Consent missing: no administrator has consented to the app | Not verified (added in this package; check on reference-error-codes) |
| AADSTS7000229 | missing permission | Consent missing: the app has no service principal in the tenant | Not verified (added in this package; check on reference-error-codes) |
other invalid_client / unauthorized_client |
wrong credentials | Generic refusal of the app’s credentials | n/a |
| 429, 5xx | couldn’t connect | The sign-in service is busy; try later | n/a |
The error-code list is at https://learn.microsoft.com/en-us/entra/identity-platform/reference-error-codes; confirming the rows above there (and live on the approved dev tenant) is still open. Halo errors are classified by HTTP status and the OAuth error (invalid_client, invalid_scope).
A connection whose sign-in values or secret change gets a new revision; no connector runs on it until it passes the test again (tested_revision == revision, enforced in discovery/engine.run).
Access checks and guided setup
Access checks use the permissions above and nothing more. Built for entra and entra_licenses (Phase 6 Sources A, discovery/access.py), okta and google_workspace (Phase 11, discovery/rest_source.py) and azure, powerplatform and halo (Phase 6 Sources B, one sign-in step per audience, then the reads below; at most 10 subscriptions or environments are probed, the rest are not_checked and a run reports each): POST /api/v1/connectors/{id}/access-check (Admin, audited as connector.access_checked with the permissions missing) signs in with the connector’s connection (no successful connection test needed), then makes one one-item read per permission, outside any transaction, and stores the result on the connector (access_check). A sign-in failure is classified by AADSTS code as in the connection test and skips the reads. A refused read (403, Graph’s Authorization_RequestDenied, “Insufficient privileges to complete the operation.”) is “missing permission” naming the permission; 401 is an authentication problem (after one fresh sign-in). An optional permission not granted is optional_missing; any other failure of an optional read (404, 429, 5xx) is warning; neither fails the check. The agent-identity read is not_used (not called) while the connector’s scope leaves include_agent_identities off. A sign-in failure carries the connection test’s classification (AADSTS code). The source’s capability is checked first (403). The Connection step names the connection only (the result is readable by every role). Throttling waits at most a minute, and a check takes three minutes at most.
| Source | Calls (permission each proves) |
|---|---|
entra |
GET /v1.0/applications?$top=1&$select=id and GET /v1.0/servicePrincipals?$top=1&$select=id (Application.Read.All); GET /v1.0/users?$top=1&$select=id (User.ReadBasic.All, optional); GET /v1.0/servicePrincipals/microsoft.graph.agentIdentity?$top=1&$select=id (AgentIdentity.Read.All, optional) |
entra_licenses |
GET /v1.0/subscribedSkus?$select=skuId (LicenseAssignment.Read.All); the exact error.code for a refusal here isn’t on Learn (Authorization_RequestDenied or Forbidden expected); any 401/403 counts |
entra_shadow_it |
GET /v1.0/organization?$select=id, GET /v1.0/servicePrincipals?$top=1&$select=id and GET /v1.0/oauth2PermissionGrants (Directory.Read.All); GET /v1.0/auditLogs/signIns?$filter=createdDateTime ge {an hour ago} and createdDateTime le {now}&$top=1 (AuditLog.Read.All; not_used while the scope’s sign_ins is off). A tenant without P1 or P2 (403 Authentication_RequestFromNonPremiumTenantOrB2CTenant) is a failed step with the problem missing_licence (“Missing license”), not a missing permission |
entra_coverage |
GET /v1.0/servicePrincipals?$top=1&$select=id (Application.Read.All); GET /v1.0/servicePrincipals/{the first one}/synchronization/jobs (Synchronization.Read.All; a 404 passes, that app just has none; not_used while provisioning is off); GET /v1.0/identity/conditionalAccess/policies (Policy.Read.All; not_used while conditional_access is off). A tenant without P1 is a warning with the problem missing_licence, not a failure: runs still report SSO and provisioning |
azure |
Token for ARM; GET /subscriptions/{id}?api-version=2022-12-01 per chosen subscription, else GET /subscriptions?api-version=2022-12-01 (none listed: “no role on any”); then per subscription the reads the scope uses: Logic workflows ($top=1), web apps, Automation accounts, and the first account’s runbooks (Reader). A 403 AuthorizationFailed names the action and scope ARM gave and the fix (Reader on that subscription). A subscription that isn’t Enabled, Warned or PastDue is a warning |
powerplatform |
With All environments: token for the admin API, then its environments list (the registration, required then). Per environment: token for that environment, GET {url}/api/data/v9.2/bots?$top=1&$select=botid (prvReadbot), and with cloud flows GET {url}/api/data/v9.2/workflows?$top=1&$select=workflowid&$filter=category eq 5 (prvReadWorkflow, optional; not_used while flows are off). 401, 403 and the privilege errors are “needs an application user with read on …”, naming the privilege when Dataverse does. RetrieveCurrentOrganization isn’t called |
halo |
Halo token, then GET /api/Asset?paginate=true&page_size=1&page_no=1 (read:assets) |
okta |
Okta token (a refused scope names okta.apps.read), then GET /api/v1/apps?limit=1&includeNonDeleted=true and GET /api/v1/apps/{the first one}/users?limit=1 (Read-only Administrator; not_used while assignment_counts is off) |
google_workspace |
Google token acting as the administrator (unauthorized_client names the Reports scope), then GET .../applications/token?eventName=authorize&startTime={a day ago}&endTime={now}&maxResults=1 (Reports admin privilege) |
The guided setup script (IT1 walkthrough.py) is displayed, never run by Softwarden: an administrator runs it in Azure Cloud Shell. It registers a single-tenant app, adds only the Graph application permissions of the chosen sources, grants admin consent, assigns Reader on listed subscriptions (Azure only) and creates a one-year secret. Power Platform application users are added by hand.
AI assistant: Anthropic (enterprise, off by default)
Status: built (Phase 6, capability assistant), tested against a mocked SDK only. Not a Microsoft permission, but data leaving Softwarden for Anthropic. An Admin turns it on with an Anthropic API key (Fernet-encrypted, write-only; without one, the installation’s ANTHROPIC_API_KEY, offered only when SOFTWARDEN_ASSISTANT_INSTALLATION_CAP_USD caps each organization’s spend on it). Calls go through the official anthropic Python SDK to the Messages API at api.anthropic.com: POST /v1/messages/count_tokens (to reserve the worst-case cost against the monthly cap before each call) and POST /v1/messages (structured JSON output, effort low, model SOFTWARDEN_ASSISTANT_MODEL, default claude-sonnet-5-5). The base URL is fixed (https://api.anthropic.com), so ANTHROPIC_BASE_URL and SDK profiles are ignored. The key travels in the SDK’s x-api-key header only. The SDK’s and its HTTP clients’ loggers (anthropic, httpx2, httpcore2, httpx, httpcore) are pinned to WARNING, because at DEBUG they log the whole request.
| Sent per candidate (at most 5 per call, 50 per batch) | Never sent |
|---|---|
| Its source name (200 characters), observation kind (application, component, ai_agent) and source type (entra, azure, powerplatform, halo) | Any ID: observation, record, connector, tenant, client, application, object, resource or external IDs (offered records are named by a short handle such as c1-r2) |
Description (300 characters), except an Entra description taken from a service principal’s notes; publisher (300 characters), except a *.onmicrosoft.com tenant domain; homepage as scheme, host and path only (no query or fragment; http and https only); agent type, component type and enabled flag; each only when the source reported it |
Entra service principal notes, homepage query strings and fragments, *.onmicrosoft.com publisher domains, owners (names, emails, object IDs), credentials (DiscoveredCredential: key IDs, names, hints, expiry), sign-in audience, tags, connection settings and secrets |
Up to 3 existing records discovery/engine.suggestions offers for it: name, kind, name similarity |
Contracts, costs, people, attachments, notes, custom fields, the audit log, anything of another organization |
The key in use and the installation’s configured secrets are redacted from the text sent, as a second line of defense. Stored: per call the model, the four token counts, the cost, the outcome and the candidate count (AssistantUsage); per candidate the suggestion. Never the prompt, the response text, an API error message or the key. The system prompt is the same for every call and comes first, the candidate data last. It is shorter than the model’s cacheable minimum (1,024 tokens), so it carries no cache_control marker and nothing is cached today; the marker is added automatically if the prompt grows past it.
Phase 11 roadmap: built and candidates
Listed so the roadmap’s permission cost is visible early. Each needs research on Microsoft Learn before it is designed. Items 1, 3 and 10 are built. Their endpoints, permissions, license, retention and throttling are in “Discovery source: entra_shadow_it”, “Discovery source: entra_coverage” and “Discovery source: entra_licence_usage” above.
| Feature (§3 Phase 11) | Endpoint | Permission | Type | License | Retention | Throttling |
|---|---|---|---|---|---|---|
| 1. Shadow IT from sign-in logs (built) | GET /v1.0/auditLogs/signIns |
AuditLog.Read.All |
Application | Entra ID P1 or P2 | 30 days (P1/P2) | 5 requests per 10 s per app per tenant; $top ≤ 1000; always a createdDateTime window |
| 1. Shadow IT from OAuth consent grants (built) | GET /v1.0/oauth2PermissionGrants |
Directory.Read.All |
Application | None | Current state | Graph defaults; eq filters only |
| 3. SSO coverage (built) | GET /v1.0/servicePrincipals (preferredSingleSignOnMode) |
Application.Read.All |
Application | None | Current state | Graph defaults |
| 3. SCIM coverage (built) | GET /v1.0/servicePrincipals/{id}/synchronization/jobs |
Synchronization.Read.All |
Application | None | Current state | One call per service principal: at most 100 a run |
| 3. MFA coverage (built) | GET /v1.0/identity/conditionalAccess/policies |
Policy.Read.All |
Application | Entra ID P1 | Current state | 1 request per second per tenant, 429 without Retry-After: once a run, own backoff |
| 10. License waste (built) | GET /v1.0/users (assignedLicenses, signInActivity) |
User.Read.All; AuditLog.Read.All (optional) |
Application | Entra ID P1 for signInActivity |
Current state (last successful sign-in, not backfilled before December 2023) | 500 users a page and 10 requests a minute with signInActivity: pages 6.5 s apart |
| Feature (§3 Phase 11) | Likely API | Permission to research | Type |
|---|---|---|---|
| 2. Offboarding and access review | Built without new Graph calls (2026-11-09): it reads the owners and observations discovery already stores. Per-user licenses would need users.assignedLicenses (users/{id}/licenseDetails and ownedObjects are not supported with application permissions). Checked on Learn 2026-11-09: user: list licenseDetails has no application permission; list users and get user with $select=assignedLicenses need at least User.Read.All, and LicenseAssignment.Read.All (which entra_licenses holds) isn’t listed for either |
User.Read.All: requested since item 10, by entra_licence_usage only. The footprint lists a Person’s licenses once that source has run, and says “Licenses: not read; needs User.Read.All” until then |
Application |
| 8. Okta and Google Workspace discovery (built) | Okta Management API, Google Reports API: see “Discovery source: okta” and “Discovery source: google_workspace” |
Not Microsoft | n/a |
Change history
- 2026-11-03, Phase 0: created from IT1’s PERMISSIONS.md and IT2’s code. No Microsoft Learn checks performed in this repository; IT1 citations carried over with their dates.
- 2026-11-05, Phase 5 channels: Email via Graph, SMTP fallback, Teams Workflows webhooks and generic webhooks built against Microsoft Learn (checked 2026-11-05); the Graph client is shared with discovery (
core/graph.py). Live checks pending on the owner-approved dev tenant. - 2026-11-06, Phase 6 Connections: MicrosoftConnection and HaloConnection with the connection test (token only; no permission beyond the token). AADSTS65001 and AADSTS7000229 added to IT1’s classification, not yet verified on Learn. Halo base URLs go through
core/outbound’s service rule. - 2026-11-06, Phase 6 Sources B:
azure(per-type ARM lists, Reader),powerplatform(Dataverse bots and workflows by environment URL; BAP for All environments, opt-in) andhalo(assets) built against the 2026-11-06 research, with access checks. Per-audience tokens with host allow-lists. Not verified on Learn and to confirm: the Web Apps list api-version (2024-04-01), the Automation accounts list api-version (2024-10-23), theworkflowappkind of Standard Logic Apps and Flex Consumption kinds, the Dataverse privileges and their error wording, workflowstatecodevalues, AADSTS500011, and the Power Platform API’s service-principal roles (BAP used instead). - 2026-11-06, Phase 6 Sources A:
entra(enterprise apps and app registrations merged by application ID, owners, credentials, agent detection) andentra_licensesbuilt against the 2026-11-06 Learn research; access checks for both; optionalUser.ReadBasic.All(not yet verified) andAgentIdentity.Read.All. Role IDs other thanApplication.Read.Allstay out of this file and the app until confirmed on Learn (DECISIONS 2026-11-06 Phase 6 (h)). 429 / Retry-After handling with a per-run budget. - 2026-11-09, Phase 11 ticket links: Jira Cloud, ServiceNow and HaloITSM ticket connections (not Microsoft; no Microsoft permission). Jira endpoints checked on developer.atlassian.com; ServiceNow and Halo to confirm during live acceptance (see “Ticket connections”). Simulators only.
- 2026-11-06, Phase 6 Assistant: the Anthropic Messages API (not Microsoft) built behind the enterprise
assistantcapability, off by default; what is sent and never sent is listed above. Tested only against a mocked SDK; no real API call was made. - 2026-11-06, Phase 6 SPA-A: the guided walkthrough links to this file, shown in the app at
/discovery/permissions(section anchors such as#discovery-source-entra). The Halo connection’s default scope is nowread:assets, all the source reads (it wasread:assets read:tickets). A connector’s last access check is cleared when its connection or scope changes. - 2026-11-07, Phase 8 SSO settings and generic OIDC: sign-in per organization from each provider’s discovery document (Entra, Okta, Google Workspace presets and generic OIDC), draft / test / activate, identities pinned on (issuer, subject). Entra discovery URL, issuer and the
tid+oidpin recorded from the researcher’s 2026-11-07 Learn check; scopes still not verified. Tested only against the in-process simulators. - 2026-11-07, Phase 8 SCIM:
ee/scimbuilt. No permission requested from Microsoft; “Provisioning: SCIM (enterprise), what Entra calls on us” lists the requests Entra’s provisioning service makes and Softwarden’s answers, from the research digest of Learn’s SCIM pages. Not yet run against a tenant or the hosted validator (owner step, docs/04-development/scim-runbook.md). - 2026-11-07, Phase 9 install docs: app role IDs added for
User.ReadBasic.All(97235f07-e226-4f63-ace3-39588e11d3a1),AgentIdentity.Read.All(b2b8f011-2898-4234-9092-5059f6c1ebfa) andLicenseAssignment.Read.All(e2f98668-2877-4f38-a2f4-8202e0717aa1), as read from the development tenant’s Graph service principal in Phase 6. docs/FIRST_RUN.md walks through theentraregistration, permission and admin consent (Privileged Role Administrator, per Learn’s grant-admin-consent prerequisites). No new permission. - 2026-11-09, Phase 11 shadow IT: the
entra_shadow_itsource built against the Phase 11 research (Learn citations):GET /auditLogs/signIns(AuditLog.Read.All, Entra ID P1 or P2, 30-day retention, 5 requests per 10 s,$topat most 1000, always acreatedDateTimewindow),GET /oauth2PermissionGrants(Directory.Read.All, least privileged), and, underDirectory.Read.All,GET /servicePrincipalsandGET /organization(coverage of those two byDirectory.Read.Allto confirm on Learn). The license refusal (Authentication_RequestFromNonPremiumTenantOrB2CTenant) is reported as a missing license. The “Candidates for Phase 11” table now lists item 1 with its real endpoints. Simulated tenant only. - 2026-11-09, Phase 11 offboarding and access review: no new permission and no new Graph call. The footprint and the report read the Entra owners the
entrasource already stores (Application.Read.All, optional User.ReadBasic.All for names and emails). Per-user license assignment is not read: on Learn (researcher, 2026-11-09)users/{id}/licenseDetailsandownedObjectsare not supported with application permissions, andusers.assignedLicensesneeds User.Read.All, which stays unrequested; the footprint shows “Licenses: not read; needs User.Read.All”. - 2026-11-09, Phase 11 SSO and MFA coverage: the
entra_coveragesource built against the Phase 11 research (Learn citations):preferredSingleSignOnModeonGET /servicePrincipals(Application.Read.All; null is unknown),GET /servicePrincipals/{id}/synchronization/jobs(Synchronization.Read.All, neverApplication.ReadWrite.OwnedBy; capped per run),GET /identity/conditionalAccess/policies(Policy.Read.All, Entra ID P1, 1 request/s without Retry-After). The P1 refusal code for conditional access is assumed (not on Learn); any 403 degrades the same way. Simulated tenant only. - 2026-11-09, Phase 11 role IDs: app role IDs confirmed on Learn’s permissions reference and filled in (
discovery/graph_source.py):Policy.Read.All246dd0d5-5bd0-4def-940b-0421030a5b68,Synchronization.Read.All5ba43d2f-fa88-4db2-bd1c-a67c5f0fb1ce,AuditLog.Read.Allb0afded3-3588-46d8-8b3d-9842eff778da,LicenseAssignment.Read.Alle2f98668-2877-4f38-a2f4-8202e0717aa1 (now on Learn).Directory.Read.Allstill unconfirmed, left empty. - 2026-11-10, Phase 11 Okta and Google Workspace sources (API):
okta(Okta Management API through an OAuth service app with a private-key client assertion,okta.apps.read, Read-only Administrator; X-Rate-Limit headers honored) andgoogle_workspace(Reports API token audit log through a service account with domain-wide delegation,admin.reports.audit.readonly; app-level aggregates only) built against the vendors’ documentation checked on 2026-11-10 (links in their sections). Review cycle 1: Okta paces below 10 % of the org’s rate limit, assigned-user counting stops 5 minutes before the run’s deadline and keeps the listing, and an empty Google window is a note rather than a failed run. Tested only against the in-process simulators; the Okta admin role and the Google Reports privilege are to confirm during live acceptance. - 2026-11-10, Phase 11 license waste: the
entra_licence_usagesource, built against the Phase 11 research (Learn citations).- It reads
GET /userswith a$selectthat includesassignedLicenses. This needsUser.Read.All, which is new (app role ID df021288-bdef-4463-88db-98f22de89214). - Optionally it also selects
signInActivity. That needsAuditLog.Read.All(b0afded3-3588-46d8-8b3d-9842eff778da) and Entra ID P1. Pages hold at most 500 users, and at most 10 are read a minute. - Both are tenant-wide admin consents. The owner grants them before live use.
- Without P1 or the AuditLog consent, the run reads users without
signInActivityand reports the last sign-in as unknown. - For each licensed user it stores only a keyed hash of the object ID, the SKU IDs, flags and dates.
- Tested on the simulated tenant only.
- It reads