ENCA
Conditional Access workspace

A clear view. A deliberate change.

Inspect policies, review the evidence and prepare your next rollout from one workspace.

Tools

⏳ Temporary tools

Deadline-driven tools for a dated migration or retirement. Each one names the date it stops mattering — and leaves once that date has passed.

📵

SMS & voice retirement BETA UPDATED

Microsoft retires its SMS and voice MFA delivery on 1 February 2027 — and from 1 September 2026 everyone still enabled for them is auto-enabled for passkeys and nudged at sign-in. Who is still in the SMS/Voice policy scope, who actually has a phone method registered, and who would be locked out — with Markdown and CSV export. read-only

🧷

memberOf retirement BETA

The memberOf dynamic rule operator leaves preview on 3 November 2026. Rules still using it don't fail — they stop updating and keep handing out whatever membership they held that day. Scans all three surfaces Microsoft names — dynamic groups, dynamic administrative units and entitlement-management auto-assignment policies — then says what it costs: which Conditional Access policies point at each group, whether it's an include that quietly stops covering joiners or an exclude that becomes a permanent bypass, which groups carry licensing, and which access packages stop being handed out. Owner notification, Markdown and CSV export. reads only

🗂 Explore & document

Read your baseline and turn it into something shareable.

🗣

User impact brief UPDATED

The rollout email, written by the tenant itself: what people will notice and what will deliberately no longer be possible, derived from the Conditional Access policies actually deployed — with what is live today kept apart from what changes at go-live. Filter by audience, export as Markdown or a Word document for the communications team.

🗂

Policies UPDATED

Browse all Conditional Access policies as cards, a list or a settings matrix — grouped by persona. One screen, and the action bar does the rest: 📄 Create documentation (Word, PDF, PNG or a PNG bundle, with auditor-ready pages for the restricted units protecting exclusion groups), 🗄 Backup (JSON) to a zip with its dependencies, 🔍 Gap analyse, 👥 Assign groups or roles and 🎚 Set policy state — the three reads falling back to every visible policy when nothing is selected, the two writes asking for an explicit pick. On a baseline tenant the document and the backup are scoped to the baseline catalog. writes to tenant

🔍 Analyse & simulate

Work out what your policies actually do, and to whom.

🔍

Gap analyse UPDATED

Who your policies actually cover, as a funnel: every user, then the ones a policy targets, then the ones an exclusion takes back out, then the ones holding the licence their own policies oblige. The last stage has its own tab — 🎫 Licences — because the obligation is the interesting half: Microsoft's licence usage blade counts who triggered a policy last month, while every user your policies target needs Entra ID P1, and risk conditions need P2.

🧪

What-If UPDATED

Two ways round the same question, one evaluator behind both. 🧪 One sign-in — describe a sign-in (user, app, platform, client, location, device state, risk) and see which policies would apply with the controls required, and which would not and why. ⚡ Every simulation — turn it around: per policy, every sign-in the policy implies and the control each one should or should not enforce, optionally narrowed to one user or persona group. Ported from Jasper Baes' CA Validator.

🕵

Who is … to CA BETA UPDATED

Pick the subject; the picture is the same one. 🕵 A user — which deployment group she is in and how she got there (direct, or nested via which parent), every policy that reaches her or misses her and why, her sign-ins, her risk, what goes live next. 🌊 A group — the same for a whole wave: who is in it and how, which policies target it, how many members an exclusion takes back out, and every member's forecast. ⚖ Compare users — two or more side by side, where Conditional Access treats them differently and which group or role membership is behind it. One subject resolver and one policy ladder under all three.

🫥

Apps with no service principal BETA UPDATED

Apps that signed in over the last 30 days and have no service principal in this tenant. Such an app is not in the Conditional Access app picker — it can be neither included nor excluded by name; only a policy on All resources reaches it. One Graph call lists them; for each one: which policies would apply the moment it exists, whether a policy already excludes the id (a phantom exclusion), and what Entra recorded on its newest sign-in.

🔗

User or Group analyzer UPDATED

Where is a group actually used? One group or user checked against Conditional Access, Entra roles, apps and licensing, Intune, Microsoft 365 and Azure RBAC — or sweep every group and find the ones nothing references. extra permissions

🚪

Exclusion analyzer UPDATED

Every exclusion across all policies — users, groups (expanded to members, with the nested groups they came through), roles, guest types, apps and locations — in an exclusion × policy matrix, with a risk review that flags an exclusion group fed by nesting.

🕓

Changes UPDATED

What moved, from either source. 🕓 Audit log — who changed which Conditional Access resource, when, and exactly what changed: a field-level diff of every policy, named location and authentication strength edit, as far back as your licence keeps (about 30 days). 📉 Snapshot file — take a snapshot today, compare a later run against it, and see what moved with no retention limit at all, because the history is a file you keep. One diff engine behind both, ranked by severity.

🚦

Sign-in log UPDATED

One read of the sign-in window, three questions of it. 🚦 Failures — which sign-ins Conditional Access failed or interrupted and which policy did it, per policy: who, on which app, from where, with the controls that weren't met, CSV export and a one-click replay in What-If. 🎚 Report-only impact — what happens the day a staged policy goes live, per policy and per user. 🛂 Session controls — what a session control actually did, from Defender. Tabs over one window and one consent, so switching costs no second read.

🧬 Compare against a baseline

Measure this tenant against a reference set of policies.

🛡

Checks UPDATED

What is wrong with this baseline, judged against four references — each its own tab, each asking for its own permissions on its own run. 🛡 Bypass & Swiss cheese — known Conditional Access bypasses and the holes a stack of policies leaves between them: MFA coverage, FOCI token sharing, break-glass coverage, bypass apps, the persona × control matrix. 📘 Microsoft Learn — the exclusions and limitations Microsoft documents, with buildable fixes. 📐 CIS 5.2.2 — the 17 automated Conditional Access recommendations of the CIS Microsoft 365 Foundations Benchmark, per control. 🖥 Intune reality — the other half of a require-compliant-device grant: whether the policy's scope is actually assigned an Intune compliance policy at all.

🧬

Baseline UPDATED

Match this tenant against a Conditional Access baseline: which baseline policies are present, outdated or missing — then import the gap. Both catalogs are one picker in the toolbar🧬 CloudFellows R26.6 (v3.x) and 🧩 Joey Verlinden, the community baseline read live from his repository (latest release, with the commit it read) with the bundled 2026.6.1 snapshot as the fallback. The ★ active baseline for this tenant — what every group, vault and exclusion-restore tool works against — is chosen here, after a dry run shows what the switch would change. A 📖 Deployment guide tab gives the order to deploy it in with the reason for each step, and reads the tenant to say what is missing before each one.

✍️ Manage the tenant

The tools that write. Each one confirms before it changes anything.

👥

Conditional Access groups UPDATED

One read, then everything is a row: every group the baseline expects or a policy references, with its status, members (direct and nested), the policies that use it and its protection — and a detail panel for the one you click: members as a tree with add and remove, policies, protection, history. Create, assign, protect, migrate, import and compare from the rows you tick. writes to tenant

🔒

Protect exclusions UPDATED

Two locks per CA exclusion group on one row: a restricted management administrative unit so only AU-scoped roles can change the members, and nesting disabled so no group can be put inside — with what each group still lacks pre-ticked. writes to tenant

🧩

Policy building blocks UPDATED

The four kinds of object a policy references, each on its own tab, plus the bin they land in when deleted. 🌐 Locations — IP ranges and countries, with what is wrong with them: dangling references, empty country locations, overly broad ranges. 💪 Strengths and 🎫 Contexts — the authentication strengths a policy grants and the contexts it is scoped to. 📜 Terms of use — the agreements it requires. ♻ Deleted — everything still inside the 30-day restore window, policies and locations, with one-click restore. Every tab says which policies use each row, from one shared lookup, and refuses a delete that would leave a policy pointing at nothing. writes to tenant

🛡

Restricted AUs UPDATED

Manage restricted management administrative units — the vaults that shield CA exclusion groups from tenant-wide admins. One vault per persona of the active baseline (CloudFellows or Joey Verlinden). List them with members and scoped role grants, edit, add or remove members, grant scoped administrators, create and delete. The restricted flag itself is immutable, and the tool says so.

Guided rollout NEW

CloudFellows from a backup ZIP, Joey via Fetch latest. Review the import, observed impact and enforcement with the existing confirmations.

📥

Import UPDATED

Restore a backup or a baseline (zip, folder or Joey's repository): groups created and attached by name, dependencies first, policies staged Off and verified; re-attach earlier imports, add the E-Admins. writes to tenant

❓ Help

📋

What's new

Everything added or changed in ENCA, newest first — the full changelog behind the overlay you get after signing in.

🗺

Roadmap UPDATED

Where ENCA is heading next — the ideas being worked toward, in the open, so you know what to expect and can say what matters most.

Help

How every tool works and what to expect from each option — opens in its own tab, jump back to any tool anytime.

About ENCA, permissions and credits

ENCA (Entra Conditional Access) is a toolset for reviewing and managing policies for your Microsoft Entra Conditional Access baseline. List Policies shows every policy as cards, a list or a settings matrix, grouped by persona (CA number ranges). Create documentation turns all or selected policies into shareable documentation — Word, PDF, PNG or a PNG bundle. Gap analyse builds a users × policies impact matrix: which policies apply to whom, who bypasses a policy via exclusions (and why), whether a bypass is covered elsewhere, and who gets no MFA from any policy. Best-practice & bypass checks checks the baseline against known CA bypasses and the Swiss-cheese defense model — MFA coverage, FOCI token sharing, resource-exclusion scope leaks, break-glass coverage, known bypass apps, and a persona × control coverage matrix. Baseline Policies matches this tenant against the CloudFellows R26.6 (v3.x) baseline on the CA number and compares versions, so you see at a glance which baseline policies are present, outdated or missing — with a Markdown gap report and a hand-off to Import. MS Learn checks compares your policies against exclusions, limitations and upcoming behavior changes documented on learn.microsoft.com — missing break-glass exclusions, token protection limits, Teams Rooms / Surface Hub impact, required app exclusions and control retirements — and can build the corrected policy for you (new version, state Off, downloadable; never written to your tenant). Backup downloads the raw JSON definitions of your policies in one zip. Conditional Access groups is the one place the groups live: it checks the tenant against the expected baseline set, creates the missing ones, reads their members into a members × groups matrix, and assigns them to policies (replace, add, or set to All Users) — always behind an explicit review step.

Permissions

The app uses delegated, read-only Microsoft Graph permissions for its base tools, consented once per tenant by an administrator — see the permission overview above for the exact scopes and their live status. The read-only tools never write to your tenant, and the signed-in user only needs a reader role such as Security Reader or Global Reader. The Conditional Access groups tool additionally requests Policy.ReadWrite.ConditionalAccess on demand (incremental consent) and requires a role that can edit Conditional Access, such as Conditional Access Administrator or Security Administrator. Creating missing groups uses ordinary security groups with nesting disabled; dynamic groups retain their rule. This requests Group.ReadWrite.All and Group-NestingSupport.ReadWrite.All. Scoped role grants are a separate privileged operation.

Credits & thanks

These community projects shaped the tools, and all deserve the credit:

  • idPowerToys — by Merill Fernando. The Conditional Access documenter that started all of this: the idea that a CA baseline should be readable as a document rather than clicked through in the portal. These tools are the web successor to that documenter, extended to the newest CA settings.
  • Conditional Access Impact Matrix — by Jasper Baes. The users × policies impact model behind the Gap analyse tool: working out which policies actually apply to a given user, who bypasses a policy through an exclusion, and whether that bypass is covered somewhere else.
  • Conditional Access Validator — by Jasper Baes, part of his Conditional Access Blueprint. The simulation generator behind the CA validator tool: deriving, per policy, the sign-in scenarios it implies and the control each one should (or should not) enforce. Reimplemented here as a report only (no Maester test-code generation). Licensed CC BY-NC-SA 4.0.
  • Conditional Access Baseline — by Joey Verlinden. A minimised community baseline on top of the Microsoft Conditional Access framework, included here as a second catalog in Baseline Policies so a tenant can be measured against it the same way. The policy set and naming convention are his; these tools only read and compare.
  • CA Policy Analyzer — by jhope188. The inspiration for the Best-practice & bypass checks set: best-practice and known-bypass checks laid out against the Swiss-cheese layered-defense model.
  • Audit Conditional Access Exclusions with PowerShell — by Tiago S. Carvalho. The governance flag patterns behind the Risk review in the Exclusion analyzer: privileged roles excluded, all-guest exclusions, direct user exclusions, oversized exclusion lists, stale disabled accounts and report-only exclusions.
  • Microsoft Entra Conditional Access blog series — by Sebastian F. Markdanner (Chance of Security). A deep, practical walk-through of designing a persona-based Conditional Access framework; several of his policy designs also informed the CloudFellows baseline bundled here.

All are independently reimplemented here in browser-side JavaScript against Microsoft Graph — no code was copied. Thanks also to the Conditional Access community whose baselines this tool is designed to compare against: Kenneth van Surksum, Joey Verlinden, Sebastian F. Markdanner and Claus Jespersen (Zero Trust persona framework).

Security

Everything runs in your browser — there is no backend and tenant data stays in browser memory and is exchanged directly with the relevant Microsoft API; downloaded exports are files you keep. Sign-in uses Microsoft Entra (MSAL, authorization code flow with PKCE, no client secret); tokens live in session storage and are gone when the tab closes. Graph tokens are attached only to graph.microsoft.com; optional Azure RBAC reads use a separate token for management.azure.com (both hostname-validated), a strict Content-Security-Policy is enforced, and all JavaScript libraries are self-hosted — nothing loads from third-party CDNs at runtime. Exports are generated locally on your machine and carry your tenant's branding, not this tool's.

Conditional Access

Policies

0 policies selected

Matrix

An exclusion group is a Conditional Access bypass, and two things widen it without the policy being touched: a tenant-wide admin adding a member, and somebody nesting a group inside it. Two locks, one row per group: a restricted management administrative unit for the first (only AU-scoped roles may change the members), disableNesting for the second (no group can be added as a member). The table says which lock each group still lacks; the ticks are pre-set to exactly that.

Same engines as 👥 CA groups → ⑥ Protect and ⑧ Disable nesting; nothing here recreates a group. Needs Privileged Role Administrator for the units, an Entra ID P1 licence for administrative-unit administrators, and a directory that has the nesting property — where it does not, the screen says so once and nesting stays in sight instead.

Deployment planning

Guided rollout

CloudFellows from ZIP. Joey from Fetch latest. Review before applying.

Only groups used by Conditional Access takes the include and exclude groups off every policy already loaded — enabled, report-only and Off alike. No /groups enumeration at all, so it is the fastest scope and the one that matches what this tool is for. An id a policy names but the directory no longer has is kept and flagged as a dangling reference: that assignment targets nobody.

The name filter narrows which groups are scanned, so a prefix like CAB-SEC- keeps a sweep of a 20 000-group tenant to the set you actually care about. It runs server-side where Graph supports it and falls back to filtering locally if not. “Groups in scope” then counts matches, not raw groups.

A sweep reads each service once and matches every group against it, so the cost barely grows with the number of groups. The exceptions are the per-group lookups — nesting, administrative units, app assignments, licensing and Teams — which are asked object by object, batched 20 at a time. Untick them for a fast first pass on a large tenant.

Where to look

📋 What's new

Everything added or changed in ENCA, newest first. The overlay after sign-in shows you only what landed since your last visit — this is the full record.

🗺 Roadmap

Where ENCA is heading, laid out in time. Order and scope may change — real-tenant feedback decides what lands first. Suggestions are welcome as GitHub issues.
Each item carries a reference (Rnn) so it can be pointed at without quoting the title. A reference belongs to that item permanently: it is never reused once the item ships, and the numbers are not a priority order. An item that ships moves from Next to Now with its reference intact — it is not deleted, because a roadmap that forgets what it delivered is only a wish list.

Now shipped & shipping

R58 💾 Keep sign-ins on this device live · build 315

A report-only review is a cadence — an hour after staging, four hours, the next morning, a week — and every look used to read the whole window again. With your consent, per tenant, the browser now keeps the report-only verdicts the hunting sources bring back (per day, policy, verdict, user and app — the buckets of T26 2.0) and a later look asks Microsoft only for the days it does not hold yet, plus the current day, which is re-read every time because sign-ins reach the hunting table late. Opening the tool shows what the device holds first, then the new days land on top. Off by default, in words: the button next to the sign-in source says what is kept, where (this browser, this device, nowhere else), who can read it (anyone in this browser profile), and Forget deletes it at once; everything ages out after the days you choose. Tokens stay out of it, the Entra-log source is not stored, and the hosted site itself still keeps nothing anywhere. Next: whole sign-in rows for 🕵 🌊 🛂, behind a second, larger consent, once the buckets have earned their keep.

R57 🧱 One frame for every tool screen live · build 312

Thirty-three tool screens, built one at a time over two years, had grown ten different orders for the same six kinds of control — find, scope, filter, window, the tool's own extras, the actions. No screen was wrong; what was wrong is that a control moved when you switched tools. The order is now one order, written into the stylesheet and enforced by tools/check-toolbar-order.js, and the spacing that 96 hand-typed inline margins used to hold together belongs to .screen.tool. The line each screen opens with came next: one title and one set of chips per tool, in the registry beside its number, after seven tools were found dropping a chip while they worked. The last four screens that were not like the others followed: the two form tools and 🔗 Group usage keep their controls in a toolbar like everybody else, every toolbar is addressable, and the one screen without one says why on itself. Then the part people actually noticed: the strip naming the tools inside a host moved above the head card and stays pinned, because under four paragraphs of 🛡 Checks nobody found it. Consistency between tools is the same programme, all of it in build 312: the verdict tile that six modules had each typed out by hand is one implementation in js/verdict.js, and 🔗 User or Group analyzer — the tool that surface was made for and the last big one not using it — is its first caller. Still open, and deliberately not done yet: whether the head card should collapse into the toolbar on the table-heavy tools so the result owns the whole window — worth it on 🗂 🚦 🕓 🕵, wrong on the tools whose prose is the tool.

R51 🫥 Apps with no service principal live · build 312

Conditional Access can only name what exists. An app that signs in but has no service principal here is not in the app picker — neither includable nor excludable, reached only by a policy on All resources. One Graph call (signInEventsAppSummary, 30 days) diffed against the tenant's service principals; per app the 🧪 What-If verdict for the moment it exists, the phantom exclusions already naming it, and the newest sign-in as evidence. Idea from Jhope188's CA Policy Analyzer v1.17.0, rebuilt on ENCA's own What-If engine and sign-in window — and without its generated PowerShell.

R05 📖 Baseline usage guide live · build 311

The toolset could build the baseline; it could not tell you what the baseline is or what order to do it in. That knowledge lived in whoever has done it before, which is the least durable place to keep it. Now it is a tool: the deployment order as six steps, each with the reason it sits where it does — the persona model and its CA ranges, groups and restricted units before the policies that target them, named locations, authentication strengths and contexts before the policies that reference them, Terms of use (which has no create API) before import, policies imported Off, then Off → report-only → 🎚 forecast → enforced, Global last. 🔎 Read the tenant turns every step into a readiness check that names what is missing before the step is run instead of after. Graduates to production once the step texts have proven themselves against enough real deployments.

R43 🛂 Session controls live · build 311

What a session control actually did. The sign-in log stops at “App Control applied”; the blocked download, the protected file and the step-up are written by Defender for Cloud Apps. Read through Defender advanced hunting on Microsoft Graph (ThreatHunting.Read.All — no second portal, no API token) and joined back to the Conditional Access policy that routed the session. Says when a policy routes but never acts (Monitor only) and when a Defender policy has no router. The activity schema is undocumented and read tolerantly; a schema panel lists what the tenant's events actually carry, which is what graduates it.

R44 🔭 Sign-in source: Entra log, Defender hunting, or hunting with non-interactive live · build 311

One segment shared by 🚦, 🎚, 🕵 and 🌊. The Graph sign-in list is interactive only and capped at 10,000; Defender hunting has the same sign-ins with no cap and 30 days — and, on the third setting, the non-interactive ones the Graph list never returns: token refreshes and background client sign-ins, where sign-in-frequency re-prompts at night, legacy-protocol blocks of service accounts and token-protection failures live. Read in explicit datetime slices that halve themselves on a large tenant. Needs Entra ID P2 for the hunting table to be populated.

R45 👥 Show nesting in the members matrix live · build 311

Every member read also reads the group's direct list and its nested member groups, so the matrix can say how as well as who: ● direct, ◐ through a nested group with the child named, a Nested groups panel with each child's members, hide-empty-groups, and ◐ cells that are not removable because the membership lives in the child. On the same build the matrix finally got the grid every other matrix has — it had been bare rows since build 97.

R48 👥 Conditional Access groups as one list live · build 311

Seven tabs that each started their own scan become one read, then everything is a row: a list of every group with its status, members, policies and protection, filter chips for what needs attention, an actions bar for the rows you tick, and a detail drawer that follows the row you click — members as a tree with add and remove (nested groups included), policies, protection, history. The engines underneath are unchanged; the list routes into them with the selection carried across.

R49 🚪 Exclusions: nesting in sight live · build 311

The exclusion analyzer shows how a user got into an exclusion group, not only that they are in it: direct or through a named nested group, per member, per matrix cell and in the head count — and the risk review flags an exclusion group fed by nested groups as the bypass it is. Same nesting read 👥 Conditional Access groups uses, so the two tools agree.

R50 ✅ One run ledger for every write live · build 311

Every tool that walks a list and writes — 139 policies, 6 groups, 4 ticks — shows the same box: the whole list before the first write, the row being written marked, ✓ or ✗ as each lands with the reason inline, the box scrolling to keep the working row in view, a count and a clock, Stop between writes. In 🎯 Assign, 🎚 Set policy state, and 👥 Migrate, Protect, Create, Import CSV, Archived groups and the Compare ticks.

R52 🗂 One door per room live · build 311

39 tiles, far fewer questions. Four of them were a second door onto a screen that already had one: 📄 Create documentation, 🗄 Backup (JSON) and 🎚 Set Policy state all opened 🗂 List Policies with one action pre-selected and a toast naming a button that was already on its action bar, and 🧩 Baseline (Joey Verlinden) was openBaseline("joey") against the same screen the catalog picker already switches. Folded: 39 tiles to 35, nothing removed, every deep link still landing. T02, T04, T05 and T11 keep their numbers, keep their Help sections under their new hosts, and the mapping is published in 🔢 Tool numbers. Phase 1 of the tool consolidation review; the tabbed hosts (🚦 Sign-in log, 🕒 Changes, 🧩 Policy building blocks, 🛡 Checks) are phase 2.

R53 🚦 One tool per data source live · build 311

Phase 2 of the consolidation review: where several tools read the SAME thing, they become tabs over one read. First the sign-in window — 🚦 Sign-in failures, 🎚 Report-only impact and 🛂 Session controls were three tiles over one readSignInWindow, one cache and one consent, and the only sign of it was a line saying the window had been re-used. Now 🚦 Sign-in log with three tabs. The tab strip is mounted into each member's own toolbar rather than moving three screens into one panel, so every screen keeps its scroll memory, its history entry and the ids other tools deep-link to; a beta-only member is a guarded tab instead of a deleted file. 🕓 Changes, 🧩 Policy building blocks and 🛡 Checks follow.

R54 🕓 What moved, and how to deploy it live · build 311

Two more hosts. 🕓 Change audit and 📉 Drift watch asked one question — what moved — of two sources over one diff engine, so they are 🕓 Changes with an Audit log tab and a Snapshot file tab. And 📖 Baseline guide, which only ever answered how do I deploy this baseline and linked seven other tools to do it, is the Deployment guide tab of 🧬 Baseline, where the baseline it describes already lives. Both bodies stay as they are: the fold removes a tile, not an answer.

R55 🧩 Policy building blocks live · build 311

A policy references four kinds of object managed elsewhere — named locations, authentication strengths, authentication contexts, terms-of-use agreements — and each had a tile over the same screen: a list, which policies use each row, create / edit / delete. Five tabs now, with ♻ Deleted as the fifth because the restore window holds deleted locations as well as deleted policies. Underneath, the four private copies of which policies use this became one implementation, so the All-trusted-locations case that only the locations tool knew about is available to every kind.

R56 🛡 Checks — four references, one tool live · build 311

The last of the four phase-2 hosts. Bypass patterns, Microsoft's documented limits, CIS 5.2.2 and the Intune compliance policy behind a require-compliant-device grant are rule packs as tabs, each asking for its own permissions on its own run. Not done, and said so rather than half done: one findings table across the four. They present a persona × control matrix, buildable fixes, a four-tier score with an L1/L2 filter and a per-platform grid — different shapes, and one table with a Source column would lose what each of them says. Phase 2 closes at 24 tiles, from 39.

R40 🕵 Who is Anna to CA live · build 309

One user, the whole Conditional Access picture on one screen — the answer to why can't Anna sign in and is Anna in the wave yet, which used to take four tools. Her deployment stage (which of the baseline's CAD-SEC-U-DG-* groups, and how: direct or nested via which parent), every policy that reaches her and via what — or excludes her and by what — the sign-ins Conditional Access actually stopped for her, and what happens to her the day report-only goes live. A standing bypass through an exclusion group is named as such. Same include/exclude resolution as ⚖ Compare users, so the two cannot disagree.

R41 🌊 Who is the wave to CA live · build 309

The same picture for a whole deployment group. The question before a report-only policy is switched On is can this wave take it — and the answer existed per policy (🎚) or per user (🕵), never per wave. Pick a deploy or persona group and get, per report-only policy that targets it, a go-live readiness verdict: locked out (named), prompted, unchanged, silent — with silent counted as no data, never as safety. Plus who is in the wave and how they got in, the policies that target it, and the members that need a look, each a click into 🕵.

R42 👥 Remove a member from a group, where you see it live · build 309

③ Members could add but not remove. Now a − Remove button next to + Add, and an × on every ● in the matrix — both ask first, and the question says what the group is used for: taking someone out of an exclusion group puts them back inside the policy. Dynamic groups are not offered; a user not read as a member is refused rather than deleted blind.

R15 🍴 Fork detection and update-from-upstream live · build 298

A high-assurance tenant is encouraged to fork, review and serve its own pinned copy — and the moment it does, it stops hearing about fixes. Now shipped: the app notices it is running as a fork (its origin is not the canonical host and the build it carries is older than upstream), says so plainly in a strip under the header, and shows what has changed since the pinned build — the What's-new headline of every build between here and there — with the commands to update a Docker instance and to pull main in and re-review the diff. The point is not auto-updating, which would defeat the reason for forking; it is making “you are 14 builds behind, and here is what those builds changed” impossible to miss. If upstream cannot be read — offline, air-gapped, a hardened CSP — it says nothing rather than something wrong. Pairs with 📋 What's new and the security document's fork-review-pin guidance.

R06 🐳 Self-hosting with Docker live · build 305

ENCA is static files and a browser — no server, no database, nothing stored anywhere. That makes it trivially self-hostable, and some tenants will require it: a security tool that reads Conditional Access is exactly the kind of thing an organisation wants served from its own infrastructure rather than from someone else's GitHub Pages, whatever the code does.

And with a host of your own, somewhere to keep things. Today nothing survives the tab closing, by design — there is no server to keep it on, so a Drift snapshot is a file you download and a report is a file you save. A self-hosted instance changes what is possible: an optional SQLite store alongside the container could hold drift snapshots, generated reports and per-tenant preferences, so a baseline signed off in March can be compared against in November without anyone having had to keep track of a JSON file. Strictly opt-in and self-hosted only — the hosted site keeps storing nothing, because "nothing is kept anywhere" is a promise worth more than the convenience. Tokens would stay out of it regardless: they belong in the browser session and nowhere else.

First cut shipped: a published image — ghcr.io/nurejev/enca, nginx over these files, built by CI from main (:latest) and the beta channel (:beta) — plus a docker compose example, one-command install scripts for Mac and Windows that check Docker, pull, run and open the browser, and a Deploy to Azure button that stands up an Azure Container App with scale-to-zero, so an idle instance costs close to nothing. SELF-HOSTING.md in the repo ties it together and leads with the one step nothing can automate: the redirect URI — every host you serve from must be registered as a SPA redirect URI on the app registration, and it is the step that produces the confusing sign-in error when missed. It pairs with the single-tenant app registration already available: your own registration, your own host, no dependency on anything of ours at run time. A self-hosted instance also gets its own branding without forking — Branding settings, in the account menu — and its own roadmap: the Self-hosted section below, where the SQLite idea above now lives as S01.

R38 🔁 Restore the exclusion group a policy has lost live · build 293

The baseline gives most numbered policies their own exclusion group, and that reference is the first thing to go missing: a policy gets rebuilt, an older version is imported, somebody tidies an exclusion list, and the group is left in the directory with nothing pointing at it. Nothing notices — a policy that lost its exclusion group looks exactly like one that never had it, until the day the exception it existed for is needed and the bypass is not there.

👥 Assign groups or roles gains an eighth action, and the only one that aims a different group at every policy: CA200 gets CA200-Exclusion, CA201 gets CA201-Exclusion, read from the CA number in the name and confirmed against the baseline catalog. Step 2 is a drift report — how many are already right, how many lost the reference, how many name a group that no longer exists — with only the repairs pre-ticked and missing groups creatable from their templates in the same run. Point it at a selection to fix one, or at every policy in the tenant for the sweep. Additive only, and the tenant-wide run asks for the typed ALL, because an exclusion widens a policy rather than narrowing it.

R27 📄 Documentation for the restricted units live · build 283

📄 Create documentation can now add the restricted administrative units to the same document as the Conditional Access policies. Each unit gets an auditor-ready page naming its persona, the groups it protects, every policy that includes or excludes those groups, the people scoped to it and whether their role can change group membership.

The review starts with the questions that decide whether the control is real: who could widen an exclusion, what would happen if they did, whether a unit has nobody scoped to it, and whether it holds a frozen role-assignable group. The integration is optional and fail-soft: Create documentation reads the units for itself; an absent, denied or broken restricted-unit read is reported and the policy document still exports.

R14 🏢 Your own single-tenant app registration live · build 273

Every tenant signs in through one multi-tenant app registration owned by Limon-IT by default — the same application ID, and therefore an application outside your directory holding a delegated grant on your data. You can run ENCA on your own instead: a single-tenant SPA (AzureADMyOrg) registered inside your tenant, with your own client ID, consent record, redirect URIs and audit trail — nothing to trust but the code you reviewed. The sign-in mechanism stays authorization code + PKCE with no client secret; only the owner changes.

Shipped: ./New-EncaAppRegistration.ps1 -SingleTenant creates the registration and prints what to paste; js/authConfig.local.js points your copy at it without editing a file upstream also changes. -RequireAssignment can restrict the enterprise application after assigning you first, so the script cannot seal you out.
📖 Read the setup manual — prerequisites, the five steps, verification, and keeping a pinned copy current. Also in Security & risk.

R11 🖥 Compliant-device reality check live · build 282

A grant control demanding a compliant device is only worth what Intune's compliance policies are worth — and until now nothing checked the other half. The tool reads the compliance policies with their assignments and flags, per CA policy and per platform, every scope that no compliance policy actually covers — with the tenant default for policy-less devices read first, because that single Intune toggle decides whether a gap means silently unprotected (marked Compliant) or users blocked (marked Not compliant). The same check runs for app-protection policies behind “require approved client app”, which is flagged as unsatisfiable outside iOS and Android. OR-alternatives are named, coverage is proven first from assignments and then from transitive group-membership overlap where names alone cannot prove it, incomplete reads stay honestly not proven, and the verdicts export as Markdown.

R29 🔍 Gap analyse — scan only who you asked about live · build 280

Gap analyse builds a users × policies matrix for every user it can read. On a tenant of any size that is a long read producing a wide answer, when the question was usually narrow: is this contractor covered, what does the finance team bypass, did that one exclusion group leave anybody exposed. The matrix is the right output; the scope is not.

It names users or groups before the scan and judge only those — the same policies, the same verdicts, fewer rows and a fraction of the reads. Two things it has to get right: a group means its members, including nested ones, or the answer is about a group rather than about people; and the report must say what was scoped as prominently as what it found, because a clean matrix over eleven users looks exactly like a clean matrix over the tenant, and only one of those is reassuring.

R30 🧪 What-If — filter the policies that did not apply live · build 279

A run against a real tenant answers with two policies that apply and 107 that do not, each with its reason. That list is the honest answer and it is also unreadable: the question behind it is almost never “show me everything”, it is why did none of the Admins policies apply to this admin — a persona question — or what happened to CA103, a number question.

It filters that list by persona and by CA number, the same grouping 🗂 List Policies already uses, so the ranges stay one idea across the toolset rather than two. Worth adding at the same time: a count per reason — “94 out of scope, 8 wrong platform, 5 excluded” — because when a policy you expected is missing, the reason is what you are hunting for, and filtering to it beats scrolling for it. The reasons must stay per policy either way; a summary that replaced them would answer a different question.

R31 🧪 What-If — search the tenant's apps, not their GUIDs live · build 279

Target resource offers the well-known apps by name and then Other — enter an App ID, which asks for a raw GUID. Nobody knows their apps by GUID, so answering it means leaving the tool, finding the enterprise application in the portal, copying the id back — and a mistyped GUID does not fail, it silently describes a sign-in to an app nobody has, which is the same class of mistake as a wrong country code.

The plan is a type-ahead over the tenant's own service principals, matching on display name and filling the id in, the way 🌐 Named locations will do for countries. A pasted GUID must keep working: a policy can reference an application that has no service principal here — 📥 Import already has to deal with exactly that — so an id ENCA cannot name is still a legitimate answer, and should be marked not found in this tenant rather than rejected.

R08 🌐 Type the country, get the code live · build 269

A country named location takes two-letter ISO 3166-1 codes, typed by hand into a free-text box. The failure mode is silent and total: a wrong code is still a valid code, so the policy saves, looks right in the portal, and quietly covers the wrong country until somebody is blocked or — worse — is not. Ireland is IE; IR is Iran. Austria is AT, Australia AU. Sweden is SE, Switzerland CH.

It types the name and fills the code in — a type-ahead over the full ISO list, matching on country name, adding the code as a chip you can see and remove. Codes typed directly keep working, but anything that is not a real ISO code is rejected at entry rather than accepted and discovered later. Worth doing for the same reason the CA-number routing exists: the tool knows the right answer, so the human should not have to be the one who remembers it.

🎚 Report-only impact (the go-live forecast) · 💪 Authentication strength advanced options (AAGUIDs, certificate restrictions) · 🛡 Restricted AUs, 📉 Drift watch and the ⌨️ command palette (all out of BETA) · 🔒 the security & risk documentation, readable before sign-in · one progress visual for every long read. The full record lives in 📋 What's new.

R02 📉 Drift watch live · build 267

Does the tenant still look like it did when we signed it off? Snapshot the Conditional Access configuration to a file you keep, and compare a later run against it — added, removed, changed, ranked so a widened exclusion or a policy switched Off outranks a rename. No server and no 30-day limit, because the history is the file rather than Microsoft's audit log. Now watches the restricted administrative units too — a group leaving one widens the bypass surface as much as editing the policy would. Next for it: exclusion-group membership drift (an exclusion group quietly gaining members is the same risk as widening the exclusion itself) and comparing two snapshots to each other without re-reading the tenant.

R03 ⌨️ Command palette live · build 267

Past twenty-odd tools, a tile grid stops being the fastest way in. Ctrl/Cmd + K anywhere opens a search box: type a few letters and land in the tool, or type a CA number or policy name and land on the policy card. Matching takes initials as well as substrings, so gap finds Gap analyse and bbc finds Best-practice & bypass checks.

R09 🔝 Flagged tiles first in a collapsed section live · build 268 · 281

A collapsed home section shows four tiles, and anything flagged NEW, BETA or UPDATED claims those slots ahead of the rest — but it claims them in page order, so a flagged tile that sits ninth is visible while still appearing after unflagged ones, and with five flagged tiles in a section one of them drops below the fold. Flagged tiles now lift to the top of the collapsed view, newest first — the ranking is read from the changelog, the build number of the newest entry naming that tool, so a tool changed in this build outranks a BETA tag that has been sitting there for weeks. Before that the tie was broken by page order, which buried the most recent change under the oldest badge. Expanding restores the normal order, because the grid's grouping is meaningful and shuffling it permanently would cost more than it gains.

R10 🎛 Group actions where you are already looking live · build 269

Clicking a group in 👥 CA groups ① Check opens a card that reads: object id, type, the policies that include or exclude it, its members. Everything you might then want to do lives on a different numbered tab, so you close the card, find the right step, and hunt for the group again. List Policies already solves this — every policy card carries its own Documentation, Backup, Assign and Policy-state buttons.
Selecting a group now shows the same treatment for a group: create it if it is missing (②), read or add members (③), assign it to policies (④), import members from CSV (⑤), protect it in a restricted AU (⑥), migrate it off role-assignable (⑦), disable nesting, or open it in 🔗 User or Group analyzer to see everything outside Conditional Access that points at it — each offered only when it actually applies, so a role-assignable group is not offered ⑥ Protect and a group already in a restricted AU is not offered it twice.

R07 👤 Bulk grant scoped administrators live · build 269

Members can be added to a persona vault in bulk; the people who may manage them still cannot. A scoped role is granted one administrator, on one unit, at a time — so a four-person team across the eleven baseline units is 44 separate grants, and the failure mode is not the tedium but the gap: miss one unit and that persona has no administrator, which in a restricted unit means nobody can change its members.

Pick the administrators once and the units once, and the grid is applied — with each unit × administrator reported as its own outcome, because a partial success has to be readable rather than rounded to “done”. It should also show the current grants across all units together, since the question people actually ask is “who can manage the Externals exclusions?” and answering it today means opening eleven cards.

R04 🧹 Retire role-assignable groups everywhere live · build 287

The baseline is moving off role-assignable groups: the flag was only ever used for a side effect — membership reachable by Global Administrator or Privileged Role Administrator only — and a restricted management administrative unit does that better, names who may manage the group, and can be undone. It also cannot be combined with one: a group carrying both has nobody who can change its members.
Done: new groups are ordinary security groups (assigned or dynamic) with an optional restricted-AU placement at creation — in production since build 254, so the role-assignable checkbox and the ↻ Recreate-role-assignable action are gone from both channels; the ① Check drift flag is inverted, so a plain group is correct and a role-assignable one is what gets flagged; ⑦ Migrate moves existing ones across; and the bundled group templates and the baseline catalogs no longer ask for the flag anywhere.
The last gap, closed in beta 25149: 📥 Import no longer walks past the groups it reuses. A created group gets a place in its persona vault; a reused one got neither that nor its nesting looked at, and naming that in beta 25129 only made the exposure visible — it still sent you to 🔒 Protect exclusions and ⑧ Disable nesting to do anything about it. The preflight now offers both, per group and ticked by hand, and applies them after the policies import. Nothing is pre-ticked and membership is never touched: an existing group may hold members and sit somewhere on purpose, so the operator decides, not the importer.

Neither write is destructive, which is what makes offering them here defensible rather than reckless: Entra refuses the nesting change when a group already holds nested groups instead of forcing it, and administrative-unit membership can be undone. The one route that is destructive — recreating a group Entra refuses — stays in ⑧ Disable nesting behind its own typed confirmation, and converting a role-assignable group stays in ⑦ Migrate. One implementation each, as before.

What this still does not do, and cannot: the flag only truly leaves a tenant once ⑦ Migrate has been run there. Nothing retires a group this app did not create, and every protection path goes on refusing a role-assignable group until it is converted. That is a fact about the tenant rather than a gap in the tools.

Shipped across beta 25128–25129 · production 285 — find the role-assignable groups a file would build on, and say what a reused group inherits — and beta 25149 · production 287, which acts on it.

R35 🧬 Rename the bundled baseline to CloudFellows live · build 287

The baseline shipped here was called Limon-IT baseline — R26.6 (v3.x) and is now CloudFellows baseline — R26.6 (v3.x). The release designation and the catalog itself did not change; this is the name it is published under.

A rename is not one string, and all of them moved together: the catalog's own label and author — which is what the 🧬 Baseline Policies header, the on-screen summary line and the Markdown gap report all read, so none of the three could drift from the others — the tile, the home overview, the Help section and the code comments that explain why a rule exists (📘 MS Learn's exclusion-group convention refers to the baseline by name).

Two things deliberately untouched. The catalog id is still limonit: saved state and 📉 Drift watch snapshots key on it, so changing it would silently orphan every comparison stored before today — an identifier that appears in no interface has no reason to follow a display name. And the CAB-SEC / CAD-SEC group names are the tenant's own objects, not ours to rename; nothing in the 99-policy catalog, the group templates or the persona routing moved. Display name everywhere, identifiers nowhere.

R37 🌐 Named locations report what is wrong with them live · build 287

🌐 Named locations already knew all of this. It resolved which policies use each location and how, it counted the ones reachable only through All trusted locations, and it warned while editing that flipping the trusted flag moves every policy consuming it — and none of it became a finding. It was a reporting gap, not a reading gap, which is why it went ahead of anything needing new Graph calls: the four checks cost no extra read, they run over the location list and the policies already in memory.

Four checks. A dangling reference — a policy naming a location id this tenant no longer has, the same failure ① Check reports for groups; high while an enforced policy still names it, because an exclusion built on it silently stopped applying while the policy went on reading as configured. An empty country location, which matches nothing, so the block scoped to it blocks nobody — and a location that includes unknown regions is deliberately not flagged, because that one does match something. An overly broad IP range: a /0 is the whole internet and a /8 is 16.7 million addresses, neither of which is an office, and a trusted location carrying one is raised a level because everything using All trusted locations inherits it without naming it. And the untrusted IP location.

The fourth check is the one that had to be careful. The trusted flag only changes behaviour when something actually consumes All trusted locations, so in a tenant where nothing does it says nothing at all. Where a Block policy names the location, or the name says so itself, nothing is raised either — _Blocked IPs is untrusted on purpose and marking it trusted would be the bug. Everywhere else it is raised as low and the finding text names the deliberate block list as a valid reason to leave it exactly as it is. A check that cries wolf about the normal case is worse than no check. The context-aware form follows ca-policy-analyzer v1.16.3, which shipped the naive version first and then had to narrow it for exactly this case.

Where they surface, and where they deliberately do not. A panel above the list, a ⚠ badge on each row, the per-location report and both Markdown exports — with findings ahead of the inventory table, because the findings are why the file was opened. They are not in 🔍 Gap analyse: that tool reads policies and this reads objects, and a first non-policy finding there would need its own answer to “what is a finding about”. The open question is still open, now with an implementation to argue from.

R28 🏷 Custom groups in the persona vaults live · build 290

Everything that routed a group to a vault read the CA number in its name. That worked for the baseline and for nothing else: a tenant's own exclusion group — SEC-VIP-Exceptions, Contractors-NoMFA — matched no persona, so ⑥ Protect skipped it unless a fallback unit was chosen by hand, + Bulk add never offered it, and the break-glass case had to be special-cased by name to work at all. Most tenants have groups that predate the baseline, and telling them the naming is wrong is not a feature.

Now there is a per-tenant mapping: say once in 🛡 Restricted AUs → 🏷 Group personas that Contractors-NoMFA belongs to Externals, and every tool routes it there afterwards. One function decides — CaMap.codeOf, this tenant's mapping first and the CA number second — and ⑥ Protect, + Bulk add, the persona chips, 📥 Import, the 🛡 Markdown report and the restricted-unit pages in 📄 Create documentation all go through it, so none of them can hold a different opinion about where a group belongs.

The two things it must not do, built in rather than promised. It does not guess: matching is the group's object id or its display name compared case-insensitively, and nothing is inferred from a prefix or a word — a mapping nobody stated is how a group ends up in the wrong vault silently, and a vault is an authorisation boundary, so the cost of a wrong guess is one persona's scoped administrator holding another's exclusions. And it does not hide the unmapped: 🔎 Find the unmapped groups lists every group the policies reference that nothing places, with a persona one click away, and ⑥ Protect says unmapped on the row rather than skipping it in silence.

Held back from production 287, while the seven items around it went: it is the only one of them that changes where a write puts a group object, and a persona vault is an authorisation boundary — file a group in the wrong one and that persona's scoped administrator can edit another persona's exclusions. An empty mapping leaves every existing routing decision byte-for-byte as it was, so the risk is entirely in what an operator states rather than in what the code assumes. It reached production 290, one release behind its neighbours. Queue item 67.

Kept with the tenant, not in the code — the browser's own storage under the tenant id, so it survives a release and never becomes our list of everybody's group names. Nothing is written to the directory and nothing leaves the machine, which also means it does not follow you to another browser: ⭳ Export / ⭱ Import is the way round that, and it is why filing a group from the unmapped list records the object id as well as the name — an id survives a rename in Entra, a typed name does not.

R33 🔢 Tool reference numbers, in order of introduction live · build 287

The roadmap items carry Rnn references so one can be pointed at without quoting its title; the tools went by name and emoji, which drift — tools get renamed, retitled and, once 🇳🇱 R26 lands, translated, at which point every English note about a tool stops being findable. Each tool now carries a permanent T-number, shown in the tile corner and in the tool's own header beside the per-tool version, and searchable in the ⌨️ command palette: type T07, or just 7, and you land on 📘 MS Learn checks.

T01–T31 were reconstructed from git, not assigned by preference: the first commit that put each tile in index.html, ordered by commit time, with tools that arrived together ordered as they were written into the page. That makes the order a fact about the repository — checkable rather than arguable — and it puts 🗂 List Policies, 📄 Create documentation, 🔍 Gap analyse and 🗄 Backup at T01–T04, which is the day this app existed at all.

Never reused — not when a tool is retired, not when it is folded into another, because a recycled number makes every older note about it wrong, which is the exact failure the number exists to prevent. A new tool takes the next free number (34 next) in the same commit that adds its tile, alongside the changelog entry and the promotion-queue row.

Three things are deliberately not numbered: ❓ Help, 📋 What's new and 🗺 Roadmap. They are the app describing itself rather than tools that read a tenant, report on it or write to it — which is also why they carry no version. They go by name, and that is written down so nobody later "fixes" the gap by numbering them and shifting everything.

R36 🧩 Joey Verlinden baseline as a first-class baseline live · build 306

Until beta 25243 (finished in 25246), 🧩 Baseline (Joey Verlinden) only compared: it told you which of his 36 policies a tenant had and what differed, while group checks, group creation, persona checks, restricted-AU creation and placement all stopped at the CloudFellows catalog. Now there is an ★ active baseline per tenant — chosen by a button in the Baseline tool, never by merely looking at a comparison — and every tool downstream works against the one you picked. The switch is preceded by a dry run that reads the tenant and shows what would change — expected groups, expected vaults, which groups stop or start being routed, which conventions move — before offering the ★ Switch button, and a 🧹 leftovers list afterwards that reads what the previous baseline had created and deletes only what has stopped doing anything: ① Check and ② Create build his “<policy name> - Exclude” groups, 🛡 Restricted AUs offers his personas as vaults, ⑥ Protect and + Bulk add route his groups into them, the exclusion-restore action names his groups, and the guide's readiness checks count his objects. Each of those screens says which baseline it is working against.

Read live. The catalog is now fetched from github.com/j0eyv/ConditionalAccessBaseline once per session — the latest release, its Config/ConditionalAccess listing and every policy JSON — and the summary card says which release and commit it read. The hand-checked snapshot (2026.6.1 at 38469a4) stays as the fallback, and the first live read already showed why: the repository ships 38 files at that tag where the snapshot lists 36.

The three things that made it more than plumbing, and how each landed. His exclusion groups are named after the policy, not the CA number, so the routing rule for his baseline is an exact match against a policy name the catalog knows — the shape R28 established: one function deciding, an exact match, the unmapped left visible. His personas (Global / Admins / Internals / ServiceAccounts / Guests / Agents) are a separate vault list under his prefix — CA-RMAU-<Persona>-Exclusions, ENCA's convention because his baseline defines no units — sharing a code with a CloudFellows persona only where it means the same thing, so the R28 mapping is kept per baseline. And the live fetch is a trust boundary: unauthenticated GitHub is rate-limited and a release can be malformed, so every file is validated before it is believed, a read that fails leaves the snapshot in place and states the reason on the card, and the two new hosts are the only widening of the content-security policy, written down beside it.

R36.1 (beta 25249) — deployed on, not chosen. A tenant that never chose a baseline now gets the one it holds most of by name as the active one for the session — the card says the counts, 📌 Keep saves it, a saved choice is never overridden — instead of defaulting to CloudFellows while 26 policies carried his names. The CloudFellows 🚨 E-Admins policies are expected under every catalog as a shared section. And the live read now brings Config/Groups and Config/NamedLocations with the policies, so 📥 Import baseline takes the repository read directly, no zip, with a third assignment mode — 🔀 Switch baseline — that creates his groups and copies the members of the CloudFellows counterparts across (break-glass, same-CA-number exclusion, persona include) before the policies land Off; the old groups stay as the rollback and surface in 🧹 leftovers.

In beta today running on the beta channel, not yet in production

R01 📐 CIS Benchmark alignment in beta today

Score the Conditional Access policies against the CIS Microsoft 365 Foundations Benchmark v7.0.0 — the 17 automated recommendations of section 5.2.2, with per-control pass / report-only / configured-but-Off / fail, licence awareness, the nearest policy for every gap, and a Markdown compliance report. Running on the beta channel now; graduates to production once it has proven itself against enough real tenants.

Next being worked toward

R12 ↩️ Undo for writes planned

Every write tool already produces a change report of exactly what it did. Make it replayable in reverse: one action that puts back the state before the run, with the same typed-confirmation discipline as the original write. It lowers the cost of every write tool already shipped, and it is the natural safety net under Editable Conditional Access policies further down this map.

R13 🔐 Improve security planned

Implementing our own advice: the hardening recommendations from the security documentation turned into defaults — a tighter Content-Security-Policy, a documented review routine for the self-hosted libraries in vendor/, and an even smoother fork-review-pin path for high-assurance tenants that want to serve a reviewed copy from their own origin.

R16 🔓 Revoke permissions when done partially done

End every session least-privileged: one click that drops the consented write scopes when the work is finished, so a delegated grant collected for an afternoon of changes does not linger on the account afterwards. The natural closing move of the consent-on-the-click model the tool already uses. First step shipped: the Permissions panel's 🔒 How to revoke guide documents the three revocation routes with ready-to-run PowerShell and their consequences — what remains is doing it in one click, which needs careful design (see the guide for why the app deliberately holds no revoke rights itself).

R46 🤖 Workload identity Conditional Access planned

Do the service-principal policies ever fire? Conditional Access for workload identities has its own sign-in log (AADSpnSignInEventsBeta in Defender hunting) and nothing in ENCA reads it. The plan: the WorkloadIDs policies next to that log — which principals each targets, which of those actually sign in, what was blocked, the report-only forecast per policy, and the principals signing in from outside every named location that no policy can reach because they are managed identities or multi-tenant apps. Same hunting scope as R44; needs Workload Identities Premium for the policies to apply at all.

R47 🖥 Device reality check — the Defender half planned

🖥 checks the Intune half: is the scope of a compliant-device policy actually assigned a compliance policy. The Defender half is the fleet as Defender for Endpoint sees it (DeviceInfo): join type, sensor health, onboarding, exposure — held against every compliant / hybrid-device policy. The findings only the fleet side can give: devices that can never be compliant because they are not joined, a hybrid-join grant that permanently excludes cloud-native laptops, and “compliant” devices with high exposure. A second tab on the existing tool.

Self-hosted for the instance you run yourself — see SELF-HOSTING.md in the repo

S01 💾 Keep things between visits planned

The hosted site stores nothing anywhere, by design, and keeps that promise. A host of your own changes what is possible: an optional SQLite store alongside the container — a volume, nothing more — holding Drift watch snapshots, generated reports and per-tenant preferences, so a baseline signed off in March can be compared against in November without anyone having kept track of a JSON file. Strictly opt-in and self-hosted only; tokens stay out of it regardless — they belong in the browser session and nowhere else. Drift is the first customer: a snapshot that survives the tab closing is the difference between a drift check and a drift watch.

S02 🎨 Branding without a fork live · build 297

Branding settings, in the account menu behind your initials, on any host: product and organisation names, logos, login text and light/dark identity colours, through the same override mechanism as the hosted per-audience looks — chrome only, exports keep the neutral product credit. Apply keeps the look in this browser; Download produces selfhost-branding.json, Import reads one back into the form for review — so a look made elsewhere or handed over by a customer never has to be retyped — and serving that file next to index.html (the compose file and install scripts mount it) gives every visitor the branding — and softens the red BETA ribbon to a neutral SELF-HOSTED one, because a deliberately configured instance is not a test site. What the dialog will never configure is the canonical host: that drives the production check and the export credit, and a settings dialog must not be able to make a copy claim to be production.

S04 ⏱ A collector that reads ahead of you planned

R58 still needs someone to open the tool for a read to happen, and Microsoft still keeps only 30 days. A self-hosted instance could run a small collector next to the container — its own identity (application permissions on your registration, a certificate or a managed identity, never a secret in the browser), the same daily bucket query every hour, the rows in a SQLite file on a volume that outlives every image update (schema kept by PRAGMA user_version, forward-only migrations, a nightly VACUUM INTO backup) — and the app reading /api/ on its own origin, so the week is already there when the tool opens and the retention is yours: ninety days, a year. Three things make it a different product and are why it is S-numbered: an unattended, tenant-wide read on a service principal; an API that has to authorise its callers with an exposed scope and a role check, because a Graph token cannot; and a second process behind nginx. On Azure Container Apps the store cannot be SQLite on Azure Files (Microsoft says not to put a database on an SMB mount) and the hourly run wants a Container Apps Job, so the storage layer has two backends — SQLite for compose and a VM, a hosted table store for ACA. Single-tenant self-hosters only; the shared registration never gets application permissions. Design note: review/SIGNIN-STORE-DESIGN.md.

S03 📦 Update channel choice planned

The image ships as :latest (production) and :beta today, and the fork notice (R15) says when you are behind — what is missing is making the choice a stated, visible thing: the instance saying which channel it follows, guidance for unattended updates (a watchtower-style puller for those who want them, and the reasons a reviewed fork should not), and a pinned-digest example for the high-assurance case where even :latest is too much trust.

Later after the above

R17 ✏️ Editable Conditional Access policies planned

Edit an existing policy from its card — not just state and group assignments but the conditions, grant and session controls themselves, with a field-level diff of exactly what will change and validation before the PATCH. The Change audit tool then shows the same diff after the fact, so intent and record match.

R18 🏢 Several tenants at once planned

For anyone who looks after more than one tenant: sign into each in turn and hold the results side by side — baseline coverage, CIS score, gap count, drift since last visit, one row per customer. One screen that answers “which of my tenants is worst off this month” without opening thirteen browser tabs. Pairs with Drift watch: a snapshot per tenant, reviewed on a schedule you keep rather than infrastructure you run.

R19 🌐 Named locations vs. the sign-in log planned

The trusted IP ranges in a tenant are usually older than the offices they describe. Cross the named locations with the sign-in log already read for Report-only impact and Sign-in failures: ranges nobody has signed in from for months, sign-ins from countries no location names, and trusted ranges that no longer match reality — the quiet way a location-based exception outlives its reason.

R20 🤝 Cross-tenant access settings planned

Inbound and outbound B2B collaboration, and whether the tenant accepts a partner's MFA and device claims. It sits directly next to Conditional Access — accepting a partner's MFA claim decides whether your own MFA requirement is satisfied by someone else's authentication — and it is invisible in every tool here today.

R21 🎫 CAE and token protection coverage planned

Continuous access evaluation and token protection are the newer session controls, and both are easy to leave half-configured: which policies opt in, which silently do not, and which resources support it. The same treatment the persona × control matrix gives the classic grant controls.

R22 📋 Change plan export planned

Export what a write would do as a reviewable file, hand it to a colleague, then apply it. The obvious companion to a toolset whose whole pitch is that nothing changes until you say so — and the practical form of a two-person rule for tenants that need one.

On the horizon builds on policy editing

R23 🏗 Policy builder planned

Build a new policy from scratch, guided: pick the persona, the resources, the conditions and the controls with best-practice hints along the way, preflight the result in What-If, and create it in report-only by default — so every new policy is born with the evidence trail the Report-only impact tool needs before go-live.

R24 🚀 Go-live checklist planned

Report-only impact forecasts one policy at a time; a checklist tracks the whole report-only backlog toward enforcement — what is staged, what has enough traffic to judge, what is ready to switch on, with the forecast attached to each. The paperwork of a go-live, kept in the tool that produced the evidence.

R25 📏 Naming-convention linter planned

The CA-number persona ranges are a convention every tool here assumes and none of them validates: numbers out of range for their persona, duplicates, gaps, policies whose name says one persona while their assignment says another. Cheap to check, and it keeps the convention that makes everything else legible from quietly rotting.

R26 🇳🇱 Dutch interface planned

The exported documentation is the part non-administrators actually read, and for a Dutch organisation that reads better in Dutch. The tool is built for it — one labels file, no build step — so the work is translation and keeping it honest, not plumbing.

R32 🧩 Every tool runs on its own planned

Today most tools quietly lean on the rest of the app having run first: the policy list a tool judges against was loaded by sign-in, the group names it prints were resolved by another tool's pass, the shared wiring holds state a tool assumes is there. It mostly works because the app loads everything up front — which means the dependency is invisible until the day it is not (a deep link straight into a tool, a tool opened before a slow read finished, a tool lifted out of this app entirely).

The rule this item works toward: every tool must be able to run with nothing but a signed-in session. Whatever its core result needs, it reads itself — or states plainly that it is waiting, instead of rendering from state that happens to be lying around. Connections to other ENCA tools — shared results, deep links and one-click hand-offs — are welcome conveniences, never prerequisites. Each connection is capability-checked and fails soft: if the other tool is absent, unfinished, denied or broken, the core tool still performs its own read and produces its result; only the shortcut or enrichment may be unavailable. Independence is also the precondition for R34 — a tool that cannot run alone cannot move alone.

R34 📦 Every tool easy to port to other applications planned

The newer tools already follow a pattern worth making a rule: the logic lives in its own file as pure functions over a plain data contract — no DOM, no globals, no Graph calls — and the app contributes only a thin wiring layer that fills the contract and renders the result. That split is why the Restricted-AUs checks, the guide and the device check can be tested with node alone, and it is exactly what makes a tool liftable: another application (a customer's own portal, a CLI in a pipeline, a scheduled report) reimplements the small wiring and takes the logic file as-is.

This item makes that the standard for every tool: each one a logic module with a documented contract (what goes in, what comes out, which reads the host must supply), thin host wiring, and optional integrations behind adapters outside the core contract. A host may supply another tool's result or expose a hand-off, but it may omit every integration and the module must still work. Tests run the core with integrations absent and throwing: an integration failure may remove convenience, never break the main tool. Portability is the honest test of R32.

R39 🤖 A second opinion, without growing a backend planned

Bring your own key, and let a model read a policy with you — starting with one 🤖 Second opinion button on the expanded policy card, next to the per-policy 🧪 What-If flow, because that is already the corner where you interrogate a single policy. The name is the promise: advisory, and labelled as such. Later the same panel takes a selection — two or five policies you suspect are interfering, which is where conflict and shadowing analysis actually lives — and later still the whole set. One policy first is not timidity; it is the only scope where the answer can be checked against tools that already know the answer.

No server, and it does not need one. The obvious build is a small backend holding the key and forwarding the calls. ENCA is not going to grow one: everything here is static files in a browser, nothing stored anywhere, and that promise is worth more than any feature. The Anthropic API can be called directly from the browser — CORS is enabled for it, opted into with the anthropic-dangerous-direct-browser-access request header. The key is pasted per session and held in memory, never in localStorage where it would outlive the tab and sit in a profile backup; it goes to api.anthropic.com and nowhere else; the whole feature is off until somebody types one in. Tools that reach for a proxy here are solving a problem this architecture does not have.

It reads the findings, not just the JSON — and it says where each line came from. A policy alone is the one thing ENCA is already strongest at: 📘 MS Learn, 📐 CIS, ✅ CA validator and the dependency inspector all judge it deterministically, and a model paraphrasing them adds nothing but a chance to be subtly wrong. So it is handed what those tools found — 🔍 Gap analyse already tags every finding with a policy id, so the per-policy slice is free — and every line it produces must be one of two things: a relayed finding, linked back to the tool that made it and styled like ENCA's own, or its own reading, marked as opinion and never dressed up as a check.

Disagreement is the output, not a bug to hide. When the model contradicts a deterministic result — “📐 CIS scores this a pass; I think the scope makes it ineffective because…” — that is shown, loudly. Either the model is wrong or a check has a blind spot, and both are worth knowing; neither survives being quietly resolved in favour of one side. And it fails soft, per R32: it works with nothing but the policy, and states what it did not see — “🔍 Gap analyse had not run, so nothing about who bypasses this was in what I read” — which tells you the answer is thinner than it could be and exactly which button makes it better.

Redaction, and the sharp edge is not the one you would expect. Replacing every name with a placeholder would destroy the most useful signal there is: CA101-GRANT-Admins-AnyApp-MFA is the stated intent, and intent-versus-implementation is precisely what a model is good at. So it is three tiers, not one switch. Always replaced: tenant name and domain, user UPNs, object GUIDs. Kept: the baseline convention — CA numbers, CAB-SEC-*, CAD-SEC-* — which is published documentation, not a secret, and carries the meaning. Replaced, with the mapping held in the browser so a finding reads back to real names: the tenant's own group and location names, SEC-VIP-Exceptions and the like, which are facts about that organisation. Conveniently that is a split ENCA can already evaluate, because it is the same convention-or-not question R28's group mapping answers.

Two things it will not do. It will not generate PowerShell: ENCA ships scripts written and tested deliberately, and a model improvising one that writes to a tenant is exactly the wrong shape for a toolset whose pitch is that it cannot do much harm. And its output never reaches 📄 Create documentation alongside the deterministic findings — in one document a model's opinion and a CIS control result look equally authoritative, and they are not. The connect-src in the Content-Security-Policy has to name api.anthropic.com: a deliberate, reviewable one-line change, not something to slip in ahead of the rest.

→ and further, steered by what you ask for

❓ How to use ENCA

A review and management toolset for your Microsoft Entra Conditional Access baseline. Sign in gives read-only Graph access; tools that can change your tenant are marked writes to tenant and ask for extra permission only when you run them. Imports are staged Off and verified. Replacements can then retain the previous state; as-is imports can retain the source state. Deletes need a typed confirmation.

🗂 Policies

Browse every Conditional Access policy, grouped by persona (the CA-number range in the name: Global 000–099, Admins 100–199, Internals 200–299, and so on) — and act on what you have picked without leaving the screen.

One door. Until build 311 there were four tiles onto this one screen: 🗂 List Policies, 📄 Create documentation, 🗄 Backup (JSON) and 🎚 Set Policy state. Each of them opened this view, pre-selected one action and showed a toast naming a button that was already on the action bar. They are one tile now, and the three verbs are below as sub-sections — T02, T04 and T05 keep their numbers (see 🔢 Tool numbers). Nothing was removed and no route changed: the action bar, the per-card quick actions and every deep link from another tool work exactly as before.

  • Cards / List / Settings matrix — Cards show each policy in full; List is a compact one-line-per-policy view; the Settings matrix is a policies × settings grid for spotting differences at a glance. The matrix has a full-screen button.
  • Search — filter by CA number, policy name or persona.
  • Select policies (checkboxes) — a green action bar appears: send the selection straight to Documentation, Backup, Assign groups or roles, Set state or Delete.
  • Per-card quick actions — Documentation, Backup, Assign groups or roles, and Policy state, scoped to that one policy.
  • Apply flow (per persona) — a visual of what actually applies to a persona, including Global policies in scope and honouring exclusions (an excluded persona group drops the policy from the flow).
  • 👯 Duplicates NEW — the same policy twice. 🧹 Housekeeping reads VERSIONS and needs one in the name; a baseline whose names carry none (Joey Verlinden's) leaves a second import sitting beside the first, which is how Courseware ended up with CA004-Global-…-AuthenticationFlows as CA018 (Off, on the deploy group) and CA019 (Report-only, All users). A duplicate set is one name carried by two or more policies, the staging prefix aside. The version is part of the name, so v1.0 beside v3.0 is not a duplicate — that is Housekeeping's case; the same CA number under two different names is a number clash, which 🧬 Baseline reports. Each set is compared with the same engine as 🔍 Compare and gets a verdict: identical (nothing but the state differs), assignment differs (only who they reach), or needs review (conditions, controls, or a guest or workload scope a merge cannot express — never merged here). Each row says when that copy was created — to the minute, in your own timezone — and which of them is the newer one, because two runs of the same import land on the same day and the date alone answers nothing. A copy changed after it was made says that too. Merge is: pick the copy to KEEP, tick what to bring across from the other (include groups, exclusions, directory roles, the state — each tick says what it changes, and whether it widens who the policy reaches), and the rest are deleted. The copy that is On is never the one deleted while the kept one is not: that set is refused with the reason on it. A needs review set carries a Reviewed tick — the tool refuses to decide a security question for you, not to be overruled on one you have looked at: tick it and the set can be selected and merged like any other, with the reason it was held recorded in the report. The page behind the popup does not scroll while it is open, the list keeps its place when you tick something, and closing a comparison comes back to the set it was opened from. Then a plan — what is patched, what is deleted — a JSON backup of what goes, the typed DELETE, the run ledger and a Markdown report. A deleted policy is restorable for 30 days in ♻️ Recycle bin.
  • 🧹 Housekeeping — lists older versions when a higher version of the same CA number exists, including On and Report-only policies. Status, assignment and configuration differences are shown as review points. Compare opens a read-only, field-by-field view of both versions, with additions and removals highlighted; turn off Differences only to see matching settings too. Every row is a setting: a block one version leaves unset is compared setting by setting against the other (build 315), an authentication strength is shown as its name and id rather than as the strength's own combinations, and Graph's @odata annotations — which name the policy's own id and made two policies on the same strength look different — are not settings and are left out. Only an Off version with matching configuration and a newer On successor can be selected for the normal delete confirmation (JSON backup + typed DELETE). Nothing is selected automatically.
  • The policy ID is on the expanded card too. The compact card carried it and the expanded one did not, so opening a policy to look at it properly took away the one string that identifies it — on the card that gets exported, screenshotted and pasted into a ticket. Two policies can share a name, a version and a persona; the portal is reached by id. One click selects the whole GUID.

Browsing is read-only; the two writing actions on the bar — 👥 Assign groups or roles and 🎚 Policy state — each confirm before they touch the tenant.

📄 Create documentation folded into this tool — build 311

Turn all or selected policies into shareable documentation carrying your tenant's branding. For a full control hand-off, the same document can include the restricted administrative units that protect the exclusion groups.

  • Word (.docx) — an editable document, one section per policy.
  • PDF — the same, print-ready.
  • PNG — a single image of the selection.
  • PNG bundle — a zip with one image per policy.
  • Include restricted administrative units — available for Word, PDF, Markdown and the PNG bundle. It adds an overview plus one page per restricted unit: persona and CA range, protected groups, every tenant policy that includes or excludes them, scoped administrators and whether the role definition can change group membership. Findings name a unit with nobody scoped, an empty unit, a frozen role-assignable group, and anything that could not be read.
  • The integration is optional and fails soft. Create documentation reads the units itself; it never depends on the Restricted AUs screen having run. If the unit list, one unit, scoped roles or role definitions are unavailable, the unknown part is labelled rather than guessed. The Conditional Access policy document still exports — an optional appendix may lose detail, but it cannot break the main tool.

On a baseline tenant (the 🧪 badge in the header) this documents the baseline catalog, not the tenant — the same rule 🗄 Backup follows, through the same code. The export is scoped to the persona CAxxx policies, the modal says how many of the tenant's own are being left out, and the scope is carried into the file itself: a line on the PDF cover, a quote under the Markdown header, the Word title, a SCOPE.txt beside the images in the PNG bundle. A document is read by somebody who was not in the room when it was made, and a policy that is absent leaves no trace of its absence.

The restricted-unit appendix is not filtered there: on a baseline tenant those units are the baseline's own persona vaults, so they are documented as they stand — scoped administrators included. The modal says so at the point of choosing, rather than leaving you to work out why one half of the document is narrowed and the other is not. PNG is the exception: loose images have no cover, header or companion file, so they cannot state their own scope — the modal warns when you pick it on a baseline tenant, and the PNG bundle is the one to reach for instead.

Read-only and produces a download. Nothing is written to the tenant. Restricted-unit pages use all loaded tenant policies for their impact references, even when only a subset of policy pages is selected, so the document cannot understate what widening an exclusion would affect.

🗄 Backup (JSON) folded into this tool — build 311

Select policies (or take all) and download their raw JSON in a zip — dependencies (groups, named locations, auth strengths/contexts) and any Terms-of-use PDFs are included, so the backup is importable.

On a baseline tenant (the 🧪 badge in the header) this tool backs up the baseline catalog, not the tenant. That tenant is where the baseline is built, so the backup is scoped to the persona CAxxx policies and the dependencies those policies reference; the tenant's own Conditional Access, and anything only it depends on, is left out. The confirmation says how many policies were skipped, the zip is named ConditionalAccess-BASELINE-… instead of -JSON-, and it carries a BackupScope.json stating what was left out — a zip is read long after the tenant that produced it is out of reach. There is no “include everything” option: a customer import is the place a stray tenant policy would surface, and by then nobody is looking.

Read-only download.

🎚 Set Policy state writes to tenant folded into this tool — build 311

Switch selected policies between On (enabled), Report-only (logged, not enforced) and Off (disabled).

Writes the new state to the tenant after you confirm.

🗣 User impact brief

The rollout communication, derived from the policies this tenant actually has: what people will notice, and what will deliberately no longer be possible. It is the one document in ENCA written for the people the baseline happens to, rather than for the person deploying it.

  • Every statement names its policies. Click one and the policy card opens — a sentence the communications team is about to send to the whole company can be checked against the configuration it claims to describe, which is the only thing that makes a generated brief safe to forward.
  • What is live is kept apart from what changes at go-live. The timing comes from the policy states, not from a guess: statements backed by enforced policies are marked live now and collected under Already live today; anything backed only by report-only or Off policies reads as at go-live. A statement resting solely on Off policies can never claim to be live.
  • Audience filter — everyone, employees, external consultants, guests, guest admins, developers, factory workers, administrators, whichever of them this tenant's policies actually address. Service accounts, workload identities and the break-glass lockdowns are deliberately not audiences and appear under no filter: nobody writes a rollout email to a service principal.
  • The persona baseline speaks first. Policies carrying a CAxxx number are analyzed first and carry the brief; policies without one are analyzed last, in their own trailing section, marked as possibly temporary — a hand-made policy that has been sitting in the tenant for a week should not put a sentence in the middle of a company-wide email. On a tenant with none, there is no trailing section and no mention of it.
  • Export as Markdown or Word for the communications team, with an appendix naming the same policies as the cards on screen.

Read-only, and it reads nothing at all: the analysis runs over the policies already loaded in this session, so there are no Graph calls and no extra consent. In BETA — the risk here is wrong words rather than wrong writes, so read the brief against what you know the tenant enforces before anybody sends it.

🔍 Gap analyse

The funnel and its last stage. 🎫 Licence gap computed the same obligation this funnel's final stage already reports — every user a policy targets needs Entra ID P1, a risk condition needs P2 — and then said how to close it. It is that stage opened up rather than a second tool, so from build 311 it is the 🎫 Licences tab; T03 and T31 keep their numbers (see 🔢 Tool numbers) and 🎫 keeps its section below. One thing to know about where the strip lives: this tool IS 🗂 Policies' screen in its analyze view, so the strip is taken back out when you open 🗂 Policies — a tab offering to switch a tool you are not in would be worse than no tab.

A users × policies impact matrix: who is in scope for what, who bypasses a policy and why.

  • Coverage NEW — the funnel above the results, and usually the first thing worth reading. Five stages, each a subset of the one above: everyone in scopetargeted by at least one active policy → covered by one that is actually enforced → required to do MFA → holding the licence their own policies oblige. Every bar is drawn against the same denominator, so the shape of the drop is readable without doing arithmetic on five percentages.
  • The drops are the finding, not the totals. A tenant with 900 targeted users and 40 covered by an enforced policy has a report-only backlog, not a coverage problem — and those two look identical in any single number. Users reached by no active policy at all and users targeted only by report-only policies are called out by name in the notes under the funnel, because for the first group Conditional Access is not weak, it is absent.
  • Click a row to list exactly who did not get that far, and click it again to clear. These are deliberately not the same sets as the summary cards below: “No MFA from CA” counts everyone without MFA including the users no policy reaches, while the funnel’s MFA drop counts only those a policy does reach and still does not ask. A row with no drop is not clickable rather than being a link that does nothing.
  • The licence stage reads its own data — the assigned licences ride along on the user read the tool was doing anyway, and the subscribed SKUs are one more call, both covered by permissions it already holds. It does not depend on 🎫 Licence gap having run, and it uses that tool's own verdict function, so the two cannot disagree about whether a user is licensed. P2 is required where a risk-based policy targets them, P1 otherwise; report-only counts, because the obligation follows targeting rather than enforcement.
  • Not read is not zero. If the licence read is refused or fails, the stage says not read and every other stage is unaffected — “we did not look” and “nobody is licensed” are opposite findings. Targeted users with no licence record at all (guests, or a capped read) are left out of the stage and counted separately, never rounded up to licensed.
  • Group filters — narrow the matrix columns to the policies that target chosen groups.
  • Apply flow — the same per-persona flow as in List Policies.
  • Its own toolbar, and one way out. Gap analyse renders inside the List Policies screen, so it used to sit under that screen’s Cards / List / Matrix picker — which switched away from Gap analyse rather than within it, with none of the three highlighted because none of them was where you were. Its Matrix also meant the policies × settings grid, while the Matrix tab in this tool’s own toolbar means users × policies: two buttons, one label, one screen. The picker is hidden here now and a single ← Back to policies button takes its place, returning you to whichever view you came from. The tool’s own Users and Matrix tabs appear once a run has produced results.
  • Export — a standalone HTML report you can open outside the tool.
  • Scan only who you asked about. Scope → Only these users or groups names principals before the run and judges those, with the same policies and the same verdicts. A group is expanded to its members, nested ones included — the scan judges people, not groups, because a group being in scope of a policy says nothing about a member another policy excludes. In this mode nothing is read tenant-wide, which is where the time goes on a large tenant. The result carries a banner saying what was scoped, and the exported report says it too: "no risky bypasses" over four named people and over the whole tenant are different answers that would otherwise look identical.

Read-only.

🎫 Licence gap folded into this tool — build 311

Microsoft has started showing a “Licensing overage” warning on the Conditional Access page, linked to the licence usage blade. The blade counts evaluated users — unique users who actually triggered a policy during the previous month. But that is not what Microsoft licenses on: every user targeted by a Conditional Access policy needs Entra ID P1, and every user targeted by a risk-based (sign-in / user risk) policy needs P2 — whether they signed in or not. A blade showing “2 of 25, no warning” can sit on a tenant whose broadest policy targets every one of its 121 users. This tool computes the targeted number — the obligation — and compares it with the seats the tenant owns.

  • Targeting is resolved on the actual members — an All-users policy is the member-user count minus its resolvable exclusions; a scoped policy is the union of its included users, transitive group members (nesting counts) and directory-role holders (a role held through a role-assignable group is expanded to its users; service principals holding a role are ignored — they hold no user licences), minus its exclusions. Per-policy counts overlap, so the obligation is the union across policies, computed on user ids — one user under five policies needs one licence, never five.
  • Seats are matched on service plans, not SKU names: any subscription carrying the AAD_PREMIUM / AAD_PREMIUM_P2 plan counts (EMS, M365 E3/E5, Business Premium…), a P2 seat counts for the P1 obligation too, and suspended or cancelled subscriptions are excluded by capabilityStatus. The comparison is against seats owned, not assigned — an unassigned seat still covers a targeted user.
  • The gap is named, in a pop-out — the on-screen card shows the breakdown; the full list opens in a searchable dialog (large tenants make long lists), and the complete list is always in the Markdown export. 🏷 Check mailbox types reads each gap user's mailbox purpose (MailboxSettings.Read, asked once on the click) and labels shared, room and equipment mailbox accounts — those are never licensed and should be disabled or excluded, not bought for. A delegated sign-in usually cannot read another mailbox's type directly, so the check also reads the failure: an unlicensed account whose mailbox read is denied has a mailbox without a licence — almost always shared/room/equipment — and is labelled likely shared/resource with a verify-in-Exchange note, while “no mailbox” confirms a regular unlicensed account.
  • Honest numbers — a count where a group was unreadable or over the read cap, or a role could not be read, is marked rather than presented as exact. Report-only policies count (they evaluate everyone in scope); disabled policies do not, but a disabled risk-based policy is named as a P2 obligation waiting to happen. Guests are deliberately not counted — guest licensing follows different rules.
  • Why gaps exist and how to close them — the result explains the usual cause (an All-users baseline sweeping in disabled accounts, stale synced users, service accounts and shared mailboxes) and lays out the options with their trade-offs: clean up and exclude before buying, map admin accounts to their owners (one person, one licence — the counts here are identities), narrow the targeting only as a deliberate decision because everyone outside a licensed-users group goes unprotected, Security defaults for small tenants (cannot coexist with CA), and finally buying the remainder.
  • 👑 Exclude admin-accounts groups — Microsoft licenses people: a second internal account (an adm- account next to a day-to-day account) needs no second licence. Pick the group or groups that hold those accounts, one after another — the field auto-fills with the groups your CA policies already reference and searches the directory as you type — and the union of their transitive user members drops out of every count and named list. Each group gets its own chip with its own ✕. The exclusion is stated on the result and in the export, with the caveat that it assumes every member maps to a licensed owner: document the mapping.
  • 📄 Export MD — the comparison, the per-policy table and the mitigation options as Markdown for the licensing conversation.

Read-only. Covered by the baseline Policy.Read.All + Directory.Read.All; only the optional 🏷 mailbox-type check asks for MailboxSettings.Read, once, on that click. After Rudy Mens' Get-EntraLicenseGap.ps1 (lazyadmin.nl). The overage banner is a warning today, not enforcement — this tool exists so your number is ready before that changes.

🛡 Checks

Three references, three tabs. 🛡 Best-practice & bypass checks, 📘 MS Learn checks and 🖥 Device reality check all answer one question — what is wrong with this baseline, judged against a reference — and each was its own tile with its own Refresh and its own Export MD. One tool with rule packs as tabs from build 311; T08, T07 and T30 keep their numbers (see 🔢 Tool numbers) and their sections below. A fourth pack, 📐 CIS Benchmark (T21), runs on the beta channel only for now and is not shown here. Each pack asks for its own permissions on its own ▶ run, never on opening the tool, so reaching 🛡 Checks never prompts for Intune scopes you were not going to use.

What is deliberately not here: one findings table across the packs. They present genuinely different shapes — a persona × control matrix, findings with buildable fixes, a four-tier CIS score with an L1/L2 filter, a per-policy-per-platform grid — and flattening them into one table with a Source column would lose information rather than share it. If that changes it will be its own release, not something that arrived behind a tab strip.

Checks the baseline against known Conditional Access bypasses and the Swiss-cheese model.

  • MFA coverage, FOCI token-sharing, break-glass coverage, known bypass apps, and a persona × control coverage matrix.
  • Each finding has a severity and an explanation; a deployed-but-Off policy is flagged as not yet enforcing.
  • Export MD — the findings as Markdown for a ticket or review.

Read-only.

📘 MS Learn checks folded into this tool — build 311

Checks policies against documented Microsoft Learn exclusions and limitations — break-glass, token protection, Teams Rooms, service providers, retired features.

  • Findings tab lists what conflicts with the docs; Refresh re-runs after you fix something.
  • Service provider (CSP / GDAP) exclusions — five checks covering the partner who administers this tenant on your behalf. They are matched by the Service provider users external user type and by nothing else: a partner admin arriving through delegated privileges holds no account, no group membership and no device here, so a group exclusion or a named break-glass account never reaches them. The checks find an external-user exclusion that lists the other five types but not this one; a Block policy their sign-in falls into; a compliant or hybrid-joined device requirement they cannot meet unless cross-tenant access settings trust device claims from their tenant; grant controls documented as unsupported for external users (approved client app, app protection policy, password change); and a guest MFA policy that leaves out the one external identity holding admin roles here. Exclusions naming specific tenants are read as such — an exclusion for two partner tenants does not cover a third.
  • Read once, judged against your tenant. Cross-tenant access settings are read to see which partners are flagged as service providers and whether this tenant accepts their MFA and device claims. A partner whose compliant-device claims you already trust does not raise a device finding, and a tenant with no partner at all has the five checks skipped rather than answered — the summary says so. If the settings cannot be read the checks still run and say the trust configuration was not verified, which is not the same as saying there is nothing there.
  • Buildable fixes — where a documented fix exists, apply it in the tenant (creating a service principal or group if the fix needs one). writes to tenant

Checks are read-only; applying a fix writes, and produces a change report.

🖥 Device reality check NEW folded into this tool — build 311

The other half of the require compliant device grant. The CA side names who must present a compliant device; the Intune side decides which devices can ever be one — and neither portal checks that the two halves meet. Per CA policy and per platform: is the policy's scope actually assigned an Intune compliance policy, or does it fall through to a tenant default almost nobody has read?

The four verdicts — one per platform, and the policy card wears the worst of them:

  • ✅ covered — proof was found: a compliance / app-protection policy for that platform is assigned to All devices or All users, or the CA policy's include group is assigned directly, or every member of the CA scope was matched inside the assigned groups (the membership pass). Covered means the check has someone to answer for this scope — it says nothing about how strict that compliance policy is.
  • ⚠️ not proven — Intune policies exist for the platform, but coverage could not be proven, and the tool refuses to guess. The reasons it says so: the CA scope cannot be enumerated (All users, guests, roles — there is no member list to match); only some members of the scope matched (“n of m covered”); an assigned group is a device group, which cannot be matched against the users of the CA scope; or the included group is empty today. Not proven is not the same as broken — it is “nobody can show you this works”, which for an audit is usually the same problem.
  • ❌ uncovered — a proven gap as far as reads go: no Intune policy exists for the platform at all, or none of the members of the CA scope is in any assigned group. What happens next is the tenant default above: marked-Compliant means the CA grant passes silently for exactly the people the policy names; marked-Not-compliant means those people get blocked instead.
  • 🚫 n/a — the control cannot exist on this platform. Approved client app / app protection is an iOS-and-Android mechanism; a CA policy demanding it on Windows or macOS has written a condition no sign-in from those platforms can ever satisfy.
  • The tenant default comes first, because it decides what every gap means: Intune's “Mark devices with no compliance policy assigned as” toggle. Set to Compliant, an uncovered device passes the CA grant silently — the policy enforces nothing for that scope. Set to Not compliant, the same gap surfaces as blocked users instead: loud, but not silent.
  • Per platform, not per tenant — compliance policies are per-platform, so a CA policy scoped to “any platform” can be covered on Windows and wide open on macOS. Settings-catalog compliance policies are read too (Linux only exists there).
  • Coverage is proven from assignments first: All devices / All users is proof; a CA include group directly assigned a compliance policy is proof. Each Intune policy on a verdict is listed with the groups it is assigned to (and its exclusion groups), because which groups decides who falls through.
  • Then the members themselves — a different group name does not mean a user is not covered: the CA policy can include a persona group while Intune assigns a BYOD group, and if the same people are in both, the coverage is real. Where assignment names could not prove it, the tool expands the memberships (transitive, so nesting counts) of the CA include groups and the Intune-assigned groups and matches the users: all matched → covered by membership, some → “n of m covered”, none → uncovered. Per-policy Intune exclusions count against that policy only. Scopes that cannot be enumerated (All users, guests, roles) stay honestly “not proven”, an Intune policy assigned a device group is flagged as unmatchable to users rather than counted as a gap, and an unreadable or over-cap group falls back to assignment names instead of pretending to be empty.
  • OR-alternatives are named — a policy granting compliant device OR MFA does not block an uncovered device, it just lets the sign-in through on MFA. That is a gap in what the policy name promises, not in availability, and the card says which it is.
  • Same check for app protection behind “require approved client app / app protection policy” — an iOS-and-Android mechanism, so a CA policy demanding it on Windows or macOS is flagged as a control that can never be satisfied there.
  • 📄 Export MD — the verdicts as Markdown for the review record.

Read-only. Needs DeviceManagementConfiguration.Read.All and DeviceManagementApps.Read.All, asked once on the click, plus an Intune RBAC role that can read the workload (e.g. Read Only Operator). Assignment targets are checked first; where names alone cannot prove overlap, the tool expands transitive group memberships and matches the users. An unreadable, unenumerable or over-cap scope stays not proven rather than being guessed empty or uncovered.

🧪 What-If

Two directions, one evaluator. 🧪 What-If evaluates one described sign-in against every policy; ⚡ CA validator turns it around and enumerates every sign-in a policy implies. They were two tiles, and the plan for folding them carried a condition: they must not be able to disagree. They cannot now — build 311 removed a dead second simulator so both go through WhatIfEval.evaluate, and gave them one scope check (CaScope.of). One tool with a mode strip from build 311; T13 and T14 keep their numbers (see 🔢 Tool numbers) and ⚡ keeps its section below.

Simulate one sign-in and see exactly which policies would hit it — the same idea as the Entra portal's What If tool.

  • Required conditions — user, target resource, device platform and client app. Everything else (IP, country, device state, sign-in/user/insider risk, authentication flow) is optional.
  • Policies that apply — each with the grant controls (and/or) and session controls that must be satisfied. A verdict line at the top says whether access ends up blocked or granted-with-controls.
  • Policies that do not apply — each with the first condition that wasn't met (user excluded via a group, wrong client app, location out of scope, …).
  • Filter that list, because on a real tenant it is a hundred long. The honest answer to a run is two policies that apply and 107 that do not, which is unreadable as one block — and the question behind it is almost never "show me everything". It is a persona question (why did no Admins policy apply to this admin?) or a number question (what happened to CA103?), so the list filters by both, using the same CA ranges 🗂 List Policies groups by. A count per reason comes first — "94 user out of scope, 8 platform not in scope, 5 user excluded" — because when a policy you expected is missing, the reason is what you are hunting for. The reason counts travel into the Markdown export too.
  • Search this tenant's applications by name. Target resource → Other searches your own service principals and fills the id in, rather than asking for a raw GUID. A pasted App ID still works and is resolved to a name underneath the box so the choice is verifiable; an id with no service principal here is reported, not refused, because a policy can legitimately name one — what is refused is a typed word matching no application, since that is neither a name nor an id.
  • Unspecified conditions — if a policy scopes something your scenario leaves blank (say sign-in risk), it can't be evaluated and so does not apply. Fill the field in to test it, exactly as the Microsoft evaluation API behaves.
  • App groups don't match — a policy targeting the Office 365 group won't match by design; pick the specific app. Only enabled and report-only policies are evaluated; Off policies are listed separately.
  • The verdict names the policy that said no. "Access would be blocked" with ten applying policies leaves you to work out which one did it; the blockers are named in the verdict and clickable straight through to the policy, and marked in the list below.
  • Report-only never counts toward the verdict. A report-only policy records what it would have done and changes nothing today, so a report-only block is reported separately — would block once enforced — rather than as a block. Counting it produced "access would be blocked" for a sign-in that in fact succeeds, which is the opposite of the answer you asked for. The same applies to grant controls: what you must satisfy comes from the enforced policies, with the report-only ones listed as what would additionally be required.
  • Export MD — the scenario and both lists as Markdown.

Read-only. Like the Microsoft tool it does not follow Conditional Access service dependencies, so a policy on a downstream service isn't pulled in.

⚡ CA validator folded into this tool — build 311

For each enabled policy, the sign-in simulations it implies — the combinations of user, application, client app, location, device platform and risk it targets — and, per grant control, the control each one should enforce.

  • Expected control — every simulation shows the control the policy would require (MFA, Block, Compliant device, …). When the principal, app, location or platform is on the excluded side, the expectation inverts to “no <control>” — proving the policy does not fire there.
  • Run against a target — optionally enter a user (UPN) or a persona group (name or ID) to scope the report to only the policies that actually apply to that principal (include minus exclude); for a user, their group and role memberships are evaluated. Leave empty to simulate every policy.
  • Include report-only — off by default; tick it to also simulate policies in report-only mode.
  • Filter & search — filter by expected control, or search by policy or simulation text; collapse/expand all policies.
  • Users are shown as representative placeholders (“all users”, “members of <group>”, “role: …”) — no individual accounts are sampled. Session-only policies are listed as having nothing to assert.

Read-only. Ported from Jasper Baes' Conditional Access Validator (CC BY-NC-SA 4.0); the Maester test-code generation is not included.

🔗 User or Group analyzer

An Entra group is a shared handle. One admin scopes a Conditional Access policy to it, another targets an Intune compliance policy at it, a third grants it Contributor on a subscription — and nobody sees the whole picture, so adding a member has consequences that are invisible at the moment of the change. This tool reads that picture back out of the tenant. After Jasper Baes' Microsoft Cloud Group Analyzer, re-implemented here against delegated permissions.

  • One group or user — paste a group name, a UPN or an object ID. For a user, every group they are a member of is taken along, so you see what the person is actually subject to.
  • Parents count too — if the group is nested inside another group, anything targeting the parent reaches these members. Those hits are reported with via parent group “…” in the Matched via column, so you can tell a direct assignment from an inherited one.
  • Sweep every group — runs the same checks across the tenant and gives a group × service count table, plus the list of groups nothing references. Each service is read once and matched against every group, so a sweep costs little more than a single lookup.
  • Only groups used by Conditional Access — the default scope, and the one that matches what ENCA is for. It takes the include and exclude groups off every policy already in memory (enabled, report-only and Off), so it needs no directory enumeration and is bounded by your baseline rather than by tenant size. Any id a policy names that the directory cannot resolve is kept and flagged as a dangling reference — the policy still points at it, but the group is gone, so that assignment targets nobody. Worth more attention than a group that is merely unused.
  • Narrow the sweep by name — a prefix (CAB-SEC-), a suffix or a substring. Naming conventions live at both ends of a name, so this is usually all it takes to cut a 20 000-group tenant down to the set you care about. It runs server-side where Graph supports it and falls back to filtering locally where it does not, and “Groups in scope” then counts matches rather than raw groups.
  • Click a group in the sweep — a popup opens with that group's references, filtered out of what the sweep already read. No second scan, so it is instant however large the tenant. A sweep matches each group only against itself, so the popup shows direct references; ⤢ Deep analyze re-reads that one group with its parent groups expanded. Markdown, HTML and CSV export for that group alone sit in the popup footer. Deep analyze does not throw the sweep away — ← Back to the sweep appears above the result and puts it straight back, filters and all, at no cost.
  • Where to look — Entra ID, Intune, Microsoft 365 and Azure are independent. Tick only what you need; each area asks for its own permissions when it first runs, and Azure needs a separate Azure sign-in (management.azure.com), which is a different token from Graph.
  • Entra — group nesting, administrative units, directory roles (active and PIM-eligible), enterprise application assignments, group-based licensing, the authentication methods policy, Conditional Access, access packages, admin-consent reviewers, and — for a user — what they have actually registered.
  • Intune — enrolment device limits and enrolment configurations (platform restrictions, Enrollment Status Page, Windows Hello for Business, notifications, co-management authority), compliance policies, configuration profiles (device, settings catalog, ADMX), scripts and remediations, app protection, app configuration, app assignments, Autopilot profiles, Windows update profiles, Windows 365 (provisioning policies and Cloud PC user settings — build 315) and Intune role assignments (build 315), in both directions: a group that is a member of a role assignment can manage devices, a group in its scope is what those admins may act on.
  • Microsoft 365 & Azure — whether the group is a Microsoft 365 group with a team provisioned on it, the Purview sensitivity label on the group (the container label that governs the team's and site's privacy, guest access and external sharing), and every Azure RBAC role assignment across the subscriptions and management groups you can read, down to resource scope.
  • What Purview cannot tell you here — the container label above is the only part of Purview exposed to Microsoft Graph. DLP policies, retention policies, sensitivity-label publishing policies, insider risk and communication compliance have no Graph API at all: they live behind Security & Compliance PowerShell, which a static site cannot call. If a group is used to scope one of those, this tool will not see it — check the Purview portal separately.
  • The summary chips are controls — in a sweep, groups clears the filters, with no usage found toggles the unused-only list, services read opens the receipt (every service, what it found, how long it took) and not read jumps to the failures. On a single group, each area chip jumps to its section.
  • Not read — any service that failed, was only partly read, or was never granted is listed by name with the reason, the permission the call needs and the role the signed-in user needs on top of it. Those are two different things: a consented scope with no matching Entra or Intune role still returns 403. Read that list before concluding a group is unused; “nothing found” only means “nothing found in what was actually read”. A 404 is normal for a workload the tenant does not use.
  • Export MD / HTML / CSV — the full report as Markdown, a standalone HTML report that opens on any machine without access to the tenant (the artefact to attach to a change request), or one CSV line per reference for a spreadsheet.

Read-only — it never writes. A Conditional Access policy name opens its card, as in List Policies.

🕵 Who is … to CA BETA

One question, three subjects. 🕵 Who is Anna to CA, 🌊 Who is the wave to CA and ⚖ Compare users were three tiles over one picture. The code always said so: 🌊 resolves every member of a group through 🕵's own policy ladder, and 🕵 resolves its user through ⚖'s resolver. Pick the subject from the strip — a user, a group, or several users side by side — from build 311; T36, T37 and T18 keep their numbers (see 🔢 Tool numbers) and their sections below. Since build 311 all three also share one scope check, so the three subjects cannot disagree about the same person and the same policy.

The question a service desk gets is never “which policies target group X” — it is why can't Anna sign in, why does she keep getting the Authenticator prompt, or is Anna in the wave yet. Answering it used to take four tools. This one takes a UPN and puts the whole Conditional Access picture for that one person on a single screen — and everything on it is hers: her policies, her devices, her sign-ins, never a tenant-wide list.

  • At a glance — five tiles: her deployment stage (which of the active baseline's CAD-SEC-U-DG-* groups she is in), how many policies reach her (enforced / report-only / off, and how many exclude her), how many sign-ins Conditional Access stopped in the window, what happens to her if everything in report-only went live — locked out, extra prompts, no change, nothing for her, or no data — and her identity risk.
  • Policy numbers are the baseline'sCA012 from “(NEW)CA012-BLOCK-…”, the number the person reading knows the policy by — never ENCA's own running number. A policy whose name carries no number is shown in full.
  • Deployment groups — every deploy group and persona group of the ★ active baseline with In / Not in / Not in this tenant, and for each membership how: direct, or nested via which parent group. The exclusion groups she is in are listed with the policies they take her out of; one that bypasses an enforced policy is called a standing bypass. Who put her there is a question for ⚖ Compare users (the membership next to a colleague's) or 🕓 Change audit.
  • Policies — every policy, including the Off ones: state, reaches her via (All users, a group with its path, a role, guest type — or EXCLUDED and by what), the controls, this user's blocked / interrupted counts from the log, and for report-only policies the forecast. Filter chips: reaches her, excluded, not targeted, all; and by state.
  • 🛡 Identity risk — what Identity Protection says about her now (at risk and at which level, remediated, dismissed, never flagged), the risk detections of the last 30 days, the risky sign-ins in the window, and only the risk-based policies that reach her — user risk, sign-in risk, insider risk — with the one that fires now on her current level named. A risk policy that does not reach her (not targeted, or she is excluded) is counted in one line and stays in the policies table, not here. The user-risk read is optional (IdentityRiskyUser.Read.All + IdentityRiskEvent.Read.All), offered as a button, and needs Entra ID P2 to be populated. Nothing here changes the tenant — dismissing or confirming a risk is done in the Entra portal.
  • 💻 Devices she signs in from — one row per device in the window with what Conditional Access saw of it on the last sign-in: Compliant, Managed, not compliant, Registered, not managed or Unmanaged; sign-in and stopped counts, the apps, when it was last seen. A callout says when a compliant-device grant reaches her and sign-ins came from devices that cannot meet it. State is per sign-in, not per device — a laptop made compliant yesterday shows compliant on the sign-ins since.
  • 📵 Retired control: Require approved client app — Microsoft retired that grant on 30 June 2026: a policy carrying it is read-only (it enforces until disabled, cannot be edited — the policies table marks it) and the day it is replaced by Require app protection policy or the control is simply removed, whoever satisfied it through the approved app — a mobile app on a device that is not compliant — is blocked unless an Intune app protection policy already reaches them. The card lists, per retired policy that reaches her, the sign-ins it let through on that path (apps, devices, last), and gives the verdict: blocked the day the control goes (no app protection policy seen for her yet — assign one first), ready for the replacement (a sign-in already shows an app protection policy satisfied on a non-compliant device), or not affected (compliant device, or no mobile app in the window).
  • 🔐 MFA on her sign-ins — the answer to “she keeps getting the prompt on app X — which policy is it?”. A prompt the user completes is a successful sign-in, so it never appears in the stopped list; this card does: per app, the applied policies that demanded MFA (result success with an MFA or authentication-strength grant), how many sign-ins required it, and — from the record's own authentication steps — how many were a fresh prompt versus a claim already in the token, from how many devices, with the last fresh prompt. Then it says why: fresh prompts spread over many devices (a non-persistent VDI pool is one prompt per host), a sign-in frequency or non-persistent browser session reaching her, or every requirement satisfied by the token — in which case the prompt she sees is per-user MFA, security defaults or the app, not Conditional Access. Hunting rows carry the requirement but not the steps and count as not known; the Entra log source tells them apart.
  • Sign-ins Conditional Access stopped — her rows from the log the way 🚦 Sign-in failures shows them: when, app, client, device state and place; under each policy the control it demanded; under the result the error code in words — MFA demanded and not completed (50074), compliant device required and this one is not (53000), device authentication demanded (50097) — and a one-click 🧪 Replay in What-If.
  • If everything in report-only went live — worst case first, per policy, from the report-only verdicts on her own sign-ins, with the reason a denial would happen (“needs a compliant device — device NOT compliant”). Three outcomes that look alike are told apart: out of scope on her sign-ins (the policy was evaluated on every one of her sign-ins and its conditions never matched — no legacy client, no risk, not that app — so going live changes nothing for her as she signs in today), no data (the policy appears on none of her records at all: created after the window, or the source dropped its verdicts — never safety), and a real verdict.
  • 🔑 What she signed in with (build 315) — at the top of the 🔐 card: the methods she used across the window (Password, Microsoft Authenticator, a passkey, an MFA claim already in the token) and the authentication strengths she was held to, from each sign-in's authentication steps — the portal's Authentication Details tab. A sign-in that asked for MFA or a strength and got a password only is called out in red with the app and the newest case; when the strength was phishing-resistant and no passkey, FIDO2 key, Windows Hello or certificate appears in the window, the callout says so: the method is the fix, not a policy. The third tile reads Asked, not given for those sign-ins, and the stopped table prints 🔑 what was used under each result (the steps in the tooltip). Hunting rows carry the requirement and no steps, and say so.
  • Every card folds on its heading — click to fold or unfold; the choice is remembered per card, so the next user or a rescan keeps your layout.
  • Buttons out — ⚖ Compare (adds her, ready for a colleague), 🧪 What-If (user filled in), 🔗 Analyzer (everything outside Conditional Access that points at her), 🌊 the wave she is in, Markdown brief for the ticket with every card, CSV of the policy table.

Read-only. Memberships and policies come from what ENCA already holds — the same include/exclude resolution as ⚖ Compare users. Sign-in source: the segment in the toolbar is shared with 🚦 Sign-in failures, 🎚 Report-only impact and 🌊 the wave — Entra sign-in log (interactive, AuditLog.Read.All), Entra + non-interactive preview, Defender hunting, or Hunting + non-interactive (ThreatHunting.Read.All, Entra ID P2) — see 🚦 for the trade-offs. On the Entra source only this user's sign-ins are read (server-filtered, with a 5,000-event safety cap), or the window another tool already read is reused when it was not capped. Registered MFA methods and identity risk are optional extra reads, offered as buttons and never required. Licence comes from the same verdict 🎫 Licence gap uses.

🌊 Who is the wave to CA BETA folded into this tool — build 311

A rollout goes wave by wave — CAD-SEC-U-DG-GLO, then -INT, then -ADM. The question before the next policy is switched from report-only to On is can this wave take it, and until now the answer was per policy (🎚 Report-only impact) or per user (🕵 Who is Anna to CA), never per wave. Pick a deployment group — the active baseline's deploy and persona groups are offered with their member counts, or type any group — and get:

  • At a glance — how many policies target the group (enforced / report-only / off, and how many reach members some other way), the sign-ins Conditional Access stopped for its members in the window, how many members would be locked out if everything in report-only went live, and the quiet ones — signed in, nothing stopped, no forecast change.
  • Go-live readiness per report-only policy — for each report-only policy that targets the wave: members with traffic, how many would be locked out (named), prompted, unchanged, and how many were silent. Verdict: Not yet (somebody would be denied), Friction only (prompts, nobody denied), Ready (evaluated, nobody stopped) or No data. Silent members are counted as no data, never as safety.
  • Policies and how they reach the wave — targets via a direct include of the group or a parent group, All users, or “reaches N members another way” (a role, another group, guest type); how many members an exclusion takes back out and through which group; the controls; blocked / interrupted counts and distinct members from the log. Policy numbers are the baseline's, as in 🕵.
  • 📵 Retired control across the wave — the same check per member: how many satisfy a reaching approved-client-app policy through the app on a non-compliant device, how many of those already show an app protection policy working, and the red rows — the members who are blocked the day the control goes unless an Intune APP policy reaches them first. Every name opens 🕵.
  • 🛡 Identity risk across the wave — on demand, the Read identity risk button in the head: Identity Protection's user risk for every member (one read each, twenty to a batch; IdentityRiskyUser.Read.All, Entra ID P2), joined to the risky sign-ins the members already have from the window and to the risk-based policies aimed at the wave. Three tiles — members at risk by level, members with a risky sign-in, risk policies firing now on whom — the risk policies with how many members each reaches, and a table of the members that are flagged, remediated or had a risky sign-in. Before the read, a callout already names the members with a risky sign-in in the window.
  • Members — every member with how they got in (direct, via which child group, dynamic rule) and flags: in an exclusion group while the policy is On (a standing bypass), in two waves at once, blocked in the window, would be locked out, no Entra ID P1, disabled. Filter chips per flag; every name opens 🕵 Who is Anna to CA for that person.
  • How the wave is built — direct members, each child group with its member count and dynamic rule, and callouts: two waves at once (both personas' policies apply, the stricter wins), standing bypasses, coverage (is every member also in the Global deploy group), disabled accounts.
  • ■ Stop — the read can be stopped. Stopped while the group is being read, nothing partial is shown (a half member list would report exclusions that are not there) and the read is offered again; stopped during the sign-in window, the group half stays and the note says the log half is missing. A running Graph call cannot be cancelled, so the stop lands after the query in flight.
  • Every card folds on its heading, remembered per card.

Read-only. Referenced group and role memberships are fully paged. Wave analysis reads up to 25,000 members, with a lower per-run limit when needed to stay within two million user-policy evaluations. The screen and exports identify partial membership or sign-in evidence; incomplete evidence cannot produce an activation recommendation. The sign-in window is shared with the other log tools, including the P1 non-interactive preview. Licence per member uses the same verdict as Licence gap.

⚖ Compare users folded into this tool — build 311

Why does it work for her but not for him? Put two or more users side by side and see where Conditional Access treats them differently.

  • Policy assignment — per enabled or report-only policy, whether each user is included (✓), excluded (✗ — hover for the group, role or direct exclusion behind it) or not targeted (·). Rows where the users differ are flagged; Differences only (default) hides the rest.
  • Memberships — the groups and directory roles each user holds, same ✓/· grid. Assignment differences almost always trace back to a row here.
  • What-If scenario (optional) — describe one sign-in (resource, platform, client, IP/country, device state, risk) and it is evaluated once per user with the What-If engine: a verdict per user, then the per-policy matrix of who it would actually hit.
  • Export MD — the whole comparison as Markdown tables, differences marked .

Read-only. Resolution is per user (a few Graph calls each), so it stays fast on any tenant size. Assignment looks at user scoping only — conditions like location or platform only enter through the scenario run.

🫥 Apps with no service principal BETA

Conditional Access can only name what exists. An app that signs in but has no service principal in the tenant — a multi-tenant SaaS nobody consented to, a Microsoft first-party client that was never registered here — is not in the app picker: it can be neither included nor excluded by name, and only a policy on All resources reaches it. Microsoft Graph gives one row per app for the past 30 days in a single call; this tool diffs that list against the tenant's service principals and reports what is left.

  • Five tiles — apps with no service principal, how many have no Conditional Access at all (nothing enabled reaches them, not even All resources), how many depend on conditions, how many are reached the moment they exist (enforced or blocked), and how many are phantom exclusions.
  • Folded rows (build 315) — one line per app: name, sign-ins, verdict, and the counts of what the policy columns hold (1 would · 9 may · 0 excluded by · newest sign-in). Click a row for the full detail — the would / may / excluded-by pills and the newest sign-in; ⊞ Expand all and ⊟ Collapse all above the table set the default for every row, and a click on a row is an exception to it. The state survives a filter, a search and a rescan.
  • Once it exists — the 🧪 What-If verdict for an All-users sign-in to this app with nothing else known: would apply (the policy and its grant), may apply (a policy whose other conditions — location, risk, platform, client app, a group scope — the scenario cannot decide, with the reason), or nothing.
  • Excluded by — a policy that already excludes the id. That exclusion protects nothing today and goes live the moment the app is consented; 🚪 Exclusion analyzer flags the same ids from the policy side.
  • Newest sign-in — after 📖 Read evidence: when, who, the client, the Conditional Access status and the policies Entra recorded as applied. Evidence, never a prediction; where it disagrees with “would apply”, the sign-in is right.
  • Names — the tenant has no service principal to name these apps by, so Microsoft's first-party ids resolve from ENCA's built-in map (tagged Microsoft); anything else is “Unknown app” with its id.
  • Exports — Markdown for the report, CSV with one row per app id for whoever registers them. ENCA does not generate PowerShell: registering a service principal is a decision per app, made in the portal or by a script someone wrote on purpose.

Read-only. GET /beta/auditLogs/signInEventsAppSummary (delegated AuditLog.Read.All, asked once on the run click; Reports Reader or Security Reader; a fixed 30-day window, at most 1,000 apps — the tool says when it hit that cap) and GET /servicePrincipals. The sign-in evidence is the shared sign-in window. An app that signs in only non-interactively still appears in the summary; whether it appears in the evidence depends on the sign-in source chosen in 🚦.

🕓 Changes

Two sources, one question. 🕓 Change audit and 📉 Drift watch were two tiles asking what moved — of the Entra audit log and of a snapshot file you keep — over one diff engine (Audit.diff, which Drift watch already imported from here). They are one tool with two tabs from build 311; T16 and T28 keep their numbers (see 🔢 Tool numbers) and their sections below. The two bodies are deliberately not merged into one: a rolling 30-day timeline of who edited what and a two-point comparison against a file are different shapes of answer, and the tab strip is what says which you are reading.

Who changed which Conditional Access resource, when, and exactly what changed — read from the Entra directory audit log.

  • Covers policies, named locations, authentication strengths and contexts, and terms of use — add, update and delete.
  • Field-level diff — expand an entry and you get the fields that actually moved (state: report-only → enabled, a group added to excludeGroups), not a wall of JSON. Assignment lists are compared as sets, so you see the one group that changed rather than the whole array.
  • Who and from where — the user or application that made the change, with the source IP, plus the result if it failed.
  • Range — use the last 1 hour, 4 hours or 24 hours to verify a fresh rollout or investigate an incident without paging through a week of unrelated changes. The 7, 30 and 90-day windows remain for reviews; Entra returns only what its retention still holds.
  • Filters — by resource kind, plus search across resource, person and changed field name.
  • Export MD — the whole change set with per-entry diff tables, for a review or a ticket.

Read-only. Needs AuditLog.Read.All (requested when you run it) and a reader role such as Reports Reader, Security Reader or Security Administrator. Audit retention is licence-bound — roughly 30 days on Entra ID P1/P2, 7 days otherwise — so this is a rolling window, not full history.

📉 Drift watch folded into this tool — build 311

What moved in the Conditional Access configuration since a snapshot you took earlier. Change audit answers who changed this inside the selected audit window — from the last hour up to the history Entra still retains; Drift watch answers does the tenant still look like it did when we signed it off — with no time limit, because the history is a file you keep rather than a log Microsoft ages out.

  • 📸 Take snapshot — reads the policies, named locations, authentication strengths, authentication contexts and administrative units and downloads them as one JSON file. Store it with your review files. Nothing is uploaded and nothing is kept server-side.
  • 📂 Load snapshot & compare — pick that file back up days, months or a year later. The tenant is re-read and the two are compared object by object.
  • Restricted administrative units are watched too. They sit nowhere near the Conditional Access blade, but they are what stops a tenant-wide Groups Administrator adding somebody to an exclusion group — so a group quietly leaving one widens the bypass surface exactly as much as editing the policy would, and nothing else in this tool would notice. A member removed from a restricted unit ranks Critical, a scoped administrator added ranks High (somebody new can now edit the protected groups), and a unit deleted ranks Critical. Needs AdministrativeUnit.Read.All, asked for on the click; decline it and the area is reported as not captured rather than as clean.
  • Ranked by severity — a policy switched Off or to report-only, an exclusion widened, a location turned trusted rank Critical, because that is how protection disappears without anyone deleting a policy. A rename ranks Low. The highest severity present is shown up front.
  • Readable diffs — GUIDs resolve to group and location names, and fields that move on their own (modifiedDateTime) are ignored, so a policy nobody touched reads as unchanged rather than as noise.
  • Honest gaps — an area that could not be read is reported as not captured, never as “no drift”. A clean bill of health is only given for what was actually compared.
  • Who, where known — run 🕓 Change audit first and choose a window that reaches back before the suspected change: 1, 4 or 24 hours for fresh drift, longer for older drift. Each drifted object then names the person who changed it while that event is still inside Entra's audit retention. Older drift is shown without attribution rather than with a guess.
  • Export MD — the drift report for a review, a ticket or a customer's change file.

Read-only, and uses the permissions the session already has. The snapshot contains your Conditional Access configuration — treat the file with the same care as any policy export.

🛡 Restricted AUs

Restricted management administrative units — the vaults that shield your Conditional Access exclusion groups from tenant-wide administrators. Members of a restricted AU answer only to roles scoped to that AU; a Global Administrator can read them but not change their membership.

  • The baseline check comes first. The tool opens with a panel that looks for the nine units the baseline expects — CAB-SEC-RMAU-GLO / ADM / INT / EXT / GUESTUSERS / GUESTAdmins / SA / WLI / DevOps / FW-Exclusions plus CAB-SEC-RMAU-BreakGlass — and marks each one present, missing, or a name clash. One unit per persona is the whole point: with a single shared vault, whoever may manage the Admins exclusions may equally manage the Externals exclusions, and the separation you built the personas for stops at the AU boundary.
  • Create the missing ones — tick them, or take all. Each is created with the restricted flag set, and you are granted Groups Administrator scoped to it in the same step. That second half is not a convenience: an administrative unit with no scoped administrator is a vault nobody can open, because the restricted flag is precisely what shuts out the tenant-wide roles. Creation and the role grant are reported separately, so a unit that exists but has no administrator is visible as the half-outcome it is rather than counted as a success.
  • A name clash is refused, not skipped — if an AU already carries an expected name but is not restricted, it is shown as name taken — not restricted and is left alone. The flag is immutable, so that unit cannot be upgraded into the one you want; rename it and create a restricted replacement, or give the persona a different name.
  • Break-glass gets its own vault. CAB-SEC-U-BreakGlass is excluded from very nearly every policy in the baseline, so it belongs to no single persona — whoever can edit it can walk through every policy at once. It gets CAB-SEC-RMAU-BreakGlass, deliberately without an -Exclusions suffix because it holds the emergency access group itself rather than a persona's exclusions. Keep this unit's list of scoped administrators shorter than any other's.
  • Break-glass groups are matched by intent, not by our spelling. Tenants name them locally — BreakGlass, Break-Glass, Emergency_Access1, BG-Admins — so + Bulk add on the break-glass unit looks for all of those rather than only the baseline's name. A group carrying a CA number keeps that number's persona even if its name also says "emergency": CAB-SEC-U-CA101-EmergencyAccess is an Admins exclusion group, filed deliberately, and a coincidence of wording should not overrule it.
  • Groups, not the accounts themselves. Bulk add offers security groups. If Emergency_Access1 is a user account rather than a group, do not add it here — a Global Administrator inside a restricted unit cannot have their password reset by anybody, which is the one thing you need to work during the incident these accounts exist for.
  • Workload identities get a vault that will often be empty — and that is correct rather than a mistake. CA900–CA999 policies target service principals, which cannot be members of an administrative unit at all; only users, groups and devices can. CAB-SEC-RMAU-WLI-Exclusions holds whatever exclusion groups the range uses, which is worth separating because those groups gate the automation that runs with nobody behind it.
  • 📄 Report — the eleven rows, their status and their scoped administrator as Markdown, for the design document or the hand-over.
  • Ceilings worth knowing — a tenant may hold at most 100 restricted management administrative units, so eleven is comfortable. Only security groups can be members: Microsoft 365, mail-enabled security and distribution groups cannot. Deleting a unit can take up to 30 minutes to lift the protection from its former members.
  • Open a card — click its header, or the 👤 Scoped admins button, to see members and scoped role grants. The ✎ Edit dialog is only the AU's own name and description; roles are not granted there.
  • + Bulk add <persona> groups — on a baseline persona unit, gathers this tenant's security groups whose CA number falls in that persona's range and adds the ticked ones in one run. Matched by the same rule ⑥ Protect routes by, so the two cannot disagree. Adding twenty groups one at a time through the type-ahead is the same decision twenty times, and the failure mode is not noticing you stopped at nineteen.
  • What cannot go in is shown as prominently as what can, because those rows need a different action rather than another attempt: a role-assignable group (would be frozen — convert it with ⑦ Migrate first), a Microsoft 365, mail-enabled security or distribution group (only cloud security groups may be members), one already in this unit, and one already in another restricted unit — that last is not a duplicate but a widening, since a scoped administrator on any unit an object belongs to can manage it.
  • Add a member — the box suggests your baseline group names before you type anything, since those are what the AU exists to protect, and folds in live directory results for any other group or user as you type.
  • 🏷 Group personas — this tenant NEW. Every tool here routes a group to its vault by reading the CA number in its name, which works for the baseline and for nothing else: SEC-VIP-Exceptions and Contractors-NoMFA match no persona, so ⑥ Protect skips them, + Bulk add never offers them, and break-glass had to be special-cased by name to work at all. Most tenants have groups that predate the baseline, and telling them their naming is wrong is not a feature. Say once where such a group belongs and every tool routes it there afterwards — ⑥ Protect files it, + Bulk add and the persona chips offer it, 📥 Import places a reused group with it, and both the Markdown report here and the restricted-unit pages in 📄 Create documentation state the mapping.
  • It does not guess. Matching is exact — the group's object id, or its display name compared case-insensitively. Nothing is inferred from a prefix or a word in a tenant's name — the baseline's own groups are the one exception, and they are not a guess: CAB-SEC-U-Persona-Admins and CAD-SEC-U-DG-INT name their persona in words the way CA101 names it in a number, and are read the same way. Everything else is left alone, because a mapping nobody stated is how a group ends up in the wrong vault silently, and a vault is an authorisation boundary: the wrong one hands a persona's scoped administrator another persona's exclusions. What you state wins over the CA number — somebody typed it, against this group, in this tenant — and ⑥ Protect marks such a row mapped here so a group filed somewhere its name does not explain always says why.
  • And it does not hide the unmapped. 🔎 Find the unmapped groups lists every group your policies reference that nothing places — no CA number the baseline recognises, and nothing said here — with a one-click persona for each. A group with no persona stays visible as unmapped rather than quietly dropping out of every list, and ⑥ Protect says the same on the row instead of silently skipping it. Filing a group from that list records its object id, so it survives a rename in Entra; typing a name into the box can only record the name.
  • Where the mapping is kept. In this browser, under this tenant's id — nothing is written to your directory, nothing leaves the machine, and the hosted site goes on storing nothing server-side. Two consequences, stated rather than hidden: it does not follow you to another browser or another machine, and a private window keeps it for the session only. ⭳ Export JSON / ⭱ Import JSON is the way round both — the mapping is a few lines of JSON that can live in a repository next to the tenant's other configuration, and importing offers replace or merge rather than choosing for you.
  • 👤 Grant across units — one set of people, several units, applied as a grid. Each unit × each administrator is a separate grant and a separate outcome, because a partial success has to be readable rather than rounded to “done”. It is deliberately not an “apply to all”: who may reach the Admins exclusions is not automatically who may reach the Externals ones, and assuming otherwise would quietly undo the per-persona split. A unit with nobody scoped to it is flagged in the list — that is a unit whose members nobody can change.
  • Grant a scoped administrator — pick the role, then start typing a name: users are suggested from the directory. Without at least one scoped grant, nobody can manage these members at all.
  • Do not combine with role-assignable groups. A role-assignable group's membership can only be changed by Global Administrator or Privileged Role Administrator, and a restricted AU blocks exactly those two — neither can be assigned at AU scope. A group with both protections has nobody who can edit its members. The tool refuses to add role-assignable groups to a restricted AU for that reason. Pick one: for a CA exclusion group the restricted AU is usually better, because it lets you name who may manage it.
  • The restricted flag is immutable — set at creation, never changeable. Converting an existing AU means creating a new one and moving the members, and the tool says so rather than letting you discover it.
  • Put the group in, not the accounts. A restricted unit holding a group protects the group's membership. Adding the break-glass user accounts themselves is a different and much sharper act: a Global Administrator inside a restricted unit cannot have their password reset by anyone, because no role that resets a Global Administrator's password can be assigned at administrative-unit scope. Recovering one means removing the account from the unit first — which is the opposite of what you want at 3am.
  • A 403 is the shield working — if a member change is refused, that is the restriction doing its job, not a fault. You need a role scoped to that AU.
  • Granting scoped administrators in bulk is searchable too. 👤 Grant across restricted units took a comma-separated list of UPNs, which assumes you already know them — so it was faster to leave and look them up. Search a person by name, pick, and they join the list as a chip you can take back off. Pasting a list still works and both feed one list: the picker writes the same field the paste box holds, because two sources of truth for who is being granted is how somebody ends up with a role they had removed.
  • Adding a group works like granting a scoped administrator. A unit already knows its persona, so the groups whose CA number maps to it are offered by name, one click each — nobody should have to remember that CAB-SEC-U-CA101-Exclusion is the Admins one. Groups already in the unit are not offered again, and when there are none left it says so rather than showing an empty row. Any other group is still typed in below, and while you are typing there the suggestion list puts this persona's groups first and the rest of the baseline after — a flat alphabetical list of every group in the tenant makes the right answer exactly as hard to reach as the wrong one.
  • A group that cannot go in says so, instead of being offered. The persona chips were built from the group NAMES the baseline knows about, which offered two things it should not: groups this tenant does not have, and groups it has that cannot be protected. Clicking a role-assignable one produced exactly the frozen group this tool exists to prevent. The chips are now backed by the same bounded scan + Bulk add runs, through the same verdict function — so what can be added is a click, and what cannot is listed with the reason: role-assignable (with a route to ⑦ Migrate), a Microsoft 365 group, a mail-enabled security or distribution group, already in this unit, or already in another restricted unit — which widens who can reach it rather than narrowing it. The type-ahead below carries the same verdicts on its options.

Writes to the tenant, and asks for each permission on the click. Creating one needs Privileged Role Administrator; granting a scoped role also needs RoleManagement.ReadWrite.Directory. Directory writes are not read-your-writes consistent, so a unit you just created may take a moment to appear in the list below — the creation log above it is the record of what actually happened.

⌨️ Command palette

Press Ctrl + K (⌘ + K on a Mac) anywhere in the app. Type a few letters, press Enter.

  • Tools — always searchable, signed in or not. Matching takes initials as well as substrings, so gap finds Gap analyse and bpbc finds Best-practice & bypass checks.
  • Tool numbers — every tool carries a permanent T-number (shown in the tile corner and in the tool's own header, next to its version). Type T07, or just 7, and you land on 📘 MS Learn checks; an exact number match outranks everything else, because a query that is a tool number is not an accident.
  • Policies — once a tenant is loaded, type a CA number (203) or any part of the policy name to open its card directly, without going through List Policies first.
  • Keys↑ ↓ move, Enter opens, Esc closes, Ctrl/⌘ + K again closes. Clicking outside closes it too.

Navigation only — it opens things, it never changes anything.

🔢 Tool numbers

Each tool carries a permanent T-numberT01 to T39 — shown in the corner of its tile and in its own header beside the per-tool version. Quote it and it means one thing: across the beta and production channels, in every build, and in any future translation of the interface.

  • Assigned in the order the tool entered ENCA, reconstructed from the repository rather than by preference — the first commit that added each tile, in commit order. So T01T04 are 🗂 List Policies, 📄 Create documentation, 🔍 Gap analyse and 🗄 Backup: the day this app existed at all.
  • Never reused. Not when a tool is retired, not when it is folded into another. A recycled number would make every older note about it wrong, which is exactly what the number exists to prevent. A new tool takes the next free number.
  • Folded tools keep their number, and the mapping is published here. A rule that says a number is never reused is only worth something if you can still find out what the number means. Twenty numbers no longer have a tile of their own:
    • T02 📄 Create documentation → 🗂 Policies, action bar (build 311)
    • T04 🗄 Backup (JSON) → 🗂 Policies, action bar (build 311)
    • T05 🎚 Set Policy state → 🗂 Policies, action bar (build 311)
    • T11 🧩 Baseline (Joey Verlinden) → 🧬 Baseline, catalog picker (build 311)
    • T26 🎚 Report-only impact → 🚦 Sign-in log, Report-only impact tab (build 311)
    • T38 🛂 Session controls → 🚦 Sign-in log, Session controls tab (build 311)
    • T28 📉 Drift watch → 🕓 Changes, Snapshot file tab (build 311)
    • T29 📖 Baseline guide → 🧬 Baseline, Deployment guide tab (build 311)
    • T22 🎫 Authentication contexts → 🧩 Policy building blocks, Contexts tab (build 311)
    • T23 💪 Authentication strengths → 🧩 Policy building blocks, Strengths tab (build 311)
    • T24 ♻ Recycle bin → 🧩 Policy building blocks, Deleted tab (build 311)
    • T25 📜 Terms of use → 🧩 Policy building blocks, Terms of use tab (build 311)
    • T07 📘 MS Learn checks → 🛡 Checks, Microsoft Learn tab (build 311)
    • T21 📐 CIS Benchmark → 🛡 Checks, CIS 5.2.2 tab — not part of this build at all: it runs on the beta channel only, and its number is listed here because a number is never reused
    • T30 🖥 Device reality check → 🛡 Checks, Intune reality tab (build 311)
    • T13 ⚡ CA validator → 🧪 What-If, Every simulation mode (build 311)
    • T37 🌊 Who is the wave to CA → 🕵 Who is … to CA, A group subject (build 311)
    • T18 ⚖ Compare users → 🕵 Who is … to CA, Compare users subject (build 311)
    • T31 🎫 Licence gap → 🔍 Gap analyse, Licences tab (build 311)
    • T35 📞 Teams devices → 👥 Conditional Access groups, the 📞 Rule action on the TeamsSharedDevices row (build 311)
    Each of them still has its own section in this Help — under the tool that hosts it now — and every in-app link or ⌘K search written against the old tile still lands on it, on the right tab.
  • The version beside it is the opposite kind of thing — the number never changes, the version always does. They sit together so they are read together: “T21 v0.5” says which tool and how far along it is.
  • ❓ Help, 📋 What's new and 🗺 Roadmap carry none, deliberately. They are the app describing itself rather than tools that read your tenant, report on it or write to it — which is also why they carry no version. They go by name.

A label, nothing more — it reads nothing and changes nothing.

🚦 Sign-in log

One window, three questions. Until build 311 these were three tiles — 🚦 Sign-in failures, 🎚 Report-only impact and 🛂 Session controls — and the first two already read the SAME window through the same cache and the same consent, so the second tile you opened re-used what the first had read and said so in a line nobody should have needed to see. They are one tool with tabs now; T17, T26 and T38 keep their numbers (see 🔢 Tool numbers) and their sections below. The range picker, the sign-in source and the search box stay per tab, because the three ask different things of the same records.

Which sign-ins Conditional Access failed and which policy did it — read from the Entra sign-in log, grouped per policy.

Sign-in source. The log tools share a source selector. Entra sign-in log reads interactive user sign-ins through the stable Graph endpoint on P1/P2. Entra + non-interactive · preview explicitly includes background user sign-ins through the beta endpoint and uses the same AuditLog.Read.All permission. Defender hunting and Hunting + non-interactive remain optional: they need a populated Entra hunting table, P2 and the relevant Defender access. P2 alone does not establish Defender access. Each source has row/response limits; a capped or failed read is incomplete. The shared window is tied to tenant, source, period and policy snapshot. The screen shows the observed event interval separately from the requested period.

While reading. Progress starts before Microsoft's first response, keeps the elapsed time across phases and shows server retry waits. A page limit is never presented as a completion percentage. Report-only impact shows provisional results from the first useful page. Stop cancels supported reads or stops at the next safe read boundary; a stopped read cannot become a complete result. Larger analyses run in background workers. Coverage analysis accepts up to two million user-policy evaluations per run: choose a deployment group or named users for larger tenants.

P1 support and optional products. Policy and directory tools use P1 as the supported baseline. Identity risk, Defender evidence, Intune checks and Governance features keep their own product, consent, role and source requirements. Import preview identifies premium requirements; policy writes and restores recheck active subscription evidence and policy capacity. Unknown entitlement blocks the write without removing premium conditions. Subscription discovery does not prove that each affected person is correctly licensed. Actual P1/P2 tenant acceptance is recorded separately in Help → Waiting for production.

  • Enforced — sign-ins with conditionalAccessStatus = failure: a policy's controls were not satisfied and the sign-in was blocked or interrupted. Filtered by Graph, so the read is cheap. An abandoned MFA prompt lands here too — a failure is a prompt to look, not proof the policy is wrong.
  • Report-only — sign-ins a report-only policy would have failed. These sign-ins complete, so Graph cannot pre-filter them: the whole window is read and filtered in the browser, capped at 10 000 sign-ins. This is the number to check before flipping a policy from report-only to On.
  • Per policy — one row per failing policy: failure count, distinct users, most affected users, and the grant controls that weren't met. Expand a row for the individual sign-ins.
  • 🔑 Signed in with (build 315) — what the person authenticated with, from the record's authentication steps (the portal's Authentication Details tab): Password — Phishing-resistant MFA required, not provided (MFA required in Azure AD), Password + Microsoft Authenticator — MFA satisfied, or MFA by a claim already in the token. A password where MFA or an authentication strength was required is the gap that explains the failure without touching a policy: it is red on the card and the per-policy detail line, counted per policy and in the header (3 without the MFA asked), and it has its own filter chip. The sign-in card lists the steps one by one. The CSV carries authRequired, authUsed and authGap; the Markdown export a Signed in with column. Hunting rows carry the requirement and no steps, and say so.
  • 🧪 Replay in What-If — one click prefills the What-If scenario with the sign-in's user, app, platform, client, IP, country and device state, so you can see exactly which policies apply and why.
  • Export CSV — one line per sign-in × failing policy, ready for a pivot table or a SIEM. Export MD — the per-policy summary with sign-in tables, for a review or a ticket.

Read-only. Needs AuditLog.Read.All (requested when you run it) and a reader role such as Reports Reader, Security Reader or Security Administrator, plus an Entra ID P1/P2 licence for the sign-in log. Retention is licence-bound (≈30 days), so this is a rolling window.

🎚 Report-only impact folded into this tool — build 311

The go-live forecast. Every policy sitting in report-only is judged against the sign-in log: who would be denied the day it is enforced, who is interrupted for an extra step, and who passes unchanged. Report-only writes a verdict into each sign-in without acting on it, so this is evidence rather than simulation.

  • Per policy — is it safe to switch THIS one on. Per user — what happens to THIS person when everything staged goes live at once, which is the question nobody can answer policy by policy.
  • Why a user was interrupted — a policy called LowMediumUserRisk reporting “3 interrupted” invites exactly one question, and the sign-in records behind the verdict carry the answer: the drill-down reads user risk low ×2, user risk medium ×1. Counted per level rather than summarised, because two lows and one medium is a different decision from three mediums. User risk and sign-in risk are shown separately — a policy can key off either, and merging them would be right about half the time. Where the tenant has no Entra ID P2 the value arrives as hidden, and is reported as hidden rather than as no risk: not knowing is not the same as nothing.
  • Why it would say no — not just why the policy looked at the user. The scope line explains the assignment; this explains the refusal: what the policy demands and what the sign-in actually brought — needs a compliant device — device NOT compliant, Azure AD joined, Windows. Stated as evidence rather than as a diagnosis, because Graph does not return a per-control verdict: a device with no compliance state on the record is reported as unregistered rather than as non-compliant, which is a different problem with a different fix. Only a denial gets one — an interruption was satisfied by doing the extra step, so calling it a refusal would be wrong — and a Block policy says it blocks outright, since nothing the user does would help.
  • The sign-ins behind the verdict, shown the way 🚦 Sign-in failures shows them: the controls the policy demanded, then the client, operating system, location and device state of the actual sign-in — up to three per user and policy, which is enough to see a pattern without holding the window in memory twice. Where the sign-in also failed for an enforced reason, the failure reason and error code are there too (Device authentication is required. (50097)). Where it does not, the row says the sign-in itself succeeded and this policy only recorded what it would have done — an absence that is information, not a gap.
  • A verdict is only as good as its window. The range and the number of sign-ins read are stated up front, and a truncated read says so — a policy with no evidence is called out rather than counted as safe, because "nobody was affected" and "nobody signed in" look identical in a summary and only one of them is a reason to go live.
  • Sign-in source. The segment in the toolbar is shared with 🚦 Sign-in failures, 🕵 Who is Anna to CA and 🌊 the wave: the Entra sign-in log (interactive only, capped at 10,000, AuditLog.Read.All), Defender hunting (the same sign-ins from EntraIdSignInEvents through advanced hunting on Graph — ThreatHunting.Read.All, no cap, 30 days, Entra ID P2 for the table to be populated) or Hunting + non-interactive, which adds the token refreshes and background client sign-ins the Graph list never returns. A forecast that counts non-interactive sign-ins is a different number — the header says which source the verdicts came from. The hunting read is one query per day, halved on size, and narrates every query; two tools asking for the same window at once share one read. Full detail under 🚦.
  • Require app protection policy cannot be evaluated in report-only. Microsoft documents that the control reports Report-only: Failure in report-only mode even where it passes once enforced, so a staged policy with that grant shows would-deny counts that are not denials. The tool says so under such a policy; enable it for a pilot group to get the real answer.
  • 💾 Keep on this device (build 315, R58). The button after the sign-in source. Off, every read asks Microsoft for the whole window. On — per tenant, after a dialog that says exactly what is kept — the browser keeps the report-only verdict buckets the hunting sources bring back (per UTC day — per hour was 24× the rows and more than a tab could hold on a large tenant), and the next read asks only for the days it does not hold yet plus the current day, which is re-read every time because sign-ins reach the hunting table late. Opening the tool with 💾 on starts the read by itself and shows what the device holds first, under a “From this device” strip, then the new days land on top. The window starts at a day boundary when the store is in use — whole days, a little more evidence than asked, never less. The coverage line says how much came from the device and how much from Microsoft (“6 of 7 settled days from this device, 1 day and the current day from Microsoft just now”). What is kept: user principal names, IP addresses, locations, device state and verdicts of real sign-ins — in this browser's IndexedDB, on this device, readable by anyone using this browser profile and by nobody else; never tokens, never the Entra-log source's rows. Forget deletes it at once; it ages out after the days you choose (3 to 30, default 8). A capped day is shown but never kept, so a partial day is never believed whole later. ⟳ Rescan reads the whole window from Microsoft and rewrites what is held.
  • On the hunting sources the verdicts are summarised before they leave Microsoft (build 315). Report-only impact never needed the sign-ins — it counts them per policy, verdict, user and app — so on Defender hunting and Hunting + non-interactive the query now asks for exactly that: one row per day × policy × verdict × user, with the device facts the “why it would say no” line is judged on (compliance, management, MFA requirement, both risk levels) and the set of apps the user hit; the per-app counts and the one real sample sign-in per user × policy that would be denied are small rows of their own (build 315 — grouping by app, client and OS as well was 24× the rows on a large tenant). The query runs once per report-only policy per day, so a day of a large tenant stays under the engine's row cap without being sliced into hours; the header says “N sign-ins summarised by Defender hunting in 35 queries (one per policy per day)”. Nothing of the window is held in the tab: every answer is folded into the running forecast as it lands and let go. The per-policy and per-user numbers are the same ones the row read produced — the offline test builds both from the same rows and compares. A tenant whose hunting engine refuses the summarised query is read row by row instead, with a note; the Entra sign-in log source always reads rows (Graph cannot summarise).
  • The hunting read keeps the slice length that worked (build 315). Advanced hunting refuses a result over 50 MB, so a day of a large tenant's sign-ins is read in slices and a slice that is too big is halved. Halving used to start again from 24 hours for every day of the window — on a tenant that needs 22-minute slices that was 63 failed queries for the 64 that returned rows, every day, each one spending the tenant's hunting CPU allowance on nothing. The reader now starts the next slice at the length that last worked, lets it grow again when three slices in a row come back light, keeps it per tenant and source between sessions, and reads four days at a time. The progress line says how many sign-ins are already back and what the stride is, instead of "waiting for Microsoft's first response" for the whole first day.
  • The forecast arrives as the days land. After every finished day (every five pages on the Entra log) the verdicts are rebuilt from what has been read so far and shown under a still reading strip, so a large tenant gives a first answer in a minute instead of after the whole window. ■ Stop keeps what was read and marks the result as a partial window — a verdict on a partial window is said to be one; ⟳ Rescan reads it whole.
  • One read, five tools. 🚦 Sign-in failures in report-only mode, 🕵, 🌊 and 🛂 issue the same window read as this tool — the whole window, because report-only verdicts cannot be filtered server-side. Whichever runs second reuses what the first read and says so, rather than spending another multi-minute pass over ten thousand records; ⟳ Rescan always re-reads the tenant. Enforced-failure mode keeps its own server-filtered read, since that is a different and much cheaper query.
  • Export MD — the forecast with every affected user, for the change record or the go-live approval.

Read-only. Needs AuditLog.Read.All (requested when you run it) and a reader role such as Reports Reader, Security Reader or Security Administrator. Audit retention is licence-bound — roughly 30 days on Entra ID P1/P2 — so the window is what Microsoft still holds, not all history.

🛂 Session controls BETA folded into this tool — build 311

“Is CA308 blocking downloads?” has no answer in any log Entra keeps. A session control such as Conditional Access App Control only routes the browser session to Defender for Cloud Apps; what happens inside the session — a download blocked, a file labelled on the way out, a step-up authentication — is decided by a Defender session policy and written to Defender's activity log. This tool reads that log and joins it back to the routing policy.

  • At a glance — policies with a session control, sessions routed to Defender in the window (from the sign-in log: an applied policy carrying App Control), downloads blocked, and the other actions: protected, step-ups, audited, logins.
  • Per Conditional Access policy — state, which session control (App Control: Monitor only / Block downloads / Use custom policy; sign-in frequency; persistent browser; token protection; CAE; app-enforced restrictions; Global Secure Access), how many sessions it routed, what Defender did behind it, which Defender policies matched, and a verdict: Acting, Watching (routed and logged, nothing acted), Monitor only (routes, never blocks), Routed, nothing acted, Nothing routed, Off, Report-only (session controls are not applied in report-only).
  • What Defender did — every session-control event: when, user, app and object, action (Blocked / Protected / Step-up / Login routed / Audited), the Defender policy, and the Conditional Access policy that routed the session. Filters per action and a search box; user names open 🕵 Who is Anna to CA.
  • Defender session policies seen — every policy name the activities carried, with its counts and the CA policy that routed to it — or the warning that none did.
  • Schema panel — the action types and raw field names the hunting rows carried. The words a blocked download uses differ per app and are documented nowhere; if a block shows as plain “Activity”, that list is what makes the classifier exact.
  • Two halves that fail on their own. The Defender half is minutes of hunting; the routing half is the shared sign-in window. When only the sign-in window fails — a gateway hiccup, a refused scope — the activities are still shown, the note says routing cannot be said, and ↻ Read the sign-in window again re-reads the log alone and joins it to the activity already in hand, instead of a Rescan that repeats the hunt.
  • ■ Stop — during the hunt, the slices already read are kept and shown as a partial window, named as such, without reading the sign-in log; during the sign-in read, the Defender half stays and routing is marked missing. The stop lands after the query in flight — a running hunting query cannot be cancelled.

Read-only. Defender activity comes from advanced hunting through Microsoft Graph — POST /security/runHuntingQuery over CloudAppEvents filtered to the session / access control audit source, delegated ThreatHunting.Read.All, the signed-in user needing Security Reader, Global Reader, Security Operator, Security Administrator or a Defender RBAC role with hunting access; 30-day retention; the window is read in 4-hour slices of up to 5,000 events each, a slice that takes more than 2 minutes is halved down to 30 minutes and then skipped with a note. Routing comes from the sign-in window 🚦 Sign-in failures, 🎚 Report-only impact, 🕵 and 🌊 share, from whichever sign-in source is chosen there — Entra sign-in log (AuditLog.Read.All) or Defender hunting. Join: same user, same app, newest routing sign-in within 8 hours; a user-only fallback is labelled; no match is shown as no match. Monitor only logs the Login activity only — a policy on Monitor only never blocks anything, and the tool says so.

🚪 Exclusion analyzer

Every exclusion across all policies in one place.

  • Matrix (default) — exclusions × policies; Effective users — the same but expanded to the individual users a group exclusion covers; Risk review — policies scored for exclusion governance, worst first.
  • Risk review flagsHigh a privileged role excluded, or all guests/external excluded; Medium direct user exclusions (no lifecycle), more than five excluded groups, or disabled accounts still in scope — directly or sitting inside an excluded group; Info a report-only policy whose exclusions go live the moment it is enforced. Each flag is a prompt to review, not a finding. Patterns follow Tiago S. Carvalho's CA exclusions audit.
  • Nesting — each excluded group is expanded to its transitive members (what Entra evaluates), and the scan also reads the group's direct members and its nested groups, so the matrix row says ↪ all through 9 nested groups, the member list names the nested group each member came through (direct first), the effective-users matrix shows for a user who is excluded through nesting only, and the head counts the users who come in that way. The risk review flags it: Medium nested groups inside an excluded group, High an exclusion group fed entirely by nested groups — whoever manages those groups decides who bypasses the policy, without ever touching the exclusion group. First 40 nested groups are resolved by name; beyond that a member is marked nested without the path.
  • Type chips — All, User, Group, Directory role, Guest/external, Application, Named location, Device platform.
  • Click to filter — click a user/group row to show only the policies excluding it, or a policy column to show only the exclusions in scope for it. The out-of-scope “·” cells drop away; a banner shows the filter with a one-click clear.
  • Export — CSV or Markdown; full-screen for wide grids.

Read-only.

🧬 Baseline

Matches this tenant against a baseline catalog and shows the gap. Both catalogs live behind the picker in the toolbar — 🧬 CloudFellows R26.6 and 🧩 Joey Verlinden — which is what T11 🧩 Baseline (Joey Verlinden) was a second tile for until build 311; it keeps its number and its section below. The CloudFellows catalog was published as the Limon-IT baseline until beta 25151 (production 287); the release designation, the catalog and the CAB-SEC / CAD-SEC group names are unchanged — only the name it goes by.

  • Status filtersMissing (not in tenant), Outdated (older version present), Up to date, Not in baseline (in tenant but not the catalog). The count chips on the summary card are the same filter: click one to see those rows, click it again for all. An active baseline with missing policies opens on Missing, because what is still to do is the question the screen answers.
  • ★ Active by match — a tenant that never chose a baseline gets the one it holds most of, by name: 26 policies carrying Joey Verlinden's names and none carrying CloudFellows' makes his the active baseline for the session, and the card says so with the counts. It is not saved — the next read decides again — until 📌 Keep or an explicit ★ Switch writes the choice; a saved choice is never overridden by a match.
  • 🚨 E-Admins on every catalog — emergency access is not one baseline's idea, and only the CloudFellows catalog writes those policies out (CA1100–CA1105). So they are expected under every catalog: the Joey Verlinden table carries them as its own section marked shared, a tenant that has them no longer shows six “not in baseline” rows for doing the right thing, and the import count says they come from the CloudFellows backup, not his repository.
  • One table, both catalogs — a baseline comparison is a row per policy with a status, so that is what it renders, whichever catalog you pick. The Cards view was removed in beta 25157: the same screen answered the same question two different ways depending on which catalog you had clicked, which made the two baselines impossible to read side by side. Search and collapsible persona sections work as before.
  • Catalog switch — CloudFellows R26.6 or the community Joey Verlinden catalog. Switching what you LOOK at does not switch what the tools work against; the ★ button on the summary card does (R36, see the next section).
  • Export MD — a gap report; Refresh re-compares in place; Import baseline hands the missing/outdated set to the Import tool — for the Joey Verlinden catalog straight from the live repository read (policy files, groups and named locations, no zip), with the gap ticked and 🔀 Switch baseline offered when this tenant's policies use the other baseline's groups.
  • 🧷 Update pre-requirements — one click, before you change anything, for the three things you cannot capture afterwards. A configuration snapshot (JSON) is taken first, because it is the only one that can be diffed against a later read in 📉 Drift watch: it is what answers what did this change? Then a full policy backup with every dependency resolved — groups, named locations, authentication strengths and contexts, terms of use — since a policy backup without its dependencies restores to a policy pointing at ids the tenant may no longer have, which is not a backup. Then documentation as Markdown, for the change record and the reviewer.
  • It takes every policy, not your current selection — a pre-requirement that captured whatever happened to be ticked would be worse than none, because it would look complete. Each artefact is attempted independently, so one failing does not cost you the other two, and the run always ends in a report saying which of the three were captured. Anything that could not be read is named: an unreadable snapshot area, a dependency that failed to fetch. If any step failed the report says so and tells you not to start the update — a failed pre-requirement is not a formality, it is the copy you would have restored from.

Read-only comparison. Only Import (below) writes.

🧩 Baseline (Joey Verlinden) folded into this tool — build 311

The same comparison view against the community Conditional Access Baseline by Joey Verlinden — a different catalog, the same table. Since beta 25243 (roadmap R36) it is a first-class baseline: not only compared against, but the one every downstream tool can work against.

  • ★ Active baseline — the summary card says which of the two baselines this tenant works against. The button under it runs a dry run first: it reads the tenant's groups and administrative units and shows what the switch would change — how many groups ① Check / ② Create would expect and how many exist, which persona vaults 🛡 Restricted AUs would expect and which are present, which groups would stop or start being routed by ⑥ Protect and + Bulk add, and the conventions that change (exclusion-group shape, break-glass name, fallback unit). Only under that card is the ★ Switch button; nothing is written by either step. The choice is kept per tenant in the browser (like the R28 group personas), defaults to CloudFellows, and is never changed by merely looking at a comparison — looking at Joey's table must not change where a write puts a group.
  • What follows the choice — 👥 Conditional Access groups ① Check and ② Create (his “<policy name> - Exclude” groups plus CA-BreakGlassAccounts - Exclude and CA-ServiceAccounts, as templates), 🔒 Protect exclusions and 🛡 Restricted AUs (his personas as vaults: CA-RMAU-Global / Admins / Internals / ServiceAccounts / Guests / Agents-Exclusions and CA-RMAU-BreakGlass — ENCA's convention under his prefix, because his baseline defines no administrative units), + Bulk add and the persona chips, the exclusion-restore action in 🗂 Assign, the persona picker, the 📘 MS Learn break-glass fix, and 📖 Baseline guide's readiness checks. Each of those screens carries a Working against … chip with a link back here.
  • 🧹 What the other baseline left behind — the switch writes nothing, so the previous baseline's exclusion groups, persona units and policies are all still in the tenant afterwards. The second button on the non-active card reads them and lists them in three sections with a verdict each: a group is safe to delete only when no policy references it, it has no members and it is not inside a unit that stays; a unit only when every member is an old-baseline group that is itself safe; a policy only when it is Off. Everything else is listed with the reason and cannot be ticked — a referenced group would leave a policy pointing at nothing, a guarding unit would open a vault, a policy that is On is still enforcing — and names the tool that deals with it. Break-glass groups are never offered, and a group every baseline shares — an E-Admins group, a CAD-SEC-U-DG-* deploy group — is not listed at all: it serves the new baseline as it served the old one. Deletion runs behind a typed DELETE, units first, then groups, then policies, with a change report; permissions are asked for on the click.
  • The naming contract — his groups carry the policy NAME, not a CA number, so a group is routed to a persona by an exact, case-insensitive match against a policy name the catalog knows, and nothing else. A group that matches nothing stays visible as unmapped, and the tenant's own mapping (🏷 Group personas, which is kept per baseline) can place it. His personas do not map onto the CloudFellows CA ranges — his CA300 block is service accounts — so the vaults are his, not a rename of ours. The groups every baseline shares are routed by the same exact rule: Emergency_Access1, Emergency_Access2 and CAB-SEC-U-BreakGlass to CA-RMAU-BreakGlass, a CAD-SEC-U-DG-<CODE> deploy group to the unit of the persona its code names when his catalog has that persona (ADM, INT, SA, GUESTUSERS, GLO, AGENTS) — never a guess for any other code.
  • 📡 Live from the repository — the catalog is read from github.com/j0eyv/ConditionalAccessBaseline once per session: the latest release, its file listing and every policy JSON in Config/ConditionalAccess, and the card says which release and commit it read. The bundled snapshot (2026.6.1 at 38469a4) is the fallback: a read that fails — GitHub's unauthenticated rate limit, a moved folder, a malformed release — leaves it in place and says so on the card rather than falling back quietly. Every file is validated before it is believed, nothing from it is executed, and a CA number used by more than one file in the repository is shown as such rather than silently de-duplicated. Each of those files is then compared against a policy of its OWN in the tenant: release 2026.6.1 numbers both the AnyPlatform and the iOS/Android copy CA005, and a tenant holding both gets two matched rows rather than one match and one policy reported as new while it sits two lines above.

Read-only itself; the writes it steers are the other tools', with their own confirmations.

📖 Baseline guide BETA folded into this tool — build 311

The deployment order, with the reason for each step rather than just the sequence — and read against your tenant, so each step says what is missing before you run it instead of after. Six steps: understand the model, groups and their vaults, named locations / strengths / contexts, Terms of use, policies imported Off, then go live.

  • Order is not arbitrary. Groups and their restricted units come before the policies that target them, because a policy referencing a group that does not exist cannot be created. Named locations, authentication strengths and contexts come before the policies that reference them for the same reason. Terms of use sits on its own because it has no Graph create API — it must be made in the portal first, and a policy granting one imports without that control until it exists.
  • Readiness, not a checklist. Each step is evaluated against the tenant as it is now: what exists, what is missing, and what that means for the step after. A step you cannot do yet says so with the reason, which is the difference between a guide and a list of instructions.
  • It ends where 🎚 Report-only impact begins — policies land Off, are switched to report-only, and are enforced once the forecast is boring. That is the whole of "go live the boring way".
  • 📄 Export MD — the plan and the current readiness as Markdown, for the change record or to hand to whoever runs the deployment.

Read-only. It reads groups, administrative units, named locations, strengths, contexts and policies to work out where you are; it writes nothing and never acts on your behalf — the tools it points at do that, and each asks for its own permission on the click.

👥 Conditional Access groups writes to tenant

📞 Teams devices is a row action here, not a tile. ② Create already makes CAB-SEC-U-TeamsSharedDevices as a dynamic group with the Teams rule; T35 rebuilds that rule from the device licences the tenant actually holds, and replaces it. One action on ONE row, so from build 311 it is offered where the group is — the 📞 Rule button on that row — and nowhere else, because on any other row it would mean nothing. Which row gets it is decided by the tool's own alias list, so a tenant that named the group CAB-SEC-U-SharedDevices still gets the action. T35 keeps its number (see 🔢 Tool numbers) and its section below, ⌘K still finds it by name and number, and its screen carries a ← Groups button back to the list it came from.

The group side of the baseline, in one tool. One read, then everything is a row. On open the tool reads the tenant once — the groups your policies reference (scope Used by CA policies), plus everything the ★ active baseline expects (Baseline + templates), or every security group in the tenant, first 5,000 (All groups) — with their policies, protection, role-assignable state and nested groups — and shows them as a list. Members and nesting are read per group when you first look at it, kept for the session, and a write re-reads only the group it touched.

  • The list — group, status (present / missing / referenced but gone), members (count, direct vs nested, or a read button), the policies that use it with their state, protection (which restricted unit, or not protected). Filter chips: needs attention, missing, gone, empty, not protected, role-assignable, ↪ has nested groups (always shown — a nested group inside an exclusion or break-glass group is needs attention, because whoever manages the nested group decides who bypasses the policy), not in the baseline; the search matches group names, object IDs and members. Sorted attention first; click a column header to sort by it, again to flip, a third time to go back. Each row offers what applies to it — + Create on a missing group, 🔁 Restore on a dangling reference, 🌊 on a deploy group, ⋯ for everything else.
  • Groups every baseline shares UPDATED — a group counts as the baseline's (present, not not in the baseline) when the ★ active baseline defines it, and also when it belongs to every baseline: the 🚨 E-Admins groupsEmergency_Access1, Emergency_Access2, CAB-SEC-U-BreakGlass, and any group an E-Admins policy includes — and the 🚀 deploy groups CAD-SEC-U-DG-* that a Deployment groups import and 🌊 waves use under any baseline. Under Joey Verlinden's baseline a group also counts when his repository names it that way (CA403-Guests-… - Exclude), when it is his example include group (APP_Microsoft365_E5), or when it is the exclusion group his naming rule gives a policy this tenant has under an older release's name (CA005-…-AppEnforcedRestrictions - Exclude). The row says why — every baseline, 🚨 E-Admins, every baseline, the baseline's naming rule, the repository's group name — and so does Export MD. Under his baseline, Baseline + templates also expects the Emergency_Access pair (the shared E-Admins policies name them; his own break-glass group does the rest) and ② Create makes them from the CloudFellows template; deploy groups are recognised but never reported missing. The ⚠ hint that this tenant carries another baseline's policy names no longer counts the six E-Admins policies, which every baseline expects. Only a group none of this explains stays under not in the baseline.
  • 🧹 Archived groups (toolbar) — the groups a migration renamed aside. Delete leaves nothing behind: a ticked group still named by a policy is taken out of those policies first (live read, removal, read-back) and deleted only when no policy names it. A policy that refuses the change (the unpatchable ones every tenant seems to have) does not fail the group: the row reads ◐ partly done — taken out of 36 of 39, still named by the three that refused, not deleted so no policy points at nothing — and the report says which policies to fix before running again. 🔗 Check other uses runs the User or Group analyzer's sources over the ticked groups — app assignments, Intune, licensing, Teams, admin units — and shows the hits per group before you delete, because a recreate moves Conditional Access for you and nothing else.
  • The actions bar — shows as soon as a row is open, and acts on that row until you tick more. Tick rows and act on them: read members, ⊞ compare selected (the members × groups matrix, only for what you ticked, with the nesting view), assign to policies, protect in a restricted unit, migrate off role-assignable, import members from CSV, create the missing ones. The writes — Create, Assign, Import, Protect, Migrate — open in a dialog over the list with your selection carried across; close it (✕ or Esc) and the ticks and the open row are where you left them. ⊞ Compare opens as a sheet rising from the bar with two views — Members (the members × groups matrix, with nesting) and Policies (groups × policies: ● in, ✗ ex, · not named; rows where the picked groups differ come first, and each group gets a line saying how many inclusions and exclusions it is missing against the others — after a migration, that is what the new group still lacks; every missing cell is a tick, and one button adds the ticked groups to those policies the way the other group already is on them) — full height by default; ▁ Half in its header drops it to half the window so the list stays visible above it and the matrix follows your ticks. Protect and Migrate check only the groups you came with on arrival, and offer the full check as a button.
  • The detail drawer — click a row, or tick it: the drawer follows the tick, and unticking the open row opens the last one still ticked. Members: direct members first, then each nested group with its own members underneath; a find box filters them as you type (nested groups open on a match) and a segment orders them as read / by name / by UPN; when the group is bigger than the 500 read, 🔎 or Enter searches the whole group in the tenant — the answer is either “not a member” or the person, direct or placed under the nested group she came through, with her ×; add inline; × removes from the group the member actually sits in — a member under a nested group is removed from that nested group, and the confirmation names what else that group feeds. Policies: include / exclude with state, names open the policy card. Protection: the unit it is in, or why it is not protected and the button that fixes it. History: the group's last 30 days from the directory audit log — who added or removed whom.

The run ledger. Every write that walks a list — Migrate, Protect, Create, Import CSV, Archived groups, the Compare ticks, and 🎯 Assign and 🎚 Set policy state elsewhere — shows the same box: the whole list before the first write, the row being written marked amber, ✓ green or ✗ red as each lands with the reason on the row, the box scrolling to keep the working row in view, a count and a clock in the header, and ■ Stop after this one, which finishes the write in flight and reports the rest as not reached. A run with failures keeps its dialog open so the ✗ rows are read where they happened.

Below: the engine screens the list opens in its dialog and sheet. They are the same flows as before, one step shorter because the groups arrive pre-selected.

  • Check — baseline groups vs this tenant: present, missing, or drifted. Create a single missing group per row (the TeamsSharedDevices group is created as a dynamic group with the Teams-Rooms membership rule). A group that IS still role-assignable is flagged the other way round now — that flag is retired, so a plain group is correct and a role-assignable one is what needs attention, with a button into ⑦ Migrate to convert it and place it in a restricted administrative unit. Everything created here is a plain security group with nesting disabled.
  • ⟳ Make dynamic — appears when the template defines a membership rule but the tenant group is assigned. A plain security group is converted in place: same id, so every policy, app and role assignment that points at it is untouched. A role-assignable group cannot be dynamic at all — Entra makes the two mutually exclusive and isAssignableToRole is immutable — so it is replaced instead: the old group is renamed -static-YYYYMMDD and kept as your rollback, a dynamic group is created under the original name, added to the include/exclude of every policy that referenced the old one, and only then is the old one removed from those policies (so nothing is uncovered mid-flight). The replacement is not role-assignable — don’t convert a group that carries a directory role or PIM assignment. In both cases members who don’t match the rule stop being members.
  • Members — a members × groups matrix.
  • Assign — add a group to policies’ include or exclude lists (ADD is additive — it never removes what’s there). A final confirmation and a change report before anything is written.
  • 🔁 Restore convention exclusions NEW — the eighth action, and the only one that targets a different group for every policy: CA200 gets CAB-SEC-U-CA200-Exclusion, CA201 gets CA201-Exclusion, from the CA number in the policy name. That reference is the first thing to go missing — a policy is rebuilt, an older version imported, an exclusion list tidied — and nothing notices, because a policy that lost its exclusion group looks exactly like one that never had it, right up until the exception it existed for is needed.
    Step 2 is a drift report rather than a list to tick: how many policies are already correct, how many have lost the reference, and how many name a group that does not exist in the tenant at all. Only the ones needing repair arrive ticked. Scope it to your selection for a repair, or to all policies in this tenant for the sweep — the same question asked of everything, which is how you find out how far an estate has drifted.
    The group name comes from the baseline catalog where the catalog has that CA number (definitive — and 14 of the 99 policies legitimately have no exclusion group, so those are reported as nothing to do rather than invented). A policy carrying a CA number the catalog does not have gets the convention applied by inference, labelled derived so it can be checked before it is ticked. Groups that do not exist yet can be created first from their baseline templates in the same run.
    Additive only — the group is added to whatever the policy already excludes; nothing is replaced and nothing is removed. But an exclusion widens a policy: anyone already in one of these groups stops being covered by the policy it is added to, so a tenant-wide sweep asks for the typed ALL like every other tenant-wide write, and the change report records the policy → group pairing rather than one group list.
  • Assign directory roles NEW — the wizard can target a policy’s Directory roles include/exclude, the same assignment the portal offers, not only groups. Choose Directory roles at the top and the same actions apply to them (bar “All users”, which has no role equivalent). Quick picks give Microsoft’s privileged set — the fourteen roles Microsoft names as the minimum to require MFA on, resolved against this tenant’s own role templates rather than hard-coded IDs — or every built-in role. Conditional Access enforces built-in roles only: custom roles and administrative-unit-scoped assignments are not covered by a policy scoped this way, which the wizard says at the point of choosing, in the review and in the change report.
  • 🚫 Disable group nesting BETA — on any present, non-dynamic group in ① Check. Not generally available: Microsoft documents the permission (Group-NestingSupport.ReadWrite.All) but publishes disableNesting on neither the v1.0 nor the beta group resource, and a tenant that lacks it answers 400 Request_BadRequest — “Unexpected request made to property ‘disableNesting’”. ENCA sends this one request to v1.0 (the only version whose docs name it), and when the directory refuses it says so, stops offering the action for the rest of the session, and does not fall through to the recreate — that route sets the property at creation, which is the request that was just refused, so it would destroy and rebuild a group to reach the same error. A nested group is an invisible route into a Conditional Access assignment: someone adds a group to a group and a policy's scope widens without the policy being touched, and the extra members never show up in a review of the policy itself. Entra's beta disableNesting property closes that side door — with it set, no group can be added as a member.
    Two things make this awkward, and the tool is explicit about both. Reading it is not free: a plain read does not return the property at all, so ENCA asks for it separately and reports three states — disabled, allowed, or not reported (which honestly means "this tenant does not surface it yet", not "allowed"). Writing it is undocumented: Microsoft lists a dedicated permission for updating it but does not list the property as updatable, and in the field it currently only takes at creation. So ENCA tries the in-place change first — and verifies it by reading the value back, because Entra will accept a PATCH and silently ignore a property it does not know. Only if that genuinely fails does it offer to recreate the group: rename the current one aside, create it again with nesting disabled, move the user members and repoint every Conditional Access assignment. That path is behind its own typed confirmation, never the first one. When Microsoft finishes shipping this, the recreate simply stops being offered.
    It refuses to run on a group that already contains nested groups. A nesting-disabled group cannot hold them, so recreating would silently drop those memberships and every user who was only a member through them. The tool names the nested groups and stops, so you resolve it deliberately.
    A recreate gives the group a new object ID. Conditional Access is moved for you; app assignments, Intune, group-based licensing and Azure RBAC are not — run User or Group analyzer on the group first. The old group is renamed, not deleted, so it is recoverable. Needs Group-NestingSupport.ReadWrite.All and Group.ReadWrite.All (on demand). Background by Daniel Bradley ↗
  • ⑥ Protect NEW — place the CA exclusion groups in a restricted management administrative unit. Membership of an exclusion group is a CA bypass, and any tenant-level Groups/User Administrator can hand it out; with the groups in a restricted AU (isMemberManagementRestricted, immutable at creation) only roles scoped to that AU can change members — Global Administrator included. Shows which exclusion groups are already protected, pre-selects the unprotected assigned exclusion groups (dynamic groups are opt-in — their membership follows a rule), creates or reuses the restricted AU, and optionally grants one account Groups Administrator scoped to the AU so membership stays manageable (do this — otherwise member changes need a new scoped assignment first). Needs AdministrativeUnit.ReadWrite.All (on demand), the Privileged Role Administrator role and P1. Only cloud security groups can be added; note that ⑤ Import members will afterwards need an AU-scoped role for these groups. Role-assignable groups are refused here — their membership is already limited to Global Administrator / Privileged Role Administrator, and a restricted AU blocks those same two, so a group with both protections has nobody who can edit its members. Choose one or the other.
  • ⑤ Import members NEW — bulk-add deployment-test users from a CSV. A UPN column is enough; with a Persona column (multi-persona cells split on , ; | or spaces) each user is auto-routed to the mapped group, pre-matched against the tenant’s group names — abbreviated conventions included (internals → …-INT). Only baseline groups (templates + active catalogs) are offered by default; ad-hoc groups that policies merely reference need an explicit opt-in checkbox. Users are resolved and existing memberships pre-checked (already-members are skipped), the plan is shown per group for review, and only then are members added. Dynamic groups are excluded — their membership is rule-managed. Ends with a Markdown change report.
  • Protection column — ① Check now shows where each group actually lives: 🔒 <unit>, not in a unit, or 🧊 frozen in <unit> when the group is role-assignable and restricted at once, which means nobody can change its members. A dash means the administrative units could not be read, which is not the same as “not protected”.
  • Group nesting is offered, not applied. A nested group is an invisible route into a Conditional Access assignment — somebody adds a group to a group and a policy’s scope changes without the policy being touched — so every create path used to set disableNesting automatically. That is off by default as of beta 25166, because the property is not generally available: Microsoft documents the permission that governs it (Group-NestingSupport.ReadWrite.All — “read and write groups’ disableNesting property”) but publishes the property on neither the v1.0 nor the beta group resource, and a directory that has not been given the feature refuses the field outright with Request_BadRequest. Applying it by default meant a red failure line under every single group created in such a tenant, for a setting the panel had promised. It is now a tick in ① Create and ② Build a group manually, off unless you ask; the request goes to v1.0, which is the only version whose documentation names the property; a refusal is reported rather than assumed; and once a tenant has refused it the tool stops asking for the rest of the session. Until it ships, the protection that actually works is a restricted management administrative unit, which limits who can change the members at all.
  • It is asked for twice and then verified. The property goes in the create body and is confirmed by a PATCH afterwards, because field reports say it only takes at creation while Microsoft documents it as patchable — doing both means whichever is true in your tenant, the group ends up right. Nothing is allowed to cost you the group: a tenant that rejects the property on create has the create retried without it, an unrelated create error still fails loudly, and a group whose nesting could not be set is marked nesting still allowed with the reason rather than reported as done. Not sent for dynamic groups (members are rule-driven, and only users and devices can be members) or role-assignable ones (Entra already refuses group-in-group).
  • ⑦ Migrate keeps running when you navigate away, and now says so. The recreate renames the original group aside before building its replacement, so "did it stop halfway?" has real consequences — and the answer used to be invisible. The progress log and bar were bound to the panel's elements once, so leaving the tool and coming back destroyed them: the run carried on writing into nodes that were no longer on the page, and you returned to an empty panel. Progress now lives with the run, so the panel replays it however often it is rebuilt, and a badge in the corner shows "⑦ Migrating 3/12" from any screen and takes you back with one click. Closing the tab mid-run asks first, and ⟳ Rescan refuses while a migration is in flight rather than discarding it.
  • ⑦ Migrate removes the old group from every policy, and now proves it did. The repoint used the reference list from the scan, so a policy edited since — by somebody else, or by an earlier group in the same run — was missed. A missed removal is not cosmetic: the old group stays assigned, and when the archive is tidied up later that policy is left naming an object id the directory no longer has, which is what a dangling reference in ① Check actually is. References are now re-read from the live policies at the moment of the repoint, extra ones found are reported, and the removal is verified by reading the policies back. If the old group is still referenced, the run says which policies and warns not to delete the archived group yet; if the check itself fails, it says that too rather than reporting a clean migration.
  • Deleting from a list no longer jumps you back to the top. Working through a list of named locations, authentication contexts or strengths, terms of use or administrative units meant scrolling back down after every single delete, because the panel re-renders from scratch. The scroll position is now kept across the re-render — and the browser clamps it when the list got shorter, which is the right answer for deleting the last row.
  • 🧹 Archived groups has select all / deselect all — a tenant that has been through a few recreates can have ninety of them, and ticking those by hand is not a review, it is an endurance test. Select all only ticks what is safe to delete: a group still referenced by a policy is never included, because one careless click across a list that long is exactly how a policy ends up pointing at nothing. Those rows stay individually tickable on purpose. Deselect all clears everything, including a referenced row you ticked by hand. A live count says how many of the total are ticked and how many are being deliberately left out, and the typed DELETE confirmation still gates the button.
  • Add a member — the matrix carries an + Add bar: type two letters and the directory suggests users, pick one of the loaded groups, and the member is added and the group re-read so the matrix shows the result rather than merely claiming it. Adding someone who is already in the group says so instead of failing.
  • Show nesting — a Members / Show nesting / Nested only segment above the matrix: ● is a direct member, ◐ came in through a nested group (named under the dot), and a Nested groups panel lists every group inside the loaded groups with its member count, dynamic rule and members. A ◐ member cannot be removed from the loaded group — the membership lives in the nested group, and the panel says so; a column whose every member is nested carries ◐ in its header. Hide empty groups drops the empty columns and folds the list of empty groups into one line that opens on click.
  • Remove a member — the same bar has a − Remove button, and every ● in the matrix shows an × on hover. Both ask first, and the question says what the group is used for: taking someone out of an exclusion group puts them back inside the policy, taking them out of an include group takes them out of its scope. Dynamic groups are not offered — their membership is the rule's to decide. The group is re-read afterwards so the matrix shows the result.
  • What you can do with it — click a group in ① Check and the overlay lists the actions that apply to that group, each carrying it across: read its members, assign it to policies, protect it, or convert it. Only what makes sense is offered — a group that does not exist yet cannot have members read, a role-assignable one cannot be protected, and one already in a vault is not offered protection again. Every one of these was reachable before by opening the right tab and finding the row a second time; the tab strip just never said which of the seven applied to the group in front of you.
  • ⑦ Migrate BETA — move the baseline off role-assignable groups and onto a restricted management administrative unit. Role-assignable was only ever used here for its side effect: membership reachable only by Global Administrator or Privileged Role Administrator. A restricted AU does that and lets you name who may manage the groups, and it drops the role-assignable costs (500-per-tenant cap, no dynamic membership, no nesting control). The two cannot be combined — a restricted AU blocks GA and PRA, so a role-assignable group inside one has nobody who can change its members.
    isAssignableToRole is immutable, so each group is recreated, in this order: rename the current group aside as (migrated YYYY-MM-DD), create a plain group under the original name (nesting disabled by default — the one property the role-assignable flag gave you for free), copy the members, add the new group to every policy, remove the old one, and only then place the new group in the restricted AU. Members move before the AU placement because afterwards only an AU-scoped role could add them; the new group joins each policy before the old one leaves, so no exclusion ever briefly vanishes.
    Skipped, with the reason shown: a group that holds a directory role (a plain group cannot carry one — deal with the role first), one already inside a restricted AU, one not in the tenant, and one already plain. A group whose role check fails is skipped rather than risked.
    Select all / deselect all above the list, and the restricted-AU placement is optional — untick it to convert in stages, verify the members, then place the groups from ⑥ Protect when ready (they are ordinary groups by then, so Protect handles them like any other). A converted-but-unplaced group is editable by any tenant-wide Groups Administrator until you do.
    The archived originals keep their members and stay role-assignable — they are your rollback until you delete them from 🧹 Archived groups. Needs Group.ReadWrite.All, RoleManagement.ReadWrite.Directory, AdministrativeUnit.ReadWrite.All and Policy.ReadWrite.ConditionalAccess, plus Privileged Role Administrator.
  • Writes only on Create / Recreate / Make dynamic / Assign / Import members / Protect / Migrate, each behind a confirmation.

    📞 Teams devices BETA writes to tenant folded into this tool — build 311

    Teams Rooms, panels, common-area phones and the resource accounts behind auto attendants and call queues sign in as users — and cannot answer an MFA prompt, accept terms of use, survive a sign-in frequency or work without device code flow. The baseline keeps them out of those controls by excluding one dynamic group, CAB-SEC-U-TeamsSharedDevices, from every global policy. Whether that works depends entirely on the group's membership rule, and the rule shipped so far names three Teams Rooms plans: a tenant whose phones sit on Teams Shared Space (Microsoft Teams Shared Devices until April 2026) and whose call queues use Teams Phone Resource Account licences has none of them in the group.

    • Why the rule cannot just list the SKUs — dynamic membership sees service plans (user.assignedPlans), never a licence SKU, and a device SKU is mostly plans every E3/E5 user also holds: Teams Phone (MCOEV), Teams, Skype for Business, Intune, Entra P1. A rule naming any of those puts people in the exclusion group. The tool reads the tenant's subscribed SKUs, recognises the device SKUs by part number, and keeps only the plans that no other subscription in the tenant carries — the plans that can isolate device accounts here. A bundled catalog of Microsoft's device-only plans is the fallback when the SKUs cannot be read, and gives every referenced plan a name.
    • Licences — every device SKU with how many of its seats are assigned and which plan isolates it. A SKU whose plans are all shared with user SKUs is marked not isolatable: type the UPN prefix those accounts share (mtr-, cap-) in the box and rescan, and it is added to the rule as a -startsWith clause — or keep those accounts in an assigned group the same policies exclude. Teams Phone Standard and the calling plans are listed as user licences, left out: people hold them, and they keep MFA.
    • And not a person — a device match alone is not enough: a DECT handset or a Shared Space licence assigned to somebody's own account matches every device plan and would put that person in the exclusion group, out of MFA. So the rule has a second half, -and (user.assignedPlans -all (servicePlanId -ne … )): the account must hold none of the plans that mark a user suite — E1/E3/E5, F1/F3, A3/A5, Business. Those markers are derived the same way in reverse (plans the tenant's suites carry that no device SKU carries, the smallest set covering every suite), with the SharePoint plans as the static fallback since no device SKU has ever carried SharePoint. The accounts this keeps out are counted and named — a device licence on a person is a licensing problem the rule avoids but cannot fix.
    • Recommended rule — the rule text, each plan named, its length against Microsoft's 3,072-character limit, and a preview: how many accounts in the directory match it today, with the first few by name. The preview is the same filter the rule expresses, run as a Graph query, so the number is what Entra will produce — before anything is written.
    • Groups — every group that looks like a Teams device group: the canonical name and its aliases, any dynamic group whose rule names a Teams plan, anything called Teams / shared / rooms. Per group: dynamic or assigned, rule state, member count, which Conditional Access policies include or exclude it, and a verdict — current, update the rule (with the missing plans named), matches PEOPLE (the rule names a plan real users hold — the group is excluding humans from MFA), assigned (nothing keeps it current) or read by hand.
    • Conditional Access reach — which active policies reach the accounts in the target group (All users, or the group included, and the group not excluded), against Microsoft's not-supported list for Teams devices: MFA and authentication strength, hybrid join, approved client app, password change, terms of use, sign-in frequency, persistent browser, app-enforced restrictions, app control, token protection, customised CAE, blocked device code flow, insider risk. Excluding the group is one click per policy in 📘 MS Learn checks.
    • ✏️ Replace the rule — writes the recommended rule onto the target group (an assigned group is converted to dynamic membership by the same write) after a confirmation that shows the old and new rule side by side; the new rule can be edited in the dialog first. + Create makes the group with the rule when the tenant has none — and reminds you it is excluded from nothing until you do that next.
    • 📄 Export MD — licences, rule, groups and reach as Markdown for the change record.

    Reads are covered by the baseline Directory.Read.All. The write asks for Group.ReadWrite.All once, on the click, and needs a role that can edit the group (Groups Administrator, or the restricted unit's scoped role when the group is protected). Entra re-evaluates a changed rule in the background: accounts the old rule matched and the new one does not leave the group and lose its exclusions; new matches join. The catalog reflects Microsoft's licensing reference as read on 2 September 2026 — the tenant's own SKUs are always preferred over it.

    🔒 Protect exclusions UPDATED writes to tenant

    An exclusion group is exposed in two ways, and one lock does not cover the other: who can change its members (a restricted management administrative unit — the vault) and whether a group can be nested into it (the group's disableNesting setting — otherwise anyone who can edit some other group can put that group inside the exclusion and walk through the policy). This tool shows both locks per group in one table and applies either or both in one run. It is the standalone version of Conditional Access groups → ⑥ Protect and shares that scan and selection.

    • One row per group, two locks. 🔒 Members — restricted unit: in which vault it sits, or unprotected with where it would go. 🚫 Nesting: disabled, allowed (with the number of nested groups already inside), never for a dynamic-membership group (its rule picks users or devices; a group can never be added), impossible for a role-assignable one, or not returned by the directory. Chips filter by state: not fully protected, vault only, nesting only, open, fully protected, cannot here.
    • Nothing is pre-ticked. A tick is something you did: a row, the header box (ticks every lock that is still open), or a selection carried over from the CA groups list. The bar counts exactly the ticks.
    • A greyed tick says why, on the tick itselfno vault to put it in — map it, or pick a fallback unit in Settings; CAB-SEC-RMAU-INT-Exclusions does not exist yet — create it in 🛡 Restricted AUs; blocked by 2 nested groups (empty it first — 👥 CA groups shows them); already; never nests (dynamic); state not returned by the directory. The row tick explains itself on hover.
    • Each group goes to its own persona vault. The destination is read from the group's name — the CA number (CA001 to CAB-SEC-RMAU-GLO-Exclusions, CA101 to ADM, CA1001 to DevOps), or, for the baseline's own groups, the persona in the name (CAB-SEC-U-Persona-Admins, CAD-SEC-U-DG-INT) and break-glass by intent (BreakGlass, Emergency_Access1) — or from what you said once in 🛡 Restricted AUs → 🏷 Group personas. Under Joey Verlinden's baseline the groups every baseline shares are placed too: the E-Admins groups in CA-RMAU-BreakGlass, a deploy group in its persona's unit when his catalog has that persona. Shown per row before you write. A tenant's own group that names no persona stays unmapped and says so; the fallback unit in Settings is used only for those, and only if you pick it — it is unset by default, because defaulting to the first unit in the list means Global on most tenants. Nothing is filed by proximity.
    • Break-glass groups are candidates on any reference — included by the break-glass policy and excluded by none, they used to be missed; the group holding the accounts that bypass every policy belongs in the break-glass vault whatever it is called.
    • Role-assignable groups are refused — their membership is already limited to Global Administrator / Privileged Role Administrator, and a restricted AU blocks those same two, so a group with both has nobody who can edit its members; ⑦ Migrate it first is offered on the row. 🧊 frozen — a group that already has both (inherited from before the guard, or from the portal) is a dead end and is called out wherever it appears: take it out of the unit to restore the two roles, then migrate.
    • The run — the run ledger with both locks as notes per group: added to the vault (or already there), nesting disabled and read back — a tenant whose directory does not return the property is told so once, tenant-wide, rather than per row. Scoped administrators are granted on every unit the run wrote to, each grant reported separately. Explicit acknowledgement before anything is written; Markdown change report after.
    • Groups no policy references are listed too, marked not referenced, never pre-selected and not swept up by select-all: a baseline exclusion group can sit in a unit, or be frozen inside one, long after its policy was retired. Protection is read in one call for the whole tenant; if that read fails the column says unknown, never “unprotected”.
    • Search — the box narrows the groups already found; select-all applies to what is visible.

    Needs AdministrativeUnit.ReadWrite.All and Group.ReadWrite.All (on demand), the Privileged Role Administrator role for the unit and Entra ID P1 for administrative-unit administrators. Nesting is read and written on Graph v1.0 — some tenants answer without the property on beta. See the CA groups section above for the migration and the vault design.

    🧩 Policy building blocks writes to tenant

    Four kinds of reference, one tool. 🌐 Named locations, 💪 Authentication strengths, 🎫 Authentication contexts and 📜 Terms of use were four tiles over four copies of the same screen — a list, which policies use each row, create / edit / delete — and four private copies of the same query, one per module. ♻ Recycle bin is the fifth tab because the restore window holds deleted named LOCATIONS as well as deleted policies. They are one tool with five tabs from build 311; T15, T22, T23, T24 and T25 keep their numbers (see 🔢 Tool numbers) and their sections below. The four which policies use this lookups are now one implementation in js/causes.js, so the All-trusted-locations case — a policy that never names a location but reaches it through All trusted locations, and so changes behaviour the moment you flip the trusted flag — is available to every kind rather than to locations alone.

    Manage the named locations your Conditional Access policies target — the IP-range and country locations — without leaving the tool.

    • Cards / Table — Cards tile at least two per row with the ranges, usage and the Edit/Delete buttons; Table is the same information one line per location, which is the readable option once a tenant has dozens.
    • View — every location with its ranges or country list, whether it's trusted, and which policies use it (included or excluded). Filter by IP / country / trusted / unused, or search.
    • + New location — create an IP ranges location (CIDR, one per line, IPv4 or IPv6, optionally marked trusted) or a countries/regions location (two-letter ISO codes, optionally including unknown countries, with client-IP or Authenticator-GPS lookup).
    • Edit — rename and change the ranges/countries. The type is fixed at creation: an IP location can't become a country location. If you change the trusted flag, you're warned how many policies use “All trusted locations” and would follow the change.
    • Delete — a location that no policy references deletes after a plain confirm; one that is still referenced lists those policies and needs a typed DELETE, because removing it drops that condition and usually widens the policy.
    • Click a location name — opens its report: every range or country, the trusted flag and what it means, the location id, and the full list of policies that name it plus the ones covering it through “All trusted locations” (each clickable through to the policy card). From there, 📄 Documentation (MD) writes that one location up on its own.
    • ⚠ Findings — four checks over what the tool has already read, so they cost no extra call to your tenant. A dangling reference (a policy names a location id this tenant no longer has — the exclusion stopped applying, and the policy still reads as configured), an empty country location (no countries and unknown regions not included, so it matches nobody while every policy using it looks scoped), an overly broad IP range (a /8 — 16.7 million addresses — or a /0; worse when the location is trusted, because everything using “All trusted locations” inherits it), and an untrusted IP location where policies do rely on “All trusted locations”. Findings appear in a panel above the list, as a ⚠ badge on the row, in the per-location report, and in Export MD. Nothing is written and nothing is stored.
    • The untrusted check stays quiet on purpose — the trusted flag only changes behaviour when something actually consumes “All trusted locations”, so in a tenant where nothing does, the check says nothing at all. And a deliberate block list is untrusted on purpose: where a Block policy names the location, or its name says so, nothing is raised; everywhere else the finding names that as a valid reason to leave it exactly as it is. A check that cries wolf about the normal case is worse than no check.
    • Export MD — findings first with the full text of each (what was found, why it matters, what closes it, which policies are involved), then the full inventory with usage.
    • ⭳ Export JSON — the configuration of every location as a snapshot file. Nothing is stored server-side, so this is how you keep a record of what the ranges looked like on a given day.
    • ⇄ Compare… — load an earlier JSON export and see what moved since: locations whose ranges, countries or trusted flag changed, ones in the file that are gone from the tenant, and ones created since. Matched by display name, because an id from another tenant means nothing here. A flipped trusted flag is the one worth looking at first — it silently changes which policies match.

    Reading is read-only; create, edit and delete write to the tenant and ask for Policy.ReadWrite.ConditionalAccess when you run them.

    🎫 Authentication contexts writes to tenant folded into this tool — build 311

    The named step-up requirements, c1c99. An app, a Protected Action or a Purview label asks for "c1", and whichever Conditional Access policy is scoped to c1 is what enforces it. Every defined context is listed with its publish state and the policies that consume it.

    • The id is the contract, and the tool treats it as one. c1 is what applications request and what the ACRS claim carries, so a context can be renamed and republished freely but its id can never change. Renaming is safe; renumbering is not possible, and nothing here pretends otherwise.
    • Publish / unpublish in one click — that is the isAvailable flag, which is what decides whether the context can be selected at all.
    • Delete follows Graph's own rules, surfaced before you try rather than as a raw error: an unpublished context can be deleted, a published one is refused with a 403, and one a policy still references is refused with a 400 whatever its publish state. Repoint or retire the policy first. The delete itself takes a typed confirmation.
    • Free slots are counted, because c1–c99 is a fixed range and running out is a thing that happens quietly.
    • Export MD — every context, its state and what enforces it.

    Writes need Policy.ReadWrite.ConditionalAccess, the same scope every other Conditional Access write uses, asked for on the click. Create and update are the same call — Graph treats it as an upsert on the id.

    💪 Authentication strengths writes to tenant folded into this tool — build 311

    A strength is a set of allowed authentication method combinations, and it is what a policy grants when it demands more than "MFA". The three built-in strengths — Multifactor, Passwordless MFA, Phishing-resistant MFA — are Microsoft-managed and read-only here; custom strengths are yours to create, rename, re-combine and delete.

    • Classified by their weakest combination — phishing-resistant, MFA, or allows-single-factor. A strength is only as strong as the easiest way to satisfy it, and that is the number worth seeing on the card rather than the name somebody gave it.
    • Advanced options, as in the portal — restrict Passkeys (FIDO2) to specific AAGUIDs (Microsoft Authenticator and Windows Hello presets, or any custom AAGUID) and certificate-based authentication to specific issuer SKIs and policy OIDs. Shown on the cards and in the report, because a strength with restrictions is a different control from one without.
    • Per-strength policy usage — which policies grant it, so you can see what a change would touch before making it.
    • Delete is blocked while any policy grants the strength, and typed-confirmed otherwise. A dangling grant control is a policy that cannot be evaluated.
    • Export MD — every strength with its combinations, restrictions and consumers.

    Writes need Policy.ReadWrite.ConditionalAccess. Changing the combinations is its own Graph action (updateAllowedCombinations) rather than part of the update — sending them in a normal edit is rejected — so the tool issues both calls and reports them separately. The valid-combination catalog is read live from Graph, with a bundled fallback when that read is refused.

    📜 Terms of use writes to tenant folded into this tool — build 311

    The agreements a Conditional Access policy can require before granting access. Each one is shown with its behaviour settings — view before accepting, per-device acceptance, re-accept schedule, expiration — its PDFs per language, and the policies that require it.

    • The PDFs are actually there — with a direct download per language, and the default marked. The list endpoint does not return file content, so each agreement is fetched individually to get them; a list that showed no languages was reporting the API's shape rather than the tenant's.
    • Create takes a PDF, uploaded inline. Behaviour settings that the create API does not accept are applied immediately afterwards, which is invisible unless it fails — and if it does, it says which half succeeded.
    • Acceptance summary on demand — accepted and declined counts with the most recent, per agreement. It is a separate permission (AgreementAcceptance.Read.All) and is therefore requested only when you ask for it, not at sign-in.
    • Delete is blocked while a policy requires the agreement. Deleting it anyway would leave that policy with a grant control pointing at nothing; repoint the policy first. Otherwise typed-confirmed.
    • Export MD — every agreement, its settings, languages and consumers.

    Reads need Agreement.Read.All, writes Agreement.ReadWrite.All, acceptances AgreementAcceptance.Read.All — all delegated-only, with Conditional Access Administrator or Security Administrator. Replacing a PDF or adding a language is deliberately not in scope: that is a new file localisation and belongs in the portal.

    ♻ Recycle bin writes to tenant folded into this tool — build 311

    Conditional Access has a soft-delete window: a deleted policy or named location sits for 30 days and can be restored, after which it is gone for good. Each item shows what it did, the state it was in when deleted, when it went and how many days remain.

    • A policy comes back in the state it was deleted in. One that was On when it went enforces again the moment it is restored — not after a review, not in report-only. That restore therefore demands a typed RESTORE, and the stored state is shown before you commit rather than discovered afterwards.
    • Name collisions are flagged against the live set, since a restore does not rename anything and two policies with one name is its own kind of confusion.
    • Restoring a trusted named location warns you that every policy using "All trusted locations" follows it again immediately — the location is restored with its trusted flag intact, so the blast radius is wider than the one object.
    • Expiring soon filter, because the useful question is usually "what am I about to lose".
    • After a policy is restored the live policy set is reloaded, so every other tool sees it without a manual refresh.
    • Export MD — the bin's contents with their deadlines.

    Reads and restores need Policy.ReadWrite.ConditionalAccess, asked for on the click. Nothing here can delete anything — the only write is a restore.

    📵 SMS & voice retirement BETA

    A temporary tool. Microsoft retires its own SMS and voice MFA delivery on 1 February 2027; from 1 September 2026 users still enabled for SMS or voice are auto-enabled for passkeys and nudged at sign-in, and after the retirement a user whose only MFA method is a phone number gets a blocking passkey-registration prompt with no opt-out. This tool reads the sms and voice authentication-method configurations (state, registration campaign, include and exclude targets), expands the scope to the actual users, and joins the registration-details report so every user carries a verdict.

    • Verdicts — phone as the only MFA method (locked out after the retirement), phone plus another method (migrate), phishing-resistant already registered (ready), in scope with no phone method (clean). A Phone role column says whether the phone is the user's only method, their default, or a backup, and a Methods registered column lists every MFA method the registration report holds for the account — the default first, bold and starred, phones in the retirement colour, the raw Graph name on hover — so a verdict can be checked against the record it was made from. The same list is in the Markdown report and, as two columns (raw values and labels), in the CSV.
    • Countdown, chips, exports — both dates, per-verdict filter chips, a capped on-screen table, Markdown report and full CSV.

    Reads only; no tenant settings are changed. The registration report needs AuditLog.Read.All, asked once; a refusal degrades the run to scope-only. Lives in the Temporary tools section and is removed once the retirement has passed.

    ⏳ memberOf retirement BETA

    A temporary tool. The memberOf dynamic-rule operator has been in public preview since 2022 and Microsoft ends that preview on 3 November 2026. Rules using it do not fail on that date — they stop updating and stay in their last known state: a frozen group keeps handing out whatever it held that day, joiners are never covered and leavers never removed, and nothing on screen says so.

    • Three surfaces — dynamic groups, dynamic administrative units and entitlement-management auto-assignment policies (the third needs EntitlementManagement.Read.All, asked once; a refusal reports that surface as not read, never as clean). The operator is detected by its documented user.memberof / device.memberof shape; anything that says memberof without matching it is reported as read-by-hand rather than dropped.
    • Consequences, not a list — every affected group is crossed against the Conditional Access policies ENCA already holds: an exclusion that stops shrinking is a permanent bypass, an inclusion that stops growing is an enforcement gap, both named per policy with the policy's state. Group-based licensing, an already-paused rule and a stale source group are called out too.
    • Owner notification — owners of the affected groups are read for an email that asks each owner to replace the rule, convert to assigned, or say it can go; administrative units have no owner and the modal says so. Countdown, Markdown report and CSV.

    Reads only and asks for no extra consent beyond the optional entitlement-management scope — rewriting a membership rule changes who a Conditional Access policy hits, and that decision belongs to a person in the portal. Lives in the Temporary tools section and is removed once the retirement has passed.

    📥 Import writes to tenant

    Restore a backup (zip or extracted folder). Dependencies are created first (create-if-missing), then the policies — always in state Off unless a replacement inherits an existing state.

    • Groups are attached by name UPDATED — the group ids in a file belong to the tenant it was exported from and are never written into a policy. Every group a policy names is created here, or found when one with that name exists, and the policy gets this tenant's group. A group that cannot be created holds back the policies that name it (they fail with the reason) instead of letting them land on an id this tenant does not have — an exclusion naming a group that does not exist excludes nobody, break-glass included. The 👥 panel counts the groups and names every correction: one source id exported under two names (Joey's CA005 and CA006) gives each policy the group that carries its own name, and a policy's own exclusion group filed under another name (Joey's CA403 and CA404) is created under the name the baseline gives that policy. A backup restored into the tenant it came from binds to the same group objects. Ids no file explains — a user, a group, a named location — must exist here, or the policy is not created.
    • Assignment mode — 🧩 As shipped NEW: the policies keep the includes and exclusions the file ships, on the groups created or found by name. Nothing already in the tenant is touched and the policies land Off. Pre-selected for a baseline that is not CloudFellows (Joey Verlinden's), which has no CAD-SEC-U-DG-* deploy groups of its own.
    • Assignment mode — 🚀 Deployment groups: new policies are scoped to this tenant’s deploy persona groups (CAD-SEC-U-DG-*). Staged and safe — nothing already in the tenant is touched.
    • Assignment mode — ♻️ Match & replace: for a policy whose CA number already exists at a different version, the new version keeps the current policy’s assignment and state, merges in any new exclusion groups the update adds (created if missing), and the old version is switched Off — a seamless swap.
    • Assignment mode — 🔀 Switch baseline BETA: for moving a tenant from one baseline's groups to the other's. Policies land with the baseline's own groups as shipped (created here — Joey Verlinden's “<policy name> - Exclude” and CA-BreakGlassAccounts - Exclude, or the CloudFellows CAB-SEC-U-CAnnn-Exclusion set), and the members of the other baseline's counterpart groups are copied across: break-glass to break-glass, the exclusion group of the same CA number, the persona include group. It is a copy — the old groups keep their members as the rollback and show in 🧹 What <baseline> left behind (🧬 Baseline Policies) once nothing references them. A same-policy-other-name it supersedes is switched Off. Offered only when the file matches a baseline the tenant is not on, and pre-selected when the tenant's policies visibly use the other baseline's groups.
    • From the repository — 📥 Import baseline on 🧬 Baseline (Joey Verlinden) and Fetch latest in ↗ Guided rollout need no zip: the live read of his repository ships the policy files, Config/Groups and Config/NamedLocations — the same layout as a backup — so the import starts from that read with the gap ticked and the rest of the release listed unticked. A downloaded copy of the repository imports too (its files are UTF-16).
    • 🚨 E-Admins from a CloudFellows backup NEW — the emergency-access policies (CA1100–CA1105) are expected under every baseline and only a CloudFellows backup ships them. A file without them shows the 🚨 panel: + E-Admins from a CloudFellows backup ZIP (or an extracted folder) takes only the E-Admins policies and the groups, locations and authentication strengths they use into this import, renames the break-glass group to the target baseline's (CAB-SEC-U-BreakGlassCA-BreakGlassAccounts - Exclude for Joey), files the groups they name in the break-glass restricted unit (never the Admins one, and never a deploy group), and lands them Off whatever the backup says — an emergency-access policy switched On in a tenant without the trusted locations or the phishing-resistant methods it expects locks the emergency accounts out.
    • Read back, patiently UPDATED — Conditional Access is eventually consistent: a policy created a moment ago can answer “does not exist” to a read by its own id. Every read after a write is repeated with a short backoff (about 20 seconds for a missing policy) before it counts, and a policy that is still unreadable is reported as created but not verified, with its id — a re-run skips it by name. Authentication contexts, locations, strengths and terms of use come along only when a chosen policy names them exactly.
    • 🔧 Re-attach NEW writes to tenant — policies already in the tenant that point at group ids from the loaded file (an import before build 315 left them when a group could not be created; 👥 CA groups lists them as referenced but gone) are listed with the group each id meant. One click creates or finds those groups by name, swaps only those ids, and reads every policy back; state, conditions and the other assignments are untouched. A separate click from Import, with its own ledger and report.
    • 🤖 Agent policies (preview) UPDATED — Joey's CA5xx block targets agent identities and agents' user accounts. His files are what a read returns, and Graph refused all five as written (a bare 400), so an agent policy is now written in the shape Graph documents for a create: agent identities without the read-only users: None block and empty service-principal list; agents' user accounts as users AllAgentIdUsers instead of the agents object; no OData annotations. The agent execution environment (CA503's “sessions initiated from endpoints”) is documented nowhere — if Graph refuses it, the policy is created once more without it and the row and report say that it then covers every session of the agents' user accounts. An agent filter, or users and agents in one policy, cannot be written that way and is refused with the reason. The tenant still needs Microsoft Entra Agent ID (Entra ID P1 or P2 with a Microsoft Agent 365 licence), and agent risk needs ID Protection. CA505 excludes Microsoft's compliant network location, which is referenced by its fixed id and never created — it applies once Global Secure Access signaling is enabled for Conditional Access.
    • Import only — pick a persona to bring in just its policies, or use All / None. A policy with the same CA number and version is skipped.
    • Unknown application references — a policy can only name an app that has a service principal in this tenant, and Graph rejects the whole policy over one it cannot resolve. The importer instantiates the missing ones first (the baseline excludes Microsoft first-party apps like Defender for Endpoint and Defender for Mobile TVM that an unused tenant does not have). If one still cannot be created, the policy is not created and the row names the missing app — dropping an exclusion would widen the policy.
    • 🔒 no Workload ID licence — a policy scoped to service principals (the CA900 range) needs the separately purchased Microsoft Entra Workload ID licence, which is not part of Entra ID P1/P2. The importer reads the tenant’s subscriptions first; without the licence those policies are shown greyed out and left out entirely, rather than attempted and rejected by Graph with a bare 400. Buy or trial the licence, then re-import.
    • 📜 needs ToU — a policy granting a Terms of use imports without that control (Terms of use has no create API); the report gives a checklist to create it in the portal, then re-import.
    • 🛡 Protection preflight BETA — before the import runs, the tool checks that a restricted administrative unit exists for each persona your selection actually touches, and offers to create the missing ones (restricted, with you scoped as Groups Administrator). Every group the import creates is then added to its persona's unit, so a tenant-wide Groups Administrator cannot afterwards widen a Conditional Access exclusion. It is optional and never blocks the import: skip it and the groups simply land unprotected.
    • Only groups this run created are placed — a group that already existed is left where it is, because it may be somewhere on purpose and that is not the import's call to make. A group used by two personas is not placed either, and the report says why: an object may sit in several restricted units and a scoped administrator on any of them can manage it, so filing a shared group under one persona hands that persona's admin control of the other's exclusions, and filing it under both hands it to either. Split it per persona, or place it by hand knowing who can reach it.
    • An unreadable check says so — if the tenant's administrative units cannot be read, the panel reports that it could not check rather than reporting everything present, and the import proceeds unprotected with each group named in the report.
    • Role-assignable groups in the file are found before the import, not after. A baseline exported from a tenant that still used the role-assignable flag brings those groups with it, and importing on top of them is not neutral: the flag is immutable, it forbids controlling nesting, and it cannot be combined with a restricted management administrative unit — a group carrying both has nobody who can change its members. So the policies would import cleanly and land on groups the baseline has deliberately moved away from, and the 🛡 protection step would silently skip exactly those. The preflight now names them and says what will happen either way. Converting means recreating each one — rename aside, plain security group, members copied, every referencing policy repointed — which runs in ⑦ Migrate behind its own typed confirmation and report, and is deliberately not reimplemented here: one destructive operation, one implementation. Import now and convert later is still a choice; the report lists them as unprotected.
    • A tenant that partly has the groups is the normal case, and the two halves are treated differently. Groups the file brings that already exist here are reused — the policies bind to them, nothing is duplicated, and nothing about them is changed. That is the safe default: an existing group may hold members and sit somewhere deliberately, and an import is not the place to overrule that. But it means a reused group inherits none of the hardening a created one gets: a created group has nesting disabled and is filed into its persona vault, a reused one keeps whatever it has, and the placement step skips it unless you say otherwise. That skip used to be invisible — a reused group appeared in the report as a bare name. The preflight now lists them with what is actually being inherited: nesting disabled, allowed, or not reported by this tenant; already in a restricted unit or unprotected; a Microsoft 365 group that can never go in one; and a shape mismatch, where the file expects a dynamic group with a membership rule and the tenant has an assigned group of that name — the rule is not applied, and that would otherwise pass unnoticed.
    • What a reused group is missing can now be finished here. Naming the gap was only half an answer: the panel said the group was unprotected and its nesting untouched, then sent you to two other tools. Each reusable row now carries a tick for what is actually still open on it — 🚫 disable nesting, 🔒 file it into its persona vault — and anything impossible says why on the row rather than offering a dead checkbox: role-assignable (⑦ Migrate first), dynamic, a Microsoft 365 group, already protected, a group two personas share, or a vault that does not exist yet. Nothing is pre-ticked, and the writes run after the policies import, so an import that fails leaves every existing group exactly as it found it. Members are never touched. Both are writes the create path already makes and neither is destructive — Entra refuses the nesting change rather than forcing it when a group holds nested groups, and unit membership can be undone — while the destructive recreate stays in ⑧ Disable nesting behind its own confirmation. Every outcome reaches the change report, a failure included: it is reported as still unprotected rather than rounded to done.

    Writes; a change report is shown on screen and downloadable. Creating administrative units and 🔧 Re-attach are separate clicks from Import; units need AdministrativeUnit.ReadWrite.All plus Privileged Role Administrator, groups Group.ReadWrite.All.

    General

    • Tabs — tools open in browser-style tabs; the + opens another, the house button returns to the tool grid, and ✕ all closes every tab at once.
    • Permissions — sign-in is read-only; a write tool requests its extra scope only when you run it, with an up-front consent step so pop-ups aren’t blocked.
    • Demo — append ?demo=1 to explore with sample policies; writes are simulated.
    • Build — the number on the sign-in screen matches the asset version; hard-refresh if it lags what you expect.
    • Forking — everything identity-shaped (name, organisation, logos, colours, host) lives in a single file, js/branding.js; nothing else hard-codes it. See “Rebranding a fork” in the README.