Permission Matrix
Backup App (Read-Only)#
| Permission | Description |
|---|---|
User.Read.All |
Read all users |
Group.Read.All |
Read all groups |
Application.Read.All |
Read all app registrations |
Policy.Read.All |
Read all policies (CA, auth methods, authorization) |
IdentityUserFlow.Read.All |
Read user flow configurations |
IdentityProvider.Read.All |
Read identity providers |
EventListener.Read.All |
Read authentication events flows (user flows) |
CustomAuthenticationExtension.Read.All |
Read custom auth extensions |
Organization.Read.All |
Read organization/tenant settings |
RoleManagement.Read.Directory |
Read directory role assignments |
Directory.Read.All |
Read directory data (also covers group settings, administrative units, devices) |
APIConnectors.Read.All |
Read API connectors (credentials are never exportable) |
Policy.Read.PermissionGrant |
Read permission grant policies (Graph special-cases these out of Policy.Read.All) |
Restore App (Read-Write)#
| Permission | Description |
|---|---|
User.ReadWrite.All |
Create/update users |
Group.ReadWrite.All |
Create/update groups and memberships |
Application.ReadWrite.All |
Create/update app registrations |
Policy.Read.All |
Read named locations & policies (Graph requires this for GET /namedLocations — Policy.ReadWrite.ConditionalAccess only covers writes) |
Policy.ReadWrite.ConditionalAccess |
Create/update CA policies and named locations |
Policy.ReadWrite.AuthenticationMethod |
Update authentication methods policy; create custom authentication strengths |
Policy.ReadWrite.Authorization |
Update authorization policy |
Policy.ReadWrite.CrossTenantAccess |
Update cross-tenant access policy (default + partners) |
Policy.ReadWrite.ApplicationConfiguration |
Create token lifetime / claims mapping / token issuance / HRD / app-management / activity-based-timeout policies |
Policy.ReadWrite.PermissionGrant |
Create permission grant policies |
Policy.ReadWrite.ConsentRequest |
Update the admin consent request policy |
IdentityUserFlow.ReadWrite.All |
Create/update user flows |
IdentityProvider.ReadWrite.All |
Create/update identity providers |
EventListener.ReadWrite.All |
Create/update authentication events flows |
CustomAuthenticationExtension.ReadWrite.All |
Create/update custom auth extensions |
Organization.ReadWrite.All |
Update organization settings |
RoleManagement.ReadWrite.Directory |
Create/update role assignments |
APIConnectors.ReadWrite.All |
Recreate API connectors (placeholder credentials — update after restore) |
AdministrativeUnit.ReadWrite.All |
Create administrative units, membership, and scoped role assignments |
Directory.ReadWrite.All |
Create group settings; create feature rollout policies |
Who needs to be an administrator, and where#
Setup needs an administrator in two places. It can be one account (e.g. an external member/guest of the External ID tenant) or separate accounts per side — the browser asks you to pick an account at each sign-in.
| Where | Minimum access | Used for |
|---|---|---|
| Workforce/billing tenant (hosts the subscription) | Rights to deploy the deployment, plus an Entra admin who can create app registrations and assign app roles — Cloud Application Administrator or higher | Deploying from the marketplace, then the one-time browser Setup Wizard that creates the dashboard's own sign-in app |
| Each External ID tenant | Cloud Application Administrator minimum; in practice Privileged Role Administrator or Global Administrator, see below | Tenant onboarding: creating the backup/restore app registrations and consenting to their application permissions |
Notes:
- Why more than Cloud Application Administrator is usually needed. The
restore app requests
RoleManagement.ReadWrite.Directory. Consenting to that particular application permission requires Privileged Role Administrator or Global Administrator; Cloud Application Administrator covers everything else in both sets. - Consent is granted during onboarding, as app-role assignments made with the signed-in admin's own delegated token. There is no separate "Grant admin consent" click in the Azure portal afterwards.
- In a multi-tenant setup the Graph role is needed in every External ID tenant you onboard; the Azure side is needed once (all Azure resources live in the workforce tenant's subscription).
- The Azure resources do not live in the External ID tenant — External ID tenants typically have no subscription.
Notes#
- All permissions are Application type (not Delegated) and require admin consent
- The backup app is intentionally limited to
.Read.Allscopes so it can never modify the tenant - The restore app is used only by restore paths — never by a backup run
- Onboarding uses the signed-in administrator's Delegated permissions
temporarily, in the browser, and stores nothing:
Application.ReadWrite.All— to create the app registrationsAppRoleAssignment.ReadWrite.All— to grant admin consent
- The permission names themselves are served by
web/api/TenantPermissions/run.ps1, which is the single definition of both sets. Keep it and this page in sync.
Tenant Feature Requirements#
authenticationEventsFlows(user flows) requires theEnableMsGraphAuthenticationEventListenerfeature flag- Available in global cloud only (not US Government or China operated by 21Vianet)
Azure RBAC — runtime versus deploy-time#
Microsoft Graph permissions are covered above; this section covers the Azure control plane, where the split that matters is runtime against deploy-time.
Every grant below is defined in marketplace/bicep/modules/roles.bicep and
assigned inside the deployment's own resource group. The Key Vault side
is covered in web-ui.md,
and the full identity picture in the architecture
diagram.
| Identity | Automation privilege | Why |
|---|---|---|
| Function App (runtime) | Custom role: job start/read/stop, schedule and jobSchedule management, runbook read, hybrid-worker-group and hybrid-worker read | Starts jobs, manages the RPO schedule, and probes worker liveness. Cannot alter what the runbooks contain |
| Automation account (runtime) | Contributor-class roles at resource-group scope, for the private-networking feature only | Deploys private endpoints, DNS, NAT and the worker VM |
| Deployment script (deploy-time) | Automation Contributor | Publishes runbook content and prunes stale schedules during deployment and upgrade |
The two hybrid-worker read actions#
The marketplace custom role (marketplace/bicep/modules/roles.bicep) carries
both of these, and it needs both:
| Action | Why |
|---|---|
Microsoft.Automation/automationAccounts/hybridRunbookWorkerGroups/read |
Read the worker group — is this deployment routing jobs to a hybrid worker at all? |
Microsoft.Automation/automationAccounts/hybridRunbookWorkerGroups/hybridRunbookWorkers/read |
Read the worker child resource, whose lastSeenDateTime heartbeat is the only evidence the worker is powered on |
The child action is not implied by the parent. RBAC action strings do not
prefix-match — a grant of …/hybridRunbookWorkerGroups/read authorizes that
operation and nothing beneath it. The heartbeat lives only on the worker
resource (the group object carries just groupType/credential), so without
the second action the offline-worker probe 403s and the guard is inert: exports
would be accepted and then queue forever against a powered-off worker. Both are
read-only.
Runbook content is written only by the deployment-script identity, and only
during a deployment: the Function App's
custom role carries runbooks/read and no write.
Changed with the solution template. A deny assignment used to provide a second, independent bound here — its
lockingPolicy.allowedActionsomittedautomationAccounts/runbooks/write, so runbook modification was refused even if an RBAC grant were reintroduced by mistake. There is no deny assignment in a customer-owned resource group, so the custom role is now the only bound. It is a real reduction in defence in depth, stated plainly rather than left implied: the grant itself is unchanged and still withholds runbook write, but nothing behind it would catch a mistake.
That custom role also deliberately
excludes automationAccounts/variables/*: Automation variables can hold
secrets, and runbooks receive their parameters at job start rather than reading
them back.
Why withholding runbook write matters. Runbooks execute as the Automation
identity, which holds Key Vault and storage access. Anything able to rewrite a
runbook body could therefore run code as the more privileged identity — which is
why the web tier's settings capability (RPO, retention, verification, alert
email) is built from a narrow custom role rather than from a broad built-in such
as Automation Contributor — which matters more now that the custom role is the
only thing standing there. The same reasoning keeps
Microsoft.Storage/storageAccounts/* off the web identity: it carries
listKeys/action, and account keys would bypass the product's AAD-only rule for
blob access entirely.
Every action name in the custom role was read from the provider
(Get-AzProviderOperation "Microsoft.Automation/*") and confirmed by a real
deployment. ARM validates these strings — an invalid one fails the deployment
with InvalidActionOrNotAction, naming the offender — so a typo cannot produce
a subtly wrong role.
Raising deletion protection — extend only#
The dashboard's Apply in the Deletion protection section of Configuration → Protection lengthens the
locked immutability window on the backups container. Stated by mechanism:
Only the runbook extends the policy. The extend is one call,
Set-AzRmStorageContainerImmutabilityPolicy -ExtendPolicy -Etag, inInvoke-ImmutableFloorRaise(modules/ExternalIDBackup.psm1), which runs in the Raise-ExternalID-Immutability runbook (automation/runbook-raise-immutability.ps1) as the Automation identity. That identity holds the custom roleEIDGuard Immutability Extender(marketplace/bicep/modules/roles.bicep, theautomationImmutabilityExtendassignment), scoped to thebackupscontainer only, with exactly two actions:Action Why Microsoft.Storage/storageAccounts/blobServices/containers/immutabilityPolicies/readRead the current window and its ETag, which the extend must carry Microsoft.Storage/storageAccounts/blobServices/containers/immutabilityPolicies/extend/actionExtend the locked policy No
immutabilityPolicies/write(which can set or shorten an unlocked policy), no/delete, no/lock/action— those stay with the deployment-script identity (EIDGuard Immutability Locker,marketplace/bicep/modules/stagingroles.bicep), which only runs during a deployment.The runbook also needs three reads, granted to the same identity as built-in Reader (
*/readonly — no key listing, no data plane), inroles.bicepbeside the Extender:Read Why Scope (assignment) Microsoft.Storage/storageAccounts/managementPolicies/readThe live lifecycle rules the new window is checked against. An account-level resource, which a container-scoped grant cannot reach Storage account ( automationStorageReader)Microsoft.Storage/storageAccounts/blobServices/containers/readThe container record that carries the policy's update history, which the daily limit is counted from Storage account ( automationStorageReader)Microsoft.Automation/automationAccounts/jobs/readIts own job record, and whether another raise is still running Automation account ( automationJobsReader)The web tier cannot change the policy. The Function App identity holds
immutabilityPolicies/read(theEIDGuard Retention Managerrole, for the window the dashboard displays) and no write, extend, lock or delete on it. What it can do is start the runbook, with a number: the Admin-only handlerweb/api/ActionRaiseImmutability/run.ps1(Assert-ApiRole -Role Admin) runs the same checks early for a fast answer, then callsStart-WebRunbookJob.The runbook bounds that number, and the web tier cannot change the runbook. Its Automation role carries
runbooks/readand no write (SEC-03). So whatever the web tier sends, the runbook allows only: 1–3650 days, strictly above the window Azure enforces now, a policy that is Locked, and at most one extension per 24 hours, counted from the policy's own update history (Get-ImmutabilityExtendHistory). Azure writes that history for every extension, whoever makes it and however the job that asked ended — a job stopped midway, or one that failed after Azure applied the extend, is still in it — and the web tier cannot write the policy at all, so it cannot forge or avoid it. The runbook refuses if the history cannot be read. The runbook also refuses a window longer than any finite retention — in the stored configuration and in the live lifecycle rules — but that is a correctness guard, not a security bound: the web tier can also change retention.Worst case for a compromised web tier: one extension a day, to at most 3650 days. Recovery points are kept longer than intended — storage cost and a longer wait to uninstall — and never deleted sooner. Because Azure allows a locked policy to be extended only a limited number of times, repeated raises could also use those extensions up; the daily limit slows that down.
Stated plainly, as roles.bicep does: the Extender role and the two Reader
grants are what the raise is designed to rely on, but they are not today the
Automation identity's only route to any of it. That identity also holds
Storage Account Contributor and Automation Contributor at resource-group
scope for private networking (automationNetworkingRoles); the first's
Microsoft.Storage/storageAccounts/* includes write, delete and lock on the
policy. Narrowing those networking grants is recorded outstanding work; the
explicit grants keep the raise working when it happens.
Right after an upgrade that adds these grants, the assignments can take a few minutes to apply; a raise started in that window refuses with an authorization error, changes nothing, and can simply be retried.
Grants the customer makes outside the deployment#
Everything above is assigned inside the deployment's own resource group, by the deployment. Two optional features need a role on a resource the deployment does not own, so the customer assigns them — and only these two.
| Feature | Role | Role definition id | Granted to | Scope |
|---|---|---|---|---|
| Export recovery points | Storage Blob Data Contributor | ba92f5b4-2d11-453d-a403-e96b0029c9fe |
The Automation account's system-assigned managed identity | The customer's destination storage account |
| Private networking into an existing VNet | Network Contributor | 4d97b98b-1d4f-4787-a291-c67834d212e7 |
The same Automation managed identity | The customer's existing virtual network |
Both share one flow (web/app/src/lib/armGrant.ts): the dashboard checks
whether the identity already holds the role at that scope, checks whether the
signed-in administrator is allowed to make the assignment, and then performs
it browser-direct with the administrator's own ARM token. The deployment
never holds the right to grant itself access to a customer's resources.
Assigning either by hand in the portal works identically.
Neither grant is required to run the product: exporting is opt-in, and private networking can instead use a dedicated VNet created inside the deployment's resource group, where the deployment's identity already has rights and the customer grants nothing at all.
Export destination — Storage Blob Data Contributor#
Exporting recovery points (Configuration → Export recovery points) copies every snapshot, manifest and report to a storage account outside the deployment, so that deleting the deployment does not destroy the backups. The copy is performed by the Automation account's managed identity, AAD-only — the repo hard rule holds here as everywhere: no SAS tokens, no account keys.
GET /api/export/info (Admin) returns the principal id to grant, fetched live
from the Automation account rather than from a stored copy — a stale value would
have the customer grant the wrong principal.
The API refuses any destination that belongs to the deployment itself, because those accounts die with it and an export into one would look like a successful evacuation while protecting nothing. Two different rules decide that, and it is worth knowing which applies to you:
| Account | Refused when |
|---|---|
| The backup storage account | Provenance says the deployment created it (storageAccountCreated) |
| The web-runtime account | Provenance says the deployment created it (storageCreated) |
Function-host environment variables identify accounts the deployment uses, not
accounts it owns. An adopted customer account can hold webconfig or Function App
host data and still be a valid destination because it survives teardown. The
Marketplace API is the exception: packaged plans carry EIDB_PLAN_NAME, so only
there are the host environment names treated as deployment-owned. The runbook
also checks the configured source account's known resource group directly and
refuses it when the group's managedBy relationship identifies a managed app;
this works with the Automation identity's resource-group-scoped permissions.
The actual runbook source account is authoritative; a stale webconfig account
name cannot replace it. Its group comes first from the non-secret,
storage-specific ExternalID_StorageResourceGroup deployment setting, with
webconfig as a compatibility fallback. If neither is available for a source-account destination,
the direct runbook path fails closed instead of relying on a subscription-wide
resource listing the identity cannot perform. It also fails closed if the
subsequent resource-group lookup is inconclusive; only a confirmed unmanaged
group can make the configured source account eligible on self-host.
Separately, the backup container is refused as a destination even when the account is allowed — copying it onto itself achieves nothing.
Existing VNet — Network Contributor#
The private-networking wizard (Configuration → Networking) offers two modes. Dedicated creates the VNet inside the resource group and needs no grant at all. Existing targets a VNet the customer already runs, and the Automation identity needs Network Contributor on it to join subnets, attach private endpoints and link private DNS zones.
GET /api/network/info (Admin) returns the principal id the same way
export/info does, alongside the current network state. The grant is scoped to
the selected VNet resource — not the subscription or resource group — and the
wizard re-checks it whenever the selected VNet changes, so a grant made against
a previously selected VNet is never mistaken for one covering the new choice.
Every resource the feature creates lands in the resource group — the private endpoints and their NICs, the private DNS zones and their VNet links, the NAT gateway and its public IP, and the worker VM — so deleting the deployment removes them and leaves the customer's VNet itself intact. It does not leave it untouched: the two sections below cover what the runbook changes inside that VNet and what remains there afterwards.
What existing-VNet mode changes in your VNet#
Give EIDGuard three subnets of its own. The wizard's defaults —
snet-eidb-pe, snet-eidb-hw, snet-eidb-app — are chosen to be unlikely to
collide, and the safest thing you can do is leave them that way and let the
runbook create all three. The rest of this section is what happens when they
are not exclusively ours.
The runbook resolves each subnet by name: it creates one that is missing, and adopts one that already exists under that name. Adoption is silent, and it is the same code path whether the subnet was left by an earlier run or built by you for something else that happens to share the name.
| Subnet | If the runbook creates it | If it already exists |
|---|---|---|
snet-eidb-pe |
Created with private endpoint network policies disabled | Adopted as-is — not modified |
snet-eidb-hw |
Created, then associated with the deployment's NAT gateway by default | Adopted; an attached NAT gateway is reused, or the deployment gateway is attached when none exists (unless you opt out) |
snet-eidb-app |
Created delegated to Microsoft.Web/serverFarms, then associated with the NAT gateway by default |
Adopted; an attached NAT gateway is reused, or the deployment gateway is attached when none exists (unless you opt out). The delegation is not added, which fails the deployment unless the subnet already has it (see below) |
The runbook never replaces a NAT gateway already attached to an adopted worker
or app subnet. It reuses each subnet's existing gateway independently, including
a gateway in another resource group; that gateway remains customer-owned and
is not deleted with EIDGuard. When a selected subnet has no gateway, the default
is to create the deployment-owned gateway and attach it. You may turn that off
when a 0.0.0.0/0 route already sends traffic through an NVA, Azure Firewall,
or another egress service. Without one of those paths, the worker and dashboard
may not reach the Internet services they require.
Deleting the deployment cannot detach a deployment-owned NAT association from a subnet across the resource-group boundary. A NAT-less subnet to which EIDGuard attached its managed gateway can therefore retain a stale reference after deletion. A customer NAT that EIDGuard reused is unaffected. See private-endpoints.md for the same note from the networking side.
An adopted app subnet must already be delegated to
Microsoft.Web/serverFarms. The runbook adds that delegation only on a
subnet it creates; adoption returns the existing subnet untouched in shape. The
dashboard's regional VNet integration requires the delegation, so if the adopted
subnet lacks it the integration call is rejected and the runbook throws
(Site VNet integration failed (HTTP …)). It does not silently half-work.
The failure lands at a recoverable point. Subnets, any required NAT gateway and associations, the private DNS zones and the private endpoints already exist by then; the data-plane lockdown step has not run, so Key Vault and storage are still publicly reachable and the dashboard still answers. Fix the delegation and re-run — every step is idempotent.
Practical guidance: either leave the app subnet's configured name unused so the
runbook creates and delegates it, or delegate your existing subnet to
Microsoft.Web/serverFarms before starting. Note that the delegation is
exclusive in practice — a subnet delegated to App Service is not usable for
arbitrary workloads — which is another reason to give EIDGuard its own.
So: if you point the wizard at subnet names that carry other workloads, their existing NAT egress is preserved. Dedicated subnets still avoid shared-lifecycle questions entirely.
What existing-VNet mode leaves behind#
The runbook cannot put subnets in the resource group — a subnet is a
child of the VNet, and in this mode the VNet is yours. Deleting the resource
group removes everything else (private endpoints and NICs, private DNS
zones and their links, any EIDGuard NAT gateway and public IP, the worker VM),
but the three subnets stay — along with the app subnet's
Microsoft.Web/serverFarms delegation, the private-endpoint subnet's disabled
network policies, and any association EIDGuard added to its managed gateway.
Empty subnets cost nothing. They do hold address space; the delegation means that subnet cannot host arbitrary workloads until it is removed; and the stale NAT association must be cleared before either subnet can route outbound through anything else.
Cleaning up by hand — check before you delete. The deployment does not record which subnets it created and which it adopted, and by deletion time that information is gone with it. Treat the three names as candidates, not as a list to remove:
- For each of the three, confirm it is genuinely ours — nothing else is attached to it, and it is not a subnet you recognise from before the deployment. A subnet with any other NIC, endpoint or delegation in it is not ours to delete.
- Clear a stale NAT association only where the subnet referenced EIDGuard's
deleted
eidb-natgw. Do not remove a customer NAT gateway the deployment merely reused. - Remove the
Microsoft.Web/serverFarmsdelegation from the app subnet before trying to delete it; a delegated subnet will not delete. - Delete only the subnets that passed step 1.
Azure refuses to delete a subnet that anything is still attached to, which is a useful backstop — but it is not a substitute for step 1, because an adopted subnet that happens to be empty right now will delete without complaint. If in doubt, leave the subnet: an empty subnet costs nothing, and the delegation is removable on its own.
Names differ if they were overridden in the wizard. This is one of the deletion
story's loose ends — the things a resource-group delete does not reach. The
full list, and the order to take them in, is
plans-and-limits.md.
In short: the backup and restore app registrations in each External ID tenant
(offboarding removes them; nothing else does), the dashboard's own
EIDGuard-WebUI (<web app name>) registration in the workforce tenant (delete
it by hand), and the five custom role definitions the
deployment creates (EIDGuard Retention Manager, EIDGuard Automation Operator,
EIDGuard Upgrade Cleanup, EIDGuard Immutability Extender in
marketplace/bicep/modules/roles.bicep, and
EIDGuard Immutability Locker in marketplace/bicep/modules/stagingroles.bicep):
a role definition is a subscription-level object even when its only assignable
scope is the resource group. Every measured teardown removed them with the
group all the same, but the read plane can keep listing them for a while and a
straggler is possible, so check after the group is gone. Telling a straggler
from a stale read, and clearing one, is covered in
deployment-troubleshooting.md.
Everything else the product creates is inside the deployment's resource group and
cascades — once the backups container has been emptied, which Azure only allows
after each recovery point's immutable window has passed. That page covers the
purge, and exporting the recovery points first.
