Skip to content

feat(lwpreflight): validate Entra ID directory roles in Azure preflight - #1859

Draft
lokesh-vadlamudi wants to merge 7 commits into
mainfrom
lvadlamudi/cad-2145-azure-preflight-directory-roles
Draft

feat(lwpreflight): validate Entra ID directory roles in Azure preflight#1859
lokesh-vadlamudi wants to merge 7 commits into
mainfrom
lvadlamudi/cad-2145-azure-preflight-directory-roles

Conversation

@lokesh-vadlamudi

@lokesh-vadlamudi lokesh-vadlamudi commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Azure preflight only checked ARM RBAC on the subscription, and skipped even that for Owner/Contributor callers (IsAdmin). Entra ID directory roles were never checked, so a service principal missing Application Administrator or Privileged Role Administrator passed discovery with Errors: {} and then failed deployment on the azuread Terraform resources.

Fixes CAD-2145.

Change

The ARM access token already decoded for oid/tid also carries a wids claim: the template IDs of directory roles actively assigned to the caller. The new CheckDirectoryRoles task reads it and runs unconditionally because subscription Owner/Contributor is orthogonal to directory roles.

Per requested integration type:

Integration App creation (Application Administrator, Cloud Application Administrator, Global Administrator, or the Application.ReadWrite.OwnedBy / Application.ReadWrite.All Graph permission) Directory role assignment (Privileged Role Administrator, Global Administrator, or the RoleManagement.ReadWrite.Directory Graph permission)
Agentless required not required
Config, new AD application required required
Activity Log, new AD application required required
Config / Activity Log, existing AD application not required not required

Agentless always creates its own Entra ID application but never assigns a directory role. Config and Activity Log create an application and assign Directory Readers to it, which is what needs Privileged Role Administrator.

Params.UseExistingAdApplication is keyed by integration type, so mixed requests are represented correctly: reusing an app for Config does not waive checks for an Activity Log integration that creates one, or vice versa. The CLI keeps --existing-ad-application as shorthand for both and also exposes --config-existing-ad-application and --activity-log-existing-ad-application for mixed runs.

Either mechanism satisfies a requirement. Entra ID grants these capabilities two ways, and wids sees only the first: an app-only principal can hold a Microsoft Graph application permission instead of a directory role, and those arrive in the roles claim of a token issued for Graph, never in the ARM token the caller check already decodes. So the credential is asked for a second token. That is a token request, not a Graph API call, so it needs no permission of its own, and it succeeds for a principal with no Graph grants at all, returning an empty roles claim rather than an error.

The accepted permission set is deliberately narrow. Directory.ReadWrite.All is documented as sufficient for azuread_directory_role_assignment but is left out: a missing entry costs a false failure the caller can fix by assigning a directory role, while a wrong entry waves a caller through to a deployment that then fails. When the Graph token cannot be read at all, the check falls back to directory roles alone and appends that to the error, because in silent or JSON output the verbose line goes nowhere.

No new dependency, no Graph API call, and no additional caller permission is required.

Verification

  • Unit tests cover the role matrix, both mixed existing/new-app directions, wids and roles parsing, each Graph permission alone, a capability satisfied by a directory role while the other is satisfied by a Graph permission, and the unreadable-permissions message.
  • The Azure CLI command tests pass and its golden help snapshot is generated from the branch binary.
  • Live, the CLI built from this branch was tested against the dev tenant with a throwaway service principal holding Contributor on the subscription (deleted afterwards):
Directory roles on the SP Flags Result Exit
none --agentless --config --activity-log 5 errors: 2 each on config and activity log, 1 on agentless 1
none same plus --existing-ad-application config OK, activity log OK, agentless FAIL 1
Privileged Role Administrator only --agentless --config --activity-log 1 error each, all for the app-creation role 1
PRA plus Application Administrator --agentless --config --activity-log all OK 0

The first row is the ticket scenario: subscription Contributor, no directory roles, previously Errors: {}. A fresh SP carries one baseline directory role in its token (Directory Readers), which satisfies none of the checks.

The Graph path was checked separately against the dev tenant. Both tokens were minted from the same client credentials and decoded: the ARM token and the Graph token carry the same wids, and neither carries roles, since that principal holds its privileges as directory roles. The branch CLI reports Directory roles: 3 and Graph application permissions: 0 and passes all three integrations. What is not covered live is a positive Graph-only run, which needs Application.ReadWrite.All admin-consented to a throwaway principal; the unit tests cover that path.

Known limitations

Preflight validates what a service principal can hold: Entra ID directory roles and Microsoft Graph application permissions. That is the whole picture for onboarding, because self-deployment discovery always passes a client ID and secret, and CLI onboarding never runs preflight at all. Only lacework preflight azure without --client-id falls back to the signed-in user, and there the Application Developer role and the tenant default that lets any user register applications also permit app creation and are not recognized here.

One gap remains for a service principal, and it fails in the safe direction, reporting a requirement the caller would in fact have met: a PIM-eligible role that has not been activated does not appear in wids, so an administrator who has not activated is treated as not holding the role.

Follow-ups (separate PRs)

  • services: bump go-sdk and pass each integration's use_existing_ad_application value from the discovery request into azure.Params.
  • rainbow: the existing-AD-application toggles are chosen after discovery runs, so discovery always evaluates the strict (new application) path today.

Related PRs

CAD-2145 ships across three repos. Merge order is go-sdk, then services, then rainbow.

  • go-sdk #1859 (this PR): the preflight check itself, plus the per-integration UseExistingAdApplication params and CLI flags.
  • services #34624: bumps go-sdk and forwards each integration's use_existing_ad_application from the discovery request into the preflight params.
  • rainbow #24118: asks the existing-AD-application question per integration before discovery runs, so the params above are populated.

Discovery previously checked only ARM RBAC (and skipped even that for
Owner/Contributor callers), so a service principal missing Application
Administrator or Privileged Role Administrator sailed through preflight
with Errors: {} and then failed deployment on the azuread resources.

The ARM access token's wids claim already lists the caller's active
directory role template IDs, so no Graph call or new dependency is
needed. New CheckDirectoryRoles task requires:
- Application Administrator (or Cloud Application Administrator or
  Global Administrator) whenever an Entra ID application is created:
  always for agentless, and for config/activity log unless an existing
  AD application is reused
- Privileged Role Administrator (or Global Administrator) for config/
  activity log, which assign the Directory Readers role to the new app

Params.UseExistingAdApplication lets callers waive the config/activity
log checks when reusing an existing AD application, exposed on the CLI
as lacework preflight azure --existing-ad-application. The human output
relabels Admin as Subscription Owner/Contributor and adds the directory
role count.
@lokesh-vadlamudi lokesh-vadlamudi self-assigned this Sep 2, 2026
The trailing sentence made each message ~200 chars and wrapped badly in the
deployment wizard's error banner without telling the user anything they act
on differently. The missing role name is the actionable part.
The directory-role check read only the wids claim, which lists directory
roles. An app-only principal can hold the equivalent Microsoft Graph
application permission instead, so a caller with
Application.ReadWrite.OwnedBy and RoleManagement.ReadWrite.Directory was
blocked even though deployment would have succeeded.

Graph application permissions arrive in the roles claim of a token issued
for Graph, never in the ARM token the caller check already decodes, so
the credential is asked for a second token. That is a token request, not
a Graph API call, and needs no permission of its own.

The accepted set stays narrow, and Directory.ReadWrite.All is left out on
purpose: a missing entry costs a false failure the caller can fix by
assigning a directory role, while a wrong entry waves a caller through to
a deployment that then fails. When the Graph token cannot be read at all
the check falls back to directory roles and says so in the error, since
in silent or JSON output the verbose line goes nowhere.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant